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.
Files changed (77) hide show
  1. package/LICENSE +21 -0
  2. package/README.ja.md +94 -0
  3. package/README.md +133 -0
  4. package/README.zh-CN.md +87 -0
  5. package/README.zh-TW.md +87 -0
  6. package/SKILL.md +353 -0
  7. package/assets/templates/platforms/claude-ai.json +34 -0
  8. package/assets/templates/platforms/claude-code.json +28 -0
  9. package/assets/templates/platforms/codex-cli.json +33 -0
  10. package/assets/templates/platforms/continue.json +31 -0
  11. package/assets/templates/platforms/copilot.json +46 -0
  12. package/assets/templates/platforms/cursor.json +36 -0
  13. package/assets/templates/platforms/gemini-cli.json +33 -0
  14. package/assets/templates/platforms/windsurf.json +34 -0
  15. package/bin/check-release.mjs +143 -0
  16. package/bin/install.mjs +452 -0
  17. package/bin/sync-version.mjs +63 -0
  18. package/data/animatable-properties.csv +23 -0
  19. package/data/condition-comparators.csv +13 -0
  20. package/data/condition-subjects.csv +60 -0
  21. package/data/db-columns.csv +207 -0
  22. package/data/default-children.csv +11 -0
  23. package/data/dynamic-variables.csv +75 -0
  24. package/data/element-classes.csv +152 -0
  25. package/data/evaluator-functions.csv +20 -0
  26. package/data/interaction-types.csv +13 -0
  27. package/data/node-properties.csv +182 -0
  28. package/data/node-property-verification.csv +182 -0
  29. package/data/node-types.csv +123 -0
  30. package/data/node-verification.csv +123 -0
  31. package/data/placement-rules.csv +123 -0
  32. package/data/pluggables.csv +208 -0
  33. package/data/property-verification.csv +171 -0
  34. package/data/rest-routes.csv +115 -0
  35. package/data/rwd-verification.csv +570 -0
  36. package/data/style-properties.csv +99 -0
  37. package/data/style-states.csv +54 -0
  38. package/data/style-value-shapes.csv +23 -0
  39. package/data/style-verification.csv +99 -0
  40. package/package.json +59 -0
  41. package/references/data-model.md +95 -0
  42. package/references/design-system.md +118 -0
  43. package/references/dynamic-content.md +113 -0
  44. package/references/failure-modes.md +182 -0
  45. package/references/interactions.md +126 -0
  46. package/references/placement.md +117 -0
  47. package/references/responsive.md +174 -0
  48. package/references/styling.md +172 -0
  49. package/references/templates-and-conditions.md +122 -0
  50. package/references/vs-elementor-gutenberg.md +73 -0
  51. package/references/write-protocol.md +79 -0
  52. package/sites/_moksa.py +1165 -0
  53. package/sites/moksa.json +8685 -0
  54. package/tools/bootstrap_probe_theme.php +68 -0
  55. package/tools/build_all.py +55 -0
  56. package/tools/build_page.py +352 -0
  57. package/tools/build_report.py +221 -0
  58. package/tools/build_site.py +174 -0
  59. package/tools/capture_live.py +130 -0
  60. package/tools/check_placement_predicts.py +72 -0
  61. package/tools/copy_styles.py +204 -0
  62. package/tools/extract_default_children.py +94 -0
  63. package/tools/extract_dynamic_variables.py +104 -0
  64. package/tools/extract_interactions.py +98 -0
  65. package/tools/extract_node_types.py +165 -0
  66. package/tools/extract_placement.py +135 -0
  67. package/tools/extract_pluggables.py +90 -0
  68. package/tools/extract_style_properties.py +163 -0
  69. package/tools/mint_session.php +52 -0
  70. package/tools/probe.py +144 -0
  71. package/tools/sweep_node_properties.py +271 -0
  72. package/tools/sweep_node_types.py +318 -0
  73. package/tools/sweep_properties.py +215 -0
  74. package/tools/sweep_style_properties.py +254 -0
  75. package/tools/theme_export.php +91 -0
  76. package/tools/theme_import.php +113 -0
  77. 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";