class-wp-block-parser.php
14.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
<?php
/**
* Block Serialization Parser
*
* @package WordPress
*/
/**
* Class WP_Block_Parser_Block
*
* Holds the block structure in memory
*
* @since 5.0.0
*/
class WP_Block_Parser_Block {
/**
* Name of block
*
* @example "core/paragraph"
*
* @since 5.0.0
* @var string
*/
public $blockName;
/**
* Optional set of attributes from block comment delimiters
*
* @example null
* @example array( 'columns' => 3 )
*
* @since 5.0.0
* @var array|null
*/
public $attrs;
/**
* List of inner blocks (of this same class)
*
* @since 5.0.0
* @var WP_Block_Parser_Block[]
*/
public $innerBlocks;
/**
* Resultant HTML from inside block comment delimiters
* after removing inner blocks
*
* @example "...Just <!-- wp:test /--> testing..." -> "Just testing..."
*
* @since 5.0.0
* @var string
*/
public $innerHTML;
/**
* List of string fragments and null markers where inner blocks were found
*
* @example array(
* 'innerHTML' => 'BeforeInnerAfter',
* 'innerBlocks' => array( block, block ),
* 'innerContent' => array( 'Before', null, 'Inner', null, 'After' ),
* )
*
* @since 4.2.0
* @var array
*/
public $innerContent;
/**
* Constructor.
*
* Will populate object properties from the provided arguments.
*
* @since 5.0.0
*
* @param string $name Name of block.
* @param array $attrs Optional set of attributes from block comment delimiters.
* @param array $innerBlocks List of inner blocks (of this same class).
* @param string $innerHTML Resultant HTML from inside block comment delimiters after removing inner blocks.
* @param array $innerContent List of string fragments and null markers where inner blocks were found.
*/
function __construct( $name, $attrs, $innerBlocks, $innerHTML, $innerContent ) {
$this->blockName = $name;
$this->attrs = $attrs;
$this->innerBlocks = $innerBlocks;
$this->innerHTML = $innerHTML;
$this->innerContent = $innerContent;
}
}
/**
* Class WP_Block_Parser_Frame
*
* Holds partial blocks in memory while parsing
*
* @internal
* @since 5.0.0
*/
class WP_Block_Parser_Frame {
/**
* Full or partial block
*
* @since 5.0.0
* @var WP_Block_Parser_Block
*/
public $block;
/**
* Byte offset into document for start of parse token
*
* @since 5.0.0
* @var int
*/
public $token_start;
/**
* Byte length of entire parse token string
*
* @since 5.0.0
* @var int
*/
public $token_length;
/**
* Byte offset into document for after parse token ends
* (used during reconstruction of stack into parse production)
*
* @since 5.0.0
* @var int
*/
public $prev_offset;
/**
* Byte offset into document where leading HTML before token starts
*
* @since 5.0.0
* @var int
*/
public $leading_html_start;
/**
* Constructor
*
* Will populate object properties from the provided arguments.
*
* @since 5.0.0
*
* @param WP_Block_Parser_Block $block Full or partial block.
* @param int $token_start Byte offset into document for start of parse token.
* @param int $token_length Byte length of entire parse token string.
* @param int $prev_offset Byte offset into document for after parse token ends.
* @param int $leading_html_start Byte offset into document where leading HTML before token starts.
*/
function __construct( $block, $token_start, $token_length, $prev_offset = null, $leading_html_start = null ) {
$this->block = $block;
$this->token_start = $token_start;
$this->token_length = $token_length;
$this->prev_offset = isset( $prev_offset ) ? $prev_offset : $token_start + $token_length;
$this->leading_html_start = $leading_html_start;
}
}
/**
* Class WP_Block_Parser
*
* Parses a document and constructs a list of parsed block objects
*
* @since 5.0.0
* @since 4.0.0 returns arrays not objects, all attributes are arrays
*/
class WP_Block_Parser {
/**
* Input document being parsed
*
* @example "Pre-text\n<!-- wp:paragraph -->This is inside a block!<!-- /wp:paragraph -->"
*
* @since 5.0.0
* @var string
*/
public $document;
/**
* Tracks parsing progress through document
*
* @since 5.0.0
* @var int
*/
public $offset;
/**
* List of parsed blocks
*
* @since 5.0.0
* @var WP_Block_Parser_Block[]
*/
public $output;
/**
* Stack of partially-parsed structures in memory during parse
*
* @since 5.0.0
* @var WP_Block_Parser_Frame[]
*/
public $stack;
/**
* Empty associative array, here due to PHP quirks
*
* @since 4.4.0
* @var array empty associative array
*/
public $empty_attrs;
/**
* Parses a document and returns a list of block structures
*
* When encountering an invalid parse will return a best-effort
* parse. In contrast to the specification parser this does not
* return an error on invalid inputs.
*
* @since 5.0.0
*
* @param string $document Input document being parsed.
* @return WP_Block_Parser_Block[]
*/
function parse( $document ) {
$this->document = $document;
$this->offset = 0;
$this->output = array();
$this->stack = array();
$this->empty_attrs = json_decode( '{}', true );
do {
// twiddle our thumbs.
} while ( $this->proceed() );
return $this->output;
}
/**
* Processes the next token from the input document
* and returns whether to proceed eating more tokens
*
* This is the "next step" function that essentially
* takes a token as its input and decides what to do
* with that token before descending deeper into a
* nested block tree or continuing along the document
* or breaking out of a level of nesting.
*
* @internal
* @since 5.0.0
* @return bool
*/
function proceed() {
$next_token = $this->next_token();
list( $token_type, $block_name, $attrs, $start_offset, $token_length ) = $next_token;
$stack_depth = count( $this->stack );
// we may have some HTML soup before the next block.
$leading_html_start = $start_offset > $this->offset ? $this->offset : null;
switch ( $token_type ) {
case 'no-more-tokens':
// if not in a block then flush output.
if ( 0 === $stack_depth ) {
$this->add_freeform();
return false;
}
/*
* Otherwise we have a problem
* This is an error
*
* we have options
* - treat it all as freeform text
* - assume an implicit closer (easiest when not nesting)
*/
// for the easy case we'll assume an implicit closer.
if ( 1 === $stack_depth ) {
$this->add_block_from_stack();
return false;
}
/*
* for the nested case where it's more difficult we'll
* have to assume that multiple closers are missing
* and so we'll collapse the whole stack piecewise
*/
while ( 0 < count( $this->stack ) ) {
$this->add_block_from_stack();
}
return false;
case 'void-block':
/*
* easy case is if we stumbled upon a void block
* in the top-level of the document
*/
if ( 0 === $stack_depth ) {
if ( isset( $leading_html_start ) ) {
$this->output[] = (array) $this->freeform(
substr(
$this->document,
$leading_html_start,
$start_offset - $leading_html_start
)
);
}
$this->output[] = (array) new WP_Block_Parser_Block( $block_name, $attrs, array(), '', array() );
$this->offset = $start_offset + $token_length;
return true;
}
// otherwise we found an inner block.
$this->add_inner_block(
new WP_Block_Parser_Block( $block_name, $attrs, array(), '', array() ),
$start_offset,
$token_length
);
$this->offset = $start_offset + $token_length;
return true;
case 'block-opener':
// track all newly-opened blocks on the stack.
array_push(
$this->stack,
new WP_Block_Parser_Frame(
new WP_Block_Parser_Block( $block_name, $attrs, array(), '', array() ),
$start_offset,
$token_length,
$start_offset + $token_length,
$leading_html_start
)
);
$this->offset = $start_offset + $token_length;
return true;
case 'block-closer':
/*
* if we're missing an opener we're in trouble
* This is an error
*/
if ( 0 === $stack_depth ) {
/*
* we have options
* - assume an implicit opener
* - assume _this_ is the opener
* - give up and close out the document
*/
$this->add_freeform();
return false;
}
// if we're not nesting then this is easy - close the block.
if ( 1 === $stack_depth ) {
$this->add_block_from_stack( $start_offset );
$this->offset = $start_offset + $token_length;
return true;
}
/*
* otherwise we're nested and we have to close out the current
* block and add it as a new innerBlock to the parent
*/
$stack_top = array_pop( $this->stack );
$html = substr( $this->document, $stack_top->prev_offset, $start_offset - $stack_top->prev_offset );
$stack_top->block->innerHTML .= $html;
$stack_top->block->innerContent[] = $html;
$stack_top->prev_offset = $start_offset + $token_length;
$this->add_inner_block(
$stack_top->block,
$stack_top->token_start,
$stack_top->token_length,
$start_offset + $token_length
);
$this->offset = $start_offset + $token_length;
return true;
default:
// This is an error.
$this->add_freeform();
return false;
}
}
/**
* Scans the document from where we last left off
* and finds the next valid token to parse if it exists
*
* Returns the type of the find: kind of find, block information, attributes
*
* @internal
* @since 5.0.0
* @since 4.6.1 fixed a bug in attribute parsing which caused catastrophic backtracking on invalid block comments
* @return array
*/
function next_token() {
$matches = null;
/*
* aye the magic
* we're using a single RegExp to tokenize the block comment delimiters
* we're also using a trick here because the only difference between a
* block opener and a block closer is the leading `/` before `wp:` (and
* a closer has no attributes). we can trap them both and process the
* match back in PHP to see which one it was.
*/
$has_match = preg_match(
'/<!--\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',
$this->document,
$matches,
PREG_OFFSET_CAPTURE,
$this->offset
);
// if we get here we probably have catastrophic backtracking or out-of-memory in the PCRE.
if ( false === $has_match ) {
return array( 'no-more-tokens', null, null, null, null );
}
// we have no more tokens.
if ( 0 === $has_match ) {
return array( 'no-more-tokens', null, null, null, null );
}
list( $match, $started_at ) = $matches[0];
$length = strlen( $match );
$is_closer = isset( $matches['closer'] ) && -1 !== $matches['closer'][1];
$is_void = isset( $matches['void'] ) && -1 !== $matches['void'][1];
$namespace = $matches['namespace'];
$namespace = ( isset( $namespace ) && -1 !== $namespace[1] ) ? $namespace[0] : 'core/';
$name = $namespace . $matches['name'][0];
$has_attrs = isset( $matches['attrs'] ) && -1 !== $matches['attrs'][1];
/*
* Fun fact! It's not trivial in PHP to create "an empty associative array" since all arrays
* are associative arrays. If we use `array()` we get a JSON `[]`
*/
$attrs = $has_attrs
? json_decode( $matches['attrs'][0], /* as-associative */ true )
: $this->empty_attrs;
/*
* This state isn't allowed
* This is an error
*/
if ( $is_closer && ( $is_void || $has_attrs ) ) {
// we can ignore them since they don't hurt anything.
}
if ( $is_void ) {
return array( 'void-block', $name, $attrs, $started_at, $length );
}
if ( $is_closer ) {
return array( 'block-closer', $name, null, $started_at, $length );
}
return array( 'block-opener', $name, $attrs, $started_at, $length );
}
/**
* Returns a new block object for freeform HTML
*
* @internal
* @since 3.9.0
*
* @param string $innerHTML HTML content of block.
* @return WP_Block_Parser_Block freeform block object.
*/
function freeform( $innerHTML ) {
return new WP_Block_Parser_Block( null, $this->empty_attrs, array(), $innerHTML, array( $innerHTML ) );
}
/**
* Pushes a length of text from the input document
* to the output list as a freeform block.
*
* @internal
* @since 5.0.0
* @param null $length how many bytes of document text to output.
*/
function add_freeform( $length = null ) {
$length = $length ? $length : strlen( $this->document ) - $this->offset;
if ( 0 === $length ) {
return;
}
$this->output[] = (array) $this->freeform( substr( $this->document, $this->offset, $length ) );
}
/**
* Given a block structure from memory pushes
* a new block to the output list.
*
* @internal
* @since 5.0.0
* @param WP_Block_Parser_Block $block The block to add to the output.
* @param int $token_start Byte offset into the document where the first token for the block starts.
* @param int $token_length Byte length of entire block from start of opening token to end of closing token.
* @param int|null $last_offset Last byte offset into document if continuing form earlier output.
*/
function add_inner_block( WP_Block_Parser_Block $block, $token_start, $token_length, $last_offset = null ) {
$parent = $this->stack[ count( $this->stack ) - 1 ];
$parent->block->innerBlocks[] = (array) $block;
$html = substr( $this->document, $parent->prev_offset, $token_start - $parent->prev_offset );
if ( ! empty( $html ) ) {
$parent->block->innerHTML .= $html;
$parent->block->innerContent[] = $html;
}
$parent->block->innerContent[] = null;
$parent->prev_offset = $last_offset ? $last_offset : $token_start + $token_length;
}
/**
* Pushes the top block from the parsing stack to the output list.
*
* @internal
* @since 5.0.0
* @param int|null $end_offset byte offset into document for where we should stop sending text output as HTML.
*/
function add_block_from_stack( $end_offset = null ) {
$stack_top = array_pop( $this->stack );
$prev_offset = $stack_top->prev_offset;
$html = isset( $end_offset )
? substr( $this->document, $prev_offset, $end_offset - $prev_offset )
: substr( $this->document, $prev_offset );
if ( ! empty( $html ) ) {
$stack_top->block->innerHTML .= $html;
$stack_top->block->innerContent[] = $html;
}
if ( isset( $stack_top->leading_html_start ) ) {
$this->output[] = (array) $this->freeform(
substr(
$this->document,
$stack_top->leading_html_start,
$stack_top->token_start - $stack_top->leading_html_start
)
);
}
$this->output[] = (array) $stack_top->block;
}
}