PluginProbe
Gutenberg / trunk
Gutenberg vtrunk
24.1.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 All 404 releases
gutenberg / packages / block-serialization-default-parser / class-wp-block-parser.php

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

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