mosaic-headless 1.2.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/LICENSE +21 -0
- package/README.ja.md +94 -0
- package/README.md +133 -0
- package/README.zh-CN.md +87 -0
- package/README.zh-TW.md +87 -0
- package/SKILL.md +353 -0
- package/assets/templates/platforms/claude-ai.json +34 -0
- package/assets/templates/platforms/claude-code.json +28 -0
- package/assets/templates/platforms/codex-cli.json +33 -0
- package/assets/templates/platforms/continue.json +31 -0
- package/assets/templates/platforms/copilot.json +46 -0
- package/assets/templates/platforms/cursor.json +36 -0
- package/assets/templates/platforms/gemini-cli.json +33 -0
- package/assets/templates/platforms/windsurf.json +34 -0
- package/bin/check-release.mjs +143 -0
- package/bin/install.mjs +452 -0
- package/bin/sync-version.mjs +63 -0
- package/data/animatable-properties.csv +23 -0
- package/data/condition-comparators.csv +13 -0
- package/data/condition-subjects.csv +60 -0
- package/data/db-columns.csv +207 -0
- package/data/default-children.csv +11 -0
- package/data/dynamic-variables.csv +75 -0
- package/data/element-classes.csv +152 -0
- package/data/evaluator-functions.csv +20 -0
- package/data/interaction-types.csv +13 -0
- package/data/node-properties.csv +182 -0
- package/data/node-property-verification.csv +182 -0
- package/data/node-types.csv +123 -0
- package/data/node-verification.csv +123 -0
- package/data/placement-rules.csv +123 -0
- package/data/pluggables.csv +208 -0
- package/data/property-verification.csv +171 -0
- package/data/rest-routes.csv +115 -0
- package/data/rwd-verification.csv +570 -0
- package/data/style-properties.csv +99 -0
- package/data/style-states.csv +54 -0
- package/data/style-value-shapes.csv +23 -0
- package/data/style-verification.csv +99 -0
- package/package.json +59 -0
- package/references/data-model.md +95 -0
- package/references/design-system.md +118 -0
- package/references/dynamic-content.md +113 -0
- package/references/failure-modes.md +182 -0
- package/references/interactions.md +126 -0
- package/references/placement.md +117 -0
- package/references/responsive.md +174 -0
- package/references/styling.md +172 -0
- package/references/templates-and-conditions.md +122 -0
- package/references/vs-elementor-gutenberg.md +73 -0
- package/references/write-protocol.md +79 -0
- package/sites/_moksa.py +1165 -0
- package/sites/moksa.json +8685 -0
- package/tools/bootstrap_probe_theme.php +68 -0
- package/tools/build_all.py +55 -0
- package/tools/build_page.py +352 -0
- package/tools/build_report.py +221 -0
- package/tools/build_site.py +174 -0
- package/tools/capture_live.py +130 -0
- package/tools/check_placement_predicts.py +72 -0
- package/tools/copy_styles.py +204 -0
- package/tools/extract_default_children.py +94 -0
- package/tools/extract_dynamic_variables.py +104 -0
- package/tools/extract_interactions.py +98 -0
- package/tools/extract_node_types.py +165 -0
- package/tools/extract_placement.py +135 -0
- package/tools/extract_pluggables.py +90 -0
- package/tools/extract_style_properties.py +163 -0
- package/tools/mint_session.php +52 -0
- package/tools/probe.py +144 -0
- package/tools/sweep_node_properties.py +271 -0
- package/tools/sweep_node_types.py +318 -0
- package/tools/sweep_properties.py +215 -0
- package/tools/sweep_style_properties.py +254 -0
- package/tools/theme_export.php +91 -0
- package/tools/theme_import.php +113 -0
- package/tools/verify_rwd.py +278 -0
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Set every declared node property to a probe value on a live site and assert the effect.
|
|
3
|
+
|
|
4
|
+
python sweep_properties.py --config sweep.json [--carriers text,button,div,...]
|
|
5
|
+
|
|
6
|
+
Writes ../data/property-verification.csv.
|
|
7
|
+
|
|
8
|
+
## What a "pass" means here
|
|
9
|
+
|
|
10
|
+
Each property is set, alone, on a node that is known to render, and the delivered page
|
|
11
|
+
is then compared against an unprobed baseline of the same page. The probe is unique per
|
|
12
|
+
(type, property) pair, so a hit means *that* property produced *that* output and
|
|
13
|
+
nothing else could have.
|
|
14
|
+
|
|
15
|
+
Enum properties (`accepted_values` in node-properties.csv) must be probed with a real
|
|
16
|
+
member of their enum - a marker string would simply be rejected - and enum members like
|
|
17
|
+
"0", "none" or "left" already appear all over a normal page. So the test is not "does
|
|
18
|
+
the value appear" but "does it appear MORE OFTEN than without the probe". An enum value
|
|
19
|
+
whose count does not grow is reported INCONCLUSIVE rather than being scored as a pass.
|
|
20
|
+
|
|
21
|
+
## Outcomes
|
|
22
|
+
|
|
23
|
+
HTML the probe value reached the delivered markup
|
|
24
|
+
CSS the probe value reached Mosaic's compiled inline CSS
|
|
25
|
+
ENUM_APPLIED an enum value was accepted and shows up in the output
|
|
26
|
+
INCONCLUSIVE an enum value that appears no more often than on an unprobed page,
|
|
27
|
+
so the probe proves nothing either way
|
|
28
|
+
NO_EFFECT accepted and stored, but nothing observable changed on a bare page
|
|
29
|
+
(normal for properties that need context: conditions, interactions,
|
|
30
|
+
loop bindings)
|
|
31
|
+
REJECTED validator said no - HTTP 200 with an `exceptions` body
|
|
32
|
+
COMMIT_5xx PHP fatal during commit
|
|
33
|
+
BROKE_PAGE the page stopped rendering
|
|
34
|
+
|
|
35
|
+
NO_EFFECT is not a failure. It is the honest answer for a property whose effect a bare
|
|
36
|
+
probe page cannot show, and it is reported as itself instead of being quietly counted
|
|
37
|
+
as a pass.
|
|
38
|
+
"""
|
|
39
|
+
import argparse
|
|
40
|
+
import csv
|
|
41
|
+
import json
|
|
42
|
+
import os
|
|
43
|
+
import re
|
|
44
|
+
import sys
|
|
45
|
+
import urllib.error
|
|
46
|
+
import urllib.parse
|
|
47
|
+
import urllib.request
|
|
48
|
+
import uuid
|
|
49
|
+
|
|
50
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
51
|
+
from sweep_node_types import ( # noqa: E402
|
|
52
|
+
MIN_HEALTHY_BYTES,
|
|
53
|
+
Client,
|
|
54
|
+
delete_subtree,
|
|
55
|
+
envelopes,
|
|
56
|
+
exceptions_of,
|
|
57
|
+
unwrap,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
# properties that are structural rather than presentational - setting them blind either
|
|
61
|
+
# does nothing observable or removes the node, so they are probed but never expected to
|
|
62
|
+
# show up in markup
|
|
63
|
+
STRUCTURAL = {"currentStatus", "locked", "hidden", "override", "conditions", "interactions", "style"}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def probe_value(prop, validators, accepted, marker):
|
|
67
|
+
"""Pick a value the validator chain will accept, as distinctive as it allows."""
|
|
68
|
+
if accepted:
|
|
69
|
+
return accepted[0], True
|
|
70
|
+
if "ValidatorURL" in validators or "ValidatorURLObject" in validators:
|
|
71
|
+
return "https://example.com/%s" % marker, False
|
|
72
|
+
if "ValidatorArray" in validators:
|
|
73
|
+
return [marker], False
|
|
74
|
+
if "ValidatorInt" in validators or "ValidatorNumber" in validators:
|
|
75
|
+
return 7, False
|
|
76
|
+
return marker, False
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
RE_MOSAIC_STYLE = re.compile(
|
|
80
|
+
r'<style id="mosaic-[^"]*-inline-css">(.*?)</style>', re.S
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def split_markup_and_css(html):
|
|
85
|
+
"""Separate Mosaic's compiled CSS from the markup it sits inside.
|
|
86
|
+
|
|
87
|
+
Mosaic ships NO stylesheet file. Every compiled rule is emitted as an inline
|
|
88
|
+
<style id="mosaic-theme-block-editor-styles_<breakpointID>-inline-css"> block in
|
|
89
|
+
the document head - one block per breakpoint, the base breakpoint being `_`.
|
|
90
|
+
|
|
91
|
+
This matters for the sweep: searching linked stylesheets finds nothing at all, so
|
|
92
|
+
a probe whose only effect is a CSS rule would be scored NO_EFFECT. Pull the blocks
|
|
93
|
+
out of the HTML, and search what is left as markup, so the two can be told apart.
|
|
94
|
+
"""
|
|
95
|
+
css = "\n".join(RE_MOSAIC_STYLE.findall(html))
|
|
96
|
+
markup = RE_MOSAIC_STYLE.sub("", html)
|
|
97
|
+
return markup, css
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def load_surface(carriers):
|
|
101
|
+
here = os.path.dirname(os.path.abspath(__file__))
|
|
102
|
+
data = os.path.join(here, "..", "data")
|
|
103
|
+
types = {r["type"]: r for r in csv.DictReader(open(os.path.join(data, "node-types.csv"), encoding="utf-8"))}
|
|
104
|
+
props = list(csv.DictReader(open(os.path.join(data, "node-properties.csv"), encoding="utf-8")))
|
|
105
|
+
by_class = {}
|
|
106
|
+
for p in props:
|
|
107
|
+
by_class.setdefault(p["owner_class"], []).append(p)
|
|
108
|
+
|
|
109
|
+
verified = os.path.join(data, "node-verification.csv")
|
|
110
|
+
renders = set()
|
|
111
|
+
if os.path.exists(verified):
|
|
112
|
+
renders = {r["type"] for r in csv.DictReader(open(verified, encoding="utf-8"))
|
|
113
|
+
if r["outcome"] == "RENDERED"}
|
|
114
|
+
|
|
115
|
+
jobs = []
|
|
116
|
+
# every element inherits these; probing them on one carrier per shape is enough,
|
|
117
|
+
# they are the same code path regardless of the node they sit on
|
|
118
|
+
inherited = by_class.get("ElementMResourceDataAbstract", []) + by_class.get("NodeMResourceDataAbstract", [])
|
|
119
|
+
for t in carriers:
|
|
120
|
+
for p in inherited:
|
|
121
|
+
jobs.append((t, p))
|
|
122
|
+
# a type's own properties are only meaningful on that type
|
|
123
|
+
for slug, row in sorted(types.items()):
|
|
124
|
+
if renders and slug not in renders:
|
|
125
|
+
continue
|
|
126
|
+
for p in by_class.get(row["data_class"], []):
|
|
127
|
+
jobs.append((slug, p))
|
|
128
|
+
return jobs
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def run(client, cfg, jobs, out_path):
|
|
132
|
+
master = cfg["masterID"]
|
|
133
|
+
key = "node/master/%s" % master
|
|
134
|
+
doc = unwrap(client.get("masterDocumentInstance/%s" % master), "masterDocumentInstance")
|
|
135
|
+
baseline_ids = {n["ID"] for n in doc[key]}
|
|
136
|
+
base_html = client.page(cfg.get("path", ""))
|
|
137
|
+
if len(base_html) < MIN_HEALTHY_BYTES:
|
|
138
|
+
sys.exit("baseline page already broken (%d bytes)" % len(base_html))
|
|
139
|
+
base_markup, base_css = split_markup_and_css(base_html)
|
|
140
|
+
print("baseline %d bytes, %d nodes, %d probes\n" % (len(base_html), len(baseline_ids), len(jobs)))
|
|
141
|
+
|
|
142
|
+
rows = []
|
|
143
|
+
for n, (node_type, prop) in enumerate(jobs):
|
|
144
|
+
marker = "MPROP%04dX" % n
|
|
145
|
+
accepted = [v for v in prop["accepted_values"].split("|") if v]
|
|
146
|
+
value, is_enum = probe_value(prop["property"], prop["validators"], accepted, marker)
|
|
147
|
+
needle = str(value[0] if isinstance(value, list) else value)
|
|
148
|
+
|
|
149
|
+
doc = unwrap(client.get("masterDocumentInstance/%s" % master), "masterDocumentInstance")
|
|
150
|
+
nid = str(uuid.uuid4())
|
|
151
|
+
rec = {
|
|
152
|
+
"ID": nid, "parentType": "node", "parentID": cfg["parentNodeID"], "ordering": "a0",
|
|
153
|
+
"status": "publish", "revision": "", "version": "", "type": node_type,
|
|
154
|
+
"data": {"attrID": "prop-%d" % n, prop["property"]: value},
|
|
155
|
+
"documentType": "master", "documentID": master,
|
|
156
|
+
}
|
|
157
|
+
resp = client.commit("masterDocumentInstance/%s" % master, envelopes(doc),
|
|
158
|
+
{key: [{"newRevisionRecord": rec, "originalRevisionRecord": None}]})
|
|
159
|
+
err = exceptions_of(resp)
|
|
160
|
+
|
|
161
|
+
if err:
|
|
162
|
+
code = resp.get("_httperror")
|
|
163
|
+
outcome = "COMMIT_%d" % code if code and code >= 500 else "REJECTED"
|
|
164
|
+
detail = "PHP fatal during commit" if code else err
|
|
165
|
+
else:
|
|
166
|
+
html = client.page(cfg.get("path", ""))
|
|
167
|
+
if len(html) < MIN_HEALTHY_BYTES:
|
|
168
|
+
outcome, detail = "BROKE_PAGE", html.strip()[:160]
|
|
169
|
+
else:
|
|
170
|
+
markup, css = split_markup_and_css(html)
|
|
171
|
+
# Counting occurrences against the unprobed baseline, rather than just
|
|
172
|
+
# testing for presence, is what makes an enum probe meaningful: values
|
|
173
|
+
# like "0", "none" or "left" already appear all over a normal page, and
|
|
174
|
+
# a presence test scores them as a hit no matter what the property did.
|
|
175
|
+
grew_css = css.count(needle) > base_css.count(needle)
|
|
176
|
+
grew_markup = markup.count(needle) > base_markup.count(needle)
|
|
177
|
+
if grew_css:
|
|
178
|
+
outcome = "CSS"
|
|
179
|
+
elif grew_markup:
|
|
180
|
+
outcome = "ENUM_APPLIED" if is_enum else "HTML"
|
|
181
|
+
elif is_enum and (needle in css or needle in markup):
|
|
182
|
+
# present, but no more often than on a page without the probe -
|
|
183
|
+
# nothing can be concluded from this value
|
|
184
|
+
outcome = "INCONCLUSIVE"
|
|
185
|
+
else:
|
|
186
|
+
outcome = "NO_EFFECT"
|
|
187
|
+
detail = ""
|
|
188
|
+
|
|
189
|
+
rows.append({
|
|
190
|
+
"type": node_type, "property": prop["property"], "owner_class": prop["owner_class"],
|
|
191
|
+
"creator": prop["creator"], "probe_value": needle, "is_enum": "yes" if is_enum else "",
|
|
192
|
+
"outcome": outcome, "detail": detail,
|
|
193
|
+
})
|
|
194
|
+
print("%-28s %-18s %-13s %s" % (node_type, prop["property"], outcome, detail[:50]), flush=True)
|
|
195
|
+
delete_subtree(client, master, baseline_ids, cfg["parentNodeID"])
|
|
196
|
+
|
|
197
|
+
with open(out_path, "w", newline="", encoding="utf-8") as fh:
|
|
198
|
+
w = csv.DictWriter(fh, fieldnames=list(rows[0].keys()))
|
|
199
|
+
w.writeheader()
|
|
200
|
+
w.writerows(rows)
|
|
201
|
+
tally = {}
|
|
202
|
+
for r in rows:
|
|
203
|
+
tally[r["outcome"]] = tally.get(r["outcome"], 0) + 1
|
|
204
|
+
print("\n" + " ".join("%s=%d" % kv for kv in sorted(tally.items())) + " -> %s" % out_path)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
if __name__ == "__main__":
|
|
208
|
+
ap = argparse.ArgumentParser()
|
|
209
|
+
ap.add_argument("--config", required=True)
|
|
210
|
+
ap.add_argument("--carriers", default="text,button,div,section,image,icon",
|
|
211
|
+
help="types used to probe the inherited property set")
|
|
212
|
+
ap.add_argument("--out", default="../data/property-verification.csv")
|
|
213
|
+
a = ap.parse_args()
|
|
214
|
+
cfg = json.load(open(a.config, encoding="utf-8"))
|
|
215
|
+
run(Client(cfg), cfg, load_surface(a.carriers.split(",")), a.out)
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Write every style property to a live page and check what came out.
|
|
3
|
+
|
|
4
|
+
python sweep_style_properties.py --config sweep.json --page moksa --csv out.csv
|
|
5
|
+
|
|
6
|
+
Why this exists
|
|
7
|
+
---------------
|
|
8
|
+
`data/style-properties.csv` lists 98 settable style properties, read out of the
|
|
9
|
+
plugin's source. Before this sweep, 18 of them had ever been written to a real page:
|
|
10
|
+
`data/style-value-shapes.csv` covers the structured ones because their SHAPE had to
|
|
11
|
+
be reverse-engineered, and the scalar majority was assumed to work because the shape
|
|
12
|
+
was obvious. Assumed is not measured. `gridColumnStart` is declared, has its own
|
|
13
|
+
factory group, and compiles to nothing - so "declared" does not imply "works", and
|
|
14
|
+
until this ran, eighty properties were resting on that implication.
|
|
15
|
+
|
|
16
|
+
Method
|
|
17
|
+
------
|
|
18
|
+
One `div` per property, all committed into one document, then the page is fetched
|
|
19
|
+
once and each element's own rule is read out of the compiled base stylesheet. Every
|
|
20
|
+
probe carries a value chosen to be unmistakable (`37px`, `rgb(9, 99, 199)`), so a
|
|
21
|
+
match means *that* declaration produced *that* value rather than colliding with a
|
|
22
|
+
default.
|
|
23
|
+
|
|
24
|
+
The node sweep runs one type per document because a bad node can kill the whole
|
|
25
|
+
page; a style property cannot - the worst it does is compile to nothing, which is
|
|
26
|
+
exactly what is being measured. So this one batches, and stays per-property
|
|
27
|
+
attributable because each div has its own attrID and therefore its own class.
|
|
28
|
+
|
|
29
|
+
Two things that will silently ruin this sweep, both learned the hard way
|
|
30
|
+
-----------------------------------------------------------------------
|
|
31
|
+
**`ordering` must be a valid fractional index.** Pass anything else - `z000` seemed
|
|
32
|
+
harmless - and the commit returns no exception, but the row is stored with BOTH
|
|
33
|
+
`ordering` and `parentID` blanked. The node is orphaned, never renders, and every
|
|
34
|
+
property reads as a false ABSENT. Use `ordering_for()`; never format your own.
|
|
35
|
+
|
|
36
|
+
**Do not parent probes to `body`.** heal() owns the body's own children and rebuilds
|
|
37
|
+
that skeleton whenever it thinks one is missing. Hang them off a div inside it.
|
|
38
|
+
|
|
39
|
+
Statuses
|
|
40
|
+
--------
|
|
41
|
+
COMPILED the property is in the element's rule with the value we sent
|
|
42
|
+
DIFFERENT in the rule, different value - recorded so it can be read
|
|
43
|
+
ABSENT declared, committed without error, nothing in the stylesheet
|
|
44
|
+
SKIPPED no test value could be synthesised; NOT counted as a pass
|
|
45
|
+
|
|
46
|
+
`SKIPPED` and `DIFFERENT` are never folded into a pass rate. A sweep that scores its
|
|
47
|
+
own blind spots as successes is the thing this skill exists to argue against.
|
|
48
|
+
"""
|
|
49
|
+
import argparse
|
|
50
|
+
import csv
|
|
51
|
+
import json
|
|
52
|
+
import os
|
|
53
|
+
import re
|
|
54
|
+
import sys
|
|
55
|
+
|
|
56
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
57
|
+
from build_page import Surface, flatten, ordering_for # noqa: E402
|
|
58
|
+
from sweep_node_types import Client, envelopes, exceptions_of, unwrap # noqa: E402
|
|
59
|
+
|
|
60
|
+
MARK_LEN = "37px"
|
|
61
|
+
MARK_COLOR = "rgb(9, 99, 199)"
|
|
62
|
+
MARK_NUM = "7"
|
|
63
|
+
|
|
64
|
+
# Style keys whose CSS name is not the kebab-case of the key.
|
|
65
|
+
ALIASES = {
|
|
66
|
+
"gridCols": "grid-template-columns",
|
|
67
|
+
"radius": "border-radius",
|
|
68
|
+
"move": "transform",
|
|
69
|
+
"shadow": "box-shadow",
|
|
70
|
+
"transitionAll": "transition",
|
|
71
|
+
"objectFitStyle": "object-fit",
|
|
72
|
+
"backgroundStyle": "background-image",
|
|
73
|
+
"customStyles": None, # raw CSS, not a single declaration
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
# Structured values whose shape was pinned down by data/style-value-shapes.csv.
|
|
77
|
+
SHAPES = {
|
|
78
|
+
"borderRadius": {"type": "all", "allOptions": {"borderRadiusValue": MARK_LEN}},
|
|
79
|
+
"move": {"translateY": MARK_LEN},
|
|
80
|
+
"shadow": {"x": "0px", "y": MARK_LEN, "blur": "9px", "spread": "0px",
|
|
81
|
+
"color": MARK_COLOR},
|
|
82
|
+
"border": {"width": "3px", "style": "dashed", "color": MARK_COLOR},
|
|
83
|
+
"gridCols": [{"type": "default", "defaultOptions": {"size": MARK_LEN}}],
|
|
84
|
+
"gridTemplateColumns": [{"type": "default", "defaultOptions": {"size": MARK_LEN}}],
|
|
85
|
+
"gridTemplateRows": [{"type": "default", "defaultOptions": {"size": MARK_LEN}}],
|
|
86
|
+
"objectFitStyle": {"objectFit": "cover", "objectPositionX": "37%",
|
|
87
|
+
"objectPositionY": "37%"},
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
LENGTHY = re.compile(
|
|
91
|
+
r"(width|height|size|top|right|bottom|left|gap|padding|margin|indent|spacing|"
|
|
92
|
+
r"offset|radius|thickness|basis|inset)", re.I)
|
|
93
|
+
COLORY = re.compile(r"color$", re.I)
|
|
94
|
+
NUMERIC = {"zIndex", "order", "flexGrow", "flexShrink", "lineHeight",
|
|
95
|
+
"fontWeight", "gridColumnStart", "gridColumnEnd", "gridRowStart",
|
|
96
|
+
"gridRowEnd"}
|
|
97
|
+
# properties whose valid range excludes the generic marker. `opacity: 7` compiles to
|
|
98
|
+
# `1` because the browser clamps it - which reads as DIFFERENT and is my test value's
|
|
99
|
+
# fault, not the property's.
|
|
100
|
+
RANGED = {"opacity": "0.37"}
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def kebab(name):
|
|
104
|
+
return re.sub(r"(?<!^)(?=[A-Z])", "-", name).lower()
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def css_name(key):
|
|
108
|
+
return ALIASES[key] if key in ALIASES else kebab(key)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def test_value(row):
|
|
112
|
+
"""Pick an unmistakable value for one property, or None if we cannot."""
|
|
113
|
+
key = row["property"]
|
|
114
|
+
if key in SHAPES:
|
|
115
|
+
return SHAPES[key]
|
|
116
|
+
if key == "customStyles":
|
|
117
|
+
return None
|
|
118
|
+
enum = (row.get("accepted_values") or "").strip()
|
|
119
|
+
if enum:
|
|
120
|
+
options = [o for o in enum.split("|") if o and " " not in o]
|
|
121
|
+
return options[-1] if options else None # least likely to be the default
|
|
122
|
+
if key in RANGED:
|
|
123
|
+
return RANGED[key]
|
|
124
|
+
if key in NUMERIC:
|
|
125
|
+
return MARK_NUM
|
|
126
|
+
if COLORY.search(key):
|
|
127
|
+
return MARK_COLOR
|
|
128
|
+
if LENGTHY.search(key):
|
|
129
|
+
return MARK_LEN
|
|
130
|
+
return None
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def parse_rules(html, suffix=""):
|
|
134
|
+
"""{M_EL 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"^\.(M_EL\d+)$", 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
|
+
def main():
|
|
155
|
+
ap = argparse.ArgumentParser()
|
|
156
|
+
ap.add_argument("--config", required=True)
|
|
157
|
+
ap.add_argument("--page", required=True, help="slug of a page that renders")
|
|
158
|
+
ap.add_argument("--csv")
|
|
159
|
+
a = ap.parse_args()
|
|
160
|
+
|
|
161
|
+
cfg = json.load(open(a.config, encoding="utf-8"))
|
|
162
|
+
here = os.path.dirname(os.path.abspath(__file__))
|
|
163
|
+
props = list(csv.DictReader(
|
|
164
|
+
open(os.path.join(here, "..", "data", "style-properties.csv"),
|
|
165
|
+
encoding="utf-8")))
|
|
166
|
+
|
|
167
|
+
client = Client(cfg)
|
|
168
|
+
tei = unwrap(client.get("adminTemplateEditorInstance"), "adminTemplateEditorInstance")
|
|
169
|
+
master = tei["template"][-1]["masterID"]
|
|
170
|
+
instance = "masterDocumentInstance/%s" % master
|
|
171
|
+
doc = unwrap(client.get(instance), "masterDocumentInstance")
|
|
172
|
+
key = "node/master/%s" % master
|
|
173
|
+
body = next(n for n in doc[key] if n["type"] == "body")
|
|
174
|
+
# a div INSIDE the body - heal() owns the body's own children
|
|
175
|
+
host = next(n for n in doc[key]
|
|
176
|
+
if n["parentID"] == body["ID"] and n["type"] == "div")
|
|
177
|
+
surface = Surface()
|
|
178
|
+
|
|
179
|
+
planned, skipped, records = [], [], []
|
|
180
|
+
for i, row in enumerate(props):
|
|
181
|
+
value = test_value(row)
|
|
182
|
+
if value is None:
|
|
183
|
+
skipped.append(row["property"])
|
|
184
|
+
continue
|
|
185
|
+
attr = "sp-%03d" % i
|
|
186
|
+
planned.append((attr, row["property"], value))
|
|
187
|
+
for rec in flatten({"type": "div", "data": {"attrID": attr},
|
|
188
|
+
"style": {"&": {"_": {row["property"]: value}}}},
|
|
189
|
+
host["ID"], master, surface, True,
|
|
190
|
+
# a valid fractional index, never a formatted number
|
|
191
|
+
ordering=ordering_for(i), parent_type="div"):
|
|
192
|
+
records.append({"newRevisionRecord": rec, "originalRevisionRecord": None})
|
|
193
|
+
|
|
194
|
+
print("%d properties, %d probes, %d skipped" % (len(props), len(planned), len(skipped)))
|
|
195
|
+
resp = client.commit(instance, envelopes(doc), {key: records})
|
|
196
|
+
err = exceptions_of(resp)
|
|
197
|
+
if err:
|
|
198
|
+
sys.exit("commit rejected: %s" % err[:400])
|
|
199
|
+
|
|
200
|
+
html = client.page(a.page)
|
|
201
|
+
classes = {}
|
|
202
|
+
for m in re.finditer(r'id="(sp-\d+)"[^>]*class="(M_EL\d+)', html):
|
|
203
|
+
classes[m.group(1)] = m.group(2)
|
|
204
|
+
print("probes rendered: %d of %d" % (len(classes), len(planned)))
|
|
205
|
+
if not classes:
|
|
206
|
+
sys.exit("no probe rendered - check ordering and parent before trusting a run")
|
|
207
|
+
|
|
208
|
+
rules = parse_rules(html)
|
|
209
|
+
|
|
210
|
+
rows = []
|
|
211
|
+
for attr, key_name, value in planned:
|
|
212
|
+
cls = classes.get(attr)
|
|
213
|
+
name = css_name(key_name)
|
|
214
|
+
got = rules.get(cls, {}) if cls else {}
|
|
215
|
+
hit = None
|
|
216
|
+
if name:
|
|
217
|
+
hit = got.get(name) or next(
|
|
218
|
+
(v for k, v in got.items() if k.startswith(name)), None)
|
|
219
|
+
want = value if isinstance(value, str) else None
|
|
220
|
+
if cls is None:
|
|
221
|
+
status = "NO_ELEMENT"
|
|
222
|
+
elif hit is None:
|
|
223
|
+
status = "ABSENT"
|
|
224
|
+
elif want is None or want.replace(" ", "") in hit.replace(" ", ""):
|
|
225
|
+
status = "COMPILED"
|
|
226
|
+
else:
|
|
227
|
+
status = "DIFFERENT"
|
|
228
|
+
rows.append([key_name, name or "", json.dumps(value, ensure_ascii=False),
|
|
229
|
+
hit or "", status])
|
|
230
|
+
for p in skipped:
|
|
231
|
+
rows.append([p, css_name(p) or "", "", "", "SKIPPED"])
|
|
232
|
+
|
|
233
|
+
counts = {}
|
|
234
|
+
for r in rows:
|
|
235
|
+
counts[r[4]] = counts.get(r[4], 0) + 1
|
|
236
|
+
print()
|
|
237
|
+
for k in ("COMPILED", "DIFFERENT", "ABSENT", "NO_ELEMENT", "SKIPPED"):
|
|
238
|
+
if counts.get(k):
|
|
239
|
+
print(" %-11s %d" % (k, counts[k]))
|
|
240
|
+
for k in ("ABSENT", "DIFFERENT"):
|
|
241
|
+
names = [r[0] for r in rows if r[4] == k]
|
|
242
|
+
if names:
|
|
243
|
+
print("\n %s: %s" % (k, ", ".join(names)))
|
|
244
|
+
|
|
245
|
+
if a.csv:
|
|
246
|
+
with open(a.csv, "w", newline="", encoding="utf-8") as fh:
|
|
247
|
+
w = csv.writer(fh)
|
|
248
|
+
w.writerow(["property", "css_property", "sent", "compiled", "status"])
|
|
249
|
+
w.writerows(sorted(rows))
|
|
250
|
+
print("\nwrote", a.csv)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
if __name__ == "__main__":
|
|
254
|
+
main()
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
<?php
|
|
2
|
+
/**
|
|
3
|
+
* Export a complete Mosaic theme to portable JSON.
|
|
4
|
+
*
|
|
5
|
+
* wp eval-file theme_export.php <themeID> > mytheme.json
|
|
6
|
+
* wp eval-file theme_export.php active > mytheme.json
|
|
7
|
+
*
|
|
8
|
+
* A Mosaic theme is not a file and not an option row - it is a themeID scattered
|
|
9
|
+
* across nineteen tables. Nothing in the plugin exports one without a licence, and
|
|
10
|
+
* "copy the site" is not a migration path when you want one theme out of fifty.
|
|
11
|
+
*
|
|
12
|
+
* What makes this safe to re-import is the schema: every theme-scoped table has a
|
|
13
|
+
* COMPOSITE primary key of (themeID, ID). Internal ids - masters, templates, nodes,
|
|
14
|
+
* breakpoints, collection variables - only have to be unique WITHIN a theme. So an
|
|
15
|
+
* import can keep every internal id exactly as it was and rewrite nothing but the
|
|
16
|
+
* themeID, which means no id remapping, no dangling parentID, and no rewriting of
|
|
17
|
+
* the ids embedded inside node `data` blobs.
|
|
18
|
+
*
|
|
19
|
+
* Node `data` is emitted verbatim as a string. It contains style objects, dynamic
|
|
20
|
+
* expressions and raw CSS; decoding and re-encoding it would risk changing it.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
global $wpdb;
|
|
24
|
+
|
|
25
|
+
$args = isset($args) ? $args : [];
|
|
26
|
+
$want = isset($args[0]) ? $args[0] : '';
|
|
27
|
+
|
|
28
|
+
// bare keywords, not --flags: wp-cli claims anything --prefixed first
|
|
29
|
+
if ($want === 'active' || $want === '') {
|
|
30
|
+
$stylesheet = get_option('stylesheet');
|
|
31
|
+
if (!preg_match('/^mosaic-\d+-\d+-([0-9a-f\-]{36})$/i', $stylesheet, $m)) {
|
|
32
|
+
fwrite(STDERR, "active theme is not a Mosaic theme: {$stylesheet}\n");
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
$want = $m[1];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
$p = $wpdb->prefix . 'mosaic_';
|
|
39
|
+
|
|
40
|
+
$theme = $wpdb->get_row($wpdb->prepare("SELECT * FROM {$p}themes WHERE ID=%s", $want), ARRAY_A);
|
|
41
|
+
if (!$theme) {
|
|
42
|
+
fwrite(STDERR, "no such theme: {$want}\n");
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Every table that carries a themeID. locks, settings and submission_actions are
|
|
47
|
+
// deliberately absent: they are site state, not part of a theme.
|
|
48
|
+
$tables = [
|
|
49
|
+
'breakpoints', 'collections', 'collection_modes', 'collection_skins',
|
|
50
|
+
'collection_groups', 'collection_variables', 'element_classes', 'sub_classes',
|
|
51
|
+
'utility_classes', 'utility_sub_classes', 'styleguides', 'masters', 'templates',
|
|
52
|
+
'template_assigns', 'nodes', 'components', 'component_documents',
|
|
53
|
+
'component_categories', 'submissions',
|
|
54
|
+
];
|
|
55
|
+
|
|
56
|
+
$out = [
|
|
57
|
+
'format' => 'mosaic-theme-export/1',
|
|
58
|
+
'exported' => gmdate('c'),
|
|
59
|
+
'source' => home_url(),
|
|
60
|
+
'mosaic' => defined('MOSAIC_VERSION') ? MOSAIC_VERSION : null,
|
|
61
|
+
'theme' => $theme,
|
|
62
|
+
'tables' => [],
|
|
63
|
+
'rowCounts' => [],
|
|
64
|
+
];
|
|
65
|
+
|
|
66
|
+
foreach ($tables as $t) {
|
|
67
|
+
$rows = $wpdb->get_results(
|
|
68
|
+
$wpdb->prepare("SELECT * FROM {$p}{$t} WHERE themeID=%s", $want), ARRAY_A);
|
|
69
|
+
$out['tables'][$t] = $rows ? $rows : [];
|
|
70
|
+
$out['rowCounts'][$t] = count($out['tables'][$t]);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// template_assigns points at posts by id. Carry the slug too, so an import onto a
|
|
74
|
+
// different site can rebind by slug instead of silently pointing at whatever post
|
|
75
|
+
// happens to hold that id there.
|
|
76
|
+
foreach ($out['tables']['template_assigns'] as &$a) {
|
|
77
|
+
if (($a['type'] ?? '') === 'post') {
|
|
78
|
+
$post = get_post((int)$a['typeIdentifier']);
|
|
79
|
+
$a['_postSlug'] = $post ? $post->post_name : null;
|
|
80
|
+
$a['_postType'] = $post ? $post->post_type : null;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
unset($a);
|
|
84
|
+
|
|
85
|
+
echo json_encode($out, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT);
|
|
86
|
+
echo "\n";
|
|
87
|
+
fwrite(STDERR, sprintf("exported theme %s (%s): %s\n", $theme['ID'], $theme['name'],
|
|
88
|
+
implode(', ', array_map(
|
|
89
|
+
function ($k, $v) { return "$k=$v"; },
|
|
90
|
+
array_keys(array_filter($out['rowCounts'])),
|
|
91
|
+
array_values(array_filter($out['rowCounts']))))));
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
<?php
|
|
2
|
+
/**
|
|
3
|
+
* Import a theme exported by theme_export.php, under a fresh themeID.
|
|
4
|
+
*
|
|
5
|
+
* wp eval-file theme_import.php mytheme.json "Imported name"
|
|
6
|
+
* wp eval-file theme_import.php mytheme.json "Imported name" activate
|
|
7
|
+
* wp eval-file theme_import.php mytheme.json "Imported name" rebind
|
|
8
|
+
*
|
|
9
|
+
* Only the themeID changes. Every internal id - masters, templates, nodes,
|
|
10
|
+
* breakpoints, collection variables - is carried across unchanged, which is legal
|
|
11
|
+
* because each theme-scoped table has a COMPOSITE primary key of (themeID, ID).
|
|
12
|
+
* That is what makes this a copy rather than a rewrite: no id remapping, so nothing
|
|
13
|
+
* can dangle, and the ids embedded inside node `data` blobs stay correct without the
|
|
14
|
+
* blob ever being decoded.
|
|
15
|
+
*
|
|
16
|
+
* Every insert passes an explicit all-%s format array. Without one,
|
|
17
|
+
* $wpdb->insert() consults $wpdb->field_types, which hardcodes 'ID' => '%d' for
|
|
18
|
+
* WordPress core tables - so a Mosaic varchar ID is cast to an integer and the
|
|
19
|
+
* breakpoint '_t' is stored as 0. It fails loudly on the second breakpoint, because
|
|
20
|
+
* '_m' also becomes 0 and collides on the primary key; on a table where only one row
|
|
21
|
+
* happened to be affected it would have failed silently.
|
|
22
|
+
*
|
|
23
|
+
* `rebind` re-points template assignments at the post with the same slug
|
|
24
|
+
* on THIS site rather than the same numeric id, which is what you want when moving a
|
|
25
|
+
* theme between installs. Without it the ids are copied verbatim and an assignment
|
|
26
|
+
* may silently land on an unrelated post.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
use Mosaic\WPTheme\WPThemeManager;
|
|
30
|
+
|
|
31
|
+
global $wpdb;
|
|
32
|
+
|
|
33
|
+
$args = isset($args) ? $args : [];
|
|
34
|
+
$path = isset($args[0]) ? $args[0] : '';
|
|
35
|
+
$name = isset($args[1]) ? $args[1] : '';
|
|
36
|
+
$flags = array_slice($args, 2);
|
|
37
|
+
// bare keywords, not --flags: wp-cli claims anything --prefixed first
|
|
38
|
+
$activate = in_array('activate', $flags, true);
|
|
39
|
+
$rebind = in_array('rebind', $flags, true);
|
|
40
|
+
|
|
41
|
+
if (!$path || !file_exists($path)) {
|
|
42
|
+
fwrite(STDERR, "usage: wp eval-file theme_import.php <export.json> [name] [--activate] [--rebind-by-slug]\n");
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
$data = json_decode(file_get_contents($path), true);
|
|
47
|
+
if (!$data || ($data['format'] ?? '') !== 'mosaic-theme-export/1') {
|
|
48
|
+
fwrite(STDERR, "not a mosaic-theme-export/1 file\n");
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
$newID = wp_generate_uuid4();
|
|
53
|
+
$p = $wpdb->prefix . 'mosaic_';
|
|
54
|
+
|
|
55
|
+
$theme = $data['theme'];
|
|
56
|
+
$theme['ID'] = $newID;
|
|
57
|
+
if ($name !== '') {
|
|
58
|
+
$theme['name'] = $name;
|
|
59
|
+
}
|
|
60
|
+
$wpdb->insert($p . 'themes', $theme, array_fill(0, count($theme), '%s'));
|
|
61
|
+
if ($wpdb->last_error) {
|
|
62
|
+
fwrite(STDERR, "theme row failed: {$wpdb->last_error}\n");
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
$counts = [];
|
|
67
|
+
foreach ($data['tables'] as $table => $rows) {
|
|
68
|
+
$n = 0;
|
|
69
|
+
foreach ($rows as $row) {
|
|
70
|
+
// strip the export-only hints before they reach a column that does not exist
|
|
71
|
+
$slug = $row['_postSlug'] ?? null;
|
|
72
|
+
$ptype = $row['_postType'] ?? null;
|
|
73
|
+
unset($row['_postSlug'], $row['_postType']);
|
|
74
|
+
|
|
75
|
+
$row['themeID'] = $newID;
|
|
76
|
+
|
|
77
|
+
if ($rebind && $table === 'template_assigns'
|
|
78
|
+
&& ($row['type'] ?? '') === 'post' && $slug) {
|
|
79
|
+
$found = get_page_by_path($slug, OBJECT, $ptype ? $ptype : 'page');
|
|
80
|
+
if ($found) {
|
|
81
|
+
$row['typeIdentifier'] = (string)$found->ID;
|
|
82
|
+
} else {
|
|
83
|
+
fwrite(STDERR, " no post with slug '{$slug}' here; assignment left pointing at id {$row['typeIdentifier']}\n");
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
$wpdb->insert($p . $table, $row, array_fill(0, count($row), '%s'));
|
|
88
|
+
if ($wpdb->last_error) {
|
|
89
|
+
fwrite(STDERR, " {$table} row failed: {$wpdb->last_error}\n");
|
|
90
|
+
$wpdb->last_error = '';
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
$n++;
|
|
94
|
+
}
|
|
95
|
+
if ($n) {
|
|
96
|
+
$counts[$table] = $n;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
WPThemeManager::createThemeOnCurrentSite($newID, $theme['name'], $theme['revision']);
|
|
101
|
+
|
|
102
|
+
$parts = [];
|
|
103
|
+
foreach ($counts as $k => $v) {
|
|
104
|
+
$parts[] = "$k=$v";
|
|
105
|
+
}
|
|
106
|
+
fwrite(STDERR, sprintf("imported as %s (%s): %s\n", $newID, $theme['name'], implode(', ', $parts)));
|
|
107
|
+
|
|
108
|
+
if ($activate) {
|
|
109
|
+
switch_theme('mosaic-1-1-' . $newID);
|
|
110
|
+
fwrite(STDERR, "activated mosaic-1-1-{$newID}\n");
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
echo "THEME_ID={$newID}\n";
|