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.
- package/README.ja.md +8 -4
- package/README.ko.md +8 -4
- package/README.md +8 -4
- package/README.zh-TW.md +8 -4
- package/SKILL.md +77 -24
- package/assets/templates/platforms/claude-ai.json +1 -1
- package/assets/templates/platforms/claude-code.json +1 -1
- package/assets/templates/platforms/codex-cli.json +1 -1
- package/assets/templates/platforms/copilot.json +1 -1
- package/assets/templates/platforms/gemini-cli.json +1 -1
- package/bin/check-release.mjs +5 -3
- package/data/accordion-verification.csv +1 -1
- package/data/condition-subjects.csv +56 -56
- package/data/conversion-batch.csv +2 -2
- package/data/custom-fields-verification.csv +63 -0
- package/data/data-class-hierarchy.csv +4 -0
- package/data/dialog-verification.csv +20 -0
- package/data/interaction-types.csv +15 -12
- package/data/intro-verification.csv +7 -7
- package/data/loop-verification.csv +1 -1
- package/data/node-properties.csv +9 -0
- package/data/node-property-verification.csv +88 -79
- package/data/node-types.csv +13 -9
- package/data/node-verification.csv +99 -95
- package/data/placement-rules.csv +4 -0
- package/data/pluggables.csv +42 -0
- package/data/rest-routes.csv +2 -1
- package/data/style-properties.csv +6 -6
- package/data/style-verification.csv +79 -79
- package/data/theme-zip-verification.csv +5 -5
- package/data/variants.csv +12 -8
- package/package.json +1 -1
- package/references/custom-fields.md +112 -0
- package/references/dialog-and-triggers.md +201 -0
- package/tools/build_page.py +9 -1
- package/tools/list_fields.php +88 -0
- package/tools/sweep_node_properties.py +21 -5
- package/tools/sweep_node_types.py +85 -0
- package/tools/sweep_style_properties.py +10 -24
- package/tools/verify_dialog.py +302 -0
- 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
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
146
|
+
1a360ae2-3d10-017a-9db6-cece7b44ce17,OpenStreetMap,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.
|
|
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.
|
package/tools/build_page.py
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
269
|
-
|
|
270
|
-
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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(" ", ""):
|