PluginProbe
WDesignKit – AI Templates, Widget Builder & MCP Workflow / 2.7.1
WDesignKit – AI Templates, Widget Builder & MCP Workflow v2.7.1
2.7.1 2.7.0 2.6.6 2.6.5 2.6.4 2.6.3 2.6.2 2.6.1 2.6.0 2.5.5 2.5.4 2.5.3 2.5.2 2.5.1 2.5.0 2.4.0 2.3.3 2.3.2 2.3.1 1.0.10 1.0.11 1.0.12 1.0.13 1.0.14 1.0.15 All 130 releases
wdesignkit / includes / admin / import / class-wdkit-import-navigation.php

class-wdkit-import-navigation.php in WDesignKit – AI Templates, Widget Builder & MCP Workflow 2.7.1, at includes/admin/import/class-wdkit-import-navigation.php

732 lines 23.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Cross-page reference rewriting after an import.
4 *
5 * ── How the browser does it ─────────────────────────────────────────────────
6 *
7 * import_loader.js#update_section_id() runs per template, once `post_ids.current` holds
8 * `old_template_id => new_post_id`. It is deliberately narrow:
9 *
10 * 1. walk the Elementor tree
11 * 2. for a widget whose `widgetType` is one of eight dynamic widgets, serialise ONLY that
12 * widget's `settings`
13 * 3. replace every occurrence of each old id inside that serialised blob
14 * 4. splice the blob back into the whole-content string
15 *
16 * So it is not a blind find-and-replace across the page — it only touches the settings of
17 * widgets that are known to store a template/post id (tabs, accordion, carousel, off-canvas,
18 * quick view, product listout, the lite nav menu). Everything else, including every external
19 * URL and every unrelated number, is left alone. This class keeps that scope exactly.
20 *
21 * `check_navigation()` is a *detector*, not a rewriter: it reports whether a template is a
22 * navigation template (title's second dash-part is "menu", or it contains a tp-navigation-menu
23 * widget), which the browser uses to skip dead-link scrubbing for those templates. It is
24 * reproduced here as `is_navigation_template()` for the same purpose.
25 *
26 * Theme-builder display conditions are a separate concern and are rewritten by
27 * wdkit_nxt_thembuilder_update(), which already does that work in PHP — see
28 * Wdkit_Import_Settings::apply_site(). This class does not duplicate it.
29 *
30 * @package Wdesignkit
31 * @since 2.6.5
32 */
33
34 if ( ! defined( 'ABSPATH' ) ) {
35 exit;
36 }
37
38 if ( ! class_exists( 'Wdkit_Import_Navigation' ) ) {
39
40 /**
41 * Post/template id rewriting.
42 */
43 class Wdkit_Import_Navigation {
44
45 /**
46 * Elementor widgets whose settings may embed a template or post id.
47 *
48 * Mirrors `dynamic_widgets` in update_section_id(). Adding to this list widens what
49 * gets rewritten, so it stays in lock-step with the browser.
50 *
51 * @var string[]
52 */
53 private static $dynamic_widgets = array(
54 'tp-tabs-tours',
55 'tp-accordion',
56 'tp-switcher',
57 'tp-carousel-anything',
58 'tp-off-canvas',
59 'tp-wp-quickview',
60 'tp-product-listout',
61 'tp-navigation-menu-lite',
62 );
63
64 /**
65 * Widgets that mark a template as navigation.
66 *
67 * @var string[]
68 */
69 private static $navigation_widgets = array( 'tp-navigation-menu' );
70
71 /**
72 * Build the id map from the per-page import results.
73 *
74 * @param array[] $page_records Per-page results carrying template_id and post_id.
75 * @return array<string,int> original template id => imported post id.
76 */
77 public static function build_map( $page_records ) {
78 $map = array();
79
80 foreach ( (array) $page_records as $page ) {
81 if ( ! is_array( $page ) || empty( $page['post_id'] ) ) {
82 continue;
83 }
84
85 /* Keyed by the post id the template had on the DEMO site, not by its cloud
86 * template id.
87 *
88 * A dynamic widget stores the saved template it embeds as that site's post id —
89 * the Pricing page's switcher holds `content_a_template: 1492`, where 1492 was the
90 * "Pricing Template" post on the site the kit was built from. The browser keyed its
91 * map the same way: post_ids.current[entry.old_page_id] = entry.id. Keying by
92 * `template_id` (21343, the cloud's id for that template) produced a map that
93 * could never match anything a widget contains, so every embedded template kept
94 * pointing at a post that does not exist here and TPAE rendered "Unauthorized
95 * Access" where the pricing plans should be. */
96 $old = isset( $page['old_page_id'] ) ? trim( (string) $page['old_page_id'] ) : '';
97
98 if ( '' === $old || '0' === $old ) {
99 continue;
100 }
101
102 $map[ $old ] = (int) $page['post_id'];
103 }
104
105 return $map;
106 }
107
108 /**
109 * Rewrite ids inside every imported Elementor page.
110 *
111 * Runs only after all pages exist — a map built halfway through would leave later
112 * pages pointing at nothing. Idempotent: once an old id has been replaced it is no
113 * longer present, so a second pass finds nothing to do.
114 *
115 * @param array<string,int> $map Id map from build_map().
116 * @param array[] $page_records Per-page results.
117 * @param string $builder Builder.
118 * @return array{rewritten:int,scanned:int,skipped:string}
119 */
120 public static function rewrite( $map, $page_records, $builder = 'elementor' ) {
121 $result = array(
122 'rewritten' => 0,
123 'scanned' => 0,
124 'skipped' => '',
125 );
126
127 if ( empty( $map ) ) {
128 $result['skipped'] = 'empty_map';
129
130 return $result;
131 }
132
133 /* Gutenberg has no equivalent pass in the browser: update_section_id() is only ever
134 * called on the Elementor branch, because the widgets it targets are Elementor
135 * widgets. Doing anything here for blocks would be inventing behaviour. */
136 if ( 'elementor' !== $builder ) {
137 $result['skipped'] = 'not_applicable_to_builder';
138
139 return $result;
140 }
141
142 foreach ( (array) $page_records as $page ) {
143 if ( ! is_array( $page ) || empty( $page['post_id'] ) ) {
144 continue;
145 }
146
147 $post_id = (int) $page['post_id'];
148 $stored = get_post_meta( $post_id, '_elementor_data', true );
149
150 if ( empty( $stored ) || ! is_string( $stored ) ) {
151 continue;
152 }
153
154 ++$result['scanned'];
155
156 $rewritten = self::rewrite_elementor_content( $stored, $map );
157
158 if ( null === $rewritten ) {
159 continue;
160 }
161
162 update_post_meta( $post_id, '_elementor_data', wp_slash( $rewritten ) );
163
164 /* The ids changed under Elementor's cached render of this page. Without this a
165 * page that had already been rendered kept serving the embedded template it
166 * pointed at BEFORE the rewrite — the Pricing switcher went on showing
167 * "Unauthorized Access" with the correct id already in _elementor_data. */
168 self::invalidate_render_cache( $post_id );
169
170 ++$result['rewritten'];
171 }
172
173 return $result;
174 }
175
176 /**
177 * Rewrite ids in one serialised Elementor tree.
178 *
179 * @param string $content Serialised `_elementor_data` (unslashed).
180 * @param array<string,int> $map Id map.
181 * @return string|null New content, or null when nothing changed.
182 */
183 public static function rewrite_elementor_content( $content, $map ) {
184 $tree = json_decode( $content, true );
185
186 if ( ! is_array( $tree ) ) {
187 return null;
188 }
189
190 $changed = false;
191
192 self::walk( $tree, $map, $changed );
193
194 if ( ! $changed ) {
195 return null;
196 }
197
198 $encoded = wp_json_encode( $tree );
199
200 return is_string( $encoded ) ? $encoded : null;
201 }
202
203 /**
204 * Walk the tree, rewriting only the targeted widgets' settings.
205 *
206 * @param array $elements Element list (by reference).
207 * @param array<string,int> $map Id map.
208 * @param bool $changed Set true when something was rewritten.
209 * @return void
210 */
211 private static function walk( &$elements, $map, &$changed ) {
212 if ( ! is_array( $elements ) ) {
213 return;
214 }
215
216 foreach ( $elements as &$element ) {
217 if ( ! is_array( $element ) ) {
218 continue;
219 }
220
221 $widget_type = isset( $element['widgetType'] ) ? (string) $element['widgetType'] : '';
222
223 if ( '' !== $widget_type && in_array( $widget_type, self::$dynamic_widgets, true ) && isset( $element['settings'] ) && is_array( $element['settings'] ) ) {
224 if ( self::swap_template_refs( $element['settings'], $map ) ) {
225 $changed = true;
226 }
227 }
228
229 if ( isset( $element['elements'] ) && is_array( $element['elements'] ) ) {
230 self::walk( $element['elements'], $map, $changed );
231 }
232 }
233
234 unset( $element );
235 }
236
237 /**
238 * The settings that hold a saved-template id on TPAE's dynamic widgets.
239 *
240 * TPAE's own list: its cross-site copy/paste extension (tpae-copy-paste.js, `keysToClear`)
241 * clears exactly these because a template id means nothing on another site — which is the
242 * same fact this import has to deal with.
243 *
244 * @var string[]
245 */
246 private static $template_ref_keys = array(
247 'content_template',
248 'content_a_template',
249 'content_b_template',
250 'fp_content_template',
251 'protected_content_template',
252 'blockTemp',
253 // tp-product-listout's custom skin: the "Product Template Design" saved template.
254 'skin_template',
255 );
256
257 /**
258 * Repoint saved-template references in one widget's settings, repeater rows included.
259 *
260 * Replaces a value only when its KEY is a template reference and the value is EXACTLY an
261 * id in the map. The previous approach serialised the settings and ran str_replace() over
262 * the digits, which also rewrote any other number containing them — a width of 1492, an
263 * id of 11492 — and, keyed as it was, never actually matched a template anyway.
264 *
265 * The value keeps its type: Elementor stores these as strings, and a string comes back a
266 * string.
267 *
268 * @since 2.7.2
269 *
270 * @param array $settings Settings (by reference).
271 * @param array<string,int> $map Demo-site post id => imported post id.
272 * @return bool Whether anything changed.
273 */
274 private static function swap_template_refs( &$settings, $map ) {
275 $changed = false;
276
277 foreach ( $settings as $key => &$value ) {
278 if ( is_array( $value ) ) {
279 if ( self::swap_template_refs( $value, $map ) ) {
280 $changed = true;
281 }
282
283 continue;
284 }
285
286 if ( ! is_string( $key ) || ! in_array( $key, self::$template_ref_keys, true ) ) {
287 continue;
288 }
289
290 if ( ! is_scalar( $value ) ) {
291 continue;
292 }
293
294 $old = trim( (string) $value );
295
296 if ( '' === $old || ! isset( $map[ $old ] ) ) {
297 continue;
298 }
299
300 $value = is_string( $value ) ? (string) $map[ $old ] : (int) $map[ $old ];
301 $changed = true;
302 }
303
304 unset( $value );
305
306 return $changed;
307 }
308
309
310 /**
311 * Drop Elementor's cached render of one post after its `_elementor_data` was rewritten.
312 *
313 * Delegates to Wdkit_Api_Call::wdkit_invalidate_elementor_page_cache(), which clears this
314 * post's CSS file, `_elementor_element_cache` and `_elementor_page_assets` and nothing
315 * else — Elementor's own clear_cache() would wipe every page on the site.
316 *
317 * @since 2.7.2
318 *
319 * @param int $post_id Post id.
320 * @return void
321 */
322 private static function invalidate_render_cache( $post_id ) {
323 if ( class_exists( 'Wdkit_Api_Call' ) && is_callable( array( 'Wdkit_Api_Call', 'wdkit_invalidate_elementor_page_cache' ) ) ) {
324 Wdkit_Api_Call::wdkit_invalidate_elementor_page_cache( (int) $post_id );
325
326 return;
327 }
328
329 delete_post_meta( (int) $post_id, '_elementor_element_cache' );
330 delete_post_meta( (int) $post_id, '_elementor_page_assets' );
331 }
332
333 /**
334 * Is this template a navigation template?
335 *
336 * Port of check_navigation(). Used by the browser to skip dead-link scrubbing on
337 * navigation templates; exposed here so a PHP run can make the same decision.
338 *
339 * @param array $decoded Decoded template payload {title, content}.
340 * @param string $builder Builder.
341 * @return bool
342 */
343 public static function is_navigation_template( $decoded, $builder = 'elementor' ) {
344 if ( ! is_array( $decoded ) ) {
345 return false;
346 }
347
348 $title = isset( $decoded['title'] ) ? (string) $decoded['title'] : '';
349
350 if ( '' !== $title ) {
351 $first = trim( explode( '|', $title )[0] );
352 $parts = explode( '-', $first );
353
354 if ( isset( $parts[1] ) && 'menu' === strtolower( trim( $parts[1] ) ) ) {
355 return true;
356 }
357 }
358
359 if ( 'elementor' !== $builder ) {
360 return false;
361 }
362
363 $content = isset( $decoded['content'] ) ? $decoded['content'] : '';
364 $tree = is_string( $content ) ? json_decode( $content, true ) : $content;
365
366 if ( ! is_array( $tree ) ) {
367 return false;
368 }
369
370 return self::contains_navigation_widget( $tree );
371 }
372
373 /**
374 * @param array $elements Element list.
375 * @return bool
376 */
377 private static function contains_navigation_widget( $elements ) {
378 foreach ( $elements as $element ) {
379 if ( ! is_array( $element ) ) {
380 continue;
381 }
382
383 $widget_type = isset( $element['widgetType'] ) ? (string) $element['widgetType'] : '';
384
385 if ( '' !== $widget_type && in_array( $widget_type, self::$navigation_widgets, true ) ) {
386 return true;
387 }
388
389 if ( isset( $element['elements'] ) && is_array( $element['elements'] ) && self::contains_navigation_widget( $element['elements'] ) ) {
390 return true;
391 }
392 }
393
394 return false;
395 }
396
397 /**
398 * The widget list this class will rewrite, for tests and audit.
399 *
400 * @return string[]
401 */
402 public static function dynamic_widgets() {
403 return self::$dynamic_widgets;
404 }
405
406 /*
407 |--------------------------------------------------------------------------
408 | Demo links
409 |--------------------------------------------------------------------------
410 */
411
412 /**
413 * Point the kit's internal links at the imported pages.
414 *
415 * Every link a kit ships is an ABSOLUTE url into the demo site it was captured from -
416 * `https://gtemplates.wdesignkit.com/tastewheels/about-us/`. Nothing in the headless
417 * path rewrote them, so an imported site's menu, hero buttons and footer all navigated
418 * the visitor off to the demo.
419 *
420 * The browser does this in two narrow places - import_sections() for the deferred
421 * header/footer, and update_navigation_link() for navigation templates - because those
422 * are the only points at which it holds a complete `kit_links`. Here the whole map
423 * exists by the time finalize runs, so the same replacement is applied once across
424 * every imported post. Pages keep their links too, which the browser's version misses.
425 *
426 * Only QUOTED occurrences are replaced, exactly as the browser does it. A bare
427 * find-and-replace would corrupt longer urls that share a prefix: the demo home
428 * `.../tastewheels` is a prefix of `.../tastewheels/about-us`.
429 *
430 * Idempotent - once a demo url is gone there is nothing left to match, so a resumed or
431 * retried run finds no work.
432 *
433 * @since 2.6.5
434 *
435 * @param array[] $templates Context templates, carrying id and post_url.
436 * @param array[] $page_records Per-page results, carrying template_id and post_id.
437 * @return array{scanned:int,rewritten:int,links:int,scrubbed:int}
438 */
439 public static function rewrite_urls( $templates, $page_records ) {
440 $map = self::build_url_rewrite_map( $templates, $page_records );
441
442 $result = array(
443 'scanned' => count( $map['post_ids'] ),
444 'rewritten' => 0,
445 'links' => $map['links'],
446 'scrubbed' => 0,
447 );
448
449 foreach ( $map['post_ids'] as $post_id ) {
450 $applied = self::apply_url_rewrite_to_post( $post_id, $map['search'], $map['replace'] );
451
452 $result['scrubbed'] += $applied['scrubbed'];
453
454 if ( $applied['rewritten'] ) {
455 ++$result['rewritten'];
456 }
457 }
458
459 return $result;
460 }
461
462 /**
463 * The cheap half of rewrite_urls(): which posts need it, and the one search/replace
464 * map every one of them is rewritten against.
465 *
466 * Split out so a caller that has to bound the WRITE side (one wp_update_post() and one
467 * get_post_meta()/update_post_meta() pair per post — the part with no upper bound on a
468 * large kit) can call apply_url_rewrite_to_post() in its own budgeted loop instead of
469 * this class looping every post unconditionally. This half stays unbounded on purpose:
470 * it is array bookkeeping over templates/page_records, not a per-post DB write.
471 *
472 * @since 2.7.3
473 *
474 * @param array[] $templates Context templates, carrying id and post_url.
475 * @param array[] $page_records Per-page results, carrying template_id and post_id.
476 * @return array{post_ids:int[],search:string[],replace:string[],links:int}
477 */
478 public static function build_url_rewrite_map( $templates, $page_records ) {
479 $demo = array();
480 $rank = array();
481
482 foreach ( (array) $templates as $template ) {
483 if ( ! is_array( $template ) || empty( $template['id'] ) || empty( $template['post_url'] ) ) {
484 continue;
485 }
486
487 $url = (string) $template['post_url'];
488
489 /* A url with a fragment names a place ON the demo page, not a page of its own -
490 * the footer ships as `.../tastewheels/#Footer`. Mapping it would send a
491 * same-page anchor off to the footer template; leaving it out lets the scrub
492 * drop it to `#`. */
493 if ( false !== strpos( $url, '#' ) ) {
494 continue;
495 }
496
497 $demo[ (string) $template['id'] ] = $url;
498
499 /* Two templates can claim the same demo url, and which one wins decides where a
500 * menu item points. The kit's landing page and its header both live at the demo
501 * root, and its blog listing and blog detail both at /blog-listing/ - so last
502 * writer wins sent "Home" to the header template and "Blog" to a single post.
503 * A real page outranks a section, and a `page` outranks a theme-builder record. */
504 $rank[ (string) $template['id'] ] =
505 ( ( isset( $template['type'] ) && 'section' === $template['type'] ) ? 0 : 2 )
506 + ( ( isset( $template['wp_post_type'] ) && 'page' === $template['wp_post_type'] ) ? 1 : 0 );
507 }
508
509 $pairs = array();
510 $post_ids = array();
511
512 foreach ( (array) $page_records as $page ) {
513 if ( ! is_array( $page ) || empty( $page['post_id'] ) ) {
514 continue;
515 }
516
517 $post_ids[] = (int) $page['post_id'];
518
519 $template_id = ! empty( $page['template_id'] ) ? (string) $page['template_id'] : '';
520
521 if ( '' === $template_id || ! isset( $demo[ $template_id ] ) ) {
522 continue;
523 }
524
525 $local = get_permalink( (int) $page['post_id'] );
526
527 if ( ! is_string( $local ) || '' === $local ) {
528 continue;
529 }
530
531 $key = untrailingslashit( $demo[ $template_id ] );
532 $score = isset( $rank[ $template_id ] ) ? (int) $rank[ $template_id ] : 0;
533
534 if ( isset( $pairs[ $key ] ) && $pairs[ $key ]['score'] >= $score ) {
535 continue;
536 }
537
538 $pairs[ $key ] = array(
539 'url' => $local,
540 'score' => $score,
541 );
542 }
543
544 $pairs = wp_list_pluck( $pairs, 'url' );
545
546 $links = count( $pairs );
547 $post_ids = array_values( array_unique( array_filter( $post_ids ) ) );
548
549 if ( empty( $post_ids ) ) {
550 return array(
551 'post_ids' => array(),
552 'search' => array(),
553 'replace' => array(),
554 'links' => $links,
555 );
556 }
557
558 /* Longest first, so `.../tastewheels/about-us` is consumed before the demo home
559 * `.../tastewheels` can match the front of it. */
560 uksort(
561 $pairs,
562 static function ( $a, $b ) {
563 return strlen( (string) $b ) <=> strlen( (string) $a );
564 }
565 );
566
567 $search = array();
568 $replace = array();
569
570 foreach ( $pairs as $from => $to ) {
571 $to = untrailingslashit( $to );
572 $esc = str_replace( '/', '\\/', $from );
573 $esc_to = str_replace( '/', '\\/', $to );
574
575 /* Plain html attributes, block-comment json (`\/`), and json that has been
576 * escaped a second time on its way into an attribute (`\"..\"`). Trailing-slash
577 * variants first: `"url/"` must not be left with a stray slash. */
578 $search[] = '"' . $from . '/"';
579 $replace[] = '"' . $to . '"';
580 $search[] = '"' . $from . '"';
581 $replace[] = '"' . $to . '"';
582 $search[] = '"' . $esc . '\\/"';
583 $replace[] = '"' . $esc_to . '"';
584 $search[] = '"' . $esc . '"';
585 $replace[] = '"' . $esc_to . '"';
586 $search[] = '\\"' . $esc . '\\/\\"';
587 $replace[] = '\\"' . $esc_to . '\\"';
588 $search[] = '\\"' . $esc . '\\"';
589 $replace[] = '\\"' . $esc_to . '\\"';
590 }
591
592 return array(
593 'post_ids' => $post_ids,
594 'search' => $search,
595 'replace' => $replace,
596 'links' => $links,
597 );
598 }
599
600 /**
601 * Rewrite one post's demo links, both halves of its content.
602 *
603 * The write side of rewrite_urls(), pulled out so a caller can bound how many of
604 * these run per request instead of this class looping every post in the kit
605 * unconditionally — a get_post_field() + a possible wp_update_post(), then a
606 * get_post_meta()/update_post_meta() pair, per post, with no cap was the P1 this
607 * exists to close (ClickUp 14ynqxyxffw): fine on a 10-page kit, a plausible second
608 * timeout source on a 50+ page one, on top of the already-known prime_ai_batch() P0.
609 *
610 * @since 2.7.3
611 *
612 * @param int $post_id Post to rewrite.
613 * @param string[] $search From build_url_rewrite_map().
614 * @param string[] $replace From build_url_rewrite_map().
615 * @return array{rewritten:bool,scrubbed:int}
616 */
617 public static function apply_url_rewrite_to_post( $post_id, $search, $replace ) {
618 $result = array(
619 'rewritten' => false,
620 'scrubbed' => 0,
621 );
622
623 $content = get_post_field( 'post_content', $post_id, 'raw' );
624
625 if ( is_string( $content ) && '' !== $content ) {
626 $updated = empty( $search ) ? $content : str_replace( $search, $replace, $content );
627 $scrub = self::scrub_demo_links( $updated );
628 $updated = $scrub['content'];
629
630 if ( $updated !== $content ) {
631 $result['scrubbed'] += $scrub['count'];
632
633 /* wp_update_post() would run the content through kses for a
634 * non-privileged user and re-fire the save hooks the importer has
635 * already run; this pass only substitutes urls inside content that
636 * was written moments ago. */
637 $saved = wp_update_post(
638 array(
639 'ID' => $post_id,
640 'post_content' => wp_slash( $updated ),
641 ),
642 true
643 );
644
645 if ( ! is_wp_error( $saved ) ) {
646 $result['rewritten'] = true;
647 }
648 }
649 }
650
651 /* An Elementor page's real content — including its nav menu, hero buttons and
652 * footer links — lives in `_elementor_data`, not `post_content` (which
653 * Elementor leaves mostly empty). Missing this half is exactly what left an
654 * imported site's whole menu still pointing at the demo: rewriting
655 * `post_content` alone touches nothing an Elementor widget actually reads.
656 * Same substitution as class-api.php's proven "4.5. Remap demo navigation
657 * links" step, including the wp_slash() on write - get_post_meta() hands back
658 * the already-unslashed JSON, and update_post_meta() unslashes again on the
659 * way in, so writing it back straight would corrupt it. */
660 $elementor_data = get_post_meta( $post_id, '_elementor_data', true );
661
662 if ( ! is_string( $elementor_data ) || '' === $elementor_data ) {
663 return $result;
664 }
665
666 $updated_elementor = empty( $search ) ? $elementor_data : str_replace( $search, $replace, $elementor_data );
667 $scrub_elementor = self::scrub_demo_links( $updated_elementor );
668 $updated_elementor = $scrub_elementor['content'];
669
670 if ( $updated_elementor === $elementor_data ) {
671 return $result;
672 }
673
674 $result['scrubbed'] += $scrub_elementor['count'];
675
676 update_post_meta( $post_id, '_elementor_data', wp_slash( $updated_elementor ) );
677 self::invalidate_render_cache( $post_id );
678
679 $result['rewritten'] = true;
680
681 return $result;
682 }
683
684 /**
685 * Blank out demo links that no imported page answers for.
686 *
687 * A kit routinely links to demo pages it does not ship - `/coming-soon/`,
688 * `/product-details/` - and those cannot be rewritten because nothing local
689 * corresponds to them. The browser drops them to `#`, and does the same. Its version
690 * only knows `etemplates.wdesignkit.com`, so it never fired for a gutenberg kit, whose
691 * demo lives on `gtemplates`; both are matched here.
692 *
693 * Media is left alone: an image url is still a valid image, and the media step rewrites
694 * those separately.
695 *
696 * @param string $content Post content.
697 * @return array{content:string,count:int}
698 */
699 private static function scrub_demo_links( $content ) {
700 $count = 0;
701
702 if ( false === strpos( $content, 'templates.wdesignkit.com' ) ) {
703 return array(
704 'content' => $content,
705 'count' => 0,
706 );
707 }
708
709 $content = preg_replace_callback(
710 '#(\\\\?")((?:https?:)?(?:\\\\?/\\\\?/|//)[a-z0-9-]*templates\\.wdesignkit\\.com[^"]*?)(\\\\?")#i',
711 static function ( $m ) use ( &$count ) {
712 $url = str_replace( '\\/', '/', $m[2] );
713
714 if ( preg_match( '/\\.(jpe?g|png|gif|svg|webp|avif|bmp|ico|mp4|webm|pdf)(\\?|\\#|$)/i', $url ) ) {
715 return $m[0];
716 }
717
718 ++$count;
719
720 return $m[1] . '#' . $m[3];
721 },
722 $content
723 );
724
725 return array(
726 'content' => $content,
727 'count' => $count,
728 );
729 }
730 }
731 }
732