PluginProbe
Gutenberg / 16.2.0
Gutenberg v16.2.0
24.0.0 23.9.1 23.9.0 23.8.0 23.7.2 23.7.1 23.7.0 23.6.1 23.6.2 23.6.0 23.5.3 23.5.2 23.5.1 23.5.0 23.4.0 23.3.2 23.3.1 23.3.0 23.2.0 23.2.1 23.2.2 23.1.1 23.1.0 23.0.1 12.6.0 All 403 releases
gutenberg / packages / block-serialization-default-parser / class-wp-block-parser.php

class-wp-block-parser.php in Gutenberg 16.2.0, at packages/block-serialization-default-parser/class-wp-block-parser.php

414 lines 11.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Block Serialization Parser
4 *
5 * @package WordPress
6 */
7
8 /**
9 * Class WP_Block_Parser
10 *
11 * Parses a document and constructs a list of parsed block objects
12 *
13 * @since 5.0.0
14 * @since 4.0.0 returns arrays not objects, all attributes are arrays
15 */
16 class WP_Block_Parser {
17 /**
18 * Input document being parsed
19 *
20 * @example "Pre-text\n<!-- wp:paragraph -->This is inside a block!<!-- /wp:paragraph -->"
21 *
22 * @since 5.0.0
23 * @var string
24 */
25 public $document;
26
27 /**
28 * Tracks parsing progress through document
29 *
30 * @since 5.0.0
31 * @var int
32 */
33 public $offset;
34
35 /**
36 * List of parsed blocks
37 *
38 * @since 5.0.0
39 * @var WP_Block_Parser_Block[]
40 */
41 public $output;
42
43 /**
44 * Stack of partially-parsed structures in memory during parse
45 *
46 * @since 5.0.0
47 * @var WP_Block_Parser_Frame[]
48 */
49 public $stack;
50
51 /**
52 * Empty associative array, here due to PHP quirks
53 *
54 * @since 4.4.0
55 * @var array empty associative array
56 */
57 public $empty_attrs;
58
59 /**
60 * Parses a document and returns a list of block structures
61 *
62 * When encountering an invalid parse will return a best-effort
63 * parse. In contrast to the specification parser this does not
64 * return an error on invalid inputs.
65 *
66 * @since 5.0.0
67 *
68 * @param string $document Input document being parsed.
69 * @return array[]
70 */
71 public function parse( $document ) {
72 $this->document = $document;
73 $this->offset = 0;
74 $this->output = array();
75 $this->stack = array();
76 $this->empty_attrs = json_decode( '{}', true );
77
78 while ( $this->proceed() ) {
79 continue;
80 }
81
82 return $this->output;
83 }
84
85 /**
86 * Processes the next token from the input document
87 * and returns whether to proceed eating more tokens
88 *
89 * This is the "next step" function that essentially
90 * takes a token as its input and decides what to do
91 * with that token before descending deeper into a
92 * nested block tree or continuing along the document
93 * or breaking out of a level of nesting.
94 *
95 * @internal
96 * @since 5.0.0
97 * @return bool
98 */
99 public function proceed() {
100 $next_token = $this->next_token();
101 list( $token_type, $block_name, $attrs, $start_offset, $token_length ) = $next_token;
102 $stack_depth = count( $this->stack );
103
104 // we may have some HTML soup before the next block.
105 $leading_html_start = $start_offset > $this->offset ? $this->offset : null;
106
107 switch ( $token_type ) {
108 case 'no-more-tokens':
109 // if not in a block then flush output.
110 if ( 0 === $stack_depth ) {
111 $this->add_freeform();
112 return false;
113 }
114
115 /*
116 * Otherwise we have a problem
117 * This is an error
118 *
119 * we have options
120 * - treat it all as freeform text
121 * - assume an implicit closer (easiest when not nesting)
122 */
123
124 // for the easy case we'll assume an implicit closer.
125 if ( 1 === $stack_depth ) {
126 $this->add_block_from_stack();
127 return false;
128 }
129
130 /*
131 * for the nested case where it's more difficult we'll
132 * have to assume that multiple closers are missing
133 * and so we'll collapse the whole stack piecewise
134 */
135 while ( 0 < count( $this->stack ) ) {
136 $this->add_block_from_stack();
137 }
138 return false;
139
140 case 'void-block':
141 /*
142 * easy case is if we stumbled upon a void block
143 * in the top-level of the document
144 */
145 if ( 0 === $stack_depth ) {
146 if ( isset( $leading_html_start ) ) {
147 $this->output[] = (array) $this->freeform(
148 substr(
149 $this->document,
150 $leading_html_start,
151 $start_offset - $leading_html_start
152 )
153 );
154 }
155
156 $this->output[] = (array) new WP_Block_Parser_Block( $block_name, $attrs, array(), '', array() );
157 $this->offset = $start_offset + $token_length;
158 return true;
159 }
160
161 // otherwise we found an inner block.
162 $this->add_inner_block(
163 new WP_Block_Parser_Block( $block_name, $attrs, array(), '', array() ),
164 $start_offset,
165 $token_length
166 );
167 $this->offset = $start_offset + $token_length;
168 return true;
169
170 case 'block-opener':
171 // track all newly-opened blocks on the stack.
172 array_push(
173 $this->stack,
174 new WP_Block_Parser_Frame(
175 new WP_Block_Parser_Block( $block_name, $attrs, array(), '', array() ),
176 $start_offset,
177 $token_length,
178 $start_offset + $token_length,
179 $leading_html_start
180 )
181 );
182 $this->offset = $start_offset + $token_length;
183 return true;
184
185 case 'block-closer':
186 /*
187 * if we're missing an opener we're in trouble
188 * This is an error
189 */
190 if ( 0 === $stack_depth ) {
191 /*
192 * we have options
193 * - assume an implicit opener
194 * - assume _this_ is the opener
195 * - give up and close out the document
196 */
197 $this->add_freeform();
198 return false;
199 }
200
201 // if we're not nesting then this is easy - close the block.
202 if ( 1 === $stack_depth ) {
203 $this->add_block_from_stack( $start_offset );
204 $this->offset = $start_offset + $token_length;
205 return true;
206 }
207
208 /*
209 * otherwise we're nested and we have to close out the current
210 * block and add it as a new innerBlock to the parent
211 */
212 $stack_top = array_pop( $this->stack );
213 $html = substr( $this->document, $stack_top->prev_offset, $start_offset - $stack_top->prev_offset );
214 $stack_top->block->innerHTML .= $html;
215 $stack_top->block->innerContent[] = $html;
216 $stack_top->prev_offset = $start_offset + $token_length;
217
218 $this->add_inner_block(
219 $stack_top->block,
220 $stack_top->token_start,
221 $stack_top->token_length,
222 $start_offset + $token_length
223 );
224 $this->offset = $start_offset + $token_length;
225 return true;
226
227 default:
228 // This is an error.
229 $this->add_freeform();
230 return false;
231 }
232 }
233
234 /**
235 * Scans the document from where we last left off
236 * and finds the next valid token to parse if it exists
237 *
238 * Returns the type of the find: kind of find, block information, attributes
239 *
240 * @internal
241 * @since 5.0.0
242 * @since 4.6.1 fixed a bug in attribute parsing which caused catastrophic backtracking on invalid block comments
243 * @return array
244 */
245 public function next_token() {
246 $matches = null;
247
248 /*
249 * aye the magic
250 * we're using a single RegExp to tokenize the block comment delimiters
251 * we're also using a trick here because the only difference between a
252 * block opener and a block closer is the leading `/` before `wp:` (and
253 * a closer has no attributes). we can trap them both and process the
254 * match back in PHP to see which one it was.
255 */
256 $has_match = preg_match(
257 '/<!--\s+(?P<closer>\/)?wp:(?P<namespace>[a-z][a-z0-9_-]*\/)?(?P<name>[a-z][a-z0-9_-]*)\s+(?P<attrs>{(?:(?:[^}]+|}+(?=})|(?!}\s+\/?-->).)*+)?}\s+)?(?P<void>\/)?-->/s',
258 $this->document,
259 $matches,
260 PREG_OFFSET_CAPTURE,
261 $this->offset
262 );
263
264 // if we get here we probably have catastrophic backtracking or out-of-memory in the PCRE.
265 if ( false === $has_match ) {
266 return array( 'no-more-tokens', null, null, null, null );
267 }
268
269 // we have no more tokens.
270 if ( 0 === $has_match ) {
271 return array( 'no-more-tokens', null, null, null, null );
272 }
273
274 list( $match, $started_at ) = $matches[0];
275
276 $length = strlen( $match );
277 $is_closer = isset( $matches['closer'] ) && -1 !== $matches['closer'][1];
278 $is_void = isset( $matches['void'] ) && -1 !== $matches['void'][1];
279 $namespace = $matches['namespace'];
280 $namespace = ( isset( $namespace ) && -1 !== $namespace[1] ) ? $namespace[0] : 'core/';
281 $name = $namespace . $matches['name'][0];
282 $has_attrs = isset( $matches['attrs'] ) && -1 !== $matches['attrs'][1];
283
284 /*
285 * Fun fact! It's not trivial in PHP to create "an empty associative array" since all arrays
286 * are associative arrays. If we use `array()` we get a JSON `[]`
287 */
288 $attrs = $has_attrs
289 ? json_decode( $matches['attrs'][0], /* as-associative */ true )
290 : $this->empty_attrs;
291
292 /*
293 * This state isn't allowed
294 * This is an error
295 */
296 if ( $is_closer && ( $is_void || $has_attrs ) ) {
297 // we can ignore them since they don't hurt anything.
298 }
299
300 if ( $is_void ) {
301 return array( 'void-block', $name, $attrs, $started_at, $length );
302 }
303
304 if ( $is_closer ) {
305 return array( 'block-closer', $name, null, $started_at, $length );
306 }
307
308 return array( 'block-opener', $name, $attrs, $started_at, $length );
309 }
310
311 /**
312 * Returns a new block object for freeform HTML
313 *
314 * @internal
315 * @since 3.9.0
316 *
317 * @param string $inner_html HTML content of block.
318 * @return WP_Block_Parser_Block freeform block object.
319 */
320 public function freeform( $inner_html ) {
321 return new WP_Block_Parser_Block( null, $this->empty_attrs, array(), $inner_html, array( $inner_html ) );
322 }
323
324 /**
325 * Pushes a length of text from the input document
326 * to the output list as a freeform block.
327 *
328 * @internal
329 * @since 5.0.0
330 * @param null $length how many bytes of document text to output.
331 */
332 public function add_freeform( $length = null ) {
333 $length = $length ? $length : strlen( $this->document ) - $this->offset;
334
335 if ( 0 === $length ) {
336 return;
337 }
338
339 $this->output[] = (array) $this->freeform( substr( $this->document, $this->offset, $length ) );
340 }
341
342 /**
343 * Given a block structure from memory pushes
344 * a new block to the output list.
345 *
346 * @internal
347 * @since 5.0.0
348 * @param WP_Block_Parser_Block $block The block to add to the output.
349 * @param int $token_start Byte offset into the document where the first token for the block starts.
350 * @param int $token_length Byte length of entire block from start of opening token to end of closing token.
351 * @param int|null $last_offset Last byte offset into document if continuing form earlier output.
352 */
353 public function add_inner_block( WP_Block_Parser_Block $block, $token_start, $token_length, $last_offset = null ) {
354 $parent = $this->stack[ count( $this->stack ) - 1 ];
355 $parent->block->innerBlocks[] = (array) $block;
356 $html = substr( $this->document, $parent->prev_offset, $token_start - $parent->prev_offset );
357
358 if ( ! empty( $html ) ) {
359 $parent->block->innerHTML .= $html;
360 $parent->block->innerContent[] = $html;
361 }
362
363 $parent->block->innerContent[] = null;
364 $parent->prev_offset = $last_offset ? $last_offset : $token_start + $token_length;
365 }
366
367 /**
368 * Pushes the top block from the parsing stack to the output list.
369 *
370 * @internal
371 * @since 5.0.0
372 * @param int|null $end_offset byte offset into document for where we should stop sending text output as HTML.
373 */
374 public function add_block_from_stack( $end_offset = null ) {
375 $stack_top = array_pop( $this->stack );
376 $prev_offset = $stack_top->prev_offset;
377
378 $html = isset( $end_offset )
379 ? substr( $this->document, $prev_offset, $end_offset - $prev_offset )
380 : substr( $this->document, $prev_offset );
381
382 if ( ! empty( $html ) ) {
383 $stack_top->block->innerHTML .= $html;
384 $stack_top->block->innerContent[] = $html;
385 }
386
387 if ( isset( $stack_top->leading_html_start ) ) {
388 $this->output[] = (array) $this->freeform(
389 substr(
390 $this->document,
391 $stack_top->leading_html_start,
392 $stack_top->token_start - $stack_top->leading_html_start
393 )
394 );
395 }
396
397 $this->output[] = (array) $stack_top->block;
398 }
399 }
400
401 /**
402 * WP_Block_Parser_Block class.
403 *
404 * Required for backward compatibility in WordPress Core.
405 */
406 require_once __DIR__ . '/class-wp-block-parser-block.php';
407
408 /**
409 * WP_Block_Parser_Frame class.
410 *
411 * Required for backward compatibility in WordPress Core.
412 */
413 require_once __DIR__ . '/class-wp-block-parser-frame.php';
414