mosaic-headless 1.18.0 → 1.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/README.ja.md +8 -4
  2. package/README.ko.md +8 -4
  3. package/README.md +8 -4
  4. package/README.zh-TW.md +8 -4
  5. package/SKILL.md +77 -24
  6. package/assets/templates/platforms/claude-ai.json +1 -1
  7. package/assets/templates/platforms/claude-code.json +1 -1
  8. package/assets/templates/platforms/codex-cli.json +1 -1
  9. package/assets/templates/platforms/copilot.json +1 -1
  10. package/assets/templates/platforms/gemini-cli.json +1 -1
  11. package/bin/check-release.mjs +5 -3
  12. package/data/accordion-verification.csv +1 -1
  13. package/data/condition-subjects.csv +56 -56
  14. package/data/conversion-batch.csv +2 -2
  15. package/data/custom-fields-verification.csv +63 -0
  16. package/data/data-class-hierarchy.csv +4 -0
  17. package/data/dialog-verification.csv +20 -0
  18. package/data/interaction-types.csv +15 -12
  19. package/data/intro-verification.csv +7 -7
  20. package/data/loop-verification.csv +1 -1
  21. package/data/node-properties.csv +9 -0
  22. package/data/node-property-verification.csv +88 -79
  23. package/data/node-types.csv +13 -9
  24. package/data/node-verification.csv +99 -95
  25. package/data/placement-rules.csv +4 -0
  26. package/data/pluggables.csv +42 -0
  27. package/data/rest-routes.csv +2 -1
  28. package/data/style-properties.csv +6 -6
  29. package/data/style-verification.csv +79 -79
  30. package/data/theme-zip-verification.csv +5 -5
  31. package/data/variants.csv +12 -8
  32. package/package.json +1 -1
  33. package/references/custom-fields.md +112 -0
  34. package/references/dialog-and-triggers.md +201 -0
  35. package/tools/build_page.py +9 -1
  36. package/tools/list_fields.php +88 -0
  37. package/tools/sweep_node_properties.py +21 -5
  38. package/tools/sweep_node_types.py +85 -0
  39. package/tools/sweep_style_properties.py +10 -24
  40. package/tools/verify_dialog.py +302 -0
  41. package/tools/verify_rwd.py +11 -27
package/data/variants.csv CHANGED
@@ -143,11 +143,15 @@ eb830eeb-7a4e-05b7-9363-13f3d57d77a8,Cite,m-gutenberg-pullquote-cite,False,tag,"
143
143
  760f938d-5475-48de-a776-6a956df43d53,Outline button,m-gutenberg-outline-button,False,tag,":is(.entry-content, .wp-block-post-content) .wp-block-button__link:where(div.wp-block-button.is-style-outline > a.wp-element-button.wp-block-button__link)",,False,True,True,b1H
144
144
  8de49a77-f767-0054-b1ac-e913ac6562ba,Video,m-video,True,,.m-video,,False,True,False,b1I
145
145
  c637b22c-61b9-0964-bcdb-d5ae0d16986a,Google Maps,m-google-maps,True,,.m-google-maps,,False,True,False,b1J
