PluginProbe
WDesignKit – AI Templates, Widget Builder & MCP Workflow / 2.7.0
WDesignKit – AI Templates, Widget Builder & MCP Workflow v2.7.0
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 1.0.16 All 129 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.0, at includes/admin/import/class-wdkit-import-navigation.php

730 lines 23.7 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 );
254
255 /**
256 * Repoint saved-template references in one widget's settings, repeater rows included.
257 *
258 * Replaces a value only when its KEY is a template reference and the value is EXACTLY an
259 * id in the map. The previous approach serialised the settings and ran str_replace() over
260 * the digits, which also rewrote any other number containing them — a width of 1492, an
261 * id of 11492 — and, keyed as it was, never actually matched a template anyway.
262 *
263 * The value keeps its type: Elementor stores these as strings, and a string comes back a
264 * string.
265 *
266 * @since 2.7.2
267 *
268 * @param array $settings Settings (by reference).
269 * @param array<string,int> $map Demo-site post id => imported post id.
270 * @return bool Whether anything changed.
271 */
272 private static function swap_template_refs( &$settings, $map ) {
273 $changed = false;
274
275 foreach ( $settings as $key => &$value ) {
276 if ( is_array( $value ) ) {
277 if ( self::swap_template_refs( $value, $map ) ) {
278 $changed = true;
279 }
280
281 continue;
282 }
283
284 if ( ! is_string( $key ) || ! in_array( $key, self::$template_ref_keys, true ) ) {
285 continue;
286 }
287
288 if ( ! is_scalar( $value ) ) {
289 continue;
290 }
291
292 $old = trim( (string) $value );
293
294 if ( '' === $old || ! isset( $map[ $old ] ) ) {
295 continue;
296 }
297
298 $value = is_string( $value ) ? (string) $map[ $old ] : (int) $map[ $old ];
299 $changed = true;
300 }
301
302 unset( $value );
303
304 return $changed;
305 }
306
307
308 /**
309 * Drop Elementor's cached render of one post after its `_elementor_data` was rewritten.
310 *
311 * Delegates to Wdkit_Api_Call::wdkit_invalidate_elementor_page_cache(), which clears this
312 * post's CSS file, `_elementor_element_cache` and `_elementor_page_assets` and nothing
313 * else — Elementor's own clear_cache() would wipe every page on the site.
314 *
315 * @since 2.7.2
316 *
317 * @param int $post_id Post id.
318 * @return void
319 */
320 private static function invalidate_render_cache( $post_id ) {
321 if ( class_exists( 'Wdkit_Api_Call' ) && is_callable( array( 'Wdkit_Api_Call', 'wdkit_invalidate_elementor_page_cache' ) ) ) {
322 Wdkit_Api_Call::wdkit_invalidate_elementor_page_cache( (int) $post_id );
323
324 return;
325 }
326
327 delete_post_meta( (int) $post_id, '_elementor_element_cache' );
328 delete_post_meta( (int) $post_id, '_elementor_page_assets' );
329 }
330
331 /**
332 * Is this template a navigation template?
333 *
334 * Port of check_navigation(). Used by the browser to skip dead-link scrubbing on
335 * navigation templates; exposed here so a PHP run can make the same decision.
336 *
337 * @param array $decoded Decoded template payload {title, content}.
338 * @param string $builder Builder.
339 * @return bool
340 */
341 public static function is_navigation_template( $decoded, $builder = 'elementor' ) {
342 if ( ! is_array( $decoded ) ) {
343 return false;
344 }
345
346 $title = isset( $decoded['title'] ) ? (string) $decoded['title'] : '';
347
348 if ( '' !== $title ) {
349 $first = trim( explode( '|', $title )[0] );
350 $parts = explode( '-', $first );
351
352 if ( isset( $parts[1] ) && 'menu' === strtolower( trim( $parts[1] ) ) ) {
353 return true;
354 }
355 }
356
357 if ( 'elementor' !== $builder ) {
358 return false;
359 }
360
361 $content = isset( $decoded['content'] ) ? $decoded['content'] : '';
362 $tree = is_string( $content ) ? json_decode( $content, true ) : $content;
363
364 if ( ! is_array( $tree ) ) {
365 return false;
366 }
367
368 return self::contains_navigation_widget( $tree );
369 }
370
371 /**
372 * @param array $elements Element list.
373 * @return bool
374 */
375 private static function contains_navigation_widget( $elements ) {
376 foreach ( $elements as $element ) {
377 if ( ! is_array( $element ) ) {
378 continue;
379 }
380
381 $widget_type = isset( $element['widgetType'] ) ? (string) $element['widgetType'] : '';
382
383 if ( '' !== $widget_type && in_array( $widget_type, self::$navigation_widgets, true ) ) {
384 return true;
385 }
386
387 if ( isset( $element['elements'] ) && is_array( $element['elements'] ) && self::contains_navigation_widget( $element['elements'] ) ) {
388 return true;
389 }
390 }
391
392 return false;
393 }
394
395 /**
396 * The widget list this class will rewrite, for tests and audit.
397 *
398 * @return string[]
399 */
400 public static function dynamic_widgets() {
401 return self::$dynamic_widgets;
402 }
403
404 /*
405 |--------------------------------------------------------------------------
406 | Demo links
407 |--------------------------------------------------------------------------
408 */
409
410 /**
411 * Point the kit's internal links at the imported pages.
412 *
413 * Every link a kit ships is an ABSOLUTE url into the demo site it was captured from -
414 * `https://gtemplates.wdesignkit.com/tastewheels/about-us/`. Nothing in the headless
415 * path rewrote them, so an imported site's menu, hero buttons and footer all navigated
416 * the visitor off to the demo.
417 *
418 * The browser does this in two narrow places - import_sections() for the deferred
419 * header/footer, and update_navigation_link() for navigation templates - because those
420 * are the only points at which it holds a complete `kit_links`. Here the whole map
421 * exists by the time finalize runs, so the same replacement is applied once across
422 * every imported post. Pages keep their links too, which the browser's version misses.
423 *
424 * Only QUOTED occurrences are replaced, exactly as the browser does it. A bare
425 * find-and-replace would corrupt longer urls that share a prefix: the demo home
426 * `.../tastewheels` is a prefix of `.../tastewheels/about-us`.
427 *
428 * Idempotent - once a demo url is gone there is nothing left to match, so a resumed or
429 * retried run finds no work.
430 *
431 * @since 2.6.5
432 *
433 * @param array[] $templates Context templates, carrying id and post_url.
434 * @param array[] $page_records Per-page results, carrying template_id and post_id.
435 * @return array{scanned:int,rewritten:int,links:int,scrubbed:int}
436 */
437 public static function rewrite_urls( $templates, $page_records ) {
438 $map = self::build_url_rewrite_map( $templates, $page_records );
439
440 $result = array(
441 'scanned' => count( $map['post_ids'] ),
442 'rewritten' => 0,
443 'links' => $map['links'],
444 'scrubbed' => 0,
445 );
446
447 foreach ( $map['post_ids'] as $post_id ) {
448 $applied = self::apply_url_rewrite_to_post( $post_id, $map['search'], $map['replace'] );
449
450 $result['scrubbed'] += $applied['scrubbed'];
451
452 if ( $applied['rewritten'] ) {
453 ++$result['rewritten'];
454 }
455 }
456
457 return $result;
458 }
459
460 /**
461 * The cheap half of rewrite_urls(): which posts need it, and the one search/replace
462 * map every one of them is rewritten against.
463 *
464 * Split out so a caller that has to bound the WRITE side (one wp_update_post() and one
465 * get_post_meta()/update_post_meta() pair per post — the part with no upper bound on a
466 * large kit) can call apply_url_rewrite_to_post() in its own budgeted loop instead of
467 * this class looping every post unconditionally. This half stays unbounded on purpose:
468 * it is array bookkeeping over templates/page_records, not a per-post DB write.
469 *
470 * @since 2.7.3
471 *
472 * @param array[] $templates Context templates, carrying id and post_url.
473 * @param array[] $page_records Per-page results, carrying template_id and post_id.
474 * @return array{post_ids:int[],search:string[],replace:string[],links:int}
475 */
476 public static function build_url_rewrite_map( $templates, $page_records ) {
477 $demo = array();
478 $rank = array();
479
480 foreach ( (array) $templates as $template ) {
481 if ( ! is_array( $template ) || empty( $template['id'] ) || empty( $template['post_url'] ) ) {
482 continue;
483 }
484
485 $url = (string) $template['post_url'];
486
487 /* A url with a fragment names a place ON the demo page, not a page of its own -
488 * the footer ships as `.../tastewheels/#Footer`. Mapping it would send a
489 * same-page anchor off to the footer template; leaving it out lets the scrub
490 * drop it to `#`. */
491 if ( false !== strpos( $url, '#' ) ) {
492 continue;
493 }
494
495 $demo[ (string) $template['id'] ] = $url;
496
497 /* Two templates can claim the same demo url, and which one wins decides where a
498 * menu item points. The kit's landing page and its header both live at the demo
499 * root, and its blog listing and blog detail both at /blog-listing/ - so last
500 * writer wins sent "Home" to the header template and "Blog" to a single post.
501 * A real page outranks a section, and a `page` outranks a theme-builder record. */
502 $rank[ (string) $template['id'] ] =
503 ( ( isset( $template['type'] ) && 'section' === $template['type'] ) ? 0 : 2 )
504 + ( ( isset( $template['wp_post_type'] ) && 'page' === $template['wp_post_type'] ) ? 1 : 0 );
505 }
506
507 $pairs = array();
508 $post_ids = array();
509
510 foreach ( (array) $page_records as $page ) {
511 if ( ! is_array( $page ) || empty( $page['post_id'] ) ) {
512 continue;
513 }
514
515 $post_ids[] = (int) $page['post_id'];
516
517 $template_id = ! empty( $page['template_id'] ) ? (string) $page['template_id'] : '';
518
519 if ( '' === $template_id || ! isset( $demo[ $template_id ] ) ) {
520 continue;
521 }
522
523 $local = get_permalink( (int) $page['post_id'] );
524
525 if ( ! is_string( $local ) || '' === $local ) {
526 continue;
527 }
528
529 $key = untrailingslashit( $demo[ $template_id ] );
530 $score = isset( $rank[ $template_id ] ) ? (int) $rank[ $template_id ] : 0;
531
532 if ( isset( $pairs[ $key ] ) && $pairs[ $key ]['score'] >= $score ) {
533 continue;
534 }
535
536 $pairs[ $key ] = array(
537 'url' => $local,
538 'score' => $score,
539 );
540 }
541
542 $pairs = wp_list_pluck( $pairs, 'url' );
543
544 $links = count( $pairs );
545 $post_ids = array_values( array_unique( array_filter( $post_ids ) ) );
546
547 if ( empty( $post_ids ) ) {
548 return array(
549 'post_ids' => array(),
550 'search' => array(),
551 'replace' => array(),
552 'links' => $links,
553 );
554 }
555
556 /* Longest first, so `.../tastewheels/about-us` is consumed before the demo home
557 * `.../tastewheels` can match the front of it. */
558 uksort(
559 $pairs,
560 static function ( $a, $b ) {
561 return strlen( (string) $b ) <=> strlen( (string) $a );
562 }
563 );
564
565 $search = array();
566 $replace = array();
567
568 foreach ( $pairs as $from => $to ) {
569 $to = untrailingslashit( $to );
570 $esc = str_replace( '/', '\\/', $from );
571 $esc_to = str_replace( '/', '\\/', $to );
572
573 /* Plain html attributes, block-comment json (`\/`), and json that has been
574 * escaped a second time on its way into an attribute (`\"..\"`). Trailing-slash
575 * variants first: `"url/"` must not be left with a stray slash. */
576 $search[] = '"' . $from . '/"';
577 $replace[] = '"' . $to . '"';
578 $search[] = '"' . $from . '"';
579 $replace[] = '"' . $to . '"';
580 $search[] = '"' . $esc . '\\/"';
581 $replace[] = '"' . $esc_to . '"';
582 $search[] = '"' . $esc . '"';
583 $replace[] = '"' . $esc_to . '"';
584 $search[] = '\\"' . $esc . '\\/\\"';
585 $replace[] = '\\"' . $esc_to . '\\"';
586 $search[] = '\\"' . $esc . '\\"';
587 $replace[] = '\\"' . $esc_to . '\\"';
588 }
589
590 return array(
591 'post_ids' => $post_ids,
592 'search' => $search,
593 'replace' => $replace,
594 'links' => $links,
595 );
596 }
597
598 /**
599 * Rewrite one post's demo links, both halves of its content.
600 *
601 * The write side of rewrite_urls(), pulled out so a caller can bound how many of
602 * these run per request instead of this class looping every post in the kit
603 * unconditionally — a get_post_field() + a possible wp_update_post(), then a
604 * get_post_meta()/update_post_meta() pair, per post, with no cap was the P1 this
605 * exists to close (ClickUp 14ynqxyxffw): fine on a 10-page kit, a plausible second
606 * timeout source on a 50+ page one, on top of the already-known prime_ai_batch() P0.
607 *
608 * @since 2.7.3
609 *
610 * @param int $post_id Post to rewrite.
611 * @param string[] $search From build_url_rewrite_map().
612 * @param string[] $replace From build_url_rewrite_map().
613 * @return array{rewritten:bool,scrubbed:int}
614 */
615 public static function apply_url_rewrite_to_post( $post_id, $search, $replace ) {
616 $result = array(
617 'rewritten' => false,
618 'scrubbed' => 0,
619 );
620
621 $content = get_post_field( 'post_content', $post_id, 'raw' );
622
623 if ( is_string( $content ) && '' !== $content ) {
624 $updated = empty( $search ) ? $content : str_replace( $search, $replace, $content );
625 $scrub = self::scrub_demo_links( $updated );
626 $updated = $scrub['content'];
627
628 if ( $updated !== $content ) {
629 $result['scrubbed'] += $scrub['count'];
630
631 /* wp_update_post() would run the content through kses for a
632 * non-privileged user and re-fire the save hooks the importer has
633 * already run; this pass only substitutes urls inside content that
634 * was written moments ago. */
635 $saved = wp_update_post(
636 array(
637 'ID' => $post_id,
638 'post_content' => wp_slash( $updated ),
639 ),
640 true
641 );
642
643 if ( ! is_wp_error( $saved ) ) {
644 $result['rewritten'] = true;
645 }
646 }
647 }
648
649 /* An Elementor page's real content — including its nav menu, hero buttons and
650 * footer links — lives in `_elementor_data`, not `post_content` (which
651 * Elementor leaves mostly empty). Missing this half is exactly what left an
652 * imported site's whole menu still pointing at the demo: rewriting
653 * `post_content` alone touches nothing an Elementor widget actually reads.
654 * Same substitution as class-api.php's proven "4.5. Remap demo navigation
655 * links" step, including the wp_slash() on write - get_post_meta() hands back
656 * the already-unslashed JSON, and update_post_meta() unslashes again on the
657 * way in, so writing it back straight would corrupt it. */
658 $elementor_data = get_post_meta( $post_id, '_elementor_data', true );
659
660 if ( ! is_string( $elementor_data ) || '' === $elementor_data ) {
661 return $result;
662 }
663
664 $updated_elementor = empty( $search ) ? $elementor_data : str_replace( $search, $replace, $elementor_data );
665 $scrub_elementor = self::scrub_demo_links( $updated_elementor );
666 $updated_elementor = $scrub_elementor['content'];
667
668 if ( $updated_elementor === $elementor_data ) {
669 return $result;
670 }
671
672 $result['scrubbed'] += $scrub_elementor['count'];
673
674 update_post_meta( $post_id, '_elementor_data', wp_slash( $updated_elementor ) );
675 self::invalidate_render_cache( $post_id );
676
677 $result['rewritten'] = true;
678
679 return $result;
680 }
681
682 /**
683 * Blank out demo links that no imported page answers for.
684 *
685 * A kit routinely links to demo pages it does not ship - `/coming-soon/`,
686 * `/product-details/` - and those cannot be rewritten because nothing local
687 * corresponds to them. The browser drops them to `#`, and does the same. Its version
688 * only knows `etemplates.wdesignkit.com`, so it never fired for a gutenberg kit, whose
689 * demo lives on `gtemplates`; both are matched here.
690 *
691 * Media is left alone: an image url is still a valid image, and the media step rewrites
692 * those separately.
693 *
694 * @param string $content Post content.
695 * @return array{content:string,count:int}
696 */
697 private static function scrub_demo_links( $content ) {
698 $count = 0;
699
700 if ( false === strpos( $content, 'templates.wdesignkit.com' ) ) {
701 return array(
702 'content' => $content,
703 'count' => 0,
704 );
705 }
706
707 $content = preg_replace_callback(
708 '#(\\\\?")((?:https?:)?(?:\\\\?/\\\\?/|//)[a-z0-9-]*templates\\.wdesignkit\\.com[^"]*?)(\\\\?")#i',
709 static function ( $m ) use ( &$count ) {
710 $url = str_replace( '\\/', '/', $m[2] );
711
712 if ( preg_match( '/\\.(jpe?g|png|gif|svg|webp|avif|bmp|ico|mp4|webm|pdf)(\\?|\\#|$)/i', $url ) ) {
713 return $m[0];
714 }
715
716 ++$count;
717
718 return $m[1] . '#' . $m[3];
719 },
720 $content
721 );
722
723 return array(
724 'content' => $content,
725 'count' => $count,
726 );
727 }
728 }
729 }
730