PluginProbe ʕ •ᴥ•ʔ
Jetpack – WP Security, Backup, Speed, & Growth / 12.8.3
Jetpack – WP Security, Backup, Speed, & Growth v12.8.3
12.0.3 12.1.3 12.2.3 12.3.2 12.4.2 12.5.2 12.6.4 12.7.3 12.8.3 12.9.5 13.0.2 13.1.5 13.2.4 13.3.3 13.4.5 13.5.2 13.6.2 13.7.2 13.8.3 13.9.2 14.0.1 14.1.1 14.2.2 14.3.1 14.4.2 14.5.1 14.6.1 14.7.1 14.8.1 14.9.2 15.0.3 15.1.2 15.2.1 15.3.2 15.4.1 15.5.1 15.6.1 15.7.2 15.8.1 15.9.2 16.0.2 16.1.3 16.2-a.5 16.2-a.3 16.1.2 16.2-a.1 16.1.1 16.1 16.1-beta 16.1-beta.2 16.1-beta.3 16.1-a.5 16.1-a.3 16.0.1 16.1-a.1 16.0 16.0-beta 16.0-a.7 16.0-a.5 15.9.1 16.0-a.3 16.0-a.1 15.9 15.9-beta 15.9-a.7 15.9-a.5 15.9-a.3 15.9-a.1 15.8 15.8-beta 15.8-a.7 15.8-a.5 5.2.5 5.3.4 5.4.4 5.5.5 5.6.5 5.7.5 5.8.4 5.9.4 6.0.4 6.1 6.1.1 6.1.2 6.1.3 6.1.4 6.1.5 6.2 6.2.1 6.2.2 6.2.3 6.2.4 6.2.5 6.3 6.3.1 6.3.2 6.3.3 6.3.4 6.3.5 6.3.6 6.3.7 6.4 6.4.1 6.4.2 6.4.3 6.4.4 6.4.5 6.4.6 6.5 6.5.1 6.5.2 6.5.3 6.5.4 6.6 6.6.1 6.6.2 6.6.3 6.6.4 6.6.5 6.7 6.7.1 6.7.2 6.7.3 6.7.4 6.8 6.8.1 6.8.2 6.8.3 6.8.4 6.8.5 6.9 6.9.1 6.9.2 6.9.3 6.9.4 7.0 7.0.1 7.0.2 7.0.3 7.0.4 7.0.5 7.1 7.1.1 7.1.2 7.1.3 7.1.4 7.1.5 7.2 7.2.1 7.2.1.1 7.2.2 7.2.3 7.2.4 7.2.5 7.3 7.3.0.1 7.3.1 7.3.1.1 7.3.2 7.3.3 7.3.4 7.3.5 7.4 7.4.1 7.4.2 7.4.3 7.4.4 7.4.5 7.5 7.5.0.1 7.5.1 7.5.2 7.5.3 7.5.4 7.5.5 7.5.6 7.5.7 7.6 7.6.1 7.6.2 7.6.3 7.6.4 7.7 7.7.1 7.7.2 7.7.3 7.7.4 7.7.5 7.7.6 7.8 7.8.1 7.8.2 7.8.3 7.8.4 7.9 7.9.1 7.9.2 7.9.3 7.9.4 8.0 8.0.1 8.0.2 8.0.3 8.1 8.1.1 8.1.2 8.1.3 8.1.4 8.2 8.2.0.1 8.2.1 8.2.2 8.2.3 8.2.4 8.2.5 8.2.6 8.3 8.3.1 8.3.2 8.3.3 8.4 8.4.1 8.4.2 8.4.3 8.4.4 8.4.5 8.5 8.5.1 8.5.2 8.5.3 8.6 8.6.1 8.6.2 8.6.3 8.6.4 8.7 8.7.0.1 8.7.1 8.7.2 8.7.3 8.7.4 8.8 8.8.1 8.8.2 8.8.3 8.8.4 8.8.5 8.9 8.9.1 8.9.2 8.9.3 8.9.4 9.0 9.0.1 9.0.2 9.0.3 9.0.4 9.0.5 9.1 9.1.1 9.1.2 9.1.3 9.2 9.2.1 9.2.2 9.2.3 9.2.4 9.3 9.3.1 9.3.2 9.3.3 9.3.4 9.3.5 9.4 9.4.1 9.4.2 9.4.3 9.4.4 9.5 9.5.1 9.5.2 9.5.3 9.5.4 9.5.5 9.6 9.6.1 9.6.2 9.6.3 9.6.4 9.7 9.7.1 9.7.2 15.7-beta.2 9.7.3 15.7.1 9.8 15.8-a.1 9.8.1 15.8-a.3 9.8.2 2.0.9 9.8.3 2.1.7 9.9 2.2.10 9.9.1 2.3.10 9.9.2 2.4.7 9.9.3 2.5.5 2.6.6 2.7.5 2.8.5 2.9.6 3.0.6 3.1.5 3.2.5 3.3.6 3.4.6 3.5.6 3.6.4 3.7.5 3.8.5 3.9.10 4.0.7 4.1.4 4.2.5 4.3.5 4.4.5 4.5.3 4.6.3 4.7.4 4.8.5 4.9.3 5.0.3 5.1.4 trunk 10.0 10.0.1 10.0.2 10.1 10.1.1 10.1.2 10.2 10.2.1 10.2.2 10.2.3 10.3 10.3.1 10.3.2 10.4 10.4.1 10.4.2 10.5 10.5.1 10.5.2 10.5.3 10.6 10.6.1 10.6.2 10.7 10.7.1 10.7.2 10.8 10.8.1 10.8.2 10.9 10.9.1 10.9.2 10.9.3 11.0 11.0.1 11.0.2 11.1 11.1.1 11.1.2 11.1.3 11.1.4 11.2 11.2.1 11.2.2 11.3 11.3.1 11.3.2 11.3.3 11.3.4 11.4 11.4.1 11.4.2 11.5 11.5.1 11.5.2 11.5.3 11.6 11.6.1 11.6.2 11.7 11.7.1 11.7.2 11.7.3 11.8 11.8.3 11.8.4 11.8.5 11.8.6 11.9 11.9.1 11.9.2 11.9.3 12.0 12.0.1 12.0.2 12.1 12.1.1 12.1.2 12.2 12.2.1 12.2.2 12.3 12.3.1 12.4 12.4.1 12.5 12.5.1 12.6 12.6.1 12.6.2 12.6.3 12.7 12.7.1 12.7.2 12.8 12.8.1 12.8.2 12.9 12.9.1 12.9.2 12.9.3 12.9.4 13.0 13.0.1 13.1 13.1.1 13.1.2 13.1.3 13.1.4 13.2 13.2.1 13.2.2 13.2.3 13.3 13.3.1 13.3.2 13.4 13.4.1 13.4.2 13.4.3 13.4.4 13.5 13.5.1 13.6 13.6.1 13.7 13.7.1 13.8 13.8.1 13.8.2 13.9 13.9.1 14.0 14.1 14.2 14.2.1 14.3 14.4 14.4.1 14.5 14.6 14.7 14.8 14.9 14.9.1 15.0 15.0.1 15.0.2 15.1 15.1.1 15.2 15.3 15.3.1 15.4 15.5 15.6 15.7 15.7-a.1 15.7-a.3 15.7-a.5 15.7-a.7 15.7-beta
jetpack / _inc / lib / class-jetpack-tweetstorm-helper.php
jetpack / _inc / lib Last commit date
admin-pages 2 years ago core-api 2 years ago debugger 2 years ago markdown 2 years ago class-jetpack-ai-helper.php 2 years ago class-jetpack-currencies.php 5 years ago class-jetpack-google-drive-helper.php 3 years ago class-jetpack-instagram-gallery-helper.php 3 years ago class-jetpack-mapbox-helper.php 3 years ago class-jetpack-podcast-feed-locator.php 2 years ago class-jetpack-podcast-helper.php 2 years ago class-jetpack-recommendations.php 3 years ago class-jetpack-tweetstorm-helper.php 2 years ago class-jetpack-wizard.php 5 years ago class.color.php 2 years ago class.core-rest-api-endpoints.php 2 years ago class.jetpack-automatic-install-skin.php 2 years ago class.jetpack-iframe-embed.php 3 years ago class.jetpack-keyring-service-helper.php 3 years ago class.jetpack-password-checker.php 3 years ago class.jetpack-search-performance-logger.php 4 years ago class.media-extractor.php 3 years ago class.media-summary.php 3 years ago class.media.php 2 years ago components.php 3 years ago debugger.php 4 years ago functions.wp-notify.php 3 years ago icalendar-reader.php 2 years ago markdown.php 3 years ago plans.php 4 years ago plugins.php 4 years ago tonesque.php 2 years ago widgets.php 4 years ago
class-jetpack-tweetstorm-helper.php
1755 lines
1 <?php
2 /**
3 * Tweetstorm block and API helper.
4 *
5 * @package automattic/jetpack
6 * @since 8.7.0
7 */
8
9 use Automattic\Jetpack\Connection\Client;
10 use Automattic\Jetpack\Connection\Manager;
11 use Automattic\Jetpack\Status;
12 use Twitter\Text\Regex as Twitter_Regex;
13 use Twitter\Text\Validator as Twitter_Validator;
14
15 /**
16 * Class Jetpack_Tweetstorm_Helper
17 *
18 * @since 8.7.0
19 */
20 class Jetpack_Tweetstorm_Helper {
21 /**
22 * Blocks that can be converted to tweets.
23 *
24 * @var array {
25 * The key for each element must match the registered block name.
26 *
27 * @type string $type Required. The type of content this block produces. Can be one of 'break', 'embed', 'image',
28 * 'multiline', 'text', or 'video'.
29 * @type string $content_location Optional. Where the block content can be found. Can be 'html', if we need to parse
30 * it out of the block HTML text, 'html-attributes', if the we need to parse it out of HTML attributes
31 * in the block HTML, or 'block-attributes', if the content can be found in the block attributes.
32 * Note that these attributes need to be available when the serialised block is
33 * parsed using `parse_blocks()`. If it isn't set, it's assumed the block doesn't add
34 * any content to the Twitter thread.
35 * @type array $content Optional. Defines what parts of the block content need to be extracted. Behaviour can vary based on
36 * `$content_location`, and `$type`:
37 *
38 * - When `$content_location` is 'html', a value of `array()` or `array( 'content' )` have the same meaning:
39 * The entire block HTML should be used. In both cases, 'content' will be the corresponding tag in `$template`.
40 * - When `$content_location` is 'html', it should be formatted as `array( 'container' => 'tag' )`,
41 * where 'container' is the name of the corresponding RichText container in the block editor, and is also the name
42 * of the corresponding tag in the $template string. 'tag' is the HTML tag within the block that corresponds to this
43 * container. When `$type` is 'multiline', there must only be one element in the array, and tag should be set to the HTML
44 * tag that corresponds to each line, though the 'container' should still be the RichText container name. (Eg, in the core/list block, the tag is 'li'.)
45 * - When `$content_location` is 'html-attributes', the array should be formatted as `array( 'name' => array( 'tag', 'attribute') )`,
46 * where 'name' is the name of a particular value that different block types require, 'tag' is the name of the HTML tag where 'attribute'
47 * can be found, containing the value to use for 'name'. When `$type` is 'image', 'url' and 'alt' must be defined. When `$type` is 'video',
48 * 'url' must be defined.
49 * - When `$content_location` is 'block-attributes', it must be an array of block attribute names. When `$type` is 'embed', there
50 * only be one element, corresponding to the URL for the embed.
51 * @type string $template Required for 'text' and 'multiline' types, ignored for all other types. Describes how the block content will be formatted when tweeted.
52 * Tags should match the keys of `$content`, except for the special "{{content}}", which matches the entire HTML content of the block.
53 * For 'multiline' types, the template will be repeated for every line in the block.
54 * @type boolean $force_new Required. Whether or not a new tweet should be started when this block is encountered.
55 * @type boolean $force_finished Required. Whether or not a new tweet should be started after this block is finished.
56 * }
57 */
58 private static $supported_blocks = array(
59 'core/embed' => array(
60 'type' => 'embed',
61 'content_location' => 'block-attributes',
62 'content' => array( 'url' ),
63 'force_new' => false,
64 'force_finished' => true,
65 ),
66 'core/gallery' => array(
67 'type' => 'image',
68 'content_location' => 'html-attributes',
69 'content' => array(
70 'url' => array( 'img', 'src' ),
71 'alt' => array( 'img', 'alt' ),
72 ),
73 'force_new' => false,
74 'force_finished' => true,
75 ),
76 'core/heading' => array(
77 'type' => 'text',
78 'content_location' => 'html',
79 'content' => array(),
80 'template' => '{{content}}',
81 'force_new' => true,
82 'force_finished' => false,
83 ),
84 'core/image' => array(
85 'type' => 'image',
86 'content_location' => 'html-attributes',
87 'content' => array(
88 'url' => array( 'img', 'src' ),
89 'alt' => array( 'img', 'alt' ),
90 ),
91 'force_new' => false,
92 'force_finished' => true,
93 ),
94 'core/list' => array(
95 'type' => 'multiline',
96 'content_location' => 'html',
97 // It looks a little weird to use the 'values' key for a single line,
98 // but 'values' is the name of the RichText content area.
99 'content' => array(
100 'values' => 'li',
101 ),
102 'template' => '- {{values}}',
103 'force_new' => false,
104 'force_finished' => false,
105 ),
106 'core/paragraph' => array(
107 'type' => 'text',
108 'content_location' => 'html',
109 'content' => array(),
110 'template' => '{{content}}',
111 'force_new' => false,
112 'force_finished' => false,
113 ),
114 'core/quote' => array(
115 'type' => 'text',
116 'content_location' => 'html',
117 // The quote content will always be inside <p> tags.
118 'content' => array(
119 'value' => 'p',
120 'citation' => 'cite',
121 ),
122 'template' => '“{{value}}” – {{citation}}',
123 'force_new' => false,
124 'force_finished' => false,
125 ),
126 'core/separator' => array(
127 'type' => 'break',
128 'force_new' => false,
129 'force_finished' => true,
130 ),
131 'core/spacer' => array(
132 'type' => 'break',
133 'force_new' => false,
134 'force_finished' => true,
135 ),
136 'core/verse' => array(
137 'type' => 'text',
138 'content_location' => 'html',
139 'content' => array(),
140 'template' => '{{content}}',
141 'force_new' => false,
142 'force_finished' => false,
143 ),
144 'core/video' => array(
145 'type' => 'video',
146 'content_location' => 'html-attributes',
147 'content' => array(
148 'url' => array( 'video', 'src' ),
149 ),
150 'force_new' => false,
151 'force_finished' => true,
152 ),
153 'jetpack/gif' => array(
154 'type' => 'embed',
155 'content_location' => 'block-attributes',
156 'content' => array( 'giphyUrl' ),
157 'force_new' => false,
158 'force_finished' => true,
159 ),
160 );
161
162 /**
163 * A cache of _wp_emoji_list( 'entities' ), after being run through html_entity_decode().
164 *
165 * Initialised in ::is_valid_tweet().
166 *
167 * @var array
168 */
169 private static $emoji_list = array();
170
171 /**
172 * Special line separator character, for multiline text.
173 *
174 * @var string
175 */
176 private static $line_separator = "\xE2\x80\xA8";
177
178 /**
179 * Special inline placeholder character, for inline tags that change content length in the RichText..
180 *
181 * @var string
182 */
183 private static $inline_placeholder = "\xE2\x81\xA3";
184
185 /**
186 * URLs always take up a fixed length from the text limit.
187 *
188 * @var int
189 */
190 private static $characters_per_url = 24;
191
192 /**
193 * Every media attachment takes up some space from the text limit.
194 *
195 * @var int
196 */
197 private static $characters_per_media = 24;
198
199 /**
200 * An array to store all the tweets in.
201 *
202 * @var array
203 */
204 private static $tweets = array();
205
206 /**
207 * While we're caching everything, we want to keep track of the URLs we're adding.
208 *
209 * @var array
210 */
211 private static $urls = array();
212
213 /**
214 * Checks if a given request is allowed to gather tweets.
215 *
216 * @param WP_REST_Request $request Full details about the request.
217 *
218 * @return true|WP_Error True if the request has access to gather tweets from a thread, WP_Error object otherwise.
219 */
220 public static function permissions_check( $request ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter, VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
221 $blog_id = get_current_blog_id();
222
223 /*
224 * User hitting the endpoint hosted on their Jetpack site, from their Jetpack site,
225 * or hitting the endpoint hosted on WPCOM, from their WPCOM site.
226 */
227 if ( current_user_can_for_blog( $blog_id, 'edit_posts' ) ) {
228 return true;
229 }
230
231 // Jetpack hitting the endpoint hosted on WPCOM, from a Jetpack site with a blog token.
232 if ( defined( 'IS_WPCOM' ) && IS_WPCOM ) {
233 if ( is_jetpack_site( $blog_id ) ) {
234 if ( ! class_exists( 'WPCOM_REST_API_V2_Endpoint_Jetpack_Auth' ) ) {
235 require_once dirname( __DIR__ ) . '/rest-api-plugins/endpoints/jetpack-auth.php';
236 }
237
238 $jp_auth_endpoint = new WPCOM_REST_API_V2_Endpoint_Jetpack_Auth();
239 if ( true === $jp_auth_endpoint->is_jetpack_authorized_for_site() ) {
240 return true;
241 }
242 }
243 }
244
245 return new WP_Error(
246 'rest_forbidden',
247 __( 'Sorry, you are not allowed to use tweetstorm endpoints on this site.', 'jetpack' ),
248 array( 'status' => rest_authorization_required_code() )
249 );
250 }
251
252 /**
253 * Gather the Tweetstorm.
254 *
255 * @param string $url The tweet URL to gather from.
256 * @return mixed
257 */
258 public static function gather( $url ) {
259 if ( ( new Status() )->is_offline_mode() ) {
260 return new WP_Error(
261 'dev_mode',
262 __( 'Tweet unrolling is not available in offline mode.', 'jetpack' )
263 );
264 }
265
266 $site_id = Manager::get_site_id();
267 if ( is_wp_error( $site_id ) ) {
268 return $site_id;
269 }
270
271 if ( defined( 'IS_WPCOM' ) && IS_WPCOM ) {
272 if ( ! class_exists( 'WPCOM_Gather_Tweetstorm' ) ) {
273 \require_lib( 'gather-tweetstorm' );
274 }
275
276 return WPCOM_Gather_Tweetstorm::gather( $url );
277 }
278
279 $response = Client::wpcom_json_api_request_as_blog(
280 sprintf( '/sites/%d/tweetstorm/gather?url=%s', $site_id, rawurlencode( $url ) ),
281 2,
282 array( 'headers' => array( 'content-type' => 'application/json' ) ),
283 null,
284 'wpcom'
285 );
286 if ( is_wp_error( $response ) ) {
287 return $response;
288 }
289
290 $data = json_decode( wp_remote_retrieve_body( $response ) );
291
292 if ( wp_remote_retrieve_response_code( $response ) >= 400 ) {
293 return new WP_Error( $data->code, $data->message, $data->data );
294 }
295
296 return $data;
297 }
298
299 /**
300 * Parse blocks into an array of tweets.
301 *
302 * @param array $blocks {
303 * An array of blocks, with optional editor-specific information, that need to be parsed into tweets.
304 *
305 * @type array $block A single block, in the form produce by parse_blocks().
306 * @type array $attributes Optional. A list of block attributes and their values from the block editor.
307 * @type string $clientId Optional. The clientId of this block in the block editor.
308 * }
309 * @return array An array of tweets.
310 */
311 public static function parse( $blocks ) {
312 // Reset the tweets array.
313 self::$tweets = array();
314
315 $blocks = self::extract_blocks( $blocks );
316
317 if ( empty( $blocks ) ) {
318 return array();
319 }
320
321 // Initialise the tweets array with an empty tweet, so we don't need to check
322 // if we're creating the first tweet while processing blocks.
323 self::start_new_tweet();
324
325 foreach ( $blocks as $block ) {
326 $block_def = self::get_block_definition( $block['name'] );
327
328 // Grab the most recent tweet.
329 $current_tweet = self::get_current_tweet();
330
331 // Break blocks have no content to add, so we can skip the rest of this loop.
332 if ( 'break' === $block_def['type'] ) {
333 self::save_current_tweet( $current_tweet, $block );
334 continue;
335 }
336
337 // Check if we need to start a new tweet.
338 if ( $current_tweet['finished'] || $block_def['force_new'] ) {
339 self::start_new_tweet();
340 }
341
342 // Process the block.
343 self::add_text_to_tweets( $block );
344 self::add_media_to_tweets( $block );
345 self::add_tweet_to_tweets( $block );
346 self::add_embed_to_tweets( $block );
347 }
348
349 return self::clean_return_tweets();
350 }
351
352 /**
353 * If the passed block name is supported, return the block definition.
354 *
355 * @param string $block_name The registered block name.
356 * @return array|null The block definition, if it's supported.
357 */
358 private static function get_block_definition( $block_name ) {
359 if ( isset( self::$supported_blocks[ $block_name ] ) ) {
360 return self::$supported_blocks[ $block_name ];
361 }
362
363 return null;
364 }
365
366 /**
367 * If the block has any text, process it, and add it to the tweet list.
368 *
369 * @param array $block The block to process.
370 */
371 private static function add_text_to_tweets( $block ) {
372 // This is a text block, is there any text?
373 if ( 0 === strlen( $block['text'] ) ) {
374 return;
375 }
376
377 $block_def = self::get_block_definition( $block['name'] );
378
379 // Grab the most recent tweet, so we can append to that if we can.
380 $current_tweet = self::get_current_tweet();
381
382 // If the entire block can't be fit in this tweet, we need to start a new tweet.
383 if ( $current_tweet['changed'] && ! self::is_valid_tweet( trim( $current_tweet['text'] ) . "\n\n{$block['text']}" ) ) {
384 self::start_new_tweet();
385 }
386
387 // Multiline blocks prioritise splitting by line, but are otherwise identical to
388 // normal text blocks. This means we can treat normal text blocks as being
389 // "multiline", but with a single line.
390 if ( 'multiline' === $block_def['type'] ) {
391 $lines = explode( self::$line_separator, $block['text'] );
392 } else {
393 $lines = array( $block['text'] );
394 }
395
396 $line_total = is_array( $lines ) ? count( $lines ) : 0;
397
398 // Keep track of how many characters from this block we've allocated to tweets.
399 $current_character_count = 0;
400
401 for ( $line_count = 0; $line_count < $line_total; $line_count++ ) {
402 $line_text = $lines[ $line_count ];
403
404 // Make sure we have the most recent tweet at the start of every loop.
405 $current_tweet = self::get_current_tweet();
406
407 if ( $current_tweet['changed'] ) {
408 // When it's the first line, add an extra blank line to seperate
409 // the tweet text from that of the previous block.
410 $separator = "\n\n";
411 if ( $line_count > 0 ) {
412 $separator = "\n";
413 }
414
415 // Is this line short enough to append to the current tweet?
416 if ( self::is_valid_tweet( trim( $current_tweet['text'] ) . "$separator$line_text" ) ) {
417 // Don't trim the text yet, as we may need it for boundary calculations.
418 $current_tweet['text'] = $current_tweet['text'] . "$separator$line_text";
419
420 self::save_current_tweet( $current_tweet, $block );
421 continue;
422 }
423
424 // This line is too long, and lines *must* be split to a new tweet if they don't fit
425 // into the current tweet. If this isn't the first line, record where we split the block.
426 if ( $line_count > 0 ) {
427 // Increment by 1 to allow for the \n between lines to be counted by ::get_boundary().
428 $current_character_count += strlen( $current_tweet['text'] ) + 1;
429 $current_tweet['boundary'] = self::get_boundary( $block, $current_character_count );
430
431 self::save_current_tweet( $current_tweet );
432 }
433
434 // Start a new tweet.
435 $current_tweet = self::start_new_tweet();
436 }
437
438 // Since we're now at the start of a new tweet, is this line short enough to be a tweet by itself?
439 if ( self::is_valid_tweet( $line_text ) ) {
440 $current_tweet['text'] = $line_text;
441
442 self::save_current_tweet( $current_tweet, $block );
443 continue;
444 }
445
446 // The line is too long for a single tweet, so split it by sentences, or linebreaks.
447 $sentences = preg_split( '/(?|(?<!\.\.\.)(?<=[.?!]|\.\)|\.["\'])(\s+)(?=[\p{L}\'"\(])|(\n+))/u', $line_text, -1, PREG_SPLIT_DELIM_CAPTURE );
448
449 $sentence_total = $sentences !== false ? count( $sentences ) : 0;
450
451 // preg_split() puts the blank space between sentences into a seperate entry in the result,
452 // so we need to step through the result array by two, and append the blank space when needed.
453 for ( $sentence_count = 0; $sentence_count < $sentence_total; $sentence_count += 2 ) {
454 $current_sentence = $sentences[ $sentence_count ];
455 if ( isset( $sentences[ $sentence_count + 1 ] ) ) {
456 $current_sentence .= $sentences[ $sentence_count + 1 ];
457 }
458
459 // Make sure we have the most recent tweet.
460 $current_tweet = self::get_current_tweet();
461
462 // After the first sentence, we can try and append sentences to the previous sentence.
463 if ( $current_tweet['changed'] && $sentence_count > 0 ) {
464 // Is this sentence short enough for appending to the current tweet?
465 if ( self::is_valid_tweet( $current_tweet['text'] . rtrim( $current_sentence ) ) ) {
466 $current_tweet['text'] .= $current_sentence;
467
468 self::save_current_tweet( $current_tweet, $block );
469 continue;
470 }
471 }
472
473 // Will this sentence fit in its own tweet?
474 if ( self::is_valid_tweet( trim( $current_sentence ) ) ) {
475 if ( $current_tweet['changed'] ) {
476 // If we're already in the middle of a block, record the boundary
477 // before creating a new tweet.
478 if ( $line_count > 0 || $sentence_count > 0 ) {
479 $current_character_count += strlen( $current_tweet['text'] );
480 $current_tweet['boundary'] = self::get_boundary( $block, $current_character_count );
481
482 self::save_current_tweet( $current_tweet );
483 }
484
485 $current_tweet = self::start_new_tweet();
486 }
487 $current_tweet['text'] = $current_sentence;
488
489 self::save_current_tweet( $current_tweet, $block );
490 continue;
491 }
492
493 // This long sentence will start the next tweet that this block is going
494 // to be turned into, so we need to record the boundary and start a new tweet.
495 if ( $current_tweet['changed'] ) {
496 $current_character_count += strlen( $current_tweet['text'] );
497 $current_tweet['boundary'] = self::get_boundary( $block, $current_character_count );
498
499 self::save_current_tweet( $current_tweet );
500
501 $current_tweet = self::start_new_tweet();
502 }
503
504 // Split the long sentence into words.
505 $words = preg_split( '/(\p{Z})/u', $current_sentence, -1, PREG_SPLIT_DELIM_CAPTURE );
506 $word_total = $words !== false ? count( $words ) : 0;
507 for ( $word_count = 0; $word_count < $word_total; $word_count += 2 ) {
508 // Make sure we have the most recent tweet.
509 $current_tweet = self::get_current_tweet();
510
511 // If we're on a new tweet, we don't want to add a space at the start.
512 if ( ! $current_tweet['changed'] ) {
513 $current_tweet['text'] = $words[ $word_count ];
514
515 self::save_current_tweet( $current_tweet, $block );
516 continue;
517 }
518
519 // Can we add this word to the current tweet?
520 if ( self::is_valid_tweet( "{$current_tweet['text']} {$words[ $word_count ]}" ) ) {
521 $space = isset( $words[ $word_count - 1 ] ) ? $words[ $word_count - 1 ] : ' ';
522
523 $current_tweet['text'] .= $space . $words[ $word_count ];
524
525 self::save_current_tweet( $current_tweet, $block );
526 continue;
527 }
528
529 // Add one for the space character that we won't include in the tweet text.
530 $current_character_count += strlen( $current_tweet['text'] ) + 1;
531
532 // We're starting a new tweet with this word. Append ellipsis to
533 // the current tweet, then move on.
534 $current_tweet['text'] .= '';
535
536 $current_tweet['boundary'] = self::get_boundary( $block, $current_character_count );
537 self::save_current_tweet( $current_tweet );
538
539 $current_tweet = self::start_new_tweet();
540
541 // If this is the second tweet created by the split sentence, it'll start
542 // with ellipsis, which we don't want to count, but we do want to count the space
543 // that was replaced by this ellipsis.
544 $current_tweet['text'] = "{$words[ $word_count ]}";
545 $current_character_count -= strlen( '' );
546
547 self::save_current_tweet( $current_tweet, $block );
548 }
549 }
550 }
551 }
552
553 /**
554 * Check if the block has any media to add, and add it.
555 *
556 * @param array $block The block to process.
557 */
558 private static function add_media_to_tweets( $block ) {
559 if ( ! is_countable( $block['media'] ) ) {
560 return;
561 }
562 // There's some media to attach!
563 $media_count = count( $block['media'] );
564 if ( 0 === $media_count ) {
565 return;
566 }
567
568 $current_tweet = self::get_current_tweet();
569
570 // We can only attach media to the previous tweet if the previous tweet
571 // doesn't already have media.
572 if ( is_countable( $current_tweet['media'] ) && count( $current_tweet['media'] ) > 0 ) {
573 $current_tweet = self::start_new_tweet();
574 }
575
576 // Would adding this media make the text of the previous tweet too long?
577 if ( ! self::is_valid_tweet( $current_tweet['text'], $media_count * self::$characters_per_media ) ) {
578 $current_tweet = self::start_new_tweet();
579 }
580
581 $media = array_values(
582 array_filter(
583 $block['media'],
584 function ( $single ) {
585 // Only images and videos can be uploaded.
586 if ( 0 === strpos( $single['type'], 'image/' ) || 0 === strpos( $single['type'], 'video/' ) ) {
587 return true;
588 }
589
590 return false;
591 }
592 )
593 );
594
595 if ( count( $media ) > 0 ) {
596 if ( 0 === strpos( $media[0]['type'], 'video/' ) || 'image/gif' === $media[0]['type'] ) {
597 // We can only attach a single video or GIF.
598 $current_tweet['media'] = array_slice( $media, 0, 1 );
599 } else {
600 // Since a GIF or video isn't the first element, we can remove all of them from the array.
601 $filtered_media = array_values(
602 array_filter(
603 $media,
604 function ( $single ) {
605 if ( 0 === strpos( $single['type'], 'video/' ) || 'image/gif' === $single['type'] ) {
606 return false;
607 }
608
609 return true;
610 }
611 )
612 );
613 // We can only add the first four images found to the tweet.
614 $current_tweet['media'] = array_slice( $filtered_media, 0, 4 );
615 }
616
617 self::save_current_tweet( $current_tweet, $block );
618 }
619 }
620
621 /**
622 * Check if the block has a tweet that we can attach to the current tweet as a quote, and add it.
623 *
624 * @param array $block The block to process.
625 */
626 private static function add_tweet_to_tweets( $block ) {
627 if ( 0 === strlen( $block['tweet'] ) ) {
628 return;
629 }
630
631 $current_tweet = self::get_current_tweet();
632
633 // We can only attach a tweet to the previous tweet if the previous tweet
634 // doesn't already have a tweet quoted.
635 if ( strlen( $current_tweet['tweet'] ) > 0 ) {
636 $current_tweet = self::start_new_tweet();
637 }
638
639 $current_tweet['tweet'] = $block['tweet'];
640
641 self::save_current_tweet( $current_tweet, $block );
642 }
643
644 /**
645 * Check if the block has an embed URL that we can append to the current tweet text.
646 *
647 * @param array $block The block to process.
648 */
649 private static function add_embed_to_tweets( $block ) {
650 if ( 0 === strlen( $block['embed'] ) ) {
651 return;
652 }
653
654 $current_tweet = self::get_current_tweet();
655
656 $reserved_characters = count( $current_tweet['media'] ) * self::$characters_per_media;
657 $reserved_characters += 1 + self::$characters_per_url;
658
659 // We can only attach an embed to the previous tweet if it doesn't already
660 // have any URLs in it. Also, we can't attach it if it'll make the tweet too long.
661 if ( preg_match( '/url-placeholder-\d+-*/', $current_tweet['text'] ) || ! self::is_valid_tweet( $current_tweet['text'], $reserved_characters ) ) {
662 $current_tweet = self::start_new_tweet();
663 $current_tweet['text'] = self::generate_url_placeholder( $block['embed'] );
664 } else {
665 $space = empty( $current_tweet['text'] ) ? '' : ' ';
666 $current_tweet['text'] .= $space . self::generate_url_placeholder( $block['embed'] );
667 }
668
669 self::save_current_tweet( $current_tweet, $block );
670 }
671
672 /**
673 * Given an array of blocks and optional editor information, this will extract them into
674 * the internal representation used during parsing.
675 *
676 * @param array $blocks An array of blocks and optional editor-related information.
677 * @return array An array of blocks, in our internal representation.
678 */
679 private static function extract_blocks( $blocks ) {
680 if ( empty( $blocks ) ) {
681 return array();
682 }
683
684 $block_count = count( $blocks );
685
686 for ( $ii = 0; $ii < $block_count; $ii++ ) {
687 if ( ! self::get_block_definition( $blocks[ $ii ]['block']['blockName'] ) ) {
688 unset( $blocks[ $ii ] );
689 continue;
690 }
691
692 $blocks[ $ii ]['name'] = $blocks[ $ii ]['block']['blockName'];
693 $blocks[ $ii ]['text'] = self::extract_text_from_block( $blocks[ $ii ]['block'] );
694 $blocks[ $ii ]['media'] = self::extract_media_from_block( $blocks[ $ii ]['block'] );
695 $blocks[ $ii ]['tweet'] = self::extract_tweet_from_block( $blocks[ $ii ]['block'] );
696 $blocks[ $ii ]['embed'] = self::extract_embed_from_block( $blocks[ $ii ]['block'] );
697 }
698
699 return array_values( $blocks );
700 }
701
702 /**
703 * Creates a blank tweet, appends it to the tweets array, and returns the tweet.
704 *
705 * @return array The blank tweet.
706 */
707 private static function start_new_tweet() {
708 self::$tweets[] = array(
709 // An array of blocks that make up this tweet.
710 'blocks' => array(),
711 // If this tweet only contains part of a block, the boundary contains
712 // information about where in the block the tweet ends.
713 'boundary' => false,
714 // The text content of the tweet.
715 'text' => '',
716 // The media content of the tweet.
717 'media' => array(),
718 // The quoted tweet in this tweet.
719 'tweet' => '',
720 // Some blocks force a hard finish to the tweet, even if subsequent blocks
721 // could technically be appended. This flag shows when a tweet is finished.
722 'finished' => false,
723 // Flag if the current tweet already has content in it.
724 'changed' => false,
725 );
726
727 return self::get_current_tweet();
728 }
729
730 /**
731 * Get the last tweet in the array.
732 *
733 * @return array The tweet.
734 */
735 private static function get_current_tweet() {
736 return end( self::$tweets );
737 }
738
739 /**
740 * Saves the passed tweet array as the last tweet, overwriting the former last tweet.
741 *
742 * This method adds some last minute checks: marking the tweet as "changed", as well
743 * as adding the $block to the tweet (if it was passed, and hasn't already been added).
744 *
745 * @param array $tweet The tweet being stored.
746 * @param array $block Optional. The block that was used to modify this tweet.
747 * @return array The saved tweet, after the last minute checks have been done.
748 */
749 private static function save_current_tweet( $tweet, $block = null ) {
750 $tweet['changed'] = true;
751
752 if ( isset( $block ) ) {
753 $block_def = self::get_block_definition( $block['name'] );
754
755 // Check if this block type will be forcing a new tweet.
756 if ( $block_def['force_finished'] ) {
757 $tweet['finished'] = true;
758 }
759
760 // Check if this block is already recorded against this tweet.
761 $last_block = end( $tweet['blocks'] );
762 if ( isset( $block['clientId'] ) && ( false === $last_block || $last_block['clientId'] !== $block['clientId'] ) ) {
763 $tweet['blocks'][] = $block;
764 }
765 }
766
767 // Find the index of the last tweet in the array.
768 end( self::$tweets );
769 $tweet_index = key( self::$tweets );
770
771 self::$tweets[ $tweet_index ] = $tweet;
772
773 return $tweet;
774 }
775
776 /**
777 * Checks if the passed text is valid for a tweet or not.
778 *
779 * @param string $text The text to check.
780 * @param int $reserved_characters Optional. The number of characters to reduce the maximum tweet length by.
781 * @return bool Whether or not the text is valid.
782 */
783 private static function is_valid_tweet( $text, $reserved_characters = 0 ) {
784 return self::is_within_twitter_length( $text, 280 - $reserved_characters );
785 }
786
787 /**
788 * Checks if the passed text is valid for image alt text.
789 *
790 * @param string $text The text to check.
791 * @return bool Whether or not the text is valid.
792 */
793 private static function is_valid_alt_text( $text ) {
794 return self::is_within_twitter_length( $text, 1000 );
795 }
796
797 /**
798 * Check if a string is shorter than a given length, according to Twitter's rules for counting string length.
799 *
800 * @param string $text The text to check.
801 * @param int $max_length The number of characters long this string can be.
802 * @return bool Whether or not the string is no longer than the length limit.
803 */
804 private static function is_within_twitter_length( $text, $max_length ) {
805 // Replace all multiline separators with a \n, since that's the
806 // character we actually want to count.
807 $text = str_replace( self::$line_separator, "\n", $text );
808
809 // Keep a running total of characters we've removed.
810 $stripped_characters = 0;
811
812 // Since we use '…' a lot, strip it out, so we can still use the ASCII checks.
813 $ellipsis_count = 0;
814 $text = str_replace( '', '', $text, $ellipsis_count );
815
816 // The ellipsis glyph counts for two characters.
817 $stripped_characters += $ellipsis_count * 2;
818
819 // Try filtering out emoji first, since ASCII text + emoji is a relatively common case.
820 if ( ! self::is_ascii( $text ) ) {
821 // Initialise the emoji cache.
822 if ( 0 === count( self::$emoji_list ) ) {
823 self::$emoji_list = array_map( 'html_entity_decode', _wp_emoji_list( 'entities' ) );
824 }
825
826 $emoji_count = 0;
827 $text = str_replace( self::$emoji_list, '', $text, $emoji_count );
828
829 // Emoji graphemes count as 2 characters each.
830 $stripped_characters += $emoji_count * 2;
831 }
832
833 if ( self::is_ascii( $text ) ) {
834 $stripped_characters += strlen( $text );
835 if ( $stripped_characters <= $max_length ) {
836 return true;
837 }
838
839 return false;
840 }
841
842 // Remove any glyphs that count as 1 character.
843 // Source: https://github.com/twitter/twitter-text/blob/master/config/v3.json .
844 // Note that the source ranges are in decimal, the regex ranges are converted to hex.
845 $single_character_count = 0;
846 $text = preg_replace( '/[\x{0000}-\x{10FF}\x{2000}-\x{200D}\x{2010}-\x{201F}\x{2032}-\x{2037}]/uS', '', $text, -1, $single_character_count );
847
848 $stripped_characters += $single_character_count;
849
850 // Check if there's any text we haven't counted yet.
851 // Any remaining glyphs count as 2 characters each.
852 if ( 0 !== strlen( $text ) ) {
853 // WP provides a compat version of mb_strlen(), no need to check if it exists.
854 $stripped_characters += mb_strlen( $text, 'UTF-8' ) * 2;
855 }
856
857 if ( $stripped_characters <= $max_length ) {
858 return true;
859 }
860
861 return false;
862 }
863
864 /**
865 * Checks if a string only contains ASCII characters.
866 *
867 * @param string $text The string to check.
868 * @return bool Whether or not the string is ASCII-only.
869 */
870 private static function is_ascii( $text ) {
871 if ( function_exists( 'mb_check_encoding' ) ) {
872 if ( mb_check_encoding( $text, 'ASCII' ) ) {
873 return true;
874 }
875 } elseif ( ! preg_match( '/[^\x00-\x7F]/', $text ) ) {
876 return true;
877 }
878
879 return false;
880 }
881
882 /**
883 * A block will generate a certain amount of text to be inserted into a tweet. If that text is too
884 * long for a tweet, we already know where the text will be split when it's published as tweet, but
885 * we need to calculate where that corresponds to in the block edit UI.
886 *
887 * The tweet template for that block may add extra characters, extra characters are added for URL
888 * placeholders, and the block may contain multiple RichText areas (corresponding to attributes),
889 * so we need to keep track of both until the this function calculates which attribute area (in the
890 * block editor, the richTextIdentifier) that offset corresponds to, and how far into that attribute
891 * area it is.
892 *
893 * @param array $block The block being checked.
894 * @param integer $offset The position in the tweet text where it will be split.
895 * @return array|false `false` if the boundary can't be determined. Otherwise, returns the
896 * position in the block editor to insert the tweet boundary annotation.
897 */
898 private static function get_boundary( $block, $offset ) {
899 // If we don't have a clientId, there's no point in generating a boundary, since this
900 // parse request doesn't have a way to map blocks back to editor UI.
901 if ( ! isset( $block['clientId'] ) ) {
902 return false;
903 }
904
905 $block_def = self::get_block_definition( $block['name'] );
906
907 if ( ! empty( $block_def['content'] ) ) {
908 $tags = $block_def['content'];
909 } else {
910 $tags = array( 'content' );
911 }
912
913 $tag_content = self::extract_tag_content_from_html( $tags, $block['block']['innerHTML'] );
914
915 // $tag_content is split up by tag first, then lines. We want to remap it to split it by lines
916 // first, then tag.
917 $lines = array();
918 foreach ( $tag_content as $tag => $content ) {
919 if ( 'content' === $tag ) {
920 $attribute_name = 'content';
921 } else {
922 $attribute_name = array_search( $tag, $block_def['content'], true );
923 }
924
925 foreach ( $content as $id => $content_string ) {
926 // Multiline blocks can have multiple lines, but other blocks will always only have 1.
927 if ( 'multiline' === $block_def['type'] ) {
928 $line_number = $id;
929 } else {
930 $line_number = 0;
931 }
932
933 if ( ! isset( $lines[ $line_number ] ) ) {
934 $lines[ $line_number ] = array();
935 }
936
937 if ( ! isset( $lines[ $line_number ][ $attribute_name ] ) ) {
938 // For multiline blocks, or the first time this attribute has been encountered
939 // in single line blocks, assign the string to the line/attribute.
940 $lines[ $line_number ][ $attribute_name ] = $content_string;
941 } else {
942 // For subsequent times this line/attribute is encountered (only in single line blocks),
943 // append the string with a line break.
944 $lines[ $line_number ][ $attribute_name ] .= "\n$content_string";
945 }
946 }
947 }
948
949 $line_count = count( $lines );
950
951 $template_parts = preg_split( '/({{\w+}})/', $block_def['template'], -1, PREG_SPLIT_DELIM_CAPTURE );
952
953 // Keep track of the total number of bytes we've processed from this block.
954 $total_bytes_processed = 0;
955
956 // Keep track of the number of characters that the processed data translates to in the editor.
957 $characters_processed = 0;
958
959 foreach ( $lines as $line_number => $line ) {
960 // Add up the length of all the parts of this line.
961 $line_byte_total = array_sum( array_map( 'strlen', $line ) );
962
963 if ( $line_byte_total > 0 ) {
964 // We have something to use in the template, so loop over each part of the template, and count it.
965 foreach ( $template_parts as $template_part ) {
966 $matches = array();
967 if ( preg_match( '/{{(\w+)}}/', $template_part, $matches ) ) {
968 $part_name = $matches[1];
969
970 $line_part_data = $line[ $part_name ];
971 $line_part_bytes = strlen( $line_part_data );
972
973 $cleaned_line_part_data = preg_replace( '/ \(url-placeholder-\d+-*\)/', '', $line_part_data );
974
975 $cleaned_line_part_data = preg_replace_callback(
976 '/url-placeholder-(\d+)-*/',
977 function ( $matches ) {
978 return self::$urls[ $matches[1] ];
979 },
980 $cleaned_line_part_data
981 );
982
983 if ( $total_bytes_processed + $line_part_bytes >= $offset ) {
984 // We know that the offset is somewhere inside this part of the tweet, but we need to remove the length
985 // of any URL placeholders that appear before the boundary, to be able to calculate the correct attribute offset.
986
987 // $total_bytes_processed is the sum of everything we've processed so far, (including previous parts)
988 // on this line. This makes it relatively easy to calculate the number of bytes into this part
989 // that the boundary will occur.
990 $line_part_byte_boundary = $offset - $total_bytes_processed;
991
992 // Grab the data from this line part that appears before the boundary.
993 $line_part_pre_boundary_data = substr( $line_part_data, 0, $line_part_byte_boundary );
994
995 // Remove any URL placeholders, since these aren't shown in the editor.
996 $line_part_pre_boundary_data = preg_replace( '/ \(url-placeholder-\d+-*\)/', '', $line_part_pre_boundary_data );
997
998 $line_part_pre_boundary_data = preg_replace_callback(
999 '/url-placeholder-(\d+)-*/',
1000 function ( $matches ) {
1001 return self::$urls[ $matches[1] ];
1002 },
1003 $line_part_pre_boundary_data
1004 );
1005
1006 $boundary_start = self::utf_16_code_unit_length( $line_part_pre_boundary_data ) - 1;
1007
1008 // Multiline blocks need to offset for the characters that are in the same content area,
1009 // but which were counted on previous lines.
1010 if ( 'multiline' === $block_def['type'] ) {
1011 $boundary_start += $characters_processed;
1012 }
1013
1014 // Check if the boundary is happening on a line break or a space.
1015 if ( "\n" === $line_part_data[ $line_part_byte_boundary - 1 ] ) {
1016 $type = 'line-break';
1017
1018 // A line break boundary can actually be multiple consecutive line breaks,
1019 // count them all up so we know how big the annotation needs to be.
1020 $matches = array();
1021 preg_match( '/\n+$/', substr( $line_part_data, 0, $line_part_byte_boundary ), $matches );
1022 $boundary_end = $boundary_start + 1;
1023 $boundary_start -= strlen( $matches[0] ) - 1;
1024 } else {
1025 $type = 'normal';
1026 $boundary_end = $boundary_start + 1;
1027 }
1028
1029 return array(
1030 'start' => $boundary_start,
1031 'end' => $boundary_end,
1032 'container' => $part_name,
1033 'type' => $type,
1034 );
1035 } else {
1036 $total_bytes_processed += $line_part_bytes;
1037 $characters_processed += self::utf_16_code_unit_length( $cleaned_line_part_data );
1038 continue;
1039 }
1040 } else {
1041 $total_bytes_processed += strlen( $template_part );
1042 }
1043 }
1044
1045 // Are we breaking at the end of this line?
1046 if ( $total_bytes_processed + 1 === $offset && $line_count > 1 ) {
1047 reset( $block_def['content'] );
1048 $container = key( $block_def['content'] );
1049 return array(
1050 'line' => $line_number,
1051 'container' => $container,
1052 'type' => 'end-of-line',
1053 );
1054 }
1055
1056 // The newline at the end of each line is 1 byte, but we don't need to count empty lines.
1057 ++$total_bytes_processed;
1058 }
1059
1060 // We do need to count empty lines in the editor, since they'll be displayed.
1061 ++$characters_processed;
1062 }
1063
1064 return false;
1065 }
1066
1067 /**
1068 * JavaScript uses UTF-16 for encoding strings, which means we need to provide UTF-16
1069 * based offsets for the block editor to render tweet boundaries in the correct location.
1070 *
1071 * UTF-16 is a variable-width character encoding: every code unit is 2 bytes, a single character
1072 * can be one or two code units long. Fortunately for us, JavaScript's String.charAt() is based
1073 * on the older UCS-2 character encoding, which only counts single code units. PHP's strlen()
1074 * counts a code unit as being 2 characters, so once a string is converted to UTF-16, we have
1075 * a fast way to determine how long it is in UTF-16 code units.
1076 *
1077 * @param string $text The natively encoded string to get the length of.
1078 * @return int The length of the string in UTF-16 code units. Returns -1 if the length could not
1079 * be calculated.
1080 */
1081 private static function utf_16_code_unit_length( $text ) {
1082 // If mb_convert_encoding() exists, we can use that for conversion.
1083 if ( function_exists( 'mb_convert_encoding' ) ) {
1084 // UTF-16 can add an additional code unit to the start of the string, called the
1085 // Byte Order Mark (BOM), which indicates whether the string is encoding as
1086 // big-endian, or little-endian. Since we don't want to count code unit, and the endianness
1087 // doesn't matter for our purposes, using PHP's UTF-16BE encoding uses big-endian
1088 // encoding, and ensures the BOM *won't* be prepended to the string to the string.
1089 return strlen( mb_convert_encoding( $text, 'UTF-16BE' ) ) / 2;
1090 }
1091
1092 // If we can't convert this string, return a result that will avoid an incorrect annotation being added.
1093 return -1;
1094 }
1095
1096 /**
1097 * Extracts the tweetable text from a block.
1098 *
1099 * @param array $block A single block, as generated by parse_block().
1100 * @return string The tweetable text from the block, in the correct template form.
1101 */
1102 private static function extract_text_from_block( $block ) {
1103 // If the block doesn't have an innerHTMl, we're not going to get any text.
1104 if ( empty( $block['innerHTML'] ) ) {
1105 return '';
1106 }
1107
1108 $block_def = self::get_block_definition( $block['blockName'] );
1109
1110 // We currently only support extracting text from HTML text nodes.
1111 if ( ! isset( $block_def['content_location'] ) || 'html' !== $block_def['content_location'] ) {
1112 return '';
1113 }
1114
1115 // Find out which tags we need to extract content from.
1116 if ( isset( $block_def['content'] ) && count( $block_def['content'] ) > 0 ) {
1117 $tags = $block_def['content'];
1118 } else {
1119 $tags = array( 'content' );
1120 }
1121
1122 $tag_values = self::extract_tag_content_from_html( $tags, $block['innerHTML'] );
1123
1124 // We can treat single line blocks as "multiline", with only one line in them.
1125 $lines = array();
1126 foreach ( $tag_values as $tag => $values ) {
1127 // For single-line blocks, we need to squash all the values for this tag into a single value.
1128 if ( 'multiline' !== $block_def['type'] ) {
1129 $values = array( implode( "\n", $values ) );
1130 }
1131
1132 // Handle the special "content" tag.
1133 if ( 'content' === $tag ) {
1134 $placeholder = 'content';
1135 } else {
1136 $placeholder = array_search( $tag, $block_def['content'], true );
1137 }
1138
1139 // Loop over each instance of this value, appling that value to the corresponding line template.
1140 foreach ( $values as $line_number => $value ) {
1141 if ( ! isset( $lines[ $line_number ] ) ) {
1142 $lines[ $line_number ] = $block_def['template'];
1143 }
1144
1145 $lines[ $line_number ] = str_replace( '{{' . $placeholder . '}}', $value, $lines[ $line_number ] );
1146 }
1147 }
1148
1149 // Remove any lines that didn't apply any content.
1150 $empty_template = preg_replace( '/{{.*?}}/', '', $block_def['template'] );
1151 $lines = array_filter(
1152 $lines,
1153 function ( $line ) use ( $empty_template ) {
1154 return $line !== $empty_template;
1155 }
1156 );
1157
1158 // Join the lines together into a single string.
1159 $text = implode( self::$line_separator, $lines );
1160
1161 // Trim off any trailing whitespace that we no longer need.
1162 $text = preg_replace( '/(\s|' . self::$line_separator . ')+$/u', '', $text );
1163
1164 return $text;
1165 }
1166
1167 /**
1168 * Extracts the tweetable media from a block.
1169 *
1170 * @param array $block A single block, as generated by parse_block().
1171 * @return array {
1172 * An array of media.
1173 *
1174 * @type string url The URL of the media.
1175 * @type string alt The alt text of the media.
1176 * }
1177 */
1178 private static function extract_media_from_block( $block ) {
1179 $block_def = self::get_block_definition( $block['blockName'] );
1180
1181 $media = array();
1182
1183 if ( 'image' === $block_def['type'] ) {
1184 $url = self::extract_attr_content_from_html(
1185 $block_def['content']['url'][0],
1186 $block_def['content']['url'][1],
1187 $block['innerHTML']
1188 );
1189 $alt = self::extract_attr_content_from_html(
1190 $block_def['content']['alt'][0],
1191 $block_def['content']['alt'][1],
1192 $block['innerHTML']
1193 );
1194
1195 $img_count = count( $url );
1196
1197 for ( $ii = 0; $ii < $img_count; $ii++ ) {
1198 $filedata = wp_check_filetype( basename( wp_parse_url( $url[ $ii ], PHP_URL_PATH ) ) );
1199
1200 $media[] = array(
1201 'url' => $url[ $ii ],
1202 'alt' => self::is_valid_alt_text( $alt[ $ii ] ) ? $alt[ $ii ] : '',
1203 'type' => $filedata['type'],
1204 );
1205 }
1206 } elseif ( 'video' === $block_def['type'] ) {
1207 // Handle VideoPress videos.
1208 if ( isset( $block['attrs']['src'] ) && 0 === strpos( $block['attrs']['src'], 'https://videos.files.wordpress.com/' ) ) {
1209 $url = array( $block['attrs']['src'] );
1210 } else {
1211 $url = self::extract_attr_content_from_html(
1212 $block_def['content']['url'][0],
1213 $block_def['content']['url'][1],
1214 $block['innerHTML']
1215 );
1216 }
1217
1218 // We can only ever use the first video found, no need to go through all of them.
1219 if ( count( $url ) > 0 ) {
1220 $filedata = wp_check_filetype( basename( wp_parse_url( $url[0], PHP_URL_PATH ) ) );
1221
1222 $media[] = array(
1223 'url' => $url[0],
1224 'type' => $filedata['type'],
1225 );
1226 }
1227 }
1228
1229 return $media;
1230 }
1231
1232 /**
1233 * Extracts the tweet URL from a Twitter embed block.
1234 *
1235 * @param array $block A single block, as generated by parse_block().
1236 * @return string The tweet URL. Empty string if there is none available.
1237 */
1238 private static function extract_tweet_from_block( $block ) {
1239 if (
1240 'core/embed' === $block['blockName']
1241 && ( isset( $block['attrs']['providerNameSlug'] ) && 'twitter' === $block['attrs']['providerNameSlug'] )
1242 ) {
1243 return $block['attrs']['url'];
1244 }
1245
1246 return '';
1247 }
1248
1249 /**
1250 * Extracts URL from an embed block.
1251 *
1252 * @param array $block A single block, as generated by parse_block().
1253 * @return string The URL. Empty string if there is none available.
1254 */
1255 private static function extract_embed_from_block( $block ) {
1256 $block_def = self::get_block_definition( $block['blockName'] );
1257
1258 if ( 'embed' !== $block_def['type'] ) {
1259 return '';
1260 }
1261
1262 // Twitter embeds are handled in ::extract_tweet_from_block().
1263 if (
1264 'core/embed' === $block['blockName']
1265 && ( isset( $block['attrs']['providerNameSlug'] ) && 'twitter' === $block['attrs']['providerNameSlug'] )
1266 ) {
1267 return '';
1268 }
1269
1270 $url = '';
1271 if ( 'block-attributes' === $block_def['content_location'] ) {
1272 $url = $block['attrs'][ $block_def['content'][0] ];
1273 }
1274
1275 if ( 'jetpack/gif' === $block['blockName'] ) {
1276 $url = str_replace( '/embed/', '/gifs/', $url );
1277 }
1278
1279 return $url;
1280 }
1281
1282 /**
1283 * There's a bunch of left-over cruft in the tweets array that we don't need to return. Removing
1284 * it helps keep the size of the data down.
1285 */
1286 private static function clean_return_tweets() {
1287 // Before we return, clean out unnecessary cruft from the return data.
1288 $tweets = array_map(
1289 function ( $tweet ) {
1290 // Remove tweets that don't have anything saved in them. eg, if the last block is a
1291 // header with no text, it'll force a new tweet, but we won't end up putting anything
1292 // in that tweet.
1293 if ( ! $tweet['changed'] ) {
1294 return false;
1295 }
1296
1297 // Replace any URL placeholders that appear in the text.
1298 $tweet['urls'] = array();
1299 foreach ( self::$urls as $id => $url ) {
1300 $count = 0;
1301
1302 $tweet['text'] = str_replace( str_pad( "url-placeholder-$id", self::$characters_per_url, '-' ), $url, $tweet['text'], $count );
1303
1304 // If we found a URL, keep track of it for the editor.
1305 if ( $count > 0 ) {
1306 $tweet['urls'][] = $url;
1307 }
1308 }
1309
1310 // Remove any inline placeholders.
1311 $tweet['text'] = str_replace( self::$inline_placeholder, '', $tweet['text'] );
1312
1313 // If the tweet text consists only of whitespace, we can remove all of it.
1314 if ( preg_match( '/^\s*$/u', $tweet['text'] ) ) {
1315 $tweet['text'] = '';
1316 }
1317
1318 // Remove trailing whitespace from every line.
1319 $tweet['text'] = preg_replace( '/\p{Z}+$/um', '', $tweet['text'] );
1320
1321 // Remove all trailing whitespace (including line breaks) from the end of the text.
1322 $tweet['text'] = rtrim( $tweet['text'] );
1323
1324 // Remove internal flags.
1325 unset( $tweet['changed'] );
1326 unset( $tweet['finished'] );
1327
1328 // Remove bulky block data.
1329 if ( ! isset( $tweet['blocks'][0]['attributes'] ) && ! isset( $tweet['blocks'][0]['clientId'] ) ) {
1330 $tweet['blocks'] = array();
1331 } else {
1332 // Remove the parts of the block data that the editor doesn't need.
1333 $block_count = count( $tweet['blocks'] );
1334 for ( $ii = 0; $ii < $block_count; $ii++ ) {
1335 $keys = array_keys( $tweet['blocks'][ $ii ] );
1336 foreach ( $keys as $key ) {
1337 // The editor only needs these attributes, everything else will be unset.
1338 if ( in_array( $key, array( 'attributes', 'clientId' ), true ) ) {
1339 continue;
1340 }
1341
1342 unset( $tweet['blocks'][ $ii ][ $key ] );
1343 }
1344 }
1345 }
1346
1347 // Once we've finished cleaning up, check if there's anything left to be tweeted.
1348 if ( empty( $tweet['text'] ) && empty( $tweet['media'] ) && empty( $tweet['tweet'] ) ) {
1349 return false;
1350 }
1351
1352 return $tweet;
1353 },
1354 self::$tweets
1355 );
1356
1357 // Clean any removed tweets out of the result.
1358 return array_values( array_filter( $tweets, 'is_array' ) );
1359 }
1360
1361 /**
1362 * Given a list of tags and a HTML blob, this will extract the text content inside
1363 * each of the given tags.
1364 *
1365 * @param array $tags An array of tag names.
1366 * @param string $html A blob of HTML.
1367 * @return array An array of the extract content. The keys in the array are the $tags,
1368 * each value is an array. The value array is indexed in the same order as the tag
1369 * appears in the HTML blob, including nested tags.
1370 */
1371 private static function extract_tag_content_from_html( $tags, $html ) {
1372 // Serialised blocks will sometimes wrap the innerHTML in newlines, but those newlines
1373 // are removed when innerHTML is parsed into an attribute. Remove them so we're working
1374 // with the same information.
1375 if ( "\n" === $html[0] && "\n" === $html[ strlen( $html ) - 1 ] ) {
1376 $html = substr( $html, 1, strlen( $html ) - 2 );
1377 }
1378
1379 // Normalise <br>.
1380 $html = preg_replace( '/<br\s*\/?>/', '<br>', $html );
1381
1382 // If there were no tags passed, assume the entire text is required.
1383 if ( empty( $tags ) ) {
1384 $tags = array( 'content' );
1385 }
1386
1387 $values = array();
1388
1389 $tokens = wp_html_split( $html );
1390
1391 $validator = new Twitter_Validator();
1392
1393 foreach ( $tags as $tag ) {
1394 $values[ $tag ] = array();
1395
1396 // Since tags can be nested, keeping track of the nesting level allows
1397 // us to extract nested content into a flat array.
1398 if ( 'content' === $tag ) {
1399 // The special "content" tag means we should store the entire content,
1400 // so assume the tag is open from the beginning.
1401 $opened = 0;
1402 $closed = -1;
1403
1404 $values['content'][0] = '';
1405 } else {
1406 $opened = -1;
1407 $closed = -1;
1408 }
1409
1410 // When we come across a URL, we need to keep track of it, so it can then be inserted
1411 // in the right place.
1412 $current_url = '';
1413 foreach ( $tokens as $token ) {
1414 if ( 0 === strlen( $token ) ) {
1415 // Skip any empty tokens.
1416 continue;
1417 }
1418
1419 // If we're currently storing content, check if it's a text-formatting
1420 // tag that we should apply.
1421 if ( $opened !== $closed ) {
1422 // End of a paragraph, put in some newlines (as long as we're not extracting paragraphs).
1423 if ( '</p>' === $token && 'p' !== $tag ) {
1424 $values[ $tag ][ $opened ] .= "\n\n";
1425 }
1426
1427 // A line break gets one newline.
1428 if ( '<br>' === $token ) {
1429 $values[ $tag ][ $opened ] .= "\n";
1430 }
1431
1432 // A link has opened, grab the URL for inserting later.
1433 if ( 0 === strpos( $token, '<a ' ) ) {
1434 $href_values = self::extract_attr_content_from_html( 'a', 'href', $token );
1435 if ( ! empty( $href_values[0] ) && $validator->isValidURL( $href_values[0] ) ) {
1436 // Remember the URL.
1437 $current_url = $href_values[0];
1438 }
1439 }
1440
1441 // A link has closed, insert the URL from that link if we have one.
1442 if ( '</a>' === $token && '' !== $current_url ) {
1443 // Generate a unique-to-this-block placeholder which takes up the
1444 // same number of characters as a URL does.
1445 $values[ $tag ][ $opened ] .= ' (' . self::generate_url_placeholder( $current_url ) . ')';
1446
1447 $current_url = '';
1448 }
1449
1450 // We don't return inline images, but they technically take up 1 character in the RichText.
1451 if ( 0 === strpos( $token, '<img ' ) ) {
1452 $values[ $tag ][ $opened ] .= self::$inline_placeholder;
1453 }
1454 }
1455
1456 if ( "<$tag>" === $token || 0 === strpos( $token, "<$tag " ) ) {
1457 // A tag has just been opened.
1458 ++$opened;
1459 // Set an empty value now, so we're keeping track of empty tags.
1460 if ( ! isset( $values[ $tag ][ $opened ] ) ) {
1461 $values[ $tag ][ $opened ] = '';
1462 }
1463 continue;
1464 }
1465
1466 if ( "</$tag>" === $token ) {
1467 // The tag has been closed.
1468 ++$closed;
1469 continue;
1470 }
1471
1472 if ( '<' === $token[0] ) {
1473 // We can skip any other tags.
1474 continue;
1475 }
1476
1477 if ( $opened !== $closed ) {
1478 // We're currently in a tag, with some content. Start by decoding any HTML entities.
1479 $token = html_entity_decode( $token, ENT_QUOTES );
1480
1481 // Find any URLs in this content, and replace them with a placeholder.
1482 preg_match_all( Twitter_Regex::getValidUrlMatcher(), $token, $matches, PREG_SET_ORDER | PREG_OFFSET_CAPTURE );
1483 $offset = 0;
1484 foreach ( $matches as $match ) {
1485 list( $url, $start ) = $match[2];
1486
1487 $token = substr_replace( $token, self::generate_url_placeholder( $url ), $start + $offset, strlen( $url ) );
1488
1489 $offset += self::$characters_per_url - strlen( $url );
1490
1491 // If we're in a link with a URL set, there's no need to keep two copies of the same link.
1492 if ( ! empty( $current_url ) ) {
1493 $lower_url = strtolower( $url );
1494 $lower_current_url = strtolower( $current_url );
1495
1496 if ( $lower_url === $lower_current_url ) {
1497 $current_url = '';
1498 }
1499
1500 // Check that the link text isn't just a shortened version of the href value.
1501 $trimmed_current_url = preg_replace( '|^https?://|', '', $lower_current_url );
1502 if ( $lower_url === $trimmed_current_url || trim( $trimmed_current_url, '/' ) === $lower_url ) {
1503 $current_url = '';
1504 }
1505 }
1506 }
1507
1508 // Append it to the right value.
1509 $values[ $tag ][ $opened ] .= $token;
1510 }
1511 }
1512 }
1513
1514 return $values;
1515 }
1516
1517 /**
1518 * Extracts the attribute content from a tag.
1519 *
1520 * This method allows for the HTML to have multiple instances of the tag, and will return
1521 * an array containing the attribute value (or an empty string, if the tag doesn't have the
1522 * requested attribute) for each occurrence of the tag.
1523 *
1524 * @param string $tag The tag we're looking for.
1525 * @param string $attr The name of the attribute we're looking for.
1526 * @param string $html The HTML we're searching through.
1527 * @param array $attr_filters Optional. Filters tags based on whether or not they have attributes with given values.
1528 * @return array The array of attribute values found.
1529 */
1530 private static function extract_attr_content_from_html( $tag, $attr, $html, $attr_filters = array() ) {
1531 // Given our single tag and attribute, construct a KSES filter for it.
1532 $kses_filter = array(
1533 $tag => array(
1534 $attr => array(),
1535 ),
1536 );
1537
1538 foreach ( $attr_filters as $filter_attr => $filter_value ) {
1539 $kses_filter[ $tag ][ $filter_attr ] = array();
1540 }
1541
1542 // Remove all HTML except for the tag we're after. On that tag,
1543 // remove all attributes except for the one we're after.
1544 $stripped_html = wp_kses( $html, $kses_filter );
1545
1546 $values = array();
1547
1548 $tokens = wp_html_split( $stripped_html );
1549 foreach ( $tokens as $token ) {
1550 $found_value = '';
1551
1552 if ( 0 === strlen( $token ) ) {
1553 // Skip any empty tokens.
1554 continue;
1555 }
1556
1557 if ( '<' !== $token[0] ) {
1558 // We can skip any non-tag tokens.
1559 continue;
1560 }
1561
1562 $token_attrs = wp_kses_attr_parse( $token );
1563
1564 // Skip tags that KSES couldn't handle.
1565 if ( false === $token_attrs ) {
1566 continue;
1567 }
1568
1569 // Remove the tag open and close chunks.
1570 $found_tag = array_shift( $token_attrs );
1571 array_pop( $token_attrs );
1572
1573 // We somehow got a tag that isn't the one we're after. Skip it.
1574 if ( 0 !== strpos( $found_tag, "<$tag " ) ) {
1575 continue;
1576 }
1577
1578 // We can only fail an attribute filter if one is set.
1579 $passed_filter = count( $attr_filters ) === 0;
1580
1581 foreach ( $token_attrs as $token_attr_string ) {
1582 // The first "=" in the string will be between the attribute name/value.
1583 list( $token_attr_name, $token_attr_value ) = explode( '=', $token_attr_string, 2 );
1584
1585 $token_attr_name = trim( $token_attr_name );
1586 $token_attr_value = trim( $token_attr_value );
1587
1588 // Remove a single set of quotes from around the value.
1589 if ( '' !== $token_attr_value && in_array( $token_attr_value[0], array( '"', "'" ), true ) ) {
1590 $token_attr_value = trim( $token_attr_value, $token_attr_value[0] );
1591 }
1592
1593 // If this is the attribute we're after, save the value for the end of the loop.
1594 if ( $token_attr_name === $attr ) {
1595 $found_value = $token_attr_value;
1596 }
1597
1598 if ( isset( $attr_filters[ $token_attr_name ] ) && $attr_filters[ $token_attr_name ] === $token_attr_value ) {
1599 $passed_filter = true;
1600 }
1601 }
1602
1603 if ( $passed_filter ) {
1604 // We always want to append the found value, even if we didn't "find" a matching attribute.
1605 // An empty string in the return value means that we found the tag, but the attribute was
1606 // either empty, or not set.
1607 $values[] = html_entity_decode( $found_value, ENT_QUOTES );
1608 }
1609 }
1610
1611 return $values;
1612 }
1613
1614 /**
1615 * Generates a placeholder for URLs, using the appropriate number of characters to imitate how
1616 * Twitter counts the length of URLs in tweets.
1617 *
1618 * @param string $url The URL to generate a placeholder for.
1619 * @return string The placeholder.
1620 */
1621 public static function generate_url_placeholder( $url ) {
1622 self::$urls[] = $url;
1623
1624 return str_pad( 'url-placeholder-' . ( count( self::$urls ) - 1 ), self::$characters_per_url, '-' );
1625 }
1626
1627 /**
1628 * Retrieves the Twitter card data for a list of URLs.
1629 *
1630 * @param array $urls The list of URLs to grab Twitter card data for.
1631 * @return array The Twitter card data.
1632 */
1633 public static function generate_cards( $urls ) {
1634 $validator = new Twitter_Validator();
1635
1636 $requests = array_map(
1637 function ( $url ) use ( $validator ) {
1638 if (
1639 false !== wp_http_validate_url( $url )
1640 && $validator->isValidURL( $url )
1641 ) {
1642 return array(
1643 'url' => $url,
1644 );
1645 }
1646
1647 return false;
1648 },
1649 $urls
1650 );
1651
1652 $requests = array_filter( $requests );
1653
1654 // @todo Remove this check when wpcom picks up the new Requests lib (it seems it was skipped during their update to 6.2)
1655 if ( ! class_exists( '\WpOrg\Requests\Hooks' ) ) {
1656 $hooks = new Requests_Hooks();
1657 } else {
1658 $hooks = new \WpOrg\Requests\Hooks();
1659 }
1660
1661 $hooks->register(
1662 'requests.before_redirect',
1663 array( self::class, 'validate_redirect_url' )
1664 );
1665
1666 // @todo Remove this check when wpcom picks up the new Requests lib (it seems it was skipped during their update to 6.2)
1667 $results = class_exists( '\WpOrg\Requests\Requests' )
1668 ? \WpOrg\Requests\Requests::request_multiple( $requests, array( 'hooks' => $hooks ) )
1669 : Requests::request_multiple( $requests, array( 'hooks' => $hooks ) );
1670
1671 foreach ( $results as $result ) {
1672 if ( $result instanceof Requests_Exception || $result instanceof \WpOrg\Requests\Exception ) {
1673 return new WP_Error(
1674 'invalid_url',
1675 __( 'Sorry, something is wrong with the requested URL.', 'jetpack' ),
1676 403
1677 );
1678 }
1679 }
1680
1681 $card_data = array(
1682 'creator' => array(
1683 'name' => 'twitter:creator',
1684 ),
1685 'description' => array(
1686 'name' => 'twitter:description',
1687 'property' => 'og:description',
1688 ),
1689 'image' => array(
1690 'name' => 'twitter:image',
1691 'property' => 'og:image',
1692 ),
1693 'title' => array(
1694 'name' => 'twitter:text:title',
1695 'property' => 'og:title',
1696 ),
1697 'type' => array(
1698 'name' => 'twitter:card',
1699 ),
1700 );
1701
1702 $cards = array();
1703 foreach ( $results as $id => $result ) {
1704 $url = $requests[ $id ]['url'];
1705
1706 if ( ! $result->success ) {
1707 $cards[ $url ] = array(
1708 'error' => 'invalid_url',
1709 );
1710 continue;
1711 }
1712
1713 $url_card_data = array();
1714
1715 foreach ( $card_data as $key => $filters ) {
1716 foreach ( $filters as $attribute => $value ) {
1717 $found_data = self::extract_attr_content_from_html( 'meta', 'content', $result->body, array( $attribute => $value ) );
1718 if ( count( $found_data ) > 0 && strlen( $found_data[0] ) > 0 ) {
1719 $url_card_data[ $key ] = html_entity_decode( $found_data[0], ENT_QUOTES );
1720 break;
1721 }
1722 }
1723 }
1724
1725 if ( count( $url_card_data ) > 0 ) {
1726 $cards[ $url ] = $url_card_data;
1727 } else {
1728 $cards[ $url ] = array(
1729 'error' => 'no_og_data',
1730 );
1731 }
1732 }
1733
1734 return $cards;
1735 }
1736
1737 /**
1738 * Filters the redirect URLs that can appear when requesting passed URLs.
1739 *
1740 * @param String $redirect_url the URL to which a redirect is requested.
1741 * @throws Requests_Exception In case the URL is not validated, if WP version is less than 6.2.
1742 * @throws \WpOrg\Requests\Exception In case the URL is not validated, if WP version is 6.2 or greater.
1743 * @return void
1744 */
1745 public static function validate_redirect_url( $redirect_url ) {
1746 if ( ! wp_http_validate_url( $redirect_url ) ) {
1747 // @todo Remove this check when wpcom picks up the new Requests lib (it seems it was skipped during their update to 6.2)
1748 if ( ! class_exists( '\WpOrg\Requests\Exception' ) ) {
1749 throw new Requests_Exception( __( 'A valid URL was not provided.', 'jetpack' ), 'wp_http.redirect_failed_validation' );
1750 }
1751 throw new \WpOrg\Requests\Exception( __( 'A valid URL was not provided.', 'jetpack' ), 'wp_http.redirect_failed_validation' );
1752 }
1753 }
1754 }
1755