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,68 @@
1
+ <?php
2
+ /**
3
+ * Build a throwaway Mosaic theme + master + template on the current site, from scratch.
4
+ *
5
+ * wp eval-file bootstrap_probe_theme.php [--user=1]
6
+ *
7
+ * Prints THEME_ID / MASTER_ID / TEMPLATE_ID / BODY_DIV_ID as key=value lines for a
8
+ * harness to consume.
9
+ *
10
+ * Why this exists: Mosaic's own "new theme" path goes through the onboarding wizard,
11
+ * which fetches from account.mosaicbuilder.com and needs an active licence. The
12
+ * plugin's internal classes do not — EditorInstanceWithNewTheme->heal() builds the
13
+ * whole default document (master-root > document > body > three divs) on its own.
14
+ * That makes a licence-free, repeatable test fixture possible, which is the only
15
+ * reason the write path in this skill could be verified at all.
16
+ *
17
+ * Set MOSAIC_PROBE_RESET=1 in the environment to delete every existing Mosaic theme
18
+ * first. That is destructive and is meant for a scratch site only.
19
+ */
20
+
21
+ use Mosaic\Common\UUID;
22
+ use Mosaic\Themes\ThemeRevisionRecord;
23
+ use Mosaic\EditorInstance\Theme\ThemeScopedTheme\EditorInstanceWithNewTheme;
24
+ use Mosaic\WPTheme\WPThemeManager;
25
+
26
+ global $wpdb;
27
+
28
+ if (getenv('MOSAIC_PROBE_RESET') === '1') {
29
+ $tables = [
30
+ 'nodes', 'templates', 'template_assigns', 'masters', 'styleguides',
31
+ 'components', 'component_documents', 'component_categories',
32
+ 'element_classes', 'sub_classes', 'utility_classes', 'utility_sub_classes',
33
+ 'breakpoints', 'collections', 'collection_modes', 'collection_skins',
34
+ 'collection_groups', 'collection_variables', 'themes',
35
+ ];
36
+ foreach ($tables as $t) {
37
+ $wpdb->query("DELETE FROM {$wpdb->prefix}mosaic_{$t}");
38
+ }
39
+ echo "reset=1\n";
40
+ }
41
+
42
+ $themeID = UUID::generate();
43
+ $row = (object)[
44
+ 'ID' => $themeID,
45
+ 'name' => 'HeadlessProbe',
46
+ 'ordering' => 'a0',
47
+ 'status' => 'publish',
48
+ 'revision' => '',
49
+ 'version' => '',
50
+ 'data' => (object)[],
51
+ ];
52
+
53
+ $editorInstance = new EditorInstanceWithNewTheme(new ThemeRevisionRecord($row, true));
54
+ $editorInstance->load();
55
+ $editorInstance->heal(); // builds the default master + node tree
56
+ $editorInstance->pushToDB();
57
+
58
+ $theme = $editorInstance->getThemeMResource();
59
+ WPThemeManager::createThemeOnCurrentSite($theme->getID(), $theme->getName(), $theme->getRevision());
60
+
61
+ echo "THEME_ID=" . $theme->getID() . "\n";
62
+ echo "THEME_REVISION=" . $theme->getRevision() . "\n";
63
+
64
+ // heal() on the theme instance stops at the theme row - it does NOT create a master.
65
+ // The master, its node tree and the template are committed over REST by the harness,
66
+ // so the documented checkout/commit protocol is what actually gets exercised rather
67
+ // than a PHP shortcut around it.
68
+ echo "WP_THEME=" . basename(WPThemeManager::getThemePath($theme->getID())) . "\n";
@@ -0,0 +1,55 @@
1
+ #!/usr/bin/env python3
2
+ """Rebuild every design page in designs/ against a freshly reset theme.
3
+
4
+ python build_all.py --config sweep.json
5
+
6
+ Each run resets the theme first, so the result is reproducible rather than accumulating
7
+ a new master and template per invocation. Prints a per-page verdict: a page that
8
+ commits but serves under MIN_HEALTHY_BYTES is reported as broken, because a 200
9
+ response is not evidence that a Mosaic write worked.
10
+ """
11
+ import argparse
12
+ import glob
13
+ import json
14
+ import os
15
+ import sys
16
+ import urllib.request
17
+
18
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
19
+ from build_page import build # noqa: E402
20
+ from sweep_node_types import MIN_HEALTHY_BYTES, Client # noqa: E402
21
+
22
+ HERE = os.path.dirname(os.path.abspath(__file__))
23
+ DESIGNS = os.path.join(HERE, "..", "designs")
24
+
25
+
26
+ def slug_of(spec):
27
+ return "probe-" + os.path.basename(spec["_path"])[:-5]
28
+
29
+
30
+ if __name__ == "__main__":
31
+ ap = argparse.ArgumentParser()
32
+ ap.add_argument("--config", required=True)
33
+ ap.add_argument("--only", help="comma-separated design names")
34
+ a = ap.parse_args()
35
+ cfg = json.load(open(a.config, encoding="utf-8"))
36
+ client = Client(cfg)
37
+
38
+ wanted = a.only.split(",") if a.only else None
39
+ specs = []
40
+ for path in sorted(glob.glob(os.path.join(DESIGNS, "*.json"))):
41
+ name = os.path.basename(path)[:-5]
42
+ if name.startswith("_") or name == "smoke" or (wanted and name not in wanted):
43
+ continue
44
+ spec = json.load(open(path, encoding="utf-8"))
45
+ spec["_path"] = path
46
+ specs.append(spec)
47
+
48
+ for spec in specs:
49
+ master_id, count = build(client, cfg, spec, force=False)
50
+ url = "%s/%s/" % (cfg["base"].rstrip("/"), slug_of(spec))
51
+ with urllib.request.urlopen(url, timeout=90) as r:
52
+ body = r.read()
53
+ ok = len(body) >= MIN_HEALTHY_BYTES
54
+ print(" %-14s %-7s %6d bytes %d nodes %s" %
55
+ (os.path.basename(spec["_path"])[:-5], "OK" if ok else "BROKEN", len(body), count, url))
@@ -0,0 +1,352 @@
1
+ #!/usr/bin/env python3
2
+ """Build a Mosaic page from a declarative design spec, through the public write path.
3
+
4
+ python build_page.py --config sweep.json --spec myspec.json
5
+
6
+ This is the skill eating its own cooking. Nothing here knows anything about Mosaic
7
+ that is not written down in ../data and ../references: node types come from
8
+ node-types.csv, the placement check comes from placement-rules.csv and
9
+ node-verification.csv, style keys come from style-properties.csv and
10
+ style-states.csv, and the commit sequence is the one in references/write-protocol.md.
11
+
12
+ ## Spec format
13
+
14
+ {
15
+ "page_id": 12, WordPress post the template is bound to
16
+ "title": "Brutalist",
17
+ "master": "Brutalist master",
18
+ "tree": { ...node... }
19
+ }
20
+
21
+ A node is:
22
+
23
+ {"type": "div",
24
+ "text": "hello", shorthand: adds a wysiwyg-text child
25
+ "data": {"tagName": "h1"}, merged into the node's data
26
+ "style": {"_": {...}, "_m": {...}}, base state, per breakpoint
27
+ "hover": {"_": {...}}, any state name works as a key
28
+ "children": [ ...nodes... ]}
29
+
30
+ Style keys are camelCase properties from style-properties.csv; the shorthand expands
31
+ to data.style.states[<state>][<breakpoint>][<property>], the shape confirmed in
32
+ references/styling.md.
33
+
34
+ ## Safety
35
+
36
+ Before committing, every parent/child pair is checked against placement-rules.csv, and
37
+ every type against node-verification.csv. A type measured BROKE_PAGE or COMMIT_5xx is
38
+ refused outright unless --force, because committing one is how you get a 200-response
39
+ page that serves a 54-byte error string. After committing, the page is fetched and its
40
+ size checked - the only reliable signal that the write actually worked.
41
+ """
42
+ import argparse
43
+ import csv
44
+ import json
45
+ import os
46
+ import sys
47
+ import uuid
48
+
49
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
50
+ from sweep_node_types import ( # noqa: E402
51
+ MIN_HEALTHY_BYTES,
52
+ Client,
53
+ envelopes,
54
+ exceptions_of,
55
+ unwrap,
56
+ )
57
+
58
+ DATA = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "data")
59
+ RESERVED = {"type", "text", "data", "children", "style"}
60
+
61
+
62
+ def load_csv(name):
63
+ with open(os.path.join(DATA, name), encoding="utf-8") as fh:
64
+ return list(csv.DictReader(fh))
65
+
66
+
67
+ class Surface:
68
+ """Everything the builder is allowed to know, loaded from the skill's own tables."""
69
+
70
+ def __init__(self):
71
+ self.types = {r["type"]: r for r in load_csv("node-types.csv")}
72
+ self.rules = {r["type"]: r for r in load_csv("placement-rules.csv")}
73
+ self.outcome = {r["type"]: r["outcome"] for r in load_csv("node-verification.csv")}
74
+ style_rows = load_csv("style-properties.csv")
75
+ self.style_props = {r["property"] for r in style_rows}
76
+ # 24 of the 98 style properties are restricted to an enum. A value outside it
77
+ # is accepted by the API, stored, and silently never compiled - white-space
78
+ # takes pre-wrap but not pre-line, and the difference is invisible until you
79
+ # look at the delivered CSS. Refuse it here instead.
80
+ self.style_enums = {r["property"]: set(r["accepted_values"].split("|"))
81
+ for r in style_rows if r.get("accepted_values")}
82
+ self.states = {r["state"] for r in load_csv("style-states.csv")}
83
+ # what a composite type needs INSIDE it, which canBeParentFor does not describe
84
+ self.default_children = {r["type"]: r["default_children"].split("|")
85
+ for r in load_csv("default-children.csv")}
86
+
87
+ def check(self, parent_type, node, force):
88
+ t = node["type"]
89
+ problems = []
90
+ if t not in self.types:
91
+ problems.append("unknown node type %r" % t)
92
+ return problems
93
+ # node-verification.csv measured every type UNDER A PLAIN DIV. A type that broke
94
+ # there is not broken in general - most of them are family members that simply
95
+ # need their own parent. So the measured verdict only applies when the parent is
96
+ # not the one the type is declared to belong under.
97
+ parent_rule = self.rules.get(parent_type or "", {})
98
+ parent_defaults = self.default_children.get(parent_type or "", [])
99
+ declared_child = (t in (parent_rule.get("allowed_children") or "").split("|")
100
+ or t in parent_defaults
101
+ # the wysiwyg family is one interchangeable content model: a
102
+ # parent that seeds itself with wysiwyg-text takes any of
103
+ # them, and wysiwyg-variable inside a text node is verified
104
+ # to render (references/dynamic-content.md)
105
+ or (t.startswith("wysiwyg-")
106
+ and any(d.startswith("wysiwyg-") for d in parent_defaults)))
107
+ outcome = self.outcome.get(t)
108
+ if not declared_child and (outcome == "BROKE_PAGE" or (outcome or "").startswith("COMMIT_5")):
109
+ problems.append("%s is measured %s under a plain container, and %s is not its declared parent"
110
+ % (t, outcome, parent_type or "<root>"))
111
+ # The wysiwyg family is inline CONTENT, not a child in the placement sense, and
112
+ # canBeParentFor does not govern it. `text` inherits canBeParentFor -> false yet
113
+ # TextElementTypeFactory::getTextElementDefaultData() seeds itself with a
114
+ # wysiwyg-text child, and that combination renders. Checking these against the
115
+ # parent rule would refuse every piece of text on the page.
116
+ if parent_type and not t.startswith("wysiwyg-"):
117
+ rule = self.rules.get(parent_type, {})
118
+ if rule.get("rule") == "none" and t not in self.default_children.get(parent_type, []):
119
+ problems.append("%s is a leaf and accepts no children (tried %s)" % (parent_type, t))
120
+ elif rule.get("rule") == "allow":
121
+ allowed = rule["allowed_children"].split("|") + self.default_children.get(parent_type, [])
122
+ if t not in allowed:
123
+ problems.append("%s accepts only %s, not %s" % (parent_type, "/".join(allowed), t))
124
+ shorthands = {"radius", "shadow", "transitionAll", "move", "border", "gridCols"}
125
+ for state, per_bp in node.get("style", {}).items():
126
+ if state not in self.states:
127
+ problems.append("unknown style state %r on %s" % (state, t))
128
+ for _bp, props in per_bp.items():
129
+ for p, value in props.items():
130
+ if p not in self.style_props and p not in shorthands:
131
+ problems.append("%r is not a supported style property (on %s)" % (p, t))
132
+ allowed = self.style_enums.get(p)
133
+ if allowed and isinstance(value, str) and value not in allowed:
134
+ problems.append("%s=%r is not one of %s (on %s)"
135
+ % (p, value, "/".join(sorted(allowed)), t))
136
+ return [] if force else problems
137
+
138
+
139
+ # Five style properties take a structured value rather than a CSS string. Writing a
140
+ # string to them is accepted and then silently produces `transform:none`, `box-shadow:none`
141
+ # or no rule at all - so these shorthands exist to make the correct shape unavoidable.
142
+ # Each was confirmed against the compiled CSS; see references/styling.md.
143
+ VAR_IDS = {}
144
+
145
+
146
+ def expand_shorthands(props):
147
+ out = {}
148
+ extra_css = []
149
+ for key, value in props.items():
150
+ if key == "gridCols":
151
+ # gridTemplateColumns is a CSSGridTemplateStylePropertyFactory property: a
152
+ # plain "repeat(3, 1fr)" string is accepted and then compiles to nothing at
153
+ # all, so a grid silently collapses to one column. Until that structured
154
+ # shape is pinned down, route it through the customStyles escape hatch.
155
+ extra_css.append("grid-template-columns:%s;" % value)
156
+ elif key == "radius" and isinstance(value, str):
157
+ out["borderRadius"] = {"type": "all", "allOptions": {"borderRadiusValue": value}}
158
+ elif key == "shadow" and isinstance(value, dict):
159
+ out["boxShadow"] = [dict({"blur": "0px", "spread": "0px", "type": "outside",
160
+ "uuid": str(uuid.uuid4())}, **value)]
161
+ elif key == "transitionAll" and isinstance(value, str):
162
+ duration, _, easing = value.partition(" ")
163
+ out["transition"] = [{"transitionProperty": "all", "transitionDuration": duration,
164
+ "transitionDelay": "0ms", "transitionTimingFunction": easing or "ease",
165
+ "uuid": str(uuid.uuid4())}]
166
+ elif key == "move" and isinstance(value, dict):
167
+ # {"translateY": "-8px"} -> the transform array entry for that transform type
168
+ out["transform"] = [{"type": t, "%sOptions" % t: {"value": v}, "uuid": str(uuid.uuid4())}
169
+ for t, v in value.items()]
170
+ elif key == "border" and isinstance(value, dict):
171
+ width, style, color = value.get("width", "1px"), value.get("style", "solid"), value.get("color", "#000")
172
+ out["borderStyle"] = {"border%s%s" % (side, part): val
173
+ for side in ("Top", "Right", "Bottom", "Left")
174
+ for part, val in (("Width", width), ("Style", style), ("Color", color))}
175
+ elif isinstance(value, dict) and "token" in value:
176
+ # {"token": "--brand"} -> the {"var": <uuid>} reference the compiler wants
177
+ vid = VAR_IDS.get(value["token"])
178
+ if not vid:
179
+ sys.exit("style references token %r before it is declared in theme.variables"
180
+ % value["token"])
181
+ out[key] = {"var": vid}
182
+ else:
183
+ out[key] = value
184
+ if extra_css:
185
+ # merge with any customStyles the spec set itself rather than clobbering it
186
+ out["customStyles"] = (out.get("customStyles", "") + "".join(extra_css))
187
+ return out
188
+
189
+
190
+ def to_style(spec_style):
191
+ """{state: {breakpoint: {prop: value}}} -> the data.style shape."""
192
+ if not spec_style:
193
+ return None
194
+ return {"states": {state: {bp: expand_shorthands(props) for bp, props in per_bp.items()}
195
+ for state, per_bp in spec_style.items()}}
196
+
197
+
198
+ def flatten(node, parent_id, master_id, surface, force, ordering="a0", parent_type=None, out=None):
199
+ """Depth-first walk producing revision records, checking placement as it goes."""
200
+ out = out if out is not None else []
201
+ problems = surface.check(parent_type, node, force)
202
+ if problems:
203
+ raise SystemExit("refusing to build:\n " + "\n ".join(problems))
204
+
205
+ nid = str(uuid.uuid4())
206
+ data = dict(node.get("data") or {})
207
+ style = to_style(node.get("style"))
208
+ if style:
209
+ data["style"] = style
210
+ out.append({
211
+ "ID": nid, "parentType": "node", "parentID": parent_id, "ordering": ordering,
212
+ "status": "publish", "revision": "", "version": "", "type": node["type"],
213
+ "data": data, "documentType": "master", "documentID": master_id,
214
+ })
215
+
216
+ children = list(node.get("children") or [])
217
+ if node.get("text") is not None:
218
+ children.insert(0, {"type": "wysiwyg-text", "data": {"text": node["text"]}})
219
+ for i, child in enumerate(children):
220
+ flatten(child, nid, master_id, surface, force, ordering_for(i), node["type"], out)
221
+ return out
222
+
223
+
224
+ def ordering_for(index):
225
+ """Siblings order by a fractional-index STRING, never by number."""
226
+ alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
227
+ return "a" + (alphabet[index] if index < len(alphabet) else alphabet[-1] + alphabet[index % len(alphabet)])
228
+
229
+
230
+ def theme_records(spec, doc, surface):
231
+ """Turn the spec's `theme` block into collectionVariable + elementClass records.
232
+
233
+ Both are theme-scoped rather than per-node, and both are how a real Mosaic site is
234
+ meant to be styled: variables are the tokens, element classes apply them to every
235
+ element of a kind. See references/design-system.md.
236
+ """
237
+ theme = spec.get("theme") or {}
238
+ out = {}
239
+
240
+ variables = theme.get("variables") or {}
241
+ if variables:
242
+ collection = (doc.get("collection") or [{}])[0].get("ID")
243
+ mode = (doc.get("collectionMode") or [{}])[0].get("ID")
244
+ skin = (doc.get("collectionSkin") or [{}])[0].get("ID")
245
+ if not (collection and mode and skin):
246
+ sys.exit("theme.variables needs a healed collection/mode/skin")
247
+ recs = []
248
+ for custom_property, value in variables.items():
249
+ vid = spec.setdefault("_varIDs", {}).setdefault(custom_property, str(uuid.uuid4()))
250
+ recs.append({"newRevisionRecord": {
251
+ "ID": vid, "parentType": "collection", "parentID": collection,
252
+ "ordering": "a0", "status": "publish", "revision": "", "version": "",
253
+ "data": {"name": custom_property.lstrip("-"),
254
+ "type": value.get("type", "color"),
255
+ "customProperty": custom_property,
256
+ "skinsData": {skin: {mode: {"value": value["value"]}}}}},
257
+ "originalRevisionRecord": None})
258
+ out["collectionVariable"] = recs
259
+ # register the IDs before element-class styles are expanded - those cite
260
+ # tokens too, and expand_shorthands resolves {"token": ...} from this map
261
+ VAR_IDS.update(spec["_varIDs"])
262
+
263
+ classes = theme.get("elementClasses") or {}
264
+ if classes:
265
+ by_name = {}
266
+ for row in load_csv("element-classes.csv"):
267
+ by_name.setdefault(row["name"], []).append(row)
268
+ recs = []
269
+ for name, states in classes.items():
270
+ matches = by_name.get(name) or []
271
+ if not matches:
272
+ sys.exit("no element-class meta named %r; see data/element-classes.csv" % name)
273
+ # a fresh UUID here is accepted and then silently dropped, so the meta ID
274
+ # is the only thing that works - take the top-level (parent-less) one
275
+ top = next((m for m in matches if not m["parent"]), matches[0])
276
+ recs.append({"newRevisionRecord": {
277
+ "ID": top["id"], "parentType": "", "parentID": "", "ordering": "a0",
278
+ "status": "publish", "revision": "", "version": "",
279
+ "data": {"states": {st: {bp: expand_shorthands(props)
280
+ for bp, props in per_bp.items()}
281
+ for st, per_bp in states.items()}}},
282
+ "originalRevisionRecord": None})
283
+ out["elementClass"] = recs
284
+ return out
285
+
286
+
287
+ def create_master(client, name):
288
+ inst = unwrap(client.get("adminMasterEditorInstance"), "adminMasterEditorInstance")
289
+ mid = str(uuid.uuid4())
290
+ resp = client.commit("adminMasterEditorInstance", envelopes(inst), {"master": [{
291
+ "newRevisionRecord": {"ID": mid, "parentType": "", "parentID": "", "ordering": "a0",
292
+ "status": "publish", "revision": "", "version": "", "name": name},
293
+ "originalRevisionRecord": None}]})
294
+ err = exceptions_of(resp)
295
+ if err:
296
+ sys.exit("master commit failed: %s" % err)
297
+ return mid
298
+
299
+
300
+ def bind_template(client, cfg, master_id, page_id):
301
+ """Attach the master to one WordPress post via a manual template assignment."""
302
+ url = "%s/templateAssign/createManualTemplate" % client.api
303
+ resp = client._call(url, {"resourceQuery": "post/%d" % page_id, "masterID": master_id})
304
+ if "_httperror" in resp:
305
+ sys.exit("template assign failed: %s %s" % (resp["_httperror"], resp["_body"][:200]))
306
+ return resp
307
+
308
+
309
+ def build(client, cfg, spec, force):
310
+ surface = Surface()
311
+ master_id = create_master(client, spec.get("master") or spec["title"])
312
+ doc = unwrap(client.get("masterDocumentInstance/%s" % master_id), "masterDocumentInstance")
313
+ key = "node/master/%s" % master_id
314
+ nodes = doc[key]
315
+ body = next((n for n in nodes if n["type"] == "body"), None)
316
+ if not body:
317
+ sys.exit("healed master has no body")
318
+ # the healed skeleton puts three empty divs in the body; clear them so the design
319
+ # is the only thing on the page
320
+ doomed = [n for n in nodes if n["parentID"] == body["ID"]]
321
+ if doomed:
322
+ client.commit("masterDocumentInstance/%s" % master_id, envelopes(doc), {key: [
323
+ {"newRevisionRecord": dict(n, status="delete"), "originalRevisionRecord": n} for n in doomed]})
324
+ doc = unwrap(client.get("masterDocumentInstance/%s" % master_id), "masterDocumentInstance")
325
+
326
+ # theme records first: the variable IDs have to exist before a node can cite one
327
+ VAR_IDS.clear()
328
+ extra = theme_records(spec, doc, surface)
329
+
330
+ records = flatten(spec["tree"], body["ID"], master_id, surface, force, parent_type="body")
331
+ payload = dict(extra)
332
+ payload[key] = [{"newRevisionRecord": r, "originalRevisionRecord": None} for r in records]
333
+ resp = client.commit("masterDocumentInstance/%s" % master_id, envelopes(doc), payload)
334
+ err = exceptions_of(resp)
335
+ if err:
336
+ sys.exit("node commit failed: %s" % err)
337
+
338
+ bind_template(client, cfg, master_id, spec["page_id"])
339
+ print("master=%s nodes=%d page_id=%s" % (master_id, len(records), spec["page_id"]))
340
+ return master_id, len(records)
341
+
342
+
343
+ if __name__ == "__main__":
344
+ ap = argparse.ArgumentParser()
345
+ ap.add_argument("--config", required=True)
346
+ ap.add_argument("--spec", required=True)
347
+ ap.add_argument("--force", action="store_true", help="skip the placement/outcome checks")
348
+ a = ap.parse_args()
349
+ cfg = json.load(open(a.config, encoding="utf-8"))
350
+ spec = json.load(open(a.spec, encoding="utf-8"))
351
+ client = Client(cfg)
352
+ build(client, cfg, spec, a.force)