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,271 @@
1
+ #!/usr/bin/env python3
2
+ """Re-probe node properties with a value shaped by their own validator chain.
3
+
4
+ python sweep_node_properties.py --config sweep.json --page moksa --csv out.csv
5
+
6
+ Why this exists
7
+ ---------------
8
+ `data/property-verification.csv` reports 91 of 170 probes as NO_EFFECT. That number
9
+ is not a finding about Mosaic - it is an artefact of the probe. Every property in
10
+ that run was sent the same string, `MPROP0000X`, regardless of what it wanted:
11
+
12
+ cssClasses ValidatorArray -> wanted a list
13
+ locked ValidatorBoolean -> wanted true/false
14
+ required ValidatorInteger -> wanted a number
15
+ target ValidatorAccepted… -> wanted one of _self|_blank|_parent|_top
16
+ tagName ValidatorTagName -> wanted a tag name
17
+
18
+ The tell is that `tagName` and `attrID` are both in the NO_EFFECT list while the
19
+ entire demo site is built on them. Same signature as the style sweep, where `color`
20
+ and `paddingTop` came back ABSENT because the probe nodes were orphaned: when a
21
+ sweep says something you know to be false, the sweep is what is broken.
22
+
23
+ So this one derives the probe value from the declared validator chain, and asserts
24
+ against the delivered markup rather than against a marker in the text.
25
+
26
+ Statuses
27
+ --------
28
+ APPLIED the property changed the delivered HTML in the way it claims to
29
+ NO_EFFECT correctly shaped value, committed, nothing changed in the markup
30
+ SKIPPED no value could be derived from the validator chain; NOT a pass
31
+ """
32
+ import argparse
33
+ import csv
34
+ import json
35
+ import os
36
+ import re
37
+ import sys
38
+
39
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
40
+ from build_page import Surface, flatten # noqa: E402
41
+ from sweep_node_types import Client, envelopes, exceptions_of, unwrap # noqa: E402
42
+
43
+ MARK = "mprobe37"
44
+ # 7 collided with the attrID (np-037) and the generated class (M_EL37), turning
45
+ # substring checks into false APPLIEDs. Use a number that cannot occur by chance.
46
+ MARK_INT = 371337
47
+
48
+ # The base class every element data class extends. Properties declared on it apply
49
+ # to every node type, so there is no concrete type to look them up by.
50
+ ABSTRACT = "ElementMResourceDataAbstract"
51
+
52
+ # Properties that are editor state by definition - they describe the node to the
53
+ # builder UI and are not meant to reach the page. Probed anyway, but a NO_EFFECT on
54
+ # these is the correct answer rather than a gap.
55
+ EDITOR_ONLY = {"name", "locked", "currentStatus", "override"}
56
+
57
+
58
+ ALPHA = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
59
+
60
+
61
+ def ordering(i):
62
+ """A monotonic fractional index with room for every probe.
63
+
64
+ build_page.ordering_for() wraps after 62 entries, so `i % 62` hands two siblings
65
+ the SAME ordering - and Mosaic drops the collisions silently. That is how a run
66
+ of 79 probes left 39 nodes in the database and still reported a result.
67
+ """
68
+ return "a" + ALPHA[i // 62] + ALPHA[i % 62]
69
+
70
+
71
+ def kebab(name):
72
+ return re.sub(r"(?<!^)(?=[A-Z])", "-", name).lower()
73
+
74
+
75
+ def probe_value(row):
76
+ """Derive a value from the validator chain, or None if we cannot."""
77
+ prop = row["property"]
78
+ chain = row.get("validators") or ""
79
+ enum = [v for v in (row.get("accepted_values") or "").split("|") if v]
80
+
81
+ if prop == "attrID":
82
+ return None # the sweep's own handle; cannot also test it
83
+ if prop == "style":
84
+ return None # a whole subsystem, swept separately
85
+ if prop == "cssClasses":
86
+ return [MARK + "-cls"]
87
+ if prop == "attributes":
88
+ return [{"name": "data-" + MARK, "value": "yes"}]
89
+ if prop == "tagName":
90
+ return "h4"
91
+ if enum:
92
+ return enum[-1] if enum[-1] != "inPlace" else enum[0]
93
+ if "ValidatorBoolean" in chain:
94
+ return True
95
+ if "ValidatorArray" in chain:
96
+ return [MARK]
97
+ if "ValidatorInteger" in chain:
98
+ return MARK_INT
99
+ if "ValidatorString" in chain or "ValidatorName" in chain:
100
+ return MARK
101
+ return None
102
+
103
+
104
+ def opening_tag(html, attr):
105
+ m = re.search(r"<([a-zA-Z][\w-]*)\s[^>]*id=\"%s\"[^>]*>" % re.escape(attr), html)
106
+ return m.group(0) if m else None
107
+
108
+
109
+ def searchable(tag):
110
+ """The tag with `id` and `class` removed.
111
+
112
+ Both carry the probe's own index and the generated M_EL number, so leaving them
113
+ in lets a value like `7` match itself and score a false APPLIED.
114
+ """
115
+ tag = re.sub(r'\sid="[^"]*"', "", tag)
116
+ return re.sub(r'\sclass="[^"]*"', "", tag)
117
+
118
+
119
+ def judge(prop, value, tag):
120
+ """Did this property change the delivered markup the way it claims to?"""
121
+ if tag is None:
122
+ return "NO_ELEMENT", ""
123
+ body = searchable(tag)
124
+ if prop == "cssClasses":
125
+ return ("APPLIED" if MARK + "-cls" in tag else "NO_EFFECT"), tag[:110]
126
+ if prop == "attributes":
127
+ return ("APPLIED" if "data-" + MARK in body else "NO_EFFECT"), tag[:110]
128
+ if prop == "tagName":
129
+ return ("APPLIED" if tag.startswith("<h4") else "NO_EFFECT"), tag[:60]
130
+ if isinstance(value, str) and value and value in body:
131
+ return "APPLIED", tag[:110]
132
+ if isinstance(value, bool):
133
+ return ("APPLIED" if re.search(r"\b(hidden|disabled|readonly|required)\b", tag)
134
+ else "NO_EFFECT"), tag[:110]
135
+ if isinstance(value, int) and str(value) in body:
136
+ return "APPLIED", tag[:110]
137
+ if isinstance(value, list) and MARK in body:
138
+ return "APPLIED", tag[:110]
139
+ return "NO_EFFECT", tag[:110]
140
+
141
+
142
+ def main():
143
+ ap = argparse.ArgumentParser()
144
+ ap.add_argument("--config", required=True)
145
+ ap.add_argument("--page", required=True)
146
+ ap.add_argument("--csv")
147
+ a = ap.parse_args()
148
+
149
+ cfg = json.load(open(a.config, encoding="utf-8"))
150
+ here = os.path.dirname(os.path.abspath(__file__))
151
+ props = list(csv.DictReader(
152
+ open(os.path.join(here, "..", "data", "node-properties.csv"), encoding="utf-8")))
153
+ types = {r["type"]: r for r in csv.DictReader(
154
+ open(os.path.join(here, "..", "data", "node-types.csv"), encoding="utf-8"))}
155
+
156
+ # A property is probed on a node type that actually declares it: owner_class in
157
+ # node-properties.csv matches data_class in node-types.csv. Properties on the
158
+ # abstract base apply to everything, so those go on a div.
159
+ rendered = {r["type"] for r in csv.DictReader(
160
+ open(os.path.join(here, "..", "data", "node-verification.csv"),
161
+ encoding="utf-8")) if r["outcome"] == "RENDERED"}
162
+ by_class = {}
163
+ for r in types.values():
164
+ by_class.setdefault(r["data_class"], []).append(r["type"])
165
+
166
+ plan = []
167
+ for row in props:
168
+ value = probe_value(row)
169
+ if value is None:
170
+ plan.append((None, row["property"], None, "SKIPPED"))
171
+ continue
172
+ if row["owner_class"] == ABSTRACT:
173
+ hosts = ["div"]
174
+ else:
175
+ hosts = [t for t in by_class.get(row["owner_class"], []) if t in rendered]
176
+ if not hosts:
177
+ plan.append((None, row["property"], None, "NO_HOST"))
178
+ continue
179
+ plan.append((hosts[0], row["property"], value, None))
180
+
181
+ client = Client(cfg)
182
+ tei = unwrap(client.get("adminTemplateEditorInstance"), "adminTemplateEditorInstance")
183
+ master = tei["template"][-1]["masterID"]
184
+ instance = "masterDocumentInstance/%s" % master
185
+ doc = unwrap(client.get(instance), "masterDocumentInstance")
186
+ key = "node/master/%s" % master
187
+ body = next(n for n in doc[key] if n["type"] == "body")
188
+ host_div = next(n for n in doc[key]
189
+ if n["parentID"] == body["ID"] and n["type"] == "div")
190
+ surface = Surface()
191
+
192
+ records, live, moved = [], [], set()
193
+ for i, (host, prop, value, pre) in enumerate(plan):
194
+ if pre is not None or host is None:
195
+ continue
196
+ attr = "np-%03d" % i
197
+ data = {"attrID": attr, prop: value}
198
+ # target and rel are anchor attributes, and button/menu-link only render an
199
+ # <a> when they carry a url - without one they are a <span> and the probe
200
+ # measures the missing companion rather than the property.
201
+ if prop in ("target", "rel"):
202
+ data["url"] = "https://example.com/" + MARK
203
+ node = {"type": host, "data": data}
204
+ if host == "text":
205
+ node["children"] = [{"type": "wysiwyg-text", "data": {"text": MARK}}]
206
+ live.append((attr, host, prop, value))
207
+ # `insertLocation` moves a code node's output out of the tree, so the probe
208
+ # correctly leaves no element where it was written. Absence IS the evidence.
209
+ if prop == "insertLocation" and value != "inPlace":
210
+ moved.add(attr)
211
+ for rec in flatten(node, host_div["ID"], master, surface, True,
212
+ ordering=ordering(i), parent_type="div"):
213
+ records.append({"newRevisionRecord": rec, "originalRevisionRecord": None})
214
+
215
+ print("%d properties, %d probes, %d skipped"
216
+ % (len(props), len(live),
217
+ sum(1 for p in plan if p[3] in ("SKIPPED", "NO_HOST"))))
218
+ resp = client.commit(instance, envelopes(doc), {key: records})
219
+ err = exceptions_of(resp)
220
+ if err:
221
+ sys.exit("commit rejected: %s" % err[:400])
222
+
223
+ html = client.page(a.page)
224
+ rendered = len(set(re.findall(r'id="(np-\d+)"', html)))
225
+ print("probes rendered: %d of %d" % (rendered, len(live)))
226
+ if rendered != len(live) - len(moved):
227
+ # Contamination is the failure mode this sweep is most exposed to: probes
228
+ # from an earlier run share the id space, and two runs can give the same id
229
+ # to different node types. If the page does not carry exactly the probes
230
+ # this run planned, nothing measured from it means anything.
231
+ sys.exit("expected %d probes on the page, found %d - clear previous probes "
232
+ "and re-run before believing any measurement"
233
+ % (len(live) - len(moved), rendered))
234
+
235
+ rows = []
236
+ for attr, host, prop, value in live:
237
+ if attr in moved:
238
+ gone = opening_tag(html, attr) is None
239
+ rows.append([prop, host, json.dumps(value, ensure_ascii=False),
240
+ "APPLIED" if gone else "NO_EFFECT",
241
+ "output relocated out of the tree" if gone else "still in place"])
242
+ continue
243
+ status, evidence = judge(prop, value, opening_tag(html, attr))
244
+ if status == "NO_EFFECT" and prop in EDITOR_ONLY:
245
+ status = "EDITOR_ONLY"
246
+ rows.append([prop, host, json.dumps(value, ensure_ascii=False), status, evidence])
247
+ for host, prop, value, pre in plan:
248
+ if pre in ("SKIPPED", "NO_HOST"):
249
+ rows.append([prop, "", "", pre, ""])
250
+
251
+ counts = {}
252
+ for r in rows:
253
+ counts[r[3]] = counts.get(r[3], 0) + 1
254
+ print()
255
+ for k in ("APPLIED", "NO_EFFECT", "EDITOR_ONLY", "NO_ELEMENT", "NO_HOST",
256
+ "SKIPPED"):
257
+ if counts.get(k):
258
+ print(" %-12s %d" % (k, counts[k]))
259
+ applied = sorted({r[0] for r in rows if r[3] == "APPLIED"})
260
+ print("\n APPLIED:", ", ".join(applied) or "-")
261
+
262
+ if a.csv:
263
+ with open(a.csv, "w", newline="", encoding="utf-8") as fh:
264
+ w = csv.writer(fh)
265
+ w.writerow(["property", "probed_on", "sent", "status", "evidence"])
266
+ w.writerows(sorted(rows))
267
+ print("\nwrote", a.csv)
268
+
269
+
270
+ if __name__ == "__main__":
271
+ main()
@@ -0,0 +1,318 @@
1
+ #!/usr/bin/env python3
2
+ """Place every node type on a real Mosaic document, ONE AT A TIME, and record what happened.
3
+
4
+ python sweep_node_types.py --config sweep.json --setup # build master + template
5
+ python sweep_node_types.py --config sweep.json --sweep # run the isolated sweep
6
+
7
+ sweep.json needs: base, version, cookie, nonce, themeID. --setup fills in masterID,
8
+ templateID and parentNodeID and writes them back.
9
+
10
+ ## Why one at a time
11
+
12
+ A batch sweep is worthless here. Mosaic accepts structurally impossible placements at
13
+ commit time - a second `master-root` nested inside a `div` commits fine - and then the
14
+ *whole page* fails to render, emitting a bare string like
15
+ `NodeMResourceFilterFunctionInterface parent is missing` as a 200 with a ~54 byte body.
16
+ One bad type therefore destroys the evidence for every other type in the same batch.
17
+
18
+ So each type gets the document to itself: commit -> render -> classify -> delete. A type
19
+ that breaks the page only reports its own failure, and the next type starts from a clean
20
+ tree. This costs one page load per type and is the only way the numbers mean anything.
21
+
22
+ ## Outcomes
23
+
24
+ RENDERED committed, and its attrID appeared in the delivered HTML
25
+ COMMITTED the row exists but nothing reached the page (usually needs content
26
+ or a specific parent to produce output)
27
+ BROKE_PAGE committed, and the page stopped rendering - the silent-failure case
28
+ REJECTED the commit came back with an `exceptions` body (HTTP 200)
29
+ """
30
+ import argparse
31
+ import csv
32
+ import json
33
+ import re
34
+ import sys
35
+ import urllib.error
36
+ import urllib.parse
37
+ import urllib.request
38
+ import uuid
39
+
40
+ MARKER = "MOSAICSWEEP"
41
+ # types whose job is to hold text get a wysiwyg-text child, so the assertion has
42
+ # something to find beyond the wrapper element
43
+ TEXT_HOLDERS = {"text", "button", "menu-link", "label", "submit-label", "accordion-title"}
44
+ # a healthy render of the probe page is far bigger than this; anything smaller is
45
+ # Mosaic's bare error string rather than a page
46
+ MIN_HEALTHY_BYTES = 2000
47
+
48
+
49
+ class Client:
50
+ def __init__(self, cfg):
51
+ self.cfg = cfg
52
+ self.base = cfg["base"].rstrip("/")
53
+ self.api = "%s/wp-json/mosaic/v%s" % (self.base, cfg["version"])
54
+
55
+ def _call(self, url, form=None):
56
+ data = urllib.parse.urlencode(form).encode() if form else None
57
+ req = urllib.request.Request(url, data=data, method="POST" if form else "GET")
58
+ req.add_header("Cookie", self.cfg["cookie"])
59
+ req.add_header("X-WP-Nonce", self.cfg["nonce"])
60
+ if form:
61
+ req.add_header("Content-Type", "application/x-www-form-urlencoded")
62
+ try:
63
+ with urllib.request.urlopen(req, timeout=120) as r:
64
+ return json.loads(r.read().decode("utf-8"))
65
+ except urllib.error.HTTPError as e:
66
+ return {"_httperror": e.code, "_body": e.read().decode("utf-8", "replace")[:400]}
67
+
68
+ def theme_url(self, tail):
69
+ return "%s/theme/%s/%s" % (self.api, self.cfg["themeID"], tail)
70
+
71
+ def get(self, tail):
72
+ return self._call(self.theme_url(tail))
73
+
74
+ def commit(self, tail, sync, revisions):
75
+ return self._call(
76
+ self.theme_url(tail + "/commit"),
77
+ {"syncCheckEnvelopes": json.dumps(sync), "revisionEnvelopes": json.dumps(revisions)},
78
+ )
79
+
80
+ def page(self, path=""):
81
+ """Fetch a public page, cache-busted. `path` picks a page other than the home page."""
82
+ req = urllib.request.Request("%s/%s?sweep=%s" % (self.base, path.strip("/") + "/" if path else "",
83
+ uuid.uuid4().hex[:8]))
84
+ try:
85
+ with urllib.request.urlopen(req, timeout=120) as r:
86
+ return r.read().decode("utf-8", "replace")
87
+ except urllib.error.HTTPError as e:
88
+ return e.read().decode("utf-8", "replace")
89
+
90
+
91
+ def envelopes(doc):
92
+ return {k: [[x["ID"], x["revision"]] for x in v] for k, v in doc.items()}
93
+
94
+
95
+ def exceptions_of(resp):
96
+ """Mosaic returns validator rejections as HTTP 200 with an `exceptions` body."""
97
+ if "_httperror" in resp:
98
+ return "http%s: %s" % (resp["_httperror"], resp["_body"][:200])
99
+ for holder in (resp, resp.get("response") or {}):
100
+ if isinstance(holder, dict) and holder.get("exceptions"):
101
+ return json.dumps(holder["exceptions"], ensure_ascii=False)[:300]
102
+ return ""
103
+
104
+
105
+ def unwrap(resp, what):
106
+ if "_httperror" in resp or "response" not in resp:
107
+ sys.exit("%s failed: %s" % (what, json.dumps(resp)[:400]))
108
+ return resp["response"]
109
+
110
+
111
+ def setup(client, cfg, config_path):
112
+ """Create the master (which heals into a default node tree) and the template."""
113
+ tei = unwrap(client.get("adminMasterEditorInstance"), "adminMasterEditorInstance")
114
+ master_id = str(uuid.uuid4())
115
+ resp = client.commit(
116
+ "adminMasterEditorInstance",
117
+ envelopes(tei),
118
+ {
119
+ "master": [
120
+ {
121
+ "newRevisionRecord": {
122
+ "ID": master_id, "parentType": "", "parentID": "", "ordering": "a0",
123
+ "status": "publish", "revision": "", "version": "", "name": "Sweep Master",
124
+ },
125
+ "originalRevisionRecord": None,
126
+ }
127
+ ]
128
+ },
129
+ )
130
+ err = exceptions_of(resp)
131
+ if err:
132
+ sys.exit("master commit rejected: %s" % err)
133
+
134
+ # opening the document instance is what heals the default tree into existence
135
+ doc = unwrap(client.get("masterDocumentInstance/%s" % master_id), "masterDocumentInstance")
136
+ nodes = doc["node/master/%s" % master_id]
137
+ divs = sorted([n for n in nodes if n["type"] == "div"], key=lambda n: n["ordering"])
138
+ if not divs:
139
+ sys.exit("healed master has no div to hang probes on")
140
+ parent = divs[0]["ID"]
141
+
142
+ tei = unwrap(client.get("adminTemplateEditorInstance"), "adminTemplateEditorInstance")
143
+ template_id = str(uuid.uuid4())
144
+ resp = client.commit(
145
+ "adminTemplateEditorInstance",
146
+ envelopes(tei),
147
+ {
148
+ "template": [
149
+ {
150
+ "newRevisionRecord": {
151
+ "ID": template_id, "parentType": "", "parentID": "", "ordering": "a0",
152
+ "status": "publish", "revision": "", "version": "", "name": "Sweep Home",
153
+ "masterID": master_id, "assign": "auto", "path": "archive-post.php",
154
+ "conditions": [],
155
+ },
156
+ "originalRevisionRecord": None,
157
+ }
158
+ ]
159
+ },
160
+ )
161
+ err = exceptions_of(resp)
162
+ if err:
163
+ sys.exit("template commit rejected: %s" % err)
164
+
165
+ cfg.update({"masterID": master_id, "templateID": template_id, "parentNodeID": parent})
166
+ with open(config_path, "w", encoding="utf-8") as fh:
167
+ json.dump(cfg, fh, indent=1)
168
+ print("masterID=%s\ntemplateID=%s\nparentNodeID=%s" % (master_id, template_id, parent))
169
+
170
+ html = client.page()
171
+ print("baseline page: %d bytes%s" % (len(html), "" if len(html) >= MIN_HEALTHY_BYTES else " <-- ALREADY BROKEN"))
172
+
173
+
174
+ def probe_records(node_type, parent_id, master_id):
175
+ nid = str(uuid.uuid4())
176
+ attr = "sweep-%s" % node_type
177
+ base = {
178
+ "parentType": "node", "parentID": parent_id, "ordering": "a0", "status": "publish",
179
+ "revision": "", "version": "", "documentType": "master", "documentID": master_id,
180
+ }
181
+ recs = [{"newRevisionRecord": dict(base, ID=nid, type=node_type, data={"attrID": attr}),
182
+ "originalRevisionRecord": None}]
183
+ if node_type in TEXT_HOLDERS:
184
+ recs.append(
185
+ {"newRevisionRecord": dict(base, ID=str(uuid.uuid4()), parentID=nid, type="wysiwyg-text",
186
+ data={"text": "%s_%s" % (MARKER, node_type)}),
187
+ "originalRevisionRecord": None}
188
+ )
189
+ return attr, nid, recs
190
+
191
+
192
+ def delete_subtree(client, master_id, keep_ids, parent_id=None):
193
+ """Remove the probe nodes, leaving the healed skeleton alone.
194
+
195
+ Deleting "everything not in the original baseline" does not work: heal() rebuilds
196
+ the master-root > document > body skeleton with FRESH ids whenever it decides one
197
+ is missing, so a fixed baseline id set marks those rebuilds as garbage, deletes
198
+ them, and heal makes new ones again - the document grows every round and later
199
+ types get measured on a dirty tree. Scope the delete to the probe parent's own
200
+ descendants instead, which the skeleton is never part of.
201
+ """
202
+ doc = unwrap(client.get("masterDocumentInstance/%s" % master_id), "masterDocumentInstance")
203
+ key = "node/master/%s" % master_id
204
+ if parent_id:
205
+ by_parent = {}
206
+ for n in doc[key]:
207
+ by_parent.setdefault(n["parentID"], []).append(n)
208
+ doomed, stack = [], list(by_parent.get(parent_id, []))
209
+ while stack:
210
+ n = stack.pop()
211
+ doomed.append(n)
212
+ stack.extend(by_parent.get(n["ID"], []))
213
+ else:
214
+ doomed = [n for n in doc[key] if n["ID"] not in keep_ids]
215
+ if not doomed:
216
+ return
217
+ # delete deepest-first so a parent never disappears out from under its child
218
+ depth = {n["ID"]: n for n in doc[key]}
219
+
220
+ def rank(n):
221
+ d, cur = 0, n
222
+ while cur and cur.get("parentType") == "node" and cur["parentID"] in depth:
223
+ cur = depth[cur["parentID"]]
224
+ d += 1
225
+ return -d
226
+
227
+ revisions = [
228
+ {"newRevisionRecord": dict(n, status="delete"), "originalRevisionRecord": n}
229
+ for n in sorted(doomed, key=rank)
230
+ ]
231
+ client.commit("masterDocumentInstance/%s" % master_id, envelopes(doc), {key: revisions})
232
+
233
+
234
+ def sweep(client, cfg, types, out_path):
235
+ master_id = cfg["masterID"]
236
+ doc = unwrap(client.get("masterDocumentInstance/%s" % master_id), "masterDocumentInstance")
237
+ key = "node/master/%s" % master_id
238
+ baseline_ids = {n["ID"] for n in doc[key]}
239
+
240
+ baseline_html = client.page()
241
+ if len(baseline_html) < MIN_HEALTHY_BYTES:
242
+ sys.exit("baseline page is already broken (%d bytes); reset the theme first" % len(baseline_html))
243
+ print("baseline: %d bytes, %d nodes\n" % (len(baseline_html), len(baseline_ids)))
244
+
245
+ rows = []
246
+ for node_type in types:
247
+ doc = unwrap(client.get("masterDocumentInstance/%s" % master_id), "masterDocumentInstance")
248
+ attr, _nid, recs = probe_records(node_type, cfg["parentNodeID"], master_id)
249
+ resp = client.commit("masterDocumentInstance/%s" % master_id, envelopes(doc), {key: recs})
250
+ err = exceptions_of(resp)
251
+
252
+ if err:
253
+ # a 5xx is a PHP fatal on the server, not a graceful validator rejection -
254
+ # the two say very different things about whether the type is usable
255
+ code = resp.get("_httperror")
256
+ outcome = "COMMIT_%d" % code if code and code >= 500 else "REJECTED"
257
+ if code:
258
+ err = "PHP fatal / gateway error during commit"
259
+ tag, classes, echoed, size = "", "", "", ""
260
+ else:
261
+ html = client.page()
262
+ size = len(html)
263
+ if size < MIN_HEALTHY_BYTES:
264
+ outcome, tag, classes, echoed = "BROKE_PAGE", "", "", ""
265
+ err = html.strip()[:200]
266
+ else:
267
+ m = re.search(r'<([a-zA-Z0-9-]+)([^>]*\bid="%s"[^>]*)>' % re.escape(attr), html)
268
+ if m:
269
+ outcome, tag = "RENDERED", m.group(1)
270
+ c = re.search(r'class="([^"]*)"', m.group(2))
271
+ classes = c.group(1) if c else ""
272
+ else:
273
+ outcome, tag, classes = "COMMITTED", "", ""
274
+ echoed = "yes" if ("%s_%s" % (MARKER, node_type)) in html else ""
275
+
276
+ rows.append({"type": node_type, "outcome": outcome, "rendered_tag": tag,
277
+ "rendered_classes": classes, "text_echoed": echoed,
278
+ "page_bytes": size, "detail": err})
279
+ print("%-40s %-11s %s" % (node_type, outcome, tag or err[:60]), flush=True)
280
+ delete_subtree(client, master_id, baseline_ids, cfg["parentNodeID"])
281
+
282
+ with open(out_path, "w", newline="", encoding="utf-8") as fh:
283
+ w = csv.DictWriter(fh, fieldnames=list(rows[0].keys()))
284
+ w.writeheader()
285
+ w.writerows(rows)
286
+
287
+ tally = {}
288
+ for r in rows:
289
+ tally[r["outcome"]] = tally.get(r["outcome"], 0) + 1
290
+ print("\n" + " ".join("%s=%d" % kv for kv in sorted(tally.items())) + " -> %s" % out_path)
291
+
292
+
293
+ if __name__ == "__main__":
294
+ ap = argparse.ArgumentParser()
295
+ ap.add_argument("--config", required=True)
296
+ ap.add_argument("--setup", action="store_true")
297
+ ap.add_argument("--sweep", action="store_true")
298
+ ap.add_argument("--types", help="comma-separated subset; overrides --edition")
299
+ ap.add_argument("--edition", default="all", choices=["all", "free", "pro"],
300
+ help="which slice of node-types.csv to sweep (default: all)")
301
+ ap.add_argument("--node-types-csv", default="../data/node-types.csv")
302
+ ap.add_argument("--out", default="../data/node-verification.csv")
303
+ a = ap.parse_args()
304
+
305
+ cfg = json.load(open(a.config, encoding="utf-8"))
306
+ client = Client(cfg)
307
+ if a.setup:
308
+ setup(client, cfg, a.config)
309
+ if a.sweep:
310
+ if a.types:
311
+ wanted = a.types.split(",")
312
+ else:
313
+ with open(a.node_types_csv, encoding="utf-8") as fh:
314
+ wanted = [r["type"] for r in csv.DictReader(fh)
315
+ if a.edition in ("all", r["edition"])]
316
+ sweep(client, cfg, wanted, a.out)
317
+ if not (a.setup or a.sweep):
318
+ ap.error("pass --setup and/or --sweep")