mosaic-headless 1.19.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 +6 -4
- package/README.ko.md +6 -4
- package/README.md +6 -4
- package/README.zh-TW.md +6 -4
- package/SKILL.md +55 -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 +4 -3
- package/data/accordion-verification.csv +1 -1
- package/data/condition-subjects.csv +56 -56
- package/data/conversion-batch.csv +2 -2
- 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/dialog-and-triggers.md +201 -0
- package/tools/build_page.py +9 -1
- 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,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:
|
|
@@ -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(" ", ""):
|