PluginProbe
Extendify / 3.1.5
Extendify v3.1.5
3.2.1 3.2.0 3.1.6 3.1.5 3.1.4 3.1.3 3.1.2 3.1.1 3.1.0 3.0.6 3.0.5 3.0.4 trunk 0.1.0 0.10.0 0.10.1 0.10.2 0.11.0 0.11.1 0.2.0 0.3.0 0.3.1 0.4.0 0.5.0 0.6.0 All 127 releases
extendify / app / QuickEdit / Controllers / SaveController.php

SaveController.php in Extendify 3.1.5, at app/QuickEdit/Controllers/SaveController.php

637 lines 26.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace Extendify\QuickEdit\Controllers;
4
5 defined('ABSPATH') || die('No direct access.');
6
7 use Extendify\Agent\TagBlocks;
8 use Extendify\Agent\TemplatePartBlockFinder;
9 use Extendify\Config;
10 use Extendify\QuickEdit\Schemas\Registry;
11 use Extendify\QuickEdit\Services\BlockFingerprint;
12 use Extendify\QuickEdit\Services\TranslatedContext;
13
14 class SaveController
15 {
16 public static function init()
17 {
18 add_action('rest_api_init', [self::class, 'registerRoutes']);
19 }
20
21 public static function registerRoutes()
22 {
23 register_rest_route('extendify/v1', '/quick-edit/save', [
24 'methods' => 'POST',
25 'permission_callback' => [self::class, 'permissionCallback'],
26 'callback' => [self::class, 'handleSave'],
27 ]);
28
29 register_rest_route('extendify/v1', '/quick-edit/schemas', [
30 'methods' => 'GET',
31 'permission_callback' => [self::class, 'permissionCallback'],
32 'callback' => function () {
33 return new \WP_REST_Response(Registry::describe());
34 },
35 ]);
36 }
37
38 public static function permissionCallback(): bool
39 {
40 return current_user_can(Config::$requiredCapability);
41 }
42
43 public static function handleSave(\WP_REST_Request $req)
44 {
45 $body = $req->get_json_params() ?: [];
46 $source = $body['source'] ?? null;
47 $blockId = isset($body['blockId']) ? (int) $body['blockId'] : 0;
48 $blockType = isset($body['blockType']) ? (string) $body['blockType'] : '';
49 $patches = is_array($body['patches'] ?? null) ? $body['patches'] : [];
50 // patches: schema-driven (image/cover). rawBlock: serialized block markup
51 // from the BlockEditor text editor; bypasses schema apply.
52 $rawBlock = isset($body['rawBlock']) ? (string) $body['rawBlock'] : '';
53
54 if (!is_array($source) || !$blockId || !$blockType || (!$patches && !$rawBlock)) {
55 return new \WP_REST_Response(
56 ['error' => 'source, blockId, blockType + (patches OR rawBlock) required'],
57 400
58 );
59 }
60
61 $schema = Registry::get($blockType);
62 if (!$rawBlock && !$schema) {
63 return new \WP_REST_Response(
64 ['error' => 'no schema registered for block type', 'blockType' => $blockType],
65 400
66 );
67 }
68
69 // rawBlock bypasses schema apply and is spliced in whole, so restrict it
70 // to the text blocks BlockTextEditor actually emits — otherwise an
71 // arbitrary block type could be smuggled through this path.
72 $rawBlockAllowed = ['core/paragraph', 'core/heading', 'core/button'];
73 if ($rawBlock !== '' && !in_array($blockType, $rawBlockAllowed, true)) {
74 return new \WP_REST_Response(
75 ['error' => 'rawBlock not supported for this block type'],
76 400
77 );
78 }
79
80 // A text save rewrites the source post_content, but on a non-default-
81 // language render the user is looking at the translation — writing here
82 // would overwrite the source with the wrong language. The client
83 // suppresses the text editor on those pages; refuse here too so the path
84 // fails closed even when no fingerprint is sent. Image / layout /
85 // schema-patch saves touch shared (untranslated) source and stay allowed.
86 if (self::writesText($rawBlock, $patches) && self::isTranslatedRender($body)) {
87 return new \WP_REST_Response([
88 'error' => 'translated_content',
89 'message' => "Quick Edit can't edit translated content from a non-default-language page.",
90 ], 409);
91 }
92
93 $sourcePost = self::resolveSourcePost($source);
94 if (is_wp_error($sourcePost)) {
95 return new \WP_REST_Response(['error' => $sourcePost->get_error_message()], 404);
96 }
97
98 // edit_posts is a coarse gate; per-source check enforces edit_theme_options
99 // for template parts and edit_post on this specific id for posts.
100 if (!self::userCanEditSource($sourcePost)) {
101 return new \WP_REST_Response(['error' => 'forbidden for this source'], 403);
102 }
103
104 $blocks = parse_blocks($sourcePost->post_content);
105 // TagBlocks counts top-level blocks only (post-content), TagTemplateParts
106 // counts every nested block in preorder — keep the two ID spaces apart
107 // here or `findBlock`'s top-level walk never reaches inner template-part
108 // blockIds (e.g. social-link inside core/social-links in a header).
109 $maxCounter = 0;
110 $visited = [];
111 $found = (($source['kind'] ?? '') === 'template-part')
112 ? TemplatePartBlockFinder::find($blocks, $blockId, $maxCounter, $visited)
113 : self::findBlock($blocks, $blockId);
114 $fingerprint = is_array($body['fingerprint'] ?? null) ? $body['fingerprint'] : [];
115
116 // Block ids are best-effort: TagBlocks numbers at render time while
117 // findBlock re-derives at parse time, and the two diverge on synced
118 // patterns, nested navs, and dynamic expansion. Accept the count-resolved
119 // block only when it's the right type AND carries the clicked block's
120 // fingerprint — matched against the raw markup, then the rendered block
121 // so shortcodes / wptexturize line up with what the client read.
122 $countOk = $found !== null
123 && ($found['block']['blockName'] ?? '') === $blockType
124 && (
125 !$fingerprint
126 || BlockFingerprint::matches($found['block'], $fingerprint)
127 || BlockFingerprint::matches(
128 $found['block'],
129 $fingerprint,
130 self::renderBlockHtml($found['block'], $sourcePost)
131 )
132 );
133
134 if (!$countOk && $fingerprint) {
135 // The count missed or landed on the wrong block; recover by identity
136 // and edit the unique block that carries the fingerprint. Ambiguous
137 // (or absent) → refuse rather than overwrite an unintended block.
138 $matches = self::findBlocksByFingerprint($blocks, $blockType, $fingerprint, $sourcePost);
139 if (count($matches) !== 1) {
140 $resp = [
141 'error' => 'block fingerprint mismatch',
142 'blockId' => $blockId,
143 'candidates' => count($matches),
144 ];
145 // Devmode-only: surface what the post actually holds so a
146 // candidates:0 (text not in storage) vs ambiguous mismatch can be
147 // diagnosed straight from the response.
148 if (defined('EXTENDIFY_DEVMODE') && EXTENDIFY_DEVMODE) {
149 $resp['debug'] = [
150 'wanted' => $fingerprint,
151 'countLanded' => $found ? [
152 'name' => $found['block']['blockName'] ?? null,
153 'text' => self::debugSnippet($found['block']),
154 ] : null,
155 'sameTypeInPost' => self::collectTextsByType($blocks, $blockType),
156 ];
157 }
158 return new \WP_REST_Response($resp, 409);
159 }
160 $found = $matches[0];
161 } elseif (!$countOk) {
162 // No fingerprint to recover with — surface the original count failure.
163 if ($found === null) {
164 return new \WP_REST_Response([
165 'error' => 'block not found in source',
166 'blockId' => $blockId,
167 'maxCounter' => $maxCounter,
168 'sourceKind' => $source['kind'] ?? null,
169 'partSlug' => $source['partSlug'] ?? null,
170 'visited' => $visited,
171 ], 404);
172 }
173 return new \WP_REST_Response([
174 'error' => 'block type mismatch',
175 'expected' => $blockType,
176 'actual' => $found['block']['blockName'] ?? null,
177 ], 409);
178 }
179
180 $targetBlock = $found['block'];
181
182 if ($rawBlock !== '') {
183 // From get_json_params, so never slashed — unslashing here stripped
184 // the real backslashes in serialized attrs and corrupted the block.
185 $parsed = parse_blocks($rawBlock);
186 $parsed = array_values(array_filter(
187 $parsed,
188 static function ($b) {
189 return is_array($b) && !empty($b['blockName']);
190 }
191 ));
192 if (count($parsed) !== 1) {
193 return new \WP_REST_Response([
194 'error' => 'rawBlock must parse to exactly one block',
195 'parsed_count' => count($parsed),
196 ], 400);
197 }
198 if ($parsed[0]['blockName'] !== $blockType) {
199 return new \WP_REST_Response([
200 'error' => 'rawBlock type does not match blockType',
201 'expected' => $blockType,
202 'got' => $parsed[0]['blockName'],
203 ], 400);
204 }
205 // kses the parsed innerHTML in place so both the persisted content
206 // and the re-rendered HTML echoed back below are sanitized, not just
207 // what wp_update_post stores for non-unfiltered_html users.
208 $inner = wp_kses_post($parsed[0]['innerHTML'] ?? '');
209 if ($blockType === 'core/paragraph') {
210 $inner = self::syncTelLink($inner);
211 }
212 $parsed[0]['innerHTML'] = $inner;
213 $parsed[0]['innerContent'] = [$inner];
214 $targetBlock = $parsed[0];
215 } else {
216 // Patch order matters: some schemas cross-refer to innerHTML
217 // so text-then-align differs from align-then-text.
218 foreach ($patches as $patch) {
219 if (!is_array($patch)) {
220 continue;
221 }
222 $fieldKey = (string) ($patch['fieldKey'] ?? '');
223 if ($fieldKey === '') {
224 continue;
225 }
226 $targetBlock = $schema->apply($targetBlock, $fieldKey, $patch['value'] ?? null);
227 }
228 }
229
230 $blocks = self::replaceBlockAtPath($blocks, $found['path'], $targetBlock);
231 $newContent = serialize_blocks($blocks);
232
233 $update = wp_update_post([
234 'ID' => $sourcePost->ID,
235 'post_content' => wp_slash($newContent),
236 ], true);
237 if (is_wp_error($update)) {
238 return new \WP_REST_Response(['error' => $update->get_error_message()], 500);
239 }
240
241 // Re-render via the same filter chain a live page uses. Counter classes
242 // start at 1 here; client splices via patchVariantClasses to align them.
243 $rendered = self::renderBlockHtml($targetBlock, $sourcePost);
244
245 return new \WP_REST_Response([
246 'ok' => true,
247 'blockId' => $blockId,
248 'blockType' => $blockType,
249 'rendered' => trim($rendered),
250 ]);
251 }
252
253 // Whether the save edits translatable text: the rawBlock text-editor path is
254 // always text, and the schema-patch path is text only for the content / text
255 // fields (align / image / url / service / level are shared, untranslated).
256 private static function writesText(string $rawBlock, array $patches): bool
257 {
258 if ($rawBlock !== '') {
259 return true;
260 }
261 foreach ($patches as $patch) {
262 if (is_array($patch) && in_array((string) ($patch['fieldKey'] ?? ''), ['content', 'text'], true)) {
263 return true;
264 }
265 }
266 return false;
267 }
268
269 // Keep a phone CTA dialing the number the user can see. When a saved
270 // paragraph's content is a single <a href="tel:…"> link, re-point the
271 // anchor's href + data-id at the normalized digits of its visible text and
272 // pin data-type="tel". Editing the digits in RichText keeps the link format
273 // but only swaps the text, leaving href on the number the link was first
274 // built with — without this, tap-to-call dials the stale number.
275 //
276 // Scoped narrowly: only a lone tel: anchor is touched. http/mailto links,
277 // multi-link paragraphs, no link, or text that yields no usable number are
278 // returned unchanged, so an ordinary paragraph's link is never rewritten.
279 // Runs after wp_kses_post; the value written (a tel: URI of digits and an
280 // optional leading +) needs no further sanitizing.
281 private static function syncTelLink(string $innerHtml): string
282 {
283 if (stripos($innerHtml, '<a') === false || stripos($innerHtml, 'tel:') === false) {
284 return $innerHtml;
285 }
286
287 $dom = new \DOMDocument();
288 $previous = libxml_use_internal_errors(true);
289 // The encoding hint stops DOMDocument mangling UTF-8; the flags keep it
290 // from wrapping the fragment in <html>/<body>.
291 $loaded = $dom->loadHTML(
292 '<?xml encoding="utf-8"?>' . $innerHtml,
293 LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD
294 );
295 libxml_clear_errors();
296 libxml_use_internal_errors($previous);
297 if (!$loaded) {
298 return $innerHtml;
299 }
300
301 $anchors = $dom->getElementsByTagName('a');
302 if ($anchors->length !== 1) {
303 return $innerHtml;
304 }
305 $anchor = $anchors->item(0);
306 if (stripos((string) $anchor->getAttribute('href'), 'tel:') !== 0) {
307 return $innerHtml;
308 }
309
310 $normalized = self::normalizePhoneNumber($anchor->textContent);
311 if ($normalized === null) {
312 return $innerHtml;
313 }
314 $newHref = 'tel:' . $normalized;
315
316 // Rewrite the single anchor's opening tag only — the <p> wrapper and the
317 // link text stay byte-for-byte intact.
318 return preg_replace_callback(
319 '/<a\b[^>]*>/i',
320 static function ($match) use ($newHref) {
321 return self::setTagAttributes($match[0], [
322 'href' => $newHref,
323 'data-id' => $newHref,
324 'data-type' => 'tel',
325 ]);
326 },
327 $innerHtml,
328 1
329 );
330 }
331
332 // Reduce visible phone text to bare dialable digits: keep a single leading
333 // + (international prefix) and drop spaces / dashes / parens / other visual
334 // separators. Returns null when the result isn't a plausible phone number
335 // (E.164 caps at 15 digits) so the caller leaves the href untouched rather
336 // than writing a broken tel: link.
337 private static function normalizePhoneNumber(string $text)
338 {
339 $text = trim($text);
340 $plus = (strncmp($text, '+', 1) === 0) ? '+' : '';
341 $digits = (string) preg_replace('/\D+/', '', $text);
342 $length = strlen($digits);
343 if ($length < 7 || $length > 15) {
344 return null;
345 }
346 return $plus . $digits;
347 }
348
349 // Set attributes within a single opening-tag string: replace an existing
350 // attribute's value in place, otherwise inject it before the closing '>'.
351 private static function setTagAttributes(string $tag, array $attributes): string
352 {
353 foreach ($attributes as $name => $value) {
354 $rendered = ' ' . $name . '="' . esc_attr($value) . '"';
355 $pattern = '/\s' . preg_quote($name, '/') . '\s*=\s*("[^"]*"|\'[^\']*\'|[^\s>]+)/i';
356 $tag = preg_match($pattern, $tag)
357 ? preg_replace($pattern, $rendered, $tag, 1)
358 : preg_replace('/\s*\/?>$/', $rendered . '>', $tag, 1);
359 }
360 return $tag;
361 }
362
363 // The REST save request isn't language-scoped the way the page render is, so
364 // trust the translatedContext the client forwards (detected at enqueue); also
365 // re-check server-side in case this request is language-scoped on its own.
366 private static function isTranslatedRender(array $body): bool
367 {
368 $clientContext = $body['translatedContext'] ?? null;
369 if (is_array($clientContext) && !empty($clientContext['isTranslated'])) {
370 return true;
371 }
372 return !empty(TranslatedContext::detect()['isTranslated']);
373 }
374
375 // Render a single block through the same the_content chain a live page uses
376 // (expanding shortcodes, wptexturize, etc.). In a REST request the main
377 // query has no post, so wp_reset_postdata() can't restore $GLOBALS['post'] —
378 // snapshot and restore it so a template-part save can't leave global $post
379 // dangling for the rest of the request.
380 private static function renderBlockHtml(array $block, \WP_Post $sourcePost): string
381 {
382 $previousPost = $GLOBALS['post'] ?? null;
383 $GLOBALS['post'] = $sourcePost;
384 setup_postdata($sourcePost);
385 // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- core WP filter
386 $html = apply_filters('the_content', serialize_blocks([$block]));
387 wp_reset_postdata();
388 $GLOBALS['post'] = $previousPost;
389 return (string) $html;
390 }
391
392 // Every block of $blockType that carries the fingerprint, with its path.
393 // The caller only proceeds on a *unique* match — two blocks with the same
394 // content can't be told apart, so that refuses rather than guesses. Skips
395 // ignored dynamic loops and nested template-part scopes. Tries the cheap
396 // raw/fold match across candidates first and only renders (expanding
397 // shortcodes etc.) if nothing matched raw.
398 private static function findBlocksByFingerprint(
399 array $blocks,
400 string $blockType,
401 array $fingerprint,
402 \WP_Post $sourcePost
403 ): array {
404 $ignored = TagBlocks::$ignored;
405 $candidates = [];
406 $walk = function (array $list, array $pathSoFar) use (&$walk, &$candidates, $blockType, $ignored) {
407 foreach ($list as $i => $block) {
408 $name = $block['blockName'] ?? '';
409 if ($name === '') {
410 if (!empty($block['innerBlocks'])) {
411 $walk($block['innerBlocks'], array_merge($pathSoFar, [$i, 'innerBlocks']));
412 }
413 continue;
414 }
415 if ($name === 'core/template-part' || in_array($name, $ignored, true)) {
416 continue;
417 }
418 if ($name === $blockType) {
419 $candidates[] = ['block' => $block, 'path' => array_merge($pathSoFar, [$i])];
420 }
421 if (!empty($block['innerBlocks'])) {
422 $walk($block['innerBlocks'], array_merge($pathSoFar, [$i, 'innerBlocks']));
423 }
424 }
425 };
426 $walk($blocks, []);
427
428 $raw = array_values(array_filter(
429 $candidates,
430 static function ($c) use ($fingerprint) {
431 return BlockFingerprint::matches($c['block'], $fingerprint);
432 }
433 ));
434 if ($raw) {
435 return $raw;
436 }
437
438 $rendered = array_values(array_filter(
439 $candidates,
440 static function ($c) use ($fingerprint, $sourcePost) {
441 return BlockFingerprint::matches(
442 $c['block'],
443 $fingerprint,
444 self::renderBlockHtml($c['block'], $sourcePost)
445 );
446 }
447 ));
448 if ($rendered) {
449 return $rendered;
450 }
451
452 // Last resort: a block-level shortcode render (e.g. [products]) splits
453 // the paragraph in the browser, so the live element's text — and thus
454 // the fingerprint — is truncated to a prefix of the stored block.
455 return array_values(array_filter(
456 $candidates,
457 static function ($c) use ($fingerprint) {
458 return BlockFingerprint::matches($c['block'], $fingerprint, '', true);
459 }
460 ));
461 }
462
463 private static function debugSnippet(array $block): string
464 {
465 return mb_substr(trim((string) wp_strip_all_tags((string) ($block['innerHTML'] ?? ''))), 0, 80);
466 }
467
468 // Devmode diagnostics: the raw text of every block of $blockType in the
469 // post, so a fingerprint mismatch can be compared against what's stored.
470 private static function collectTextsByType(array $blocks, string $blockType): array
471 {
472 $out = [];
473 $ignored = TagBlocks::$ignored;
474 $walk = function (array $list) use (&$walk, &$out, $blockType, $ignored) {
475 foreach ($list as $block) {
476 $name = $block['blockName'] ?? '';
477 if ($name === '') {
478 if (!empty($block['innerBlocks'])) {
479 $walk($block['innerBlocks']);
480 }
481 continue;
482 }
483 if ($name === 'core/template-part' || in_array($name, $ignored, true)) {
484 continue;
485 }
486 if ($name === $blockType) {
487 $out[] = self::debugSnippet($block);
488 }
489 if (!empty($block['innerBlocks'])) {
490 $walk($block['innerBlocks']);
491 }
492 }
493 };
494 $walk($blocks);
495 return $out;
496 }
497
498 /**
499 * @return \WP_Post|\WP_Error
500 */
501 private static function resolveSourcePost(array $source)
502 {
503 $kind = (string) ($source['kind'] ?? '');
504
505 if ($kind === 'post') {
506 $id = (int) ($source['id'] ?? 0);
507 $post = $id ? get_post($id) : null;
508 if (!$post) {
509 return new \WP_Error('not_found', 'post not found');
510 }
511 $disallowed = ['revision', 'wp_navigation', 'wp_template',
512 'wp_template_part', 'wp_block', 'attachment'];
513 if (
514 in_array($post->post_type, $disallowed, true)
515 || $post->post_status === 'auto-draft'
516 ) {
517 return new \WP_Error(
518 'post_type_not_supported',
519 'Edit this content via its dedicated endpoint'
520 );
521 }
522 return $post;
523 }
524
525 if ($kind === 'template-part') {
526 $slug = (string) ($source['partSlug'] ?? '');
527 if ($slug === '') {
528 return new \WP_Error('bad_source', 'template-part requires partSlug');
529 }
530 // Use WP's own resolver so the save lands on the post WP renders
531 // from. Raw get_posts by name returns rows from every wp_theme
532 // term — when an install has had multiple theme variants active
533 // at different times (e.g. `extendable` and `extendable-2` both
534 // owning a "header" post), the wrong row wins on post_date
535 // ordering and we patch a stale orphan instead of the live part.
536 $stylesheet = wp_get_theme()->get_stylesheet();
537 $template = get_block_template("{$stylesheet}//{$slug}", 'wp_template_part');
538 if (!$template || empty($template->wp_id)) {
539 return new \WP_Error('not_found', 'template-part not found');
540 }
541 $post = get_post($template->wp_id);
542 if (!$post) {
543 return new \WP_Error('not_found', 'template-part not found');
544 }
545 return $post;
546 }
547
548 return new \WP_Error('bad_source', 'unknown source kind');
549 }
550
551 private static function userCanEditSource(\WP_Post $post): bool
552 {
553 if ($post->post_type === 'wp_template_part') {
554 return current_user_can('edit_theme_options');
555 }
556 return current_user_can('edit_post', $post->ID);
557 }
558
559 // Walks the parsed-block tree the same way TagBlocks counts on the front-end
560 // so client blockIds line up with what the server resolves.
561 private static function findBlock(array $blocks, int $targetId)
562 {
563 $ignored = TagBlocks::$ignored;
564 $counter = 0;
565 $found = null;
566
567 $walk = function (array &$list, array $pathSoFar, int $skipDepth)
568 use (&$walk, &$counter, &$found, $targetId, $ignored) {
569 foreach ($list as $i => &$block) {
570 if (empty($block['blockName'])) {
571 if (!empty($block['innerBlocks'])) {
572 $walk($block['innerBlocks'], array_merge($pathSoFar, [$i, 'innerBlocks']), $skipDepth);
573 if ($found !== null) {
574 return;
575 }
576 }
577 continue;
578 }
579 $isIgnored = in_array($block['blockName'], $ignored, true);
580 if ($isIgnored || $skipDepth > 0) {
581 if (!empty($block['innerBlocks'])) {
582 $walk(
583 $block['innerBlocks'],
584 array_merge($pathSoFar, [$i, 'innerBlocks']),
585 $skipDepth + ($isIgnored ? 1 : 0)
586 );
587 if ($found !== null) {
588 return;
589 }
590 }
591 continue;
592 }
593 $counter++;
594 if ($counter === $targetId) {
595 $found = ['block' => $block, 'path' => array_merge($pathSoFar, [$i])];
596 return;
597 }
598 if (!empty($block['innerBlocks'])) {
599 $walk($block['innerBlocks'], array_merge($pathSoFar, [$i, 'innerBlocks']), 0);
600 if ($found !== null) {
601 return;
602 }
603 }
604 }
605 unset($block);
606 };
607 $walk($blocks, [], 0);
608
609 return $found;
610 }
611
612 // Path elements alternate index / 'innerBlocks' / index / 'innerBlocks' / ...
613 private static function replaceBlockAtPath(array $blocks, array $path, array $newBlock): array
614 {
615 if (empty($path)) {
616 return $blocks;
617 }
618 $head = $path[0];
619 $rest = array_slice($path, 1);
620 if (!is_int($head) || !isset($blocks[$head])) {
621 return $blocks;
622 }
623 if (empty($rest)) {
624 $blocks[$head] = $newBlock;
625 return $blocks;
626 }
627 if ($rest[0] === 'innerBlocks') {
628 $blocks[$head]['innerBlocks'] = self::replaceBlockAtPath(
629 $blocks[$head]['innerBlocks'] ?? [],
630 array_slice($rest, 1),
631 $newBlock
632 );
633 }
634 return $blocks;
635 }
636 }
637