Repository navigation
Expand file tree
/
Copy pathvisual-edit-lite.php
More file actions
2728 lines (2583 loc) · 116 KB
/
Copy pathvisual-edit-lite.php
File metadata and controls
2728 lines (2583 loc) · 116 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
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
<?php
/**
* Plugin Name: Visual Edit Lite – Visual Editor for Block Themes
* Plugin URI: https://html2wp.dev/visualedit/
* Description: Visual editing for raw-HTML sites and complete native Gutenberg editing for block themes, with responsive controls, movement, forms, SEO and AI-readiness.
* Version: 1.31.5
* Requires at least: 6.6
* Requires PHP: 7.4
* Author: Filip Dvoran
* Author URI: https://github.com/iOSDevSK
* License: GPL-2.0-or-later
* License URI: https://www.gnu.org/licenses/gpl-2.0.html
* Text Domain: visual-edit-lite
* Domain Path: /languages
*/
defined( 'ABSPATH' ) || exit;
/**
* Whether Visual Edit Pro is active on this install.
*
* Lite and Pro share every class, option and REST namespace, and
* `visual-edit-lite/` sorts BEFORE `visual-edit/`, so Lite loads first and
* would win a collision it has no business winning. Lite is therefore the one
* that stands down. The function name carries the `lite` infix deliberately:
* Pro has no such symbol, so this check cannot collide with the thing it is
* checking for.
*/
function clara_ve_lite_pro_active() {
$active = (array) get_option( 'active_plugins', array() );
if ( is_multisite() ) {
$active = array_merge( $active, array_keys( (array) get_site_option( 'active_sitewide_plugins', array() ) ) );
}
foreach ( $active as $plugin ) {
if ( 'visual-edit.php' === basename( (string) $plugin ) ) {
return true;
}
}
return false;
}
if ( clara_ve_lite_pro_active() ) {
add_action(
'admin_notices',
function () {
if ( ! current_user_can( 'activate_plugins' ) ) {
return;
}
echo '<div class="notice notice-warning is-dismissible"><p>';
echo esc_html__( 'Visual Edit Lite is switched off because Visual Edit Pro is active on this site. Pro already includes everything Lite does — deactivate Lite to clear this notice.', 'visual-edit-lite' );
echo '</p></div>';
}
);
return;
}
define( 'CLARA_VE_VERSION', '1.31.5' );
// Signals schema-1 generated themes that this plugin delegates every public
// rendering concern to them. Themes generated before that contract ignore the
// signal and continue to receive the complete legacy runtime below.
define( 'CLARA_VE_THEME_RUNTIME_DELEGATION', 1 );
define( 'CLARA_VE_DIR', plugin_dir_path( __FILE__ ) );
define( 'CLARA_VE_URL', plugin_dir_url( __FILE__ ) );
/**
* Whether the active theme declares the standalone html2wp runtime contract.
*
* get_theme_support() returns an outer list of argument sets. Be deliberately
* tolerant here: schema is the discriminator, not the exact PHP nesting a
* particular WordPress version chooses to preserve.
*/
function clara_ve_theme_owns_public_runtime() {
$support = get_theme_support( 'html2wp-runtime' );
if ( false === $support ) {
return false;
}
foreach ( (array) $support as $entry ) {
if ( is_array( $entry ) && isset( $entry['schema'] ) && (int) $entry['schema'] >= 1 ) {
return true;
}
if ( is_array( $entry ) && isset( $entry[0] ) && is_array( $entry[0] ) && isset( $entry[0]['schema'] ) && (int) $entry[0]['schema'] >= 1 ) {
return true;
}
}
return false;
}
/*
* No licensing, no self-updater.
*
* Visual Edit Lite is the free edition: every feature it ships is available to
* every install, unconditionally and offline. There is no key to enter, no
* activation call, no licence heartbeat and no bundled update checker — the
* WordPress.org directory is the only update channel (and bundling an updater
* is forbidden there anyway).
*
* What Lite therefore does NOT contain, rather than merely hiding it: the AI
* Assistant and AI image/video tools, the AI Settings screen, Cloudflare
* Turnstile on forms, and Theme Export.
*
* Everything else — editing, saving, forms, menus, dynamic tokens, SEO,
* redirects, AI-readiness/llms.txt, block mode, motion, import — is here in
* full. Edit history keeps ten saves per page plus the Original, and lists and
* restores every one of them: a list shorter than the table behind it would be
* a limit a payment lifts, which the directory does not allow (guideline 5).
*/
// The unqualified name of the pattern whose rendered HTML is the editable
// front-page source. WordPress namespaces a theme's auto-registered patterns
// with the theme's own slug, so the qualified name is resolved at runtime by
// clara_ve_pattern_name() rather than hardcoded to one theme.
define( 'CLARA_VE_PATTERN_SLUG', 'front-page-original' );
define( 'CLARA_VE_OPTION', 'clara_ve_front_source' );
define( 'CLARA_VE_THEME_URI_TOKEN', '__CLARA_THEME_URI__' );
define( 'CLARA_VE_NAV_LOCATION', 'clara_ve_front_nav' );
define( 'CLARA_VE_PSEUDO_OPTION', 'clara_ve_pseudo_css' );
/**
* The folder under uploads/ that every imported media file lands in.
*
* It is the one internal name a site's VISITORS read: it sits in the address
* of every photograph on a converted site, and the themes this pipeline builds
* are sold to people with no connection to the site it was first written for.
* `ve-import` says which plugin put the file there and nothing else.
*
* The old name is still read, and that is not tidiness — it is every site
* already running. `clara_ve_import_dirs()` is what anything SCANNING must
* use; only new writes take the constant. An install that migrated years of
* media under the old prefix keeps managing it, and the same media library can
* hold both without either half going unrecognised.
*/
define( 'CLARA_VE_IMPORT_DIR', 've-import' );
define( 'CLARA_VE_IMPORT_DIR_LEGACY', 'clara-ve-import' );
/** Every prefix an imported attachment may live under, newest first. */
function clara_ve_import_dirs() {
return array( CLARA_VE_IMPORT_DIR, CLARA_VE_IMPORT_DIR_LEGACY );
}
/** True when a stored path is one this plugin imported, under either name. */
function clara_ve_is_import_path( $relative ) {
foreach ( clara_ve_import_dirs() as $dir ) {
if ( 0 === strpos( (string) $relative, $dir . '/' ) ) {
return true;
}
}
return false;
}
/** A stored path with whichever import prefix it carries taken off. */
function clara_ve_strip_import_dir( $relative ) {
foreach ( clara_ve_import_dirs() as $dir ) {
if ( 0 === strpos( (string) $relative, $dir . '/' ) ) {
return substr( (string) $relative, strlen( $dir ) + 1 );
}
}
return (string) $relative;
}
// Which theme the stored editable content belongs to. Everything this plugin
// persists — page sources, pseudo styles, history, the page bindings — is
// keyed by page key ALONE, with no theme in the name. That is deliberate and
// correct for the model this plugin is built for (one WordPress site = one
// converted theme = one set of sources), and it is silently destructive the
// moment a SECOND converted theme is activated on the same install: the front
// page renders theme A's HTML through theme B's CSS, B's import is refused as
// a conflict, a save under B writes A's header into B's template part, the
// theme-URI token resolves to B's directory, and restoring history writes A's
// markup into B's pages. Verified against the code, every one of them.
//
// So the data records who owns it, and the plugin refuses to serve or
// overwrite content that belongs to a different theme (see
// clara_ve_foreign_data() and its call sites). That does not make theme
// switching WORK — supporting it needs the stored data namespaced per
// stylesheet, which is a larger change — but it turns a silent hybrid into an
// explicit, reversible state that says what is wrong.
define( 'CLARA_VE_OWNER_OPTION', 'clara_ve_owner_theme' );
// Reserved page key for the front page (which isn't a real WP Page — it's
// the pattern-override mechanism above). Other WP Pages become "visual-edit
// enabled" by carrying this post meta, pointing at their own keyed source.
define( 'CLARA_VE_DEFAULT_KEY', 'front-page' );
define( 'CLARA_VE_PAGE_KEY_META', '_clara_ve_key' );
// Which theme a bound Page belongs to. A page key alone stopped being unique
// the moment two converted themes could share an install — both would name a
// page "about" — so the binding records the theme too and lookups prefer the
// active one. Absent on pages bound before scoping shipped; the migration
// stamps those, and until it runs an unstamped page still resolves.
define( 'CLARA_VE_PAGE_THEME_META', '_clara_ve_theme' );
/**
* The address a page held before its theme was switched away from — see
* clara_ve_handover_pages_on_switch(). Present only while parked.
*/
define( 'CLARA_VE_PAGE_SLUG_META', '_clara_ve_slug' );
/**
* The status a parked page or post held before its theme was deactivated, and
* the status it holds while parked.
*
* Deactivating a converted theme has to leave the site looking as though that
* theme had never been installed, which a renamed slug alone does not achieve:
* the pages stay published, listed, searchable and reachable at their new
* address. A status of its own takes them out of every query at once.
*
* Deliberately not `trash`. wp_scheduled_delete() permanently deletes anything
* trashed longer than EMPTY_TRASH_DAYS ago — 30 by default — so parking into
* the trash would destroy a site's content a month after someone switched
* themes, silently, on a timer.
*
* Slug parking stays alongside it rather than being replaced by it, because
* the two solve different problems: wp_unique_post_slug() does not consider
* post_status at all for hierarchical types, so a parked page still holding
* `about` makes the next theme's page land on `about-2`.
*/
define( 'CLARA_VE_PARKED_STATUS', 'clara_ve_parked' );
define( 'CLARA_VE_PAGE_STATUS_META', '_clara_ve_status' );
// Reserved keys for the shared header/footer template parts (parts/header.html,
// parts/footer.html) — like the front page, these have no bound WP Page; the
// render target is WordPress's own template-part customization mechanism
// (a wp_template_part post), the same one the Site Editor itself writes to.
define( 'CLARA_VE_HEADER_KEY', 'header' );
define( 'CLARA_VE_FOOTER_KEY', 'footer' );
// The single-post layout (parts/article.html, rendered by templates/single.html
// between the header and footer parts). Same shape as the two above — no bound
// WP Page, mirrored into a wp_template_part — which is exactly why the article
// template is a PART rather than a wp_template: every piece of machinery those
// two already rely on (sync, root selector, shape guard, History, pseudo
// styles) is keyed by name and works for this one unchanged.
define( 'CLARA_VE_ARTICLE_KEY', 'article' );
// The 404 page (parts/404.html, rendered by templates/404.html between the
// header and footer parts), for the same reason as the article layout above.
//
// It has to be a part rather than a template because WordPress renders the 404
// for addresses that DO NOT EXIST — there is no stable URL the editor could
// load to edit it. A theme that instead ships an ordinary "404 preview" Page
// alongside a separate wp_template hands the owner two independent copies of
// the same design in two different markup systems, where editing the one they
// can reach changes nothing about the one visitors see. As a part it is edited
// like any other key, and previewed by opening any address that is not a page.
define( 'CLARA_VE_404_KEY', '404' );
// Delimiters around the article template's typography specimen — the block of
// sample elements whose styles are compiled into every article's body CSS. It
// is editor-only furniture, cut from the published page by
// clara_ve_strip_specimen().
define( 'CLARA_VE_SPECIMEN_START', '<!-- cve-specimen-start -->' );
define( 'CLARA_VE_SPECIMEN_END', '<!-- cve-specimen-end -->' );
require_once CLARA_VE_DIR . 'includes/class-block-gate.php';
require_once CLARA_VE_DIR . 'includes/class-block-convert.php';
require_once CLARA_VE_DIR . 'includes/class-block-supports.php';
require_once CLARA_VE_DIR . 'includes/class-patterns.php';
require_once CLARA_VE_DIR . 'includes/class-motion.php';
require_once CLARA_VE_DIR . 'includes/class-responsive.php';
require_once CLARA_VE_DIR . 'includes/class-block-extras.php';
require_once CLARA_VE_DIR . 'includes/class-block-stamp.php';
require_once CLARA_VE_DIR . 'includes/class-block-patch.php';
require_once CLARA_VE_DIR . 'includes/class-source-store.php';
require_once CLARA_VE_DIR . 'includes/class-pseudo-store.php';
require_once CLARA_VE_DIR . 'includes/class-history.php';
require_once CLARA_VE_DIR . 'includes/class-theme-registry.php';
require_once CLARA_VE_DIR . 'includes/class-theme-park.php';
require_once CLARA_VE_DIR . 'includes/class-theme-purge.php';
require_once CLARA_VE_DIR . 'includes/class-rest.php';
require_once CLARA_VE_DIR . 'includes/class-front-nav.php';
require_once CLARA_VE_DIR . 'includes/class-forms.php';
require_once CLARA_VE_DIR . 'includes/class-lists.php';
require_once CLARA_VE_DIR . 'includes/class-optin.php';
require_once CLARA_VE_DIR . 'includes/class-tokens.php';
require_once CLARA_VE_DIR . 'includes/class-form-blocks.php';
require_once CLARA_VE_DIR . 'includes/class-form-connect.php';
require_once CLARA_VE_DIR . 'includes/class-form-handlers.php';
require_once CLARA_VE_DIR . 'includes/class-fonts.php';
require_once CLARA_VE_DIR . 'includes/class-editor-page.php';
require_once CLARA_VE_DIR . 'includes/class-native-gutenberg.php';
require_once CLARA_VE_DIR . 'includes/class-native-history.php';
require_once CLARA_VE_DIR . 'includes/class-media.php';
require_once CLARA_VE_DIR . 'includes/class-form-settings.php';
require_once CLARA_VE_DIR . 'includes/class-mailer.php';
require_once CLARA_VE_DIR . 'includes/class-bundle-format.php';
// After class-bundle-format.php: Clara_VE_SEO normalises through
// Clara_VE_Bundle_Format::sanitize_seo(), the one definition of a record's shape.
require_once CLARA_VE_DIR . 'includes/class-seo.php';
require_once CLARA_VE_DIR . 'includes/class-redirects.php';
require_once CLARA_VE_DIR . 'includes/class-geo.php';
require_once CLARA_VE_DIR . 'includes/class-geo-audit.php';
require_once CLARA_VE_DIR . 'includes/class-seo-settings.php';
require_once CLARA_VE_DIR . 'includes/class-zip.php';
require_once CLARA_VE_DIR . 'includes/class-bundle-writer.php';
require_once CLARA_VE_DIR . 'includes/class-bundle-reader.php';
require_once CLARA_VE_DIR . 'includes/class-import-legacy.php';
require_once CLARA_VE_DIR . 'includes/class-import-plan.php';
require_once CLARA_VE_DIR . 'includes/class-import-page.php';
require_once CLARA_VE_DIR . 'includes/class-parked-page.php';
require_once CLARA_VE_DIR . 'includes/class-page-actions.php';
// Last of the admin screens on purpose: it reads the Visual Edit menu as the
// files above have already built it. Required here, below the stand-down at
// the top of this file, so an install running Visual Edit Pro never registers
// an upsell item beside the real screen.
require_once CLARA_VE_DIR . 'includes/class-get-pro.php';
// Create the history table on the site's first request after install/update —
// dbDelta is idempotent, so this is a cheap no-op once the schema is current.
add_action( 'init', array( 'Clara_VE_History', 'maybe_install' ), 1 );
/**
* Editing requires theme-level trust: raw HTML round-trips through the editor.
*/
function clara_ve_user_can_edit() {
return current_user_can( 'edit_theme_options' ) && current_user_can( 'unfiltered_html' );
}
/**
* The active theme's declared contract with this plugin — everything the
* plugin needs to know about markup it cannot know on its own.
*
* The plugin converts ARBITRARY sites, so it ships no defaults here: every
* markup-specific fact (which substrings a save must preserve, which
* elements are navigation) belongs to the theme that owns the markup. A
* theme that declares nothing gets the theme-agnostic behavior only — the
* generic save floor, and menu management visibly OFF (see
* clara_ve_contract_notice()) rather than silently matching nothing.
* Hardcoded defaults were tried and are exactly what broke every non-Clara
* theme; the history is in includes/class-source-store.php.
*
* Shape:
* 'anchors' => array( '<key>' => array( 'substring', … ), … )
* 'menus' => array(
* array(
* 'location' => 'theme_nav_1', // nav menu location slug
* 'selector' => 'nav.nav-links', // "tag.class", "tag", or the
* // generated '[data-ve-nav="1"]'
* 'label' => 'Header navigation',
* 'active' => '…', // optional: the class value the
* 'rest' => '…', // design gives the CURRENT page's
* // nav link / every other link —
* // lets render restore the per-page
* // highlight a shared part loses
* ), …
* )
* 'parts' => array( // chrome VARIANT template parts
* array( // beyond the standard header/footer
* 'key' => 'header-2', // part slug = visual-edit key
* 'area' => 'header', // header | footer (the wrapper tag)
* 'label' => 'Header (variant 2)',
* 'preview_key' => 'signin', // a page key that RENDERS this part
* ), …
* )
*
* Several zones may share one location (a desktop nav and its mobile drawer
* rendering the same menu).
*
* @return array Normalized: all keys always present.
*/
function clara_ve_theme_contract() {
/**
* Declare the theme's contract. Generated themes ship this in
* inc/visual-edit.php; see the docblock above for the shape.
*
* @param array $contract Default empty — the plugin assumes nothing.
*/
$contract = apply_filters( 'clara_ve_theme_contract', array() );
$contract = is_array( $contract ) ? $contract : array();
$contract += array(
'anchors' => array(),
'menus' => array(),
'parts' => array(),
);
$menus = array();
foreach ( (array) $contract['menus'] as $menu ) {
if ( empty( $menu['location'] ) || empty( $menu['selector'] ) ) {
continue;
}
$menus[] = array(
'location' => sanitize_key( $menu['location'] ),
'selector' => (string) $menu['selector'],
'label' => isset( $menu['label'] ) ? (string) $menu['label'] : (string) $menu['location'],
'active' => isset( $menu['active'] ) ? (string) $menu['active'] : '',
'rest' => isset( $menu['rest'] ) ? (string) $menu['rest'] : '',
);
}
$contract['menus'] = $menus;
$contract['anchors'] = is_array( $contract['anchors'] ) ? $contract['anchors'] : array();
$parts = array();
foreach ( (array) $contract['parts'] as $part ) {
if ( empty( $part['key'] ) || empty( $part['area'] ) ) {
continue;
}
$area = 'footer' === $part['area'] ? 'footer' : 'header';
$key = sanitize_key( $part['key'] );
// The standard keys are built in; redeclaring one would double it in
// every list the contract parts get appended to.
if ( in_array( $key, array( CLARA_VE_DEFAULT_KEY, CLARA_VE_HEADER_KEY, CLARA_VE_FOOTER_KEY, CLARA_VE_ARTICLE_KEY, CLARA_VE_404_KEY ), true ) ) {
continue;
}
$parts[] = array(
'key' => $key,
'area' => $area,
'label' => isset( $part['label'] ) ? (string) $part['label'] : $key,
'preview_key' => isset( $part['preview_key'] ) ? sanitize_key( $part['preview_key'] ) : '',
);
}
$contract['parts'] = $parts;
return $contract;
}
/**
* The contract part entry for a key, or null — the lookup every place that
* special-cases the built-in chrome keys uses to extend the same treatment
* to a theme's declared variant parts.
*
* @param string $key
* @return array|null
*/
function clara_ve_contract_part( $key ) {
$key = sanitize_key( $key );
foreach ( clara_ve_theme_contract()['parts'] as $part ) {
if ( $part['key'] === $key ) {
return $part;
}
}
return null;
}
/**
* The theme slug the stored editable content belongs to, or '' when nothing
* has been stored yet.
*
* @return string
*/
function clara_ve_data_owner() {
return (string) get_option( CLARA_VE_OWNER_OPTION, '' );
}
/**
* The meta_query that keeps one converted theme's posts out of another's.
*
* Pages were only half the collision. POSTS are global too, and a `[wp-posts]`
* listing asks for `post_type=post` with no notion of theme — so on an install
* carrying three converted themes, every listing, every related-articles strip
* and every archive showed whichever theme's articles happened to be newest.
* Measured: Sonic's own /blog/ rendered Kinto's journal entries, and Lumen's
* article pages recommended them too, because Kinto's imported dates were the
* most recent on the install.
*
* Posts the OWNER wrote have no theme stamp and must keep appearing — only a
* post imported FOR ANOTHER THEME is excluded.
*
* @return array
*/
function clara_ve_theme_post_scope() {
$mine = array(
'key' => CLARA_VE_PAGE_THEME_META,
'value' => sanitize_key( get_stylesheet() ),
);
$unstamped = array(
'key' => CLARA_VE_PAGE_THEME_META,
'compare' => 'NOT EXISTS',
);
// Does the active theme have posts of its own?
$own = get_posts(
array(
'post_type' => 'post',
'post_status' => 'publish',
'numberposts' => 1,
'fields' => 'ids',
'no_found_rows' => true,
'meta_query' => array( $mine ), // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
)
);
if ( ! $own ) {
// It imported nothing, so it has no claim to press: show everything.
// This is the ordinary single-theme install, including one whose
// content predates stamping entirely.
return array( 'relation' => 'OR', $unstamped, $mine );
}
// It has its own. An unstamped post that carries a KEY was imported by
// SOME theme — a key is only ever written by an import — and since the
// active theme's own imports are stamped, that theme is not this one. Seen
// live: a previous theme's ten articles sat in a converted theme's blog
// listing because an older importer had stamped keys but not themes, and
// the theme that made them was no longer even installed to be recognised.
// A post with NO key is the owner's own writing and always belongs.
return array(
'relation' => 'OR',
$mine,
array(
'relation' => 'AND',
$unstamped,
array(
'key' => CLARA_VE_PAGE_KEY_META,
'compare' => 'NOT EXISTS',
),
),
);
}
/**
* Apply that scope to the site's own post listings — the blog home, archives,
* search and feeds. The [wp-posts] token does it for itself; this is for the
* queries WordPress runs from the theme's templates.
*
* @param WP_Query $query
*/
function clara_ve_scope_posts_to_theme( $query ) {
// Not this plugin's theme, not this plugin's query. The scope exists to
// stop one converted theme's imported articles surfacing in another's
// listings; on a theme the plugin does not run, it can only subtract —
// hiding published posts from a blog page for a reason the owner has no
// way to see, and adding a meta_query to every archive to do it.
if ( ! clara_ve_active_theme_is_ours() ) {
return;
}
if ( is_admin() || ! $query->is_main_query() ) {
return;
}
if ( ! ( $query->is_home() || $query->is_archive() || $query->is_search() || $query->is_feed() ) ) {
return;
}
$existing = $query->get( 'meta_query' );
$scope = clara_ve_theme_post_scope();
$query->set( 'meta_query', $existing ? array( 'relation' => 'AND', $existing, $scope ) : $scope );
}
add_action( 'pre_get_posts', 'clara_ve_scope_posts_to_theme' );
/**
* The parked slug a theme's page wears while that theme is not active.
*
* @param string $key Page key.
* @param string $theme Theme stylesheet.
* @return string
*/
function clara_ve_parked_slug( $key, $theme ) {
return sanitize_title( $key . '--ve-' . $theme );
}
/**
* Whether this plugin has content of its own for a theme — i.e. whether the
* theme is one of ours rather than an unrelated WordPress theme.
*
* @param string $theme Theme stylesheet.
* @return bool
*/
function clara_ve_theme_is_known( $theme ) {
if ( '' === $theme ) {
return false;
}
// The registry first: it is the one answer that survives both deactivation
// and deletion. The two checks below need either the theme's files or its
// pages to still be findable, and a parked theme whose directory someone
// removed over FTP has neither — at which point every park, restore and
// purge decision about it would silently become "not ours", stranding its
// content on the site with nothing able to claim it.
if ( class_exists( 'Clara_VE_Theme_Registry' ) && Clara_VE_Theme_Registry::known( $theme ) ) {
return true;
}
// A converter-built theme counts even before it has imported anything —
// otherwise its very FIRST import is the one that collides, because the
// previous theme still holds every address while the new theme has no
// pages yet to prove it is ours. Verified live: a third theme's first
// import landed on /about-2/, /blog-2/, /contact-2/.
if ( clara_ve_theme_is_converted( $theme ) ) {
return true;
}
$pages = get_posts(
array(
'post_type' => 'page',
// Parked included: once a theme is deactivated every one of its
// pages carries that status, so a list without it answers "this
// theme has no pages" precisely when it has most.
'post_status' => array( 'publish', 'draft', 'pending', 'private', 'future', 'trash', CLARA_VE_PARKED_STATUS ),
'numberposts' => 1,
'fields' => 'ids',
'no_found_rows' => true,
'meta_query' => array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
array(
'key' => CLARA_VE_PAGE_THEME_META,
'value' => sanitize_key( $theme ),
),
),
)
);
return ! empty( $pages );
}
/**
* Every page this plugin imported for one theme.
*
* Parked pages are included: restoring a theme has to find the very pages that
* parking hid, and both directions go through here.
*
* @param string $theme Theme stylesheet.
* @return WP_Post[]
*/
function clara_ve_theme_pages( $theme ) {
return get_posts(
array(
'post_type' => 'page',
'post_status' => array( 'publish', 'draft', 'pending', 'private', 'future', CLARA_VE_PARKED_STATUS ),
'numberposts' => -1,
'no_found_rows' => true,
'meta_query' => array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
'relation' => 'AND',
array(
'key' => CLARA_VE_PAGE_THEME_META,
'value' => sanitize_key( $theme ),
),
array(
'key' => CLARA_VE_PAGE_KEY_META,
'compare' => 'EXISTS',
),
),
)
);
}
/**
* Hand the site's page ADDRESSES from the outgoing theme to the incoming one.
*
* Everything this plugin stores is already scoped per theme — sources, pseudo
* styles, history. Pages are not, and cannot be: a WP Page is one row with one
* slug, and WordPress lets exactly one page own `/about/`. So installing a
* second converted theme and importing its content gives the SECOND theme
* `/about-2/`, `/blog-7/`, `/pricing-2/` — while its own navigation, its
* template parts and its stored markup all still link to `/about/`. Every one
* of those links lands on the FIRST theme's page, which renders that theme's
* markup inside this theme's CSS. That is the "themes collide and the design
* breaks" report, and it is not a storage bug: the storage is fine, the
* addresses are what collided.
*
* Measured on a two-theme install before this existed: theme B's own /about/,
* /blog/ and /pricing/ all served theme A's pages, and the front page kept
* theme A's title because `page_on_front` is a single global option.
*
* So on every switch away from a theme this plugin knows, that theme's whole
* world is PARKED — see Clara_VE_Theme_Park — and the incoming theme's, if it
* has one, is taken back out. Nothing is deleted: switch back and everything
* returns, because the parking is symmetric. The plugin's own lookups are
* unaffected either way; they resolve by key meta, never by slug or status.
*
* This used to run only when the theme being switched TO was also one of
* ours, so that a converted site moving to Twenty Twenty-Five kept every
* address working. That was the wrong instinct: it left the site full of a
* design nobody could see, with its pages still answering, its images still in
* the library and its front page still set. Leaving now means leaving, and
* what is held is listed under Visual Edit → Parked content, where it can be
* brought back or exported.
*
* @param string $new_name Incoming theme name (unused).
* @param WP_Theme|null $new_theme Incoming theme.
* @param WP_Theme|null $old_theme Outgoing theme.
*/
function clara_ve_handover_pages_on_switch( $new_name = '', $new_theme = null, $old_theme = null ) {
$incoming = get_stylesheet();
$outgoing = ( $old_theme instanceof WP_Theme ) ? $old_theme->get_stylesheet() : '';
if ( '' === $outgoing || $outgoing === $incoming ) {
return;
}
// Park first, then claim — the incoming theme cannot take `/about/` while
// the outgoing theme still holds it, and wp_unique_post_slug() would
// silently hand it `/about-2/` again. Parking a page does not free its
// slug on its own: that function does not look at post_status at all for
// hierarchical types.
Clara_VE_Theme_Park::park( $outgoing );
Clara_VE_Theme_Park::restore( $incoming );
// The data-owner record follows the addresses, or the admin notice keeps
// warning about a collision that has just been resolved. Only when the
// incoming theme is one of ours: handing the record to Twenty Twenty-Five
// would name a theme that owns nothing as the owner of everything.
if ( clara_ve_theme_is_known( $incoming ) ) {
update_option( CLARA_VE_OWNER_OPTION, $incoming, false );
}
flush_rewrite_rules( false );
}
add_action( 'switch_theme', 'clara_ve_handover_pages_on_switch', 10, 3 );
/**
* Whether the stored content could plausibly be the ACTIVE theme's own.
*
* This exists because the ownership record alone protects nothing on any
* install that already had content when it shipped — which is every real one.
* Verified the hard way: a site holding one theme's imported pages, with a
* different theme activated, rendered the wrong design while the guard sat
* inert because nothing had claimed the data yet. Treating unclaimed data as
* "not foreign" was meant to avoid disturbing existing installs; it disabled
* the protection exactly where it was needed.
*
* So when there is no record, the DATA is asked instead, by two signals that
* need no bookkeeping and work retroactively:
*
* 1. Referenced theme assets. A stored source carries
* `__CLARA_THEME_URI__/live.css`-style references, which resolve against
* whatever theme is active. If EVERY referenced asset is absent from the
* active theme's directory, the source was written for a different theme.
* All of them, not some: one deleted file is an edit, none of them
* existing is a different site.
* 2. The active theme's own declared anchors. The contract names substrings
* this theme's markup must contain; a stored source lacking them is not
* this theme's page. Same test validate_shape() applies to saves.
*
* Either signal failing is conclusive; either signal PASSING is positive
* evidence. The distinction matters because ownership is claimed off this
* answer, and "no evidence either way" must never be read as "yes": a plugin
* activated while a stock theme was still active would otherwise record that
* theme as the owner — the stored source has no theme assets to check and a
* stock theme declares no anchors, so nothing can be evaluated — and the real
* converted theme is then locked out of its own content the moment it is
* activated. Verified live: a site reported its pages as belonging to Twenty
* Twenty-Five while correctly rendering the converted design underneath.
*
* @return string 'match' | 'mismatch' | 'unknown'
*/
function clara_ve_data_theme_verdict() {
// Per-request memo, keyed by what the answer actually depends on — the
// active theme and the stored source — rather than a bare flag. A single
// flag is right for one web request and quietly wrong for anything that
// evaluates more than one state in one process (a test harness, WP-CLI
// looping over themes), which is exactly where a cached verdict is most
// misleading.
static $memo = array();
$source = get_option( CLARA_VE_OPTION, '' );
if ( ! is_string( $source ) || '' === trim( $source ) ) {
return 'unknown'; // nothing stored — nothing to judge, and nothing to claim
}
$memo_key = get_stylesheet() . '|' . strlen( $source );
if ( isset( $memo[ $memo_key ] ) ) {
return $memo[ $memo_key ];
}
// 1. the theme assets the source references, against this theme's files.
$checked = 0;
$missing = 0;
if ( preg_match_all( '#' . preg_quote( CLARA_VE_THEME_URI_TOKEN, '#' ) . '/([^"\'\s>)]+)#', $source, $m ) ) {
$dir = untrailingslashit( get_template_directory() );
foreach ( array_unique( $m[1] ) as $rel ) {
$rel = strtok( $rel, '?#' );
if ( '' === $rel || false !== strpos( $rel, '..' ) ) {
continue;
}
++$checked;
if ( ! file_exists( $dir . '/' . $rel ) ) {
++$missing;
}
}
if ( $checked > 0 && $missing === $checked ) {
return $memo[ $memo_key ] = 'mismatch';
}
}
// 2. the active theme's declared front-page anchors.
$contract = clara_ve_theme_contract();
$anchors = array_filter( array_map( 'strval', isset( $contract['anchors'][ CLARA_VE_DEFAULT_KEY ] )
? (array) $contract['anchors'][ CLARA_VE_DEFAULT_KEY ]
: array() ) );
foreach ( $anchors as $anchor ) {
if ( false === strpos( $source, $anchor ) ) {
return $memo[ $memo_key ] = 'mismatch';
}
}
// Positive evidence: an asset this theme actually has, or an anchor it
// declared and the source carries. Absent both, no opinion — which must
// NOT be mistaken for a match, because ownership is claimed off this.
$matched = ( $checked > 0 && 0 === $missing ) || ! empty( $anchors );
return $memo[ $memo_key ] = $matched ? 'match' : 'unknown';
}
/**
* Whether a theme slug looks like a Visual Edit converted theme at all.
*
* A stock theme cannot meaningfully own converted content, so an ownership
* record naming one is junk — written by accident while it happened to be
* active — and must not lock the real theme out. Judged by artifacts on disk
* rather than by running the theme's own filters, which is impossible for a
* theme that is not active.
*
* @param string $slug
* @return bool
*/
function clara_ve_theme_is_converted( $slug ) {
$slug = (string) $slug;
if ( '' === $slug || '(unknown)' === $slug ) {
return false;
}
$root = trailingslashit( get_theme_root( $slug ) ) . $slug;
foreach ( array( '/inc/visual-edit.php', '/patterns/front-page-original.php', '/clara-content' ) as $probe ) {
if ( file_exists( $root . $probe ) ) {
return true;
}
}
return false;
}
/**
* Whether the ACTIVE theme is one this plugin is the editor FOR.
*
* Everything the plugin does to the public site — the four chrome keys, the
* SEO head, the meta_query on every archive, the activation menu seed — was
* written for a theme the html2wp converter built, whose pages are raw HTML
* this plugin owns end to end. A native block theme asked for none of it and
* got it anyway: every og:* tag and the JSON-LD emitted twice, header and
* footer canvases that loaded empty and wrote a template-part override the
* first time they were saved, an extra meta_query on listings it does not
* need. Measured on amanda-rose-blocks with 1.20.7.
*
* This is NOT an on/off switch for the plugin. On a foreign theme it remains
* a fully working editor with its history; it simply stops behaving like
* that theme's runtime.
*
* Three signals, cheapest first, any one of which is enough:
* - the theme declares a contract (anchors, menus or variant parts);
* - converter artifacts on disk — the same probe used to judge a theme
* that is not active;
* - a source row already stored for it, so an install that predates both
* of the above keeps every behavior it has today.
*
* @return bool
*/
function clara_ve_active_theme_is_ours() {
static $cache = array();
$slug = get_stylesheet();
// The contract arrives from the theme's own functions.php, so an answer
// taken before the theme has booted would cache "declares nothing" for
// the rest of the request. Answer early if asked, but only remember once
// there is something settled to remember.
$settled = did_action( 'after_setup_theme' ) > 0;
if ( $settled && isset( $cache[ $slug ] ) ) {
return $cache[ $slug ];
}
$contract = clara_ve_theme_contract();
$ours = ! empty( $contract['anchors'] )
|| ! empty( $contract['menus'] )
|| ! empty( $contract['parts'] )
|| clara_ve_theme_is_converted( $slug )
|| ( class_exists( 'Clara_VE_Source_Store' ) && Clara_VE_Source_Store::theme_has_stored_source() );
if ( $settled ) {
$cache[ $slug ] = $ours;
}
return $ours;
}
/**
* The OTHER theme's slug when the stored content belongs to a theme that is
* not the active one, else ''. This is the single predicate every protection
* below is written against.
*
* With no usable ownership record the data itself is judged (see
* clara_ve_data_theme_verdict) — and only POSITIVE evidence backfills
* ownership, so the cheap slug comparison covers every switch from then on.
* Returns the sentinel '(unknown)' for content that demonstrably belongs
* elsewhere but never recorded where it came from.
*
* A record naming a theme that is not a converted theme at all is treated as
* absent, and re-pointed at the active theme when the data matches it. That
* record can only have been written by accident — the plugin was activated
* while a stock theme was still active — and honouring it would lock the real
* theme out of its own content, which is exactly the failure this guard
* exists to prevent, inverted.
*
* @return string
*/
/**
* Whether any UNSCOPED (pre-1.15) stored content still exists.
*
* This is what makes the foreign-data guard below meaningful. Since per-theme
* scoping every source, pseudo style and history row is namespaced
* (`clara_ve_source__{theme}__{key}`), so the active theme can only ever read
* its OWN — another theme's content is unreachable by construction and cannot
* leak into this design. Only a legacy unscoped row, which any theme would
* read, can.
*
* Without this check the guard fires on a state that is now completely normal:
* a second or third converted theme installed alongside the first, with its
* own namespace and nothing of its own in it yet. Verified live on a
* three-theme install — the third theme was refused its own import, told its
* content "belongs to Sonic", while every stored row on the site was properly
* scoped and no legacy row existed at all.
*
* @return bool
*/
function clara_ve_legacy_data_exists() {
static $exists = null;
if ( null !== $exists ) {
return $exists;
}
global $wpdb;
$found = $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
$wpdb->prepare(
"SELECT option_name FROM {$wpdb->options}
WHERE ( option_name = %s OR ( option_name LIKE %s AND option_name NOT LIKE %s ) )
LIMIT 1",
CLARA_VE_OPTION,
$wpdb->esc_like( 'clara_ve_source__' ) . '%',
$wpdb->esc_like( 'clara_ve_source__' ) . '%' . $wpdb->esc_like( '__' ) . '%'
)
);
$exists = ! empty( $found );
return $exists;
}
function clara_ve_foreign_data() {
$owner = clara_ve_data_owner();
$active = get_stylesheet();
if ( $owner === $active ) {
return '';
}
// Nothing unscoped left to leak: every row is namespaced per theme, so the
// active theme reads only its own and a stale ownership record naming some
// other theme is just bookkeeping. Blocking here is what stopped a third
// theme from importing its own content on a perfectly healthy install.
if ( ! clara_ve_legacy_data_exists() ) {
return '';
}
$junk_record = ( '' !== $owner && ! clara_ve_theme_is_converted( $owner ) );
if ( '' === $owner || $junk_record ) {
$verdict = clara_ve_data_theme_verdict();
if ( 'match' === $verdict ) {
// Positive evidence for the active theme: claim (or re-claim off a
// junk record) so this resolves once rather than every request.
update_option( CLARA_VE_OWNER_OPTION, $active, false );
return '';
}
if ( 'unknown' === $verdict ) {
return ''; // no opinion, and no claim — never block on ignorance
}
$owner = $junk_record ? $owner : '(unknown)';
}
/**
* Bypass the foreign-data guard.
*
* For a developer who knows both themes share a markup contract and wants
* the old behavior. Returning true restores the pre-guard semantics
* wholesale, including the destructive ones.
*
* @param bool $ignore Default false.
* @param string $owner The theme slug that owns the stored content.
*/
return apply_filters( 'clara_ve_ignore_theme_owner', false, $owner ) ? '' : $owner;
}
/**
* Hand the stored content over to the active theme — the explicit act that
* ownership will not perform on its own. Only ever reached through the
* nonced link in the notice below, and deliberately narrow: it re-points the
* ownership record and nothing else. No data is moved, renamed or deleted,
* so it is reversible by handing back.
*
* It does NOT make the content correct for the new theme: the sources are
* still the other design's markup. It exists for the one legitimate case —
* a rebuilt theme with the same markup contract under a new slug — and the
* notice says so.
*
* @return void
*/
function clara_ve_handover_owner() {
if ( ! current_user_can( 'switch_themes' ) || ! check_admin_referer( 'clara_ve_handover' ) ) {
wp_die( esc_html__( 'Not allowed.', 'visual-edit-lite' ) );
}
// Refused when the content demonstrably is not this theme's. Taking
// ownership only lifts the guard — it cannot make another design's markup
// correct — so on a genuine collision it would hand the front page back to
// the very override that breaks it, which is the failure this whole guard
// exists to prevent. The notice does not offer the button in that case;
// this is the same check for a replayed or hand-typed URL.
if ( 'mismatch' === clara_ve_data_theme_verdict() ) {
wp_die(
esc_html__( 'This content was written for a different design — its own assets and markers are missing from the active theme, so taking ownership would only put the wrong page back on the site. Activate the theme it belongs to, or give this theme its own WordPress install.', 'visual-edit-lite' ),
esc_html__( 'Visual Edit Lite', 'visual-edit-lite' ),
array( 'back_link' => true )
);
}
update_option( CLARA_VE_OWNER_OPTION, get_stylesheet(), false );
wp_safe_redirect( wp_get_referer() ? wp_get_referer() : admin_url() );
exit;
}
add_action( 'admin_post_clara_ve_handover', 'clara_ve_handover_owner' );
/**
* Say, on every admin screen, that the stored content belongs to a different
* theme — and what the two ways out are. Unmissable on purpose: while this
* state lasts the editor shows the active theme's own files rather than the
* stored content, and saving is refused, so an owner who is not told will