146
- 0dce60de-c4a6-0a2d-9990-b93070aeb7da,YouTube,m-youtube,True,,.m-youtube,,False,True,False,b1K
147
- be107226-ba10-0fc6-b22e-f984fb21d63b,Vimeo,m-vimeo,True,,.m-vimeo,,False,True,False,b1L
148
- ea692231-151b-02df-bea7-d567347d1422,List,m-list,True,,.m-list,,True,True,False,b1M
149
- 69a8ab3f-dbdf-09bd-8449-6420570155b8,Horizontal list,m-list-horizontal,True,,.m-list-horizontal,ea692231-151b-02df-bea7-d567347d1422,False,True,False,b1N
150
- 3a8cfda3-1ded-0a3c-9e55-b56510827850,Unordered list,m-list-unordered,True,,.m-list-unordered,ea692231-151b-02df-bea7-d567347d1422,False,True,False,b1O
151
- 7dbba514-fd83-0e8f-ac46-68e6f651f2ff,Ordered list,m-list-ordered,True,,.m-list-ordered,ea692231-151b-02df-bea7-d567347d1422,False,True,False,b1P
152
- 1864859b-2942-44db-b09d-c7073d1df01e,Item,m-list-item,True,,.m-list-item,ea692231-151b-02df-bea7-d567347d1422,False,True,False,b1Q
153
- fb0c2625-dd5a-43c5-82e2-22e8c09ca06c,Loop items,m-list-loop-items,True,,.m-list-loop-items,ea692231-151b-02df-bea7-d567347d1422,False,True,False,b1R
146
+ 1a360ae2-3d10-017a-9db6-cece7b44ce17,OpenStreet​Map,m-osm,True,,.m-osm,,False,True,False,b1K
147
+ 0dce60de-c4a6-0a2d-9990-b93070aeb7da,YouTube,m-youtube,True,,.m-youtube,,False,True,False,b1L
148
+ be107226-ba10-0fc6-b22e-f984fb21d63b,Vimeo,m-vimeo,True,,.m-vimeo,,False,True,False,b1M
149
+ ea692231-151b-02df-bea7-d567347d1422,List,m-list,True,,.m-list,,True,True,False,b1N
150
+ 69a8ab3f-dbdf-09bd-8449-6420570155b8,Horizontal list,m-list-horizontal,True,,.m-list-horizontal,ea692231-151b-02df-bea7-d567347d1422,False,True,False,b1O
151
+ 3a8cfda3-1ded-0a3c-9e55-b56510827850,Unordered list,m-list-unordered,True,,.m-list-unordered,ea692231-151b-02df-bea7-d567347d1422,False,True,False,b1P
152
+ 7dbba514-fd83-0e8f-ac46-68e6f651f2ff,Ordered list,m-list-ordered,True,,.m-list-ordered,ea692231-151b-02df-bea7-d567347d1422,False,True,False,b1Q
153
+ 1864859b-2942-44db-b09d-c7073d1df01e,Item,m-list-item,True,,.m-list-item,ea692231-151b-02df-bea7-d567347d1422,False,True,False,b1R
154
+ fb0c2625-dd5a-43c5-82e2-22e8c09ca06c,Loop items,m-list-loop-items,True,,.m-list-loop-items,ea692231-151b-02df-bea7-d567347d1422,False,True,False,b1S
155
+ d1a109c7-3b52-04ef-9a6c-11e0dc0d1a70,Modal,m-modal,True,,.m-modal,,False,True,False,b1T
156
+ d1a1090d-6e4f-04ef-9a6c-11e0dc0d1a71,Window,m-modal-window,True,,.m-modal-window,d1a109c7-3b52-04ef-9a6c-11e0dc0d1a70,False,True,False,b1U
157
+ d1a10004-e51a-04ef-9a6c-11e0dc0d1a72,Overlay,m-modal-overlay,True,,.m-modal-overlay,d1a109c7-3b52-04ef-9a6c-11e0dc0d1a70,False,True,False,b1V
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mosaic-headless",
3
- "version": "1.18.0",
3
+ "version": "1.20.0",
4
4
  "description": "AI-agent skill: build Mosaic Pro (Nextend) WordPress sites by writing the underlying data model directly - 23 custom tables, no visual editor, no DOM. Every node type, style property and node property swept against a live install and asserted on the delivered HTML and compiled CSS. Installs into Claude Code, Cursor, Codex CLI, Gemini CLI, Copilot, Continue, Windsurf and Claude.ai.",
5
5
  "keywords": [
6
6
  "wordpress",
@@ -0,0 +1,112 @@
1
+ # Custom fields: ACF and Meta Box through `@VAR` and `@LOOP`, measured
2
+
3
+ Mosaic 1.0.8 reads custom fields through one provider-neutral core with two
4
+ providers wired in: **ACF** (free or Pro) and **Meta Box** (free; Group,
5
+ Relationships, Term/User/Settings add-ons have handlers too). Plain `post_meta`
6
+ without either plugin is read as well, as a bare string. There is no public hook to
7
+ add a provider - `CustomFields/AUTHORING.md` in the plugin says so in its first
8
+ paragraph.
9
+
10
+ Everything below was measured on a live 1.0.8 install: ACF 6.8.10 and Meta Box
11
+ 5.15.0, forty fields registered in code on a page, values written with
12
+ `update_field()` / `rwmb_set_meta()`, then read back **off the delivered HTML** of
13
+ a Mosaic page built through the tables. `data/custom-fields-verification.csv`, 62
14
+ rows: 59 resolve to the value expected, 3 are empty for a reason given below.
15
+
16
+ ## The names
17
+
18
+ A field with meta key `k` on the current post is
19
+
20
+ ```
21
+ @VAR('post/meta_k') the field's primary value
22
+ @VAR('post/meta_k__label') derived properties, when the field type has them
23
+ @VAR('post/meta_k__url')
24
+ @VAR('post/meta_k__id')
25
+ @VAR('post/meta_k__title') ACF link
26
+ @VAR('post/meta_k__target') ACF link
27
+ ```
28
+
29
+ A field that holds MANY values (checkbox, relationship, taxonomy, gallery,
30
+ image_advanced, cloneable, group, repeater) is not a variable at all. It is a
31
+ **loop**, named `loop` + key - `@LOOP('post/loopk')` - and its rows carry the same
32
+ properties under the loop node's namespace (`item` unless `loopNamespace` says
33
+ otherwise): `@VAR('item/value')`, `@VAR('item/value__label')`, `@VAR('item/index')`.
34
+ An ACF **group** is the exception in spelling: `loop-k` with a hyphen, one row, and
35
+ its sub-fields are `item/value_<subfield>` with a single underscore.
36
+
37
+ Do not guess any of this. `wp eval-file tools/list_fields.php <post_id>` prints the
38
+ exact variable and loop names Mosaic registers for that post with the value each
39
+ resolves to and the row variables of every loop - the same schema the frontend
40
+ evaluates, so a name it does not print does not exist:
41
+
42
+ ```
43
+ variable post/meta_acf_po ACF / Post object / ID 6
44
+ variable post/meta_acf_po__label ACF / Post object / 標題 Shop
45
+ variable post/meta_acf_po__url ACF / Post object / 網址 https://…/shop/
46
+ loop post/loopacf_rel ACF / Relationship 2 items; first row: value=2 | value__label=Sample Page | value__url=… | index=1
47
+ loop post/loop-acf_group ACF / Group 1 items; first row: value_ga=group text | value_gb=7 | index=1
48
+ ```
49
+
50
+ ## What each field type resolves to
51
+
52
+ | field | `meta_k` | derived |
53
+ |---|---|---|
54
+ | text, textarea, number, url, email, color, date, select, radio, true_false | the stored value, as a string; a textarea keeps its newline and gets no `<br>`; a date honours ACF's `return_format` | select/radio: `__label` **only when ACF's return format is `array`** - with the default `value` format the label variable is not registered (measured empty) |
55
+ | image, file | the URL, whatever ACF's return format says (`array` and `id` both rendered the URL) | `__id` |
56
+ | link (ACF, array) | the URL | `__title`, `__target` |
57
+ | page_link | the permalink | - |
58
+ | post_object (ACF) | **the ID** | `__label` = title, `__url` = permalink |
59
+ | post (Meta Box) | **the title** | `__id`, `__url` - the two providers put opposite things in the primary slot |
60
+ | user (ACF) | the ID | `__label` = display name, `__url` = author archive |
61
+ | user (Meta Box) | the display name | `__id`, `__url` |
62
+ | oembed | the `<iframe>` HTML - **but see below** | Meta Box adds `__url`, the source URL |
63
+ | wysiwyg | the HTML; Meta Box's arrives wrapped in Mosaic's own `wp-site-blocks` / `entry-content` divs because its value runs through `the_content` | - |
64
+ | checkbox (ACF), checkbox_list (Meta Box), relationship, taxonomy, image_advanced, file_advanced, post (multiple), cloneable | a loop | rows: `value` (ACF reference rows: the ID; Meta Box: the title / URL), `value__label`, `value__url`, `value__id`, `index` |
65
+ | group (ACF) | a loop of one row, named `loop-k` | `value_<sub>` per sub-field |
66
+
67
+ ## Inline access to a loop
68
+
69
+ `@LOOP` takes `(loop, index, row-variable)` pairs and works inside a text node
70
+ without a loop element:
71
+
72
+ ```
73
+ @LOOP('post/loopacf_rel', 1, 'value__label') -> Sample Page row 1 (1-based)
74
+ @LOOP('post/loopacf_rel', 0, 'value__label') -> Sample Page, Shop index 0 = every row, joined ", "
75
+ @LOOP('post/loop-acf_group', 1, 'value_ga') -> group text
76
+ ```
77
+
78
+ A **loop element** is the real thing: `loop > loop-items > loop-item > …`, with
79
+ the source on the loop node:
80
+
81
+ ```json
82
+ {"type": "loop",
83
+ "data": {"loopType": "localContext",
84
+ "localContextOptions": {"loopSource": {"v": "@LOOP('post/loopacf_rel')"}}},
85
+ "children": [{"type": "loop-items", "children": [{"type": "loop-item", "children": [
86
+ {"type": "text", "data": {"tagName": "p"},
87
+ "children": [{"type": "wysiwyg-variable", "data": {"dynamicCode": "@VAR('item/value__label')"}}]}
88
+ ]}]}]}
89
+ ```
90
+
91
+ `loopType:"localContext"` is the built-in source that evaluates an expression in the
92
+ page's context; the sweep's `loop` COMMIT_500 is what happens without it. The loop
93
+ rendered exactly the rows the field holds for every family above (2, 2, 1, 1, 2, 2,
94
+ 2 rows), and an empty field renders `m-loop--no-result` with the `loop-no-result`
95
+ child rather than nothing.
96
+
97
+ ## The three empties, explained
98
+
99
+ - `@VAR('post/meta_acf_select__label')` with ACF's default `value` return format: the
100
+ handler registers `__label` only for `return_format: array`. Switch the field or
101
+ use `@VAR('post/meta_k')` and map it yourself.
102
+ - **oEmbed inside a text node is empty**, with `@VAR` and with `@VAR_RAW` alike - the
103
+ wysiwyg text pipeline strips the `<iframe>`. Put it in a `code` node instead:
104
+ `<div>@VAR_RAW('post/meta_acf_oembed')</div>` delivered the full YouTube iframe.
105
+ The same route carries Meta Box's wysiwyg HTML intact.
106
+
107
+ ## What did not need a field plugin
108
+
109
+ `meta_footnotes` appeared in the list on a page with neither ACF nor Meta Box
110
+ registering it: every non-underscore `post_meta` key present on the post type is
111
+ exposed as `meta_<key>` (`MetaFieldUnknown`, a plain string). A site that stores
112
+ its data with `update_post_meta()` and nothing else is already readable.
@@ -0,0 +1,201 @@
1
+ # Modal, OpenStreetMap, the new triggers and run rules (1.0.9), measured
2
+
3
+ 1.0.9 is the first release since this skill began that adds *features* rather than
4
+ renaming things: four node types, three interaction triggers, a rule engine that
5
+ decides whether a trigger is allowed to run, two memory actions, and a shorthand
6
+ that collapses "open this modal when …" into one object.
7
+
8
+ Everything below was built on the live site and read back out of the delivered page
9
+ or out of the browser. `data/dialog-verification.csv`, 19 checks, all passing.
10
+
11
+ ## Modal — a real `<dialog>`
12
+
13
+ Three node types: `modal`, and inside it `modal-window` and `modal-overlay`.
14
+
15
+ ```json
16
+ {"type": "modal", "nodeID": "<uuid you mint>",
17
+ "data": {"role": "dialog", "accessibleLabel": "Feature probe dialog",
18
+ "closedby": "everything"},
19
+ "children": [
20
+ {"type": "modal-overlay", "data": {}},
21
+ {"type": "modal-window", "style": {"&": {"_": {"flexDirection": "column"}}},
22
+ "children": [ … ]}]}
23
+ ```
24
+
25
+ delivers
26
+
27
+ ```html
28
+ <dialog class="m-modal M_Dialog M_IJ _g" id="pr-modal" role="dialog"
29
+ aria-label="Feature probe dialog" aria-modal="true"
30
+ data-mosaic-modal-closedby="everything">
31
+ <div class="m-modal-overlay _h" tabindex="-1"></div>
32
+ <div class="m-modal-window _i" tabindex="-1"> … </div>
33
+ </dialog>
34
+ ```
35
+
36
+ - **`aria-modal="true"` is emergent, not authored.** It appears exactly when the
37
+ modal has an overlay child, because the overlay is what actually blocks the page.
38
+ No overlay, no `aria-modal`.
39
+ - **`closedby` is `everything` | `clickOutside` | `esc` | `nothing`**, and it is
40
+ emitted as `data-mosaic-modal-closedby`, NOT as the native `closedby` attribute —
41
+ Mosaic opens the host with `show()`, where the native light-dismiss does not
42
+ apply, so its own controller reads the attribute. Measured: with `everything`,
43
+ Esc closes it. `nothing` is the cookie-gate case, where the only way out is a
44
+ `modalClose` action you place yourself.
45
+ - **`role` has no default.** A `<dialog>` already carries an implicit dialog role;
46
+ the property only emits when you set one (`dialog`, `alertdialog`).
47
+ - **`accessibleLabel` always emits `aria-label`**, empty if you leave it blank — so
48
+ an unlabelled modal ships an empty accessible name. Set it.
49
+ - **`modal-window` arrives as `display:flex`, row.** Three children in a row squeeze
50
+ a `width: max-content` button to zero and Playwright reports it as not visible,
51
+ which is what a reader would see too. Set `flexDirection: "column"`.
52
+
53
+ ## Opening and closing it
54
+
55
+ `modalOpen` and `modalClose` are interaction actions. Their target is the modal's
56
+ **node id**, not its `attrID`:
57
+
58
+ ```json
59
+ {"type": "modalOpen", "uuid": "<uuid>",
60
+ "settings": {"target": {"type": "element", "uuid": "<the modal's node id>"}}}
61
+ ```
62
+
63
+ which the page resolves to `{"targetSelectorData": [{"type": "all", "selector": "._g"}]}`
64
+ — the modal's generated class. Two consequences:
65
+
66
+ - **The spec has to know the node id before either node is written**, because the
67
+ button and the modal are committed in the same pass. `build_page.py` takes a
68
+ `nodeID` on any spec node for exactly this.
69
+ - **That id must be unique in the THEME, not the document.** `wp_mosaic_nodes`'
70
+ primary key is `(ID, themeID)`, so a hard-coded id collides with whatever an
71
+ earlier build left behind — the commit answers HTTP 500 on a duplicate key. Mint
72
+ it per run.
73
+
74
+ `type` accepts `element`, `variant` or `universalClass` — the last two aim an action
75
+ at every element of a class.
76
+
77
+ ## The three new triggers
78
+
79
+ | id | family | fires | default settings |
80
+ |---|---|---|---|
81
+ | `exitIntent` | timed | the pointer leaves the page through the top. **Desktop only** — it never runs on a touch device | `awayFor: 600ms` |
82
+ | `scrollDepth` | timed | the visitor scrolls this far down | `threshold: 50%` |
83
+ | `modal` | timed | the modal opens or closes | — |
84
+
85
+ They are ordinary interaction types, so they take the envelope the rest of the
86
+ language uses (`references/interactions.md`). The interaction type list is also
87
+ re-grouped in 1.0.9 — Mouse / Page & scroll / Element events — which changes the
88
+ `family` column of `data/interaction-types.csv` and nothing else.
89
+
90
+ ## Run rules: how often a trigger may run
91
+
92
+ A timed interaction can carry `runs`, and that is what makes a popup shippable:
93
+
94
+ ```json
95
+ "runs": {"rules": [{"uuid": "<uuid>", "type": "cap",
96
+ "capOptions": {"max": "1", "remember": "session",
97
+ "name": "probe-exit-modal"}}]}
98
+ ```
99
+
100
+ | type | options | what it means |
101
+ |---|---|---|
102
+ | `cap` | `max` | run at most N times |
103
+ | `cooldown` | `duration`, `durationUnit` | wait this long between runs |
104
+ | `firstRunWindow` | `duration`, `durationUnit` | only within this long of the first run |
105
+ | `pickOne` | — | one of several interactions sharing a name runs; the name is the group, so it is required here |
106
+
107
+ `durationUnit` is `minute` | `hour` | `day`. `remember` is `page` | `session` |
108
+ `forever`, and `name` must match `^[a-z][a-z0-9_-]*$` — a rule with a name outside
109
+ that grammar is skipped rather than stored.
110
+
111
+ What that writes, measured: `localStorage` **and** `sessionStorage`, both under
112
+ `mos:v1:r:<name>`, holding `{"counter":1,"firstAt":<ms>,"lastAt":<ms>}`. Only
113
+ `timed` interactions support run rules; a progress interaction has no "run" to count.
114
+
115
+ **`remember: "session"` is sessionStorage, which browsers scope per TAB.** A test
116
+ that opens a second tab to prove a cap is testing nothing — reload the same tab.
117
+ That mistake made the cap look broken here until the tab was reused.
118
+
119
+ ## Memory: remember / forget
120
+
121
+ Two actions that write and clear the same records a run rule reads, so one
122
+ interaction can decide what another is allowed to do.
123
+
124
+ ```json
125
+ {"type": "remember", "uuid": "<uuid>",
126
+ "rememberOptions": {"settings": {"name": "probe-seen"}}}
127
+ ```
128
+
129
+ **The `name` goes under `<type>Options.settings`.** Four other placements were
130
+ committed and silently dropped — `rememberOptions.name`, `settings.name`,
131
+ `rememberOptions.timelines._.name`, and a bare top-level `name` — and the action
132
+ then runs as a no-op that writes nothing, because the source skips an unnamed
133
+ memory rather than inventing a key. The server reshapes the accepted one into
134
+ `rememberOptions.timelines._.name`, which is what the payload shows.
135
+
136
+ ## The interaction shorthand — modals only
137
+
138
+ Every element's data can carry `interactionShorthand`, but `ModalElementMResourceData`
139
+ is the only class that overrides `getInteractionShorthandActionTypes()` (it returns
140
+ `['modalOpen']`), so it is the only element the shorthand does anything for. The
141
+ feature is "open this modal when …" in the modal's own settings:
142
+
143
+ ```json
144
+ "interactionShorthand": {
145
+ "type": "exitIntent",
146
+ "exitIntentOptions": {
147
+ "settings": {"awayFor": "400ms"},
148
+ "runs": {"rules": [{"uuid": "<uuid>", "type": "cap",
149
+ "capOptions": {"max": "1", "remember": "session",
150
+ "name": "probe-exit-modal"}}]}}}
151
+ ```
152
+
153
+ At render that expands into a full interaction on the modal itself — trigger from
154
+ the type, action from the element — and the page's `mosaicInteractions` gains
155
+ `{"type":"exitIntent","triggerSelector":"._0","action":{"exitIntent":{"actions":[{"type":"modalOpen"…`
156
+ with the cap attached. No trigger element, no action, no target: an exit-intent
157
+ popup that fires once a session is one object.
158
+
159
+ `"type": "manual"` is the stored answer for "nothing opens this automatically" —
160
+ spelled as a word rather than left absent, because absent means *inherit* on a
161
+ component instance.
162
+
163
+ The shorthand cannot carry conditions, and that is a property of the data shape
164
+ rather than a rule to remember: it has no field for them.
165
+
166
+ ## OpenStreetMap
167
+
168
+ `openstreetmap` renders `<mosaic-openstreetmap>` wrapping an `<iframe>` at
169
+ openstreetmap.org's own embed endpoint. Five properties:
170
+
171
+ | property | shape | values |
172
+ |---|---|---|
173
+ | `latitude`, `longitude` | **`{"v": "25.0330"}`** | −90..90, −180..180 |
174
+ | `zoom` | **`{"v": "15"}`** | 0..19 |
175
+ | `layer` | bare string | `mapnik`, `cyclosm`, `cyclemap`, `transportmap`, `hot` |
176
+ | `lazyLoad` | bare string | `"0"` / `"1"` → `loading="lazy"` on the iframe |
177
+
178
+ The first three carry `ValidatorDynamicCodeObject` in their chain, so they take the
179
+ `{"v": …}` object form the rest of the dynamic language uses — a bare string is
180
+ dropped and the map quietly shows its default location (Times Square) with no error
181
+ anywhere. That is the skill's existing rule (`ValidatorDynamicCode` = bare string,
182
+ `ValidatorDynamicCodeObject` = `{"v": …}`) applying to a new element, and it is also
183
+ why those three can hold `@VAR('post/meta_lat')` and a custom field can drive the map.
184
+
185
+ ## What else moved in the delivered page
186
+
187
+ **The three per-breakpoint stylesheets became one.** Through 1.0.8 a page carried
188
+ `mosaic-theme-block-editor-styles_-inline-css`, `…_t-…` and `…_m-…`; 1.0.9 emits a
189
+ single `mosaic-theme-document-styles-inline-css` with the breakpoints as `@media`
190
+ blocks inside it. Any tool that read a breakpoint by block id reads nothing now —
191
+ `document_styles()` in `tools/sweep_node_types.py` handles both, and the base is now
192
+ "everything outside an at-rule" rather than "its own block".
193
+
194
+ **An element whose styles compile to nothing gets no generated class.**
195
+ `<div class="m-div" id="sp-015">` — no `_token`. On 1.0.8 every element had one. So
196
+ the twenty inert grouped properties are now visible in the markup as well as absent
197
+ from the CSS, and a checker must not read "no token" as "the node did not render".
198
+
199
+ **`youtube`'s `privacy` property suppresses the element.** Set on its own it emits
200
+ nothing at all, where 1.0.8 rendered the element and recorded NO_EFFECT. One row of
201
+ `data/node-property-verification.csv`, the only `NO_OUTPUT` in the table.
@@ -238,7 +238,15 @@ def flatten(node, parent_id, master_id, surface, force, ordering="a0", parent_ty
238
238
  if problems:
239
239
  raise SystemExit("refusing to build:\n " + "\n ".join(problems))
240
240
 
241
- nid = str(uuid.uuid4())
241
+ # `nodeID` pins the node's own id instead of minting one. An interaction that
242
+ # targets another element stores that element's NODE id (`settings.target =
243
+ # {"type": "element", "uuid": <nodeID>}`), not its attrID, so the two have to be
244
+ # written in the same pass - there is no way to point at a node whose id the
245
+ # spec never sees. The id must be unique in the THEME, not the document:
246
+ # wp_mosaic_nodes' primary key is (ID, themeID), so a hard-coded one collides
247
+ # with whatever a previous build left behind and the commit 500s on a duplicate
248
+ # key. Mint it per run.
249
+ nid = node.get("nodeID") or str(uuid.uuid4())
242
250
  data = dict(node.get("data") or {})
243
251
  style = to_style(node.get("style"))
244
252
  if style:
@@ -0,0 +1,88 @@
1
+ <?php
2
+ /**
3
+ * Every dynamic variable and loop Mosaic exposes for one post - the exact names
4
+ * `@VAR('post/<name>')` and `@LOOP('post/<name>')` take - with the value each
5
+ * resolves to on that post. Custom fields (ACF, Meta Box, plain post meta) included,
6
+ * with the derived properties a structured field grows (`__label`, `__url`, `__id`,
7
+ * `__title`, `__target`) and the row variables inside each loop.
8
+ *
9
+ * wp eval-file tools/list_fields.php <post_id> [csv]
10
+ *
11
+ * Reads the same schema the frontend evaluates (VariableProviderPost), so a name that
12
+ * is not printed here does not exist for that post, whatever the field plugin calls it.
13
+ * Loop names are the plugin's own: `loop<key>` for most fields, `loop-<key>` for an ACF
14
+ * group. Row variables live under the loop node's `loopNamespace` (default `item`).
15
+ */
16
+
17
+ use Mosaic\Compatibility\VariableProvider\AbstractVariableProvider;
18
+ use Mosaic\Compatibility\VariableProvider\VariableProvider;
19
+ use Mosaic\Compatibility\WordPress\PathContext\VariableProviderPost;
20
+
21
+ $postID = (int)($args[0] ?? 0);
22
+ $asCsv = ($args[1] ?? '') === 'csv';
23
+ $post = get_post($postID);
24
+ if (!$post) {
25
+ WP_CLI::error("no post $postID");
26
+ }
27
+
28
+ $provider = new VariableProvider(VariableProviderPost::getVariableProviderDefinition(get_post_type_object($post->post_type), $postID));
29
+ $ref = new ReflectionClass(AbstractVariableProvider::class);
30
+ $vars = $ref->getProperty('schemaVariables');
31
+ $vars->setAccessible(true);
32
+ $loops = $ref->getProperty('schemaLoops');
33
+ $loops->setAccessible(true);
34
+ $record = $ref->getProperty('currentRecord');
35
+ $record->setAccessible(true);
36
+ $record->setValue($provider, $post);
37
+
38
+ $rows = [];
39
+ foreach ($vars->getValue($provider) as $name => $variable) {
40
+ $value = '';
41
+ try {
42
+ $value = (string)$variable->getValue($post);
43
+ } catch (Throwable $e) {
44
+ $value = 'ERR ' . $e->getMessage();
45
+ }
46
+ $rows[] = ['variable', "post/$name", $variable->getLabel(), $value];
47
+ }
48
+ foreach ($loops->getValue($provider) as $name => $loop) {
49
+ $count = 0;
50
+ $inner = [];
51
+ try {
52
+ $lp = $loop->toLoop($post);
53
+ if ($lp) {
54
+ $count = $lp->getTotalItems();
55
+ if ($count > 0) {
56
+ // read row 1 the way the evaluator does: seek, then getVariable per name
57
+ $lp->seek(0);
58
+ $lv = $ref->getProperty('schemaVariables');
59
+ $lv->setAccessible(true);
60
+ foreach (array_keys($lv->getValue($lp)) as $n) {
61
+ $val = '';
62
+ try {
63
+ $val = (string)$lp->getVariable($n);
64
+ } catch (Throwable $e) {
65
+ $val = '?';
66
+ }
67
+ $inner[] = "$n=" . mb_substr(str_replace(PHP_EOL, ' ', $val), 0, 30);
68
+ }
69
+ }
70
+ }
71
+ } catch (Throwable $e) {
72
+ $inner = ['ERR ' . $e->getMessage()];
73
+ }
74
+ $rows[] = ['loop', "post/$name", $loop->getLabel(), $count . ' items; first row: ' . implode(' | ', $inner)];
75
+ }
76
+
77
+ if ($asCsv) {
78
+ $out = fopen('php://output', 'w');
79
+ fputcsv($out, ['kind', 'name', 'label', 'value']);
80
+ foreach ($rows as $r) {
81
+ fputcsv($out, $r);
82
+ }
83
+ fclose($out);
84
+ } else {
85
+ foreach ($rows as [$kind, $name, $label, $value]) {
86
+ printf("%-8s %-34s %-28s %s\n", $kind, $name, mb_substr($label, 0, 28), mb_substr(str_replace("\n", ' ', $value), 0, 80));
87
+ }
88
+ }
@@ -132,7 +132,11 @@ def searchable(tag):
132
132
  def judge(prop, value, tag):
133
133
  """Did this property change the delivered markup the way it claims to?"""
134
134
  if tag is None:
135
- return "NO_ELEMENT", ""
135
+ # The probe was committed and the page rendered, so the node exists; the
136
+ # property suppressed its own element's output. `youtube.privacy` does
137
+ # exactly this since 1.0.9 - on 1.0.8 the same probe rendered and read
138
+ # NO_EFFECT - so it is its own outcome, not a missing host.
139
+ return "NO_OUTPUT", "the element emitted nothing with this property set"
136
140
  body = searchable(tag)
137
141
  if prop == "cssClasses":
138
142
  return ("APPLIED" if MARK + "-cls" in tag else "NO_EFFECT"), tag[:110]
@@ -265,9 +269,21 @@ def main():
265
269
  # from an earlier run share the id space, and two runs can give the same id
266
270
  # to different node types. If the page does not carry exactly the probes
267
271
  # this run planned, nothing measured from it means anything.
268
- sys.exit("expected %d probes on the page, found %d - clear previous probes "
269
- "and re-run before believing any measurement"
270
- % (len(live) - len(moved), rendered))
272
+ on_page = set(re.findall(r'id="(np-\d+)"', html))
273
+ extra = sorted(on_page - {attr for attr, _h, _p, _v in live})
274
+ # An id this run did not plan is contamination: two runs can give the same
275
+ # id to different node types and nothing measured from the page would mean
276
+ # anything. A probe that this run DID plan and that is simply not there is
277
+ # a result, not contamination - the property suppressed its own element -
278
+ # so it is recorded as NO_OUTPUT below instead of aborting the run.
279
+ if extra:
280
+ sys.exit("probes on the page that this run did not plan: %s\n"
281
+ "clear them and re-run before believing any measurement"
282
+ % ", ".join(extra))
283
+ missing = [(attr, host, prop) for attr, host, prop, _v in live
284
+ if attr not in on_page and attr not in moved]
285
+ print(" %d planned probe(s) rendered nothing: %s"
286
+ % (len(missing), ", ".join("%s.%s" % (h, p) for _a, h, p in missing)))
271
287
 
272
288
  rows = []
273
289
  for attr, host, prop, value in live:
@@ -291,7 +307,7 @@ def main():
291
307
  for r in rows:
292
308
  counts[r[3]] = counts.get(r[3], 0) + 1
293
309
  print()
294
- for k in ("APPLIED", "NO_EFFECT", "EDITOR_ONLY", "NO_ELEMENT", "NO_HOST",
310
+ for k in ("APPLIED", "NO_EFFECT", "NO_OUTPUT", "EDITOR_ONLY", "NO_ELEMENT", "NO_HOST",
295
311
  "INSTRUMENT", "SKIPPED"):
296
312
  if counts.get(k):
297
313
  print(" %-12s %d" % (k, counts[k]))
@@ -117,6 +117,91 @@ def token_by_id(html, id_pattern):
117
117
  return out
118
118
 
119
119
 
120
+ def document_styles(html, suffix=""):
121
+ """{generated element class: {css property: value}} for one breakpoint's rules.
122
+
123
+ `suffix` is the breakpoint's own key with the leading underscore dropped: "" is
124
+ the base, "t" tablet, "m" mobile - the same spelling the three separate style
125
+ blocks used before 1.0.9.
126
+
127
+ 1.0.9 merged those three blocks into ONE `mosaic-theme-document-styles-inline-css`
128
+ with the breakpoints as `@media` blocks inside it, so a suffix is now a position
129
+ in one stylesheet rather than a block id. The old ids are still read first, so
130
+ the same tool measures a 1.0.8 site unchanged.
131
+
132
+ The base is everything OUTSIDE any at-rule: scanning the whole block for `._x{...}`
133
+ would read a tablet override as if it were the base, which is exactly the error
134
+ the three-block layout made impossible.
135
+ """
136
+ old = re.search(
137
+ r'<style id="mosaic-theme-block-editor-styles_%s-inline-css">(.*?)</style>'
138
+ % re.escape(suffix), html, re.S)
139
+ if old:
140
+ css = old.group(1)
141
+ inner = re.search(r"@media[^{]*\{(.*)\}\s*$", css, re.S)
142
+ return _rules_of(inner.group(1) if inner else css)
143
+
144
+ one = re.search(r'<style id="mosaic-theme-document-styles-inline-css">(.*?)</style>',
145
+ html, re.S)
146
+ if not one:
147
+ return None
148
+ css = one.group(1)
149
+ blocks = _at_rule_blocks(css)
150
+ if suffix == "":
151
+ outside = css
152
+ for start, end, _head in blocks:
153
+ outside = outside.replace(css[start:end], " ")
154
+ return _rules_of(outside)
155
+ # a breakpoint block is a max-width media query; they appear widest-first, the
156
+ # order Mosaic emits them in, so "t" is the first and "m" the second
157
+ media = [(s, e, h) for s, e, h in blocks if "max-width" in h]
158
+ want = {"t": 0, "m": 1}.get(suffix)
159
+ if want is None or want >= len(media):
160
+ return None
161
+ s, e, _h = media[want]
162
+ body = css[css.index("{", s) + 1:e - 1]
163
+ return _rules_of(body)
164
+
165
+
166
+ def _at_rule_blocks(css):
167
+ """[(start, end, header)] for every top-level at-rule, found by brace depth."""
168
+ out, i, n = [], 0, len(css)
169
+ while True:
170
+ at = css.find("@", i)
171
+ if at < 0:
172
+ return out
173
+ brace = css.find("{", at)
174
+ if brace < 0:
175
+ return out
176
+ depth, j = 0, brace
177
+ while j < n:
178
+ if css[j] == "{":
179
+ depth += 1
180
+ elif css[j] == "}":
181
+ depth -= 1
182
+ if depth == 0:
183
+ break
184
+ j += 1
185
+ out.append((at, j + 1, css[at:brace]))
186
+ i = j + 1
187
+
188
+
189
+ def _rules_of(css):
190
+ """{class: {prop: value}} from flat CSS, ignoring anything nested in an at-rule."""
191
+ rules = {}
192
+ for sel, decls in re.findall(r"([^{}]+)\{([^{}]*)\}", css):
193
+ props = {}
194
+ for decl in decls.split(";"):
195
+ if ":" in decl:
196
+ k, v = decl.split(":", 1)
197
+ props[k.strip()] = v.strip()
198
+ for one in sel.split(","):
199
+ cls = re.match(r"^\.(_[a-z0-9_-]+)$", one.strip())
200
+ if cls:
201
+ rules.setdefault(cls.group(1), {}).update(props)
202
+ return rules
203
+
204
+
120
205
  def envelopes(doc):
121
206
  return {k: [[x["ID"], x["revision"]] for x in v] for k, v in doc.items()}
122
207
 
@@ -55,7 +55,8 @@ import sys
55
55
 
56
56
  sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
57
57
  from build_page import Surface, flatten, ordering_for # noqa: E402
58
- from sweep_node_types import Client, envelopes, exceptions_of, token_by_id, unwrap # noqa: E402
58
+ from sweep_node_types import ( # noqa: E402
59
+ Client, document_styles, envelopes, exceptions_of, token_by_id, unwrap)
59
60
 
60
61
  MARK_LEN = "37px"
61
62
  MARK_COLOR = "rgb(9, 99, 199)"
@@ -130,27 +131,6 @@ def test_value(row):
130
131
  return None
131
132
 
132
133
 
133
- def parse_rules(html, suffix=""):
134
- """{generated element class: {css property: value}} for one compiled stylesheet."""
135
- m = re.search(
136
- r'<style id="mosaic-theme-block-editor-styles_%s-inline-css">(.*?)</style>'
137
- % suffix, html, re.S)
138
- rules = {}
139
- if not m:
140
- return rules
141
- for sel, decls in re.findall(r"([^{}]+)\{([^{}]*)\}", m.group(1)):
142
- cls = re.match(r"^\.(_[a-z0-9_-]+)$", sel.strip())
143
- if not cls:
144
- continue
145
- props = {}
146
- for d in decls.split(";"):
147
- if ":" in d:
148
- k, v = d.split(":", 1)
149
- props[k.strip()] = v.strip()
150
- rules.setdefault(cls.group(1), {}).update(props)
151
- return rules
152
-
153
-
154
134
  def main():
155
135
  ap = argparse.ArgumentParser()
156
136
  ap.add_argument("--config", required=True)
@@ -204,7 +184,7 @@ def main():
204
184
  if not classes:
205
185
  sys.exit("no probe rendered - check ordering and parent before trusting a run")
206
186
 
207
- rules = parse_rules(html)
187
+ rules = document_styles(html) or {}
208
188
 
209
189
  rows = []
210
190
  for attr, key_name, value in planned:
@@ -217,7 +197,13 @@ def main():
217
197
  (v for k, v in got.items() if k.startswith(name)), None)
218
198
  want = value if isinstance(value, str) else None
219
199
  if cls is None:
220
- status = "NO_ELEMENT"
200
+ # Since 1.0.9 an element whose local styles compile to NOTHING is
201
+ # emitted without a generated class at all - `<div class="m-div"
202
+ # id="sp-015">`, no `_token`. So "no class" no longer means the node
203
+ # failed to render; for a probe that did render it means the property
204
+ # produced no CSS, which is what ABSENT has always meant. The two are
205
+ # told apart by looking for the id in the markup.
206
+ status = "ABSENT" if ('id="%s"' % attr) in html else "NO_ELEMENT"
221
207
  elif hit is None:
222
208
  status = "ABSENT"
223
209
  elif want is None or want.replace(" ", "") in hit.replace(" ", ""):