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,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)
|