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,204 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Copy one node's style object onto other nodes, by attrID.
|
|
3
|
+
|
|
4
|
+
python copy_styles.py --config sweep.json --from mk-svc-0 --to mk-svc-1,mk-svc-2
|
|
5
|
+
python copy_styles.py --config sweep.json --from mk-svc-0 --to-prefix mk-svc-
|
|
6
|
+
python copy_styles.py --config sweep.json --from mk-svc-0 --to-prefix mk-svc- --dry-run
|
|
7
|
+
python copy_styles.py --config sweep.json --from a --to b --only "&._m"
|
|
8
|
+
|
|
9
|
+
Why this exists
|
|
10
|
+
---------------
|
|
11
|
+
There is no "paste style" in the data model. A node's appearance lives in
|
|
12
|
+
`data.style`, and the only way to give twenty rows the same treatment is to write the
|
|
13
|
+
same object twenty times - which is exactly how a page drifts, because the twenty-
|
|
14
|
+
first gets edited and the rest do not.
|
|
15
|
+
|
|
16
|
+
This reads the source node's `style` straight out of its document and writes it to
|
|
17
|
+
each target, so what lands is what the source has, not what a spec file thinks the
|
|
18
|
+
source has.
|
|
19
|
+
|
|
20
|
+
--only takes `state.breakpoint` selectors and copies just those slices, so you can
|
|
21
|
+
push a corrected `_m` across a row of siblings without touching their desktop
|
|
22
|
+
styles. `&._m` is the base state's mobile breakpoint; `&.*` is every breakpoint of
|
|
23
|
+
the base state; `hover.*` is the whole hover state.
|
|
24
|
+
|
|
25
|
+
Always shows the diff and asks, unless --yes. A style copy is not reversible from
|
|
26
|
+
this tool - the previous value is gone once the commit lands - so it prints the
|
|
27
|
+
before and after of every target first.
|
|
28
|
+
"""
|
|
29
|
+
import argparse
|
|
30
|
+
import json
|
|
31
|
+
import os
|
|
32
|
+
import sys
|
|
33
|
+
|
|
34
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
35
|
+
from sweep_node_types import Client, envelopes, exceptions_of, unwrap # noqa: E402
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def documents(client, cfg):
|
|
39
|
+
"""Every document on the theme that can hold nodes, newest master first."""
|
|
40
|
+
tei = unwrap(client.get("adminTemplateEditorInstance"), "adminTemplateEditorInstance")
|
|
41
|
+
seen, out = set(), []
|
|
42
|
+
for tpl in reversed(tei.get("template", [])):
|
|
43
|
+
master = tpl["masterID"]
|
|
44
|
+
if master not in seen:
|
|
45
|
+
seen.add(master)
|
|
46
|
+
out.append(("masterDocumentInstance/%s" % master,
|
|
47
|
+
"node/master/%s" % master))
|
|
48
|
+
out.append(("templateDocumentInstance/%s/%s" % (master, tpl["ID"]),
|
|
49
|
+
"node/template/%s" % tpl["ID"]))
|
|
50
|
+
return out
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def find(client, cfg, attrs):
|
|
54
|
+
"""attrID -> (instance, key, node, whole document). One pass over the theme."""
|
|
55
|
+
found = {}
|
|
56
|
+
for instance, key in documents(client, cfg):
|
|
57
|
+
doc = client.get(instance)
|
|
58
|
+
if "_httperror" in doc:
|
|
59
|
+
continue
|
|
60
|
+
doc = unwrap(doc, instance.split("/")[0])
|
|
61
|
+
for node in doc.get(key, []):
|
|
62
|
+
a = (node.get("data") or {}).get("attrID")
|
|
63
|
+
if a in attrs and a not in found:
|
|
64
|
+
found[a] = (instance, key, node, doc)
|
|
65
|
+
if len(found) == len(attrs):
|
|
66
|
+
break
|
|
67
|
+
return found
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def slice_style(style, only):
|
|
71
|
+
"""Keep just the state.breakpoint slices named by --only."""
|
|
72
|
+
if not only:
|
|
73
|
+
return style
|
|
74
|
+
out = {}
|
|
75
|
+
for sel in only.split(","):
|
|
76
|
+
sel = sel.strip()
|
|
77
|
+
state, _, bp = sel.partition(".")
|
|
78
|
+
if state not in (style or {}):
|
|
79
|
+
continue
|
|
80
|
+
if bp in ("", "*"):
|
|
81
|
+
out[state] = style[state]
|
|
82
|
+
elif bp in style[state]:
|
|
83
|
+
out.setdefault(state, {})[bp] = style[state][bp]
|
|
84
|
+
return out
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def merge(dst, src):
|
|
88
|
+
"""State-and-breakpoint-wise merge, so a partial copy leaves the rest alone."""
|
|
89
|
+
out = json.loads(json.dumps(dst or {}))
|
|
90
|
+
for state, bps in (src or {}).items():
|
|
91
|
+
out.setdefault(state, {})
|
|
92
|
+
for bp, props in bps.items():
|
|
93
|
+
out[state][bp] = props
|
|
94
|
+
return out
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def main():
|
|
98
|
+
ap = argparse.ArgumentParser()
|
|
99
|
+
ap.add_argument("--config", required=True)
|
|
100
|
+
ap.add_argument("--from", dest="src", required=True, help="source attrID")
|
|
101
|
+
ap.add_argument("--to", help="comma-separated target attrIDs")
|
|
102
|
+
ap.add_argument("--to-prefix", help="every attrID starting with this, except the source")
|
|
103
|
+
ap.add_argument("--only", help="state.breakpoint slices, e.g. '&._m' or 'hover.*'")
|
|
104
|
+
ap.add_argument("--replace", action="store_true",
|
|
105
|
+
help="overwrite the target style instead of merging into it")
|
|
106
|
+
ap.add_argument("--dry-run", action="store_true")
|
|
107
|
+
ap.add_argument("--yes", action="store_true")
|
|
108
|
+
a = ap.parse_args()
|
|
109
|
+
|
|
110
|
+
cfg = json.load(open(a.config, encoding="utf-8"))
|
|
111
|
+
client = Client(cfg)
|
|
112
|
+
|
|
113
|
+
if a.to_prefix:
|
|
114
|
+
wanted = None # resolved after scanning, since we need every attrID
|
|
115
|
+
elif a.to:
|
|
116
|
+
wanted = {x.strip() for x in a.to.split(",") if x.strip()}
|
|
117
|
+
else:
|
|
118
|
+
sys.exit("give --to or --to-prefix")
|
|
119
|
+
|
|
120
|
+
# one pass collecting everything, so a prefix match does not need a second scan
|
|
121
|
+
catalogue = {}
|
|
122
|
+
for instance, key in documents(client, cfg):
|
|
123
|
+
doc = client.get(instance)
|
|
124
|
+
if "_httperror" in doc:
|
|
125
|
+
continue
|
|
126
|
+
doc = unwrap(doc, instance.split("/")[0])
|
|
127
|
+
for node in doc.get(key, []):
|
|
128
|
+
attr = (node.get("data") or {}).get("attrID")
|
|
129
|
+
if attr and attr not in catalogue:
|
|
130
|
+
catalogue[attr] = (instance, key, node, doc)
|
|
131
|
+
|
|
132
|
+
if a.src not in catalogue:
|
|
133
|
+
sys.exit("source attrID not found on this theme: %s" % a.src)
|
|
134
|
+
if wanted is None:
|
|
135
|
+
wanted = {k for k in catalogue if k.startswith(a.to_prefix) and k != a.src}
|
|
136
|
+
if not wanted:
|
|
137
|
+
sys.exit("no attrID starts with %r" % a.to_prefix)
|
|
138
|
+
|
|
139
|
+
missing = wanted - set(catalogue)
|
|
140
|
+
if missing:
|
|
141
|
+
print("not found, skipping: %s" % ", ".join(sorted(missing)))
|
|
142
|
+
targets = sorted(wanted & set(catalogue))
|
|
143
|
+
if not targets:
|
|
144
|
+
sys.exit("nothing to write")
|
|
145
|
+
|
|
146
|
+
src_style = ((catalogue[a.src][2].get("data") or {}).get("style") or {})
|
|
147
|
+
src_states = src_style.get("states", src_style)
|
|
148
|
+
payload = slice_style(src_states, a.only)
|
|
149
|
+
if not payload:
|
|
150
|
+
sys.exit("the source has nothing matching --only %r" % a.only)
|
|
151
|
+
|
|
152
|
+
print("copying from %s:" % a.src)
|
|
153
|
+
print(" " + json.dumps(payload, ensure_ascii=False)[:400])
|
|
154
|
+
print()
|
|
155
|
+
|
|
156
|
+
# group the writes by document, because each one commits separately
|
|
157
|
+
by_doc = {}
|
|
158
|
+
for attr in targets:
|
|
159
|
+
instance, key, node, doc = catalogue[attr]
|
|
160
|
+
dst_style = ((node.get("data") or {}).get("style") or {})
|
|
161
|
+
dst_states = dst_style.get("states", dst_style)
|
|
162
|
+
new_states = payload if a.replace else merge(dst_states, payload)
|
|
163
|
+
before = json.dumps(dst_states, ensure_ascii=False)
|
|
164
|
+
after = json.dumps(new_states, ensure_ascii=False)
|
|
165
|
+
print(" %-22s %s" % (attr, "unchanged" if before == after else "CHANGES"))
|
|
166
|
+
if before != after:
|
|
167
|
+
print(" before %s" % (before[:150] or "{}"))
|
|
168
|
+
print(" after %s" % after[:150])
|
|
169
|
+
if before != after:
|
|
170
|
+
by_doc.setdefault((instance, key), []).append((node, doc, new_states))
|
|
171
|
+
|
|
172
|
+
if a.dry_run:
|
|
173
|
+
print("\ndry run, nothing written")
|
|
174
|
+
return
|
|
175
|
+
if not by_doc:
|
|
176
|
+
print("\nnothing to change")
|
|
177
|
+
return
|
|
178
|
+
if not a.yes:
|
|
179
|
+
try:
|
|
180
|
+
if input("\nwrite these? [y/N] ").strip().lower() not in ("y", "yes"):
|
|
181
|
+
print("aborted")
|
|
182
|
+
return
|
|
183
|
+
except EOFError:
|
|
184
|
+
sys.exit("no tty; pass --yes to write without confirming")
|
|
185
|
+
|
|
186
|
+
written = 0
|
|
187
|
+
for (instance, key), items in by_doc.items():
|
|
188
|
+
doc = items[0][1]
|
|
189
|
+
records = []
|
|
190
|
+
for node, _doc, new_states in items:
|
|
191
|
+
updated = json.loads(json.dumps(node))
|
|
192
|
+
updated.setdefault("data", {})["style"] = {"states": new_states}
|
|
193
|
+
records.append({"newRevisionRecord": updated, "originalRevisionRecord": node})
|
|
194
|
+
resp = client.commit(instance, envelopes(doc), {key: records})
|
|
195
|
+
err = exceptions_of(resp)
|
|
196
|
+
if err:
|
|
197
|
+
print(" %s: REJECTED %s" % (key, err[:200]))
|
|
198
|
+
continue
|
|
199
|
+
written += len(records)
|
|
200
|
+
print("\nwrote %d node(s)" % written)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
if __name__ == "__main__":
|
|
204
|
+
main()
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Extract the required internal structure of Mosaic's composite node types.
|
|
3
|
+
|
|
4
|
+
python extract_default_children.py <path-to-mosaic-plugin-root> <out-data-dir>
|
|
5
|
+
|
|
6
|
+
Writes default-children.csv.
|
|
7
|
+
|
|
8
|
+
## Why this is a separate table from placement-rules.csv
|
|
9
|
+
|
|
10
|
+
`canBeParentFor()` answers "will the editor let me drop this in here". It does NOT
|
|
11
|
+
describe what a composite type needs inside it to work. `accordion-item` is the proof:
|
|
12
|
+
|
|
13
|
+
canBeParentFor -> accordion-item | accordion-loop-items (nothing useful)
|
|
14
|
+
default children -> accordion-title, accordion-content (what it actually needs)
|
|
15
|
+
|
|
16
|
+
Build an accordion-item without a title and the page still renders - but Mosaic's own
|
|
17
|
+
Accordion.js throws `Cannot read properties of null (reading 'addEventListener')` in the
|
|
18
|
+
browser, because the title is the element it binds the click handler to. Nothing on the
|
|
19
|
+
server side complains. This table is where that requirement is written down.
|
|
20
|
+
|
|
21
|
+
The source of truth is the `get<Something>DefaultData()` static on each type factory,
|
|
22
|
+
which is what the editor calls when you insert one of these from the UI.
|
|
23
|
+
"""
|
|
24
|
+
import csv
|
|
25
|
+
import os
|
|
26
|
+
import re
|
|
27
|
+
import sys
|
|
28
|
+
|
|
29
|
+
RE_DEFAULT = re.compile(
|
|
30
|
+
r"function\s+get\w*DefaultData\s*\([^)]*\)\s*:\s*object\s*\{(.*?)\n \}", re.S
|
|
31
|
+
)
|
|
32
|
+
RE_TYPE = re.compile(r'"type"\s*=>\s*"([^"]+)"')
|
|
33
|
+
RE_CHILD_CALL = re.compile(r"(\w+)ElementTypeFactory::get(\w+)DefaultData|(\w+)NodeTypeFactory::get(\w+)DefaultData")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def read(path):
|
|
37
|
+
with open(path, encoding="utf-8", errors="replace") as fh:
|
|
38
|
+
return fh.read()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def extract(plugin_root, out_dir):
|
|
42
|
+
node_root = os.path.join(plugin_root, "Mosaic", "NodeTypes")
|
|
43
|
+
if not os.path.isdir(node_root):
|
|
44
|
+
sys.exit("no Mosaic/NodeTypes under %s" % plugin_root)
|
|
45
|
+
|
|
46
|
+
# factory class stem -> slug, so a child call can be reported as a node type
|
|
47
|
+
stem_to_slug = {}
|
|
48
|
+
files = []
|
|
49
|
+
for dirpath, _dirs, names in os.walk(node_root):
|
|
50
|
+
for fn in sorted(names):
|
|
51
|
+
if fn.endswith("TypeFactory.php"):
|
|
52
|
+
path = os.path.join(dirpath, fn)
|
|
53
|
+
src = read(path)
|
|
54
|
+
files.append((path, src))
|
|
55
|
+
ctor = re.search(r"parent::__construct\s*\(\s*\$\w+\s*,\s*'([^']+)'", src)
|
|
56
|
+
stem = fn.replace("ElementTypeFactory.php", "").replace("NodeTypeFactory.php", "")
|
|
57
|
+
if ctor:
|
|
58
|
+
stem_to_slug[stem] = ctor.group(1)
|
|
59
|
+
|
|
60
|
+
rows = []
|
|
61
|
+
for path, src in files:
|
|
62
|
+
for body in RE_DEFAULT.findall(src):
|
|
63
|
+
owner = RE_TYPE.search(body)
|
|
64
|
+
if not owner:
|
|
65
|
+
continue
|
|
66
|
+
children = []
|
|
67
|
+
for m in RE_CHILD_CALL.finditer(body):
|
|
68
|
+
stem = m.group(1) or m.group(3)
|
|
69
|
+
children.append(stem_to_slug.get(stem, stem.lower()))
|
|
70
|
+
if not children:
|
|
71
|
+
continue
|
|
72
|
+
rows.append({
|
|
73
|
+
"type": owner.group(1),
|
|
74
|
+
"default_children": "|".join(children),
|
|
75
|
+
"child_count": len(children),
|
|
76
|
+
"declared_in": os.path.relpath(path, plugin_root).replace(os.sep, "/"),
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
rows.sort(key=lambda r: r["type"])
|
|
80
|
+
os.makedirs(out_dir, exist_ok=True)
|
|
81
|
+
out = os.path.join(out_dir, "default-children.csv")
|
|
82
|
+
with open(out, "w", newline="", encoding="utf-8") as fh:
|
|
83
|
+
w = csv.DictWriter(fh, fieldnames=["type", "default_children", "child_count", "declared_in"])
|
|
84
|
+
w.writeheader()
|
|
85
|
+
w.writerows(rows)
|
|
86
|
+
print("composite types with a declared default structure: %d" % len(rows))
|
|
87
|
+
for r in rows:
|
|
88
|
+
print(" %-26s -> %s" % (r["type"], r["default_children"]))
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
if __name__ == "__main__":
|
|
92
|
+
if len(sys.argv) != 3:
|
|
93
|
+
sys.exit(__doc__)
|
|
94
|
+
extract(sys.argv[1], sys.argv[2])
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Extract the dynamic-variable namespace: every `@VAR('namespace/name')` Mosaic resolves.
|
|
3
|
+
|
|
4
|
+
python extract_dynamic_variables.py <path-to-mosaic-plugin-root> <out-data-dir>
|
|
5
|
+
|
|
6
|
+
Writes dynamic-variables.csv.
|
|
7
|
+
|
|
8
|
+
This is Mosaic's answer to Elementor's dynamic tags, and the syntax is not guessable:
|
|
9
|
+
a bare identifier does nothing (`Evaluator` literally returns the string
|
|
10
|
+
"Identifiers are not used currently" for one), and the resolvable name is a
|
|
11
|
+
`namespace/name` pair passed as a STRING argument to the `VAR` function:
|
|
12
|
+
|
|
13
|
+
@VAR('post/title') -> the post's title
|
|
14
|
+
@concat('[', @VAR('post/title'), '] #', @VAR('post/id'))
|
|
15
|
+
@fallback(@VAR('post/nope'), 'DEFAULTED')
|
|
16
|
+
|
|
17
|
+
`@VAR_RAW(...)` is the same lookup without HTML-escaping - the evaluator escapes
|
|
18
|
+
untrusted (request-derived) values at the interpolation point, and VAR_RAW opts out.
|
|
19
|
+
|
|
20
|
+
Namespaces come from `setNamespace(...)` on the variable providers; the names under
|
|
21
|
+
each come from `setName(...)` in the schema each provider defines. A provider is only
|
|
22
|
+
registered when its context exists, so `post/*` resolves on a post/page template and
|
|
23
|
+
not on, say, a bare archive - an unresolvable name yields an empty string rather than
|
|
24
|
+
an error, which is why `@fallback()` exists.
|
|
25
|
+
"""
|
|
26
|
+
import csv
|
|
27
|
+
import os
|
|
28
|
+
import re
|
|
29
|
+
import sys
|
|
30
|
+
|
|
31
|
+
RE_NAMESPACE = re.compile(r"setNamespace\('([^']+)'\)")
|
|
32
|
+
RE_NAME = re.compile(r"setName\('([^']+)'\)")
|
|
33
|
+
RE_SHORT_LABEL = re.compile(r"setShortLabelCallback\(fn\(\)\s*:\s*string\s*=>\s*__\('([^']*)'")
|
|
34
|
+
RE_LABEL = re.compile(r"setLabelCallback\(fn\(\)\s*:\s*string\s*=>\s*__\('([^']*)'")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def read(path):
|
|
38
|
+
with open(path, encoding="utf-8", errors="replace") as fh:
|
|
39
|
+
return fh.read()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def extract(plugin_root, out_dir):
|
|
43
|
+
root = os.path.join(plugin_root, "Mosaic")
|
|
44
|
+
files = []
|
|
45
|
+
for dirpath, _dirs, names in os.walk(root):
|
|
46
|
+
for fn in sorted(names):
|
|
47
|
+
if fn.endswith(".php"):
|
|
48
|
+
files.append(os.path.join(dirpath, fn))
|
|
49
|
+
|
|
50
|
+
# namespace -> the files that declare it, so names can be attributed
|
|
51
|
+
namespaces = {}
|
|
52
|
+
for path in files:
|
|
53
|
+
src = read(path)
|
|
54
|
+
for ns in RE_NAMESPACE.findall(src):
|
|
55
|
+
namespaces.setdefault(ns, []).append(path)
|
|
56
|
+
|
|
57
|
+
rows, seen = [], set()
|
|
58
|
+
for path in files:
|
|
59
|
+
src = read(path)
|
|
60
|
+
declared = RE_NAMESPACE.findall(src)
|
|
61
|
+
if not declared:
|
|
62
|
+
continue
|
|
63
|
+
# a file usually declares one namespace and the names beside it; when it
|
|
64
|
+
# declares several, the attribution is ambiguous and is recorded as such
|
|
65
|
+
ns = declared[0] if len(declared) == 1 else "|".join(sorted(set(declared)))
|
|
66
|
+
rel = os.path.relpath(path, plugin_root).replace(os.sep, "/")
|
|
67
|
+
for m in RE_NAME.finditer(src):
|
|
68
|
+
name = m.group(1)
|
|
69
|
+
key = (ns, name)
|
|
70
|
+
if key in seen:
|
|
71
|
+
continue
|
|
72
|
+
seen.add(key)
|
|
73
|
+
tail = src[m.end(): m.end() + 400]
|
|
74
|
+
label = RE_SHORT_LABEL.search(tail) or RE_LABEL.search(tail)
|
|
75
|
+
rows.append({
|
|
76
|
+
"expression": "@VAR('%s/%s')" % (ns, name) if "|" not in ns else "",
|
|
77
|
+
"namespace": ns, "name": name,
|
|
78
|
+
"label": label.group(1) if label else "",
|
|
79
|
+
"declared_in": rel,
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
# schema helpers declare names without their own setNamespace; attribute those to
|
|
83
|
+
# the namespace whose provider includes them, by directory proximity
|
|
84
|
+
rows.sort(key=lambda r: (r["namespace"], r["name"]))
|
|
85
|
+
os.makedirs(out_dir, exist_ok=True)
|
|
86
|
+
with open(os.path.join(out_dir, "dynamic-variables.csv"), "w", newline="", encoding="utf-8") as fh:
|
|
87
|
+
w = csv.DictWriter(fh, fieldnames=["expression", "namespace", "name", "label", "declared_in"])
|
|
88
|
+
w.writeheader()
|
|
89
|
+
w.writerows(rows)
|
|
90
|
+
|
|
91
|
+
by_ns = {}
|
|
92
|
+
for r in rows:
|
|
93
|
+
by_ns.setdefault(r["namespace"], []).append(r["name"])
|
|
94
|
+
print("dynamic variables: %d across %d namespaces" % (len(rows), len(by_ns)))
|
|
95
|
+
for ns in sorted(by_ns):
|
|
96
|
+
print(" %-22s %2d %s" % (ns, len(by_ns[ns]), ", ".join(by_ns[ns][:8])))
|
|
97
|
+
print("\nnamespaces with no names found in the same file (schema declared elsewhere):")
|
|
98
|
+
print(" " + ", ".join(sorted(set(namespaces) - set(by_ns))) or " none")
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
if __name__ == "__main__":
|
|
102
|
+
if len(sys.argv) != 3:
|
|
103
|
+
sys.exit(__doc__)
|
|
104
|
+
extract(sys.argv[1], sys.argv[2])
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Extract Mosaic's interaction surface: trigger types, action types, animatable properties.
|
|
3
|
+
|
|
4
|
+
python extract_interactions.py <path-to-mosaic-plugin-root> <out-data-dir>
|
|
5
|
+
|
|
6
|
+
Writes interaction-types.csv and animatable-properties.csv.
|
|
7
|
+
|
|
8
|
+
Interactions are Mosaic's JavaScript animation system, separate from the CSS
|
|
9
|
+
transition/hover path. A node carries `data.interactions`, an array of trigger
|
|
10
|
+
definitions, each holding action slots, each holding actions, each holding a timeline
|
|
11
|
+
of keyframes. See references/interactions.md for how far that is verified.
|
|
12
|
+
|
|
13
|
+
The animatable-property list matters on its own: it is NOT the same set as the 98
|
|
14
|
+
style properties. Only these 22 can be driven by an interaction keyframe, and several
|
|
15
|
+
of them animate through a CSS custom property (`--mosaic-translate-y` and friends)
|
|
16
|
+
rather than the CSS property itself.
|
|
17
|
+
"""
|
|
18
|
+
import csv
|
|
19
|
+
import os
|
|
20
|
+
import re
|
|
21
|
+
import sys
|
|
22
|
+
|
|
23
|
+
RE_PREDEFINED = re.compile(
|
|
24
|
+
r"_addPropertyMetaOption\(\s*new\s+(\w+)KeyframePropertyMetaOption\("
|
|
25
|
+
r"[^)]*?(?:,\s*'([^']+)'\s*(?:,\s*'([^']+)')?)?\s*\)", re.S
|
|
26
|
+
)
|
|
27
|
+
RE_SETID = re.compile(r"->setID\('([^']+)'\)")
|
|
28
|
+
RE_LABEL = re.compile(r"setLabelCallback\(fn\(\)\s*:\s*string\s*=>\s*__\('([^']*)'")
|
|
29
|
+
RE_DESC = re.compile(r"setDescriptionCallback\(fn\(\)\s*:\s*string\s*=>\s*__\('([^']*)'")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def read(path):
|
|
33
|
+
with open(path, encoding="utf-8", errors="replace") as fh:
|
|
34
|
+
return fh.read()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def extract(plugin_root, out_dir):
|
|
38
|
+
root = os.path.join(plugin_root, "Mosaic")
|
|
39
|
+
os.makedirs(out_dir, exist_ok=True)
|
|
40
|
+
|
|
41
|
+
# ---- trigger types, grouped by the family that registers them -------------
|
|
42
|
+
rows = []
|
|
43
|
+
ix_root = os.path.join(root, "Plugins", "InteractionTypes")
|
|
44
|
+
for dirpath, _dirs, files in os.walk(ix_root):
|
|
45
|
+
for fn in sorted(files):
|
|
46
|
+
if not fn.endswith(".php"):
|
|
47
|
+
continue
|
|
48
|
+
path = os.path.join(dirpath, fn)
|
|
49
|
+
src = read(path)
|
|
50
|
+
m = RE_SETID.search(src)
|
|
51
|
+
if not m:
|
|
52
|
+
continue
|
|
53
|
+
rel = os.path.relpath(path, plugin_root).replace(os.sep, "/")
|
|
54
|
+
# the directory under InteractionTypes is the family: timed or progress.
|
|
55
|
+
# A timed interaction runs on its own clock; a progress one is driven by a
|
|
56
|
+
# scalar (scroll position, pointer position) and reports timelineKeys.
|
|
57
|
+
family = rel.split("InteractionTypes/")[1].split("/")[0] if "InteractionTypes/" in rel else ""
|
|
58
|
+
label = RE_LABEL.search(src)
|
|
59
|
+
desc = RE_DESC.search(src)
|
|
60
|
+
rows.append({
|
|
61
|
+
"id": m.group(1), "family": family,
|
|
62
|
+
"label": label.group(1) if label else "",
|
|
63
|
+
"description": desc.group(1) if desc else "",
|
|
64
|
+
"declared_in": rel,
|
|
65
|
+
})
|
|
66
|
+
rows.sort(key=lambda r: (r["family"], r["id"]))
|
|
67
|
+
with open(os.path.join(out_dir, "interaction-types.csv"), "w", newline="", encoding="utf-8") as fh:
|
|
68
|
+
w = csv.DictWriter(fh, fieldnames=["id", "family", "label", "description", "declared_in"])
|
|
69
|
+
w.writeheader()
|
|
70
|
+
w.writerows(rows)
|
|
71
|
+
print("interaction trigger types: %d (%s)" % (
|
|
72
|
+
len(rows), ", ".join(sorted({r["family"] for r in rows if r["family"]}))))
|
|
73
|
+
|
|
74
|
+
# ---- animatable properties ------------------------------------------------
|
|
75
|
+
factory = os.path.join(root, "Builder", "Interactions", "Data", "Keyframe", "PropertyMetas",
|
|
76
|
+
"Meta", "Predefined", "PredefinedKeyframePropertyMetaTypeFactory.php")
|
|
77
|
+
props = []
|
|
78
|
+
if os.path.exists(factory):
|
|
79
|
+
for kind, name, custom_prop in RE_PREDEFINED.findall(read(factory)):
|
|
80
|
+
props.append({
|
|
81
|
+
"property": name or kind[0].lower() + kind[1:],
|
|
82
|
+
"kind": kind,
|
|
83
|
+
# several properties animate a CSS custom property rather than the
|
|
84
|
+
# CSS property itself, which is why they compose instead of clobbering
|
|
85
|
+
"animates_via": custom_prop or "(the CSS property)",
|
|
86
|
+
})
|
|
87
|
+
with open(os.path.join(out_dir, "animatable-properties.csv"), "w", newline="", encoding="utf-8") as fh:
|
|
88
|
+
w = csv.DictWriter(fh, fieldnames=["property", "kind", "animates_via"])
|
|
89
|
+
w.writeheader()
|
|
90
|
+
w.writerows(props)
|
|
91
|
+
print("animatable keyframe properties: %d (%d via a custom property)"
|
|
92
|
+
% (len(props), sum(1 for p in props if p["animates_via"].startswith("--"))))
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
if __name__ == "__main__":
|
|
96
|
+
if len(sys.argv) != 3:
|
|
97
|
+
sys.exit(__doc__)
|
|
98
|
+
extract(sys.argv[1], sys.argv[2])
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Extract Mosaic's node-type (element) registry and property surface from plugin source.
|
|
3
|
+
|
|
4
|
+
Usage:
|
|
5
|
+
python extract_node_types.py <path-to-mosaic-plugin-root> <out-data-dir>
|
|
6
|
+
|
|
7
|
+
Writes:
|
|
8
|
+
node-types.csv one row per registered node type
|
|
9
|
+
node-properties.csv one row per (node type, data property)
|
|
10
|
+
|
|
11
|
+
Mosaic has no runtime endpoint that dumps the element registry, so the registry is
|
|
12
|
+
recovered from the PHP source: every `*TypeFactory.php` under Mosaic/NodeTypes
|
|
13
|
+
registers exactly one type slug via its parent::__construct() call, and the paired
|
|
14
|
+
`*MResourceData*.php` declares that type's own data properties via createData*().
|
|
15
|
+
Inherited properties come from the abstract chain and are emitted under the
|
|
16
|
+
abstract class name so callers can resolve them once instead of per type.
|
|
17
|
+
"""
|
|
18
|
+
import csv
|
|
19
|
+
import os
|
|
20
|
+
import re
|
|
21
|
+
import sys
|
|
22
|
+
|
|
23
|
+
# parent::__construct($nodeFactoryManager, 'slug', <label expr>) -- may span lines
|
|
24
|
+
RE_CTOR = re.compile(
|
|
25
|
+
r"parent::__construct\s*\(\s*\$\w+\s*,\s*'([^']+)'\s*,\s*(.*?)\)\s*;",
|
|
26
|
+
re.S,
|
|
27
|
+
)
|
|
28
|
+
RE_CLASS = re.compile(r"^\s*(?:final\s+)?(abstract\s+)?class\s+(\w+)\s+extends\s+(\w+)", re.M)
|
|
29
|
+
RE_METHOD_BOOL = re.compile(r"function\s+(\w+)\s*\([^)]*\)\s*:\s*bool\s*\{\s*return\s+(true|false)\s*;", re.S)
|
|
30
|
+
RE_ALIAS = re.compile(r"function\s+getAliasTypes\s*\([^)]*\)\s*:\s*array\s*\{\s*return\s*\[(.*?)\]", re.S)
|
|
31
|
+
RE_DEFAULT_CLASS = re.compile(
|
|
32
|
+
r"function\s+getDefaultElementClassID\s*\([^)]*\)\s*:\s*string\s*\{\s*return\s+([^;]+);", re.S
|
|
33
|
+
)
|
|
34
|
+
# createDataSimple('name', ... ) / createDataSub / createDataArray / createDataResponsive ...
|
|
35
|
+
RE_CREATE_DATA = re.compile(r"\$this->(createData\w*)\s*\(\s*'([^']+)'", re.S)
|
|
36
|
+
RE_VALIDATOR = re.compile(r"(Validator\w+|ValidateAllowUndefined)")
|
|
37
|
+
RE_OPTS = re.compile(r"'(supportsInherit|isResponsive|supportsState|allowDuplicates)'\s*=>\s*(true|false)")
|
|
38
|
+
# createValidatorAcceptedValues([...]) is Mosaic's enum; the list is the only place
|
|
39
|
+
# the legal values for a property are written down
|
|
40
|
+
RE_ACCEPTED = re.compile(r"createValidatorAcceptedValues\s*\(\s*\[(.*?)\]", re.S)
|
|
41
|
+
RE_LABEL_TEXT = re.compile(r"__\(\s*'([^']*)'")
|
|
42
|
+
RE_RESERVED = re.compile(r"setReservedAttributes\s*\(\s*\[(.*?)\]", re.S)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def php_label(expr):
|
|
46
|
+
m = RE_LABEL_TEXT.search(expr or "")
|
|
47
|
+
return m.group(1) if m else ""
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def rel(path, root):
|
|
51
|
+
return os.path.relpath(path, root).replace(os.sep, "/")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def walk(root, suffixes):
|
|
55
|
+
for dirpath, _dirs, files in os.walk(root):
|
|
56
|
+
for fn in files:
|
|
57
|
+
if any(fn.endswith(s) for s in suffixes):
|
|
58
|
+
yield os.path.join(dirpath, fn)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def read(path):
|
|
62
|
+
with open(path, encoding="utf-8", errors="replace") as fh:
|
|
63
|
+
return fh.read()
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def extract(plugin_root, out_dir):
|
|
67
|
+
node_root = os.path.join(plugin_root, "Mosaic", "NodeTypes")
|
|
68
|
+
if not os.path.isdir(node_root):
|
|
69
|
+
sys.exit("no Mosaic/NodeTypes under %s" % plugin_root)
|
|
70
|
+
|
|
71
|
+
types = []
|
|
72
|
+
for path in sorted(walk(node_root, ("TypeFactory.php",))):
|
|
73
|
+
src = read(path)
|
|
74
|
+
cls = RE_CLASS.search(src)
|
|
75
|
+
ctor = RE_CTOR.search(src)
|
|
76
|
+
if not ctor:
|
|
77
|
+
continue # abstract factory with no own slug
|
|
78
|
+
relpath = rel(path, plugin_root)
|
|
79
|
+
# the data class declaring this type's own properties sits beside the factory;
|
|
80
|
+
# node-properties.csv is keyed by that class name, so record it to make the join
|
|
81
|
+
data_class = ""
|
|
82
|
+
for sibling in sorted(os.listdir(os.path.dirname(path))):
|
|
83
|
+
if sibling.endswith("MResourceData.php"):
|
|
84
|
+
data_class = sibling[:-4]
|
|
85
|
+
break
|
|
86
|
+
bools = dict(RE_METHOD_BOOL.findall(src))
|
|
87
|
+
alias = RE_ALIAS.search(src)
|
|
88
|
+
aliases = re.findall(r"'([^']+)'", alias.group(1)) if alias else []
|
|
89
|
+
dflt = RE_DEFAULT_CLASS.search(src)
|
|
90
|
+
types.append(
|
|
91
|
+
{
|
|
92
|
+
"type": ctor.group(1),
|
|
93
|
+
"label": php_label(ctor.group(2)),
|
|
94
|
+
"class": cls.group(2) if cls else "",
|
|
95
|
+
"extends": cls.group(3) if cls else "",
|
|
96
|
+
"abstract": "yes" if (cls and cls.group(1)) else "no",
|
|
97
|
+
"edition": "pro" if "/Pro/" in relpath else "free",
|
|
98
|
+
"alias_types": "|".join(aliases),
|
|
99
|
+
"is_link": bools.get("isLink", ""),
|
|
100
|
+
"can_be_parent": bools.get("canBeParentFor", ""),
|
|
101
|
+
"default_element_class": (dflt.group(1).strip() if dflt else ""),
|
|
102
|
+
"data_class": data_class,
|
|
103
|
+
"file": relpath,
|
|
104
|
+
}
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
props = []
|
|
108
|
+
for path in sorted(walk(node_root, ("MResourceData.php", "MResourceDataAbstract.php"))):
|
|
109
|
+
src = read(path)
|
|
110
|
+
cls = RE_CLASS.search(src)
|
|
111
|
+
owner = cls.group(2) if cls else os.path.basename(path)[:-4]
|
|
112
|
+
relpath = rel(path, plugin_root)
|
|
113
|
+
reserved = RE_RESERVED.search(src)
|
|
114
|
+
reserved_attrs = re.findall(r"'([^']+)'", reserved.group(1)) if reserved else []
|
|
115
|
+
for m in RE_CREATE_DATA.finditer(src):
|
|
116
|
+
tail = src[m.end() : m.end() + 1400]
|
|
117
|
+
# cut at the next createData call so validators do not bleed across properties
|
|
118
|
+
nxt = RE_CREATE_DATA.search(tail)
|
|
119
|
+
if nxt:
|
|
120
|
+
tail = tail[: nxt.start()]
|
|
121
|
+
validators = sorted(set(RE_VALIDATOR.findall(tail)))
|
|
122
|
+
opts = {k: v for k, v in RE_OPTS.findall(tail)}
|
|
123
|
+
acc = RE_ACCEPTED.search(tail)
|
|
124
|
+
# constants (MResourceStatus::PUBLISH) sit alongside plain strings; keep
|
|
125
|
+
# the literal strings and record the constants by their short name
|
|
126
|
+
accepted = []
|
|
127
|
+
if acc:
|
|
128
|
+
accepted = re.findall(r"'([^']+)'", acc.group(1))
|
|
129
|
+
accepted += [c for c in re.findall(r"\w+::(\w+)", acc.group(1))]
|
|
130
|
+
props.append(
|
|
131
|
+
{
|
|
132
|
+
"owner_class": owner,
|
|
133
|
+
"extends": cls.group(3) if cls else "",
|
|
134
|
+
"edition": "pro" if "/Pro/" in relpath else "free",
|
|
135
|
+
"creator": m.group(1),
|
|
136
|
+
"property": m.group(2),
|
|
137
|
+
"validators": "|".join(validators),
|
|
138
|
+
"accepted_values": "|".join(accepted),
|
|
139
|
+
"supports_inherit": opts.get("supportsInherit", ""),
|
|
140
|
+
"is_responsive": opts.get("isResponsive", ""),
|
|
141
|
+
"reserved_attributes": "|".join(reserved_attrs),
|
|
142
|
+
"file": relpath,
|
|
143
|
+
}
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
os.makedirs(out_dir, exist_ok=True)
|
|
147
|
+
write_csv(os.path.join(out_dir, "node-types.csv"), types)
|
|
148
|
+
write_csv(os.path.join(out_dir, "node-properties.csv"), props)
|
|
149
|
+
print("node types: %d (%d pro)" % (len(types), sum(t["edition"] == "pro" for t in types)))
|
|
150
|
+
print("node properties: %d across %d classes" % (len(props), len({p["owner_class"] for p in props})))
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def write_csv(path, rows):
|
|
154
|
+
if not rows:
|
|
155
|
+
return
|
|
156
|
+
with open(path, "w", newline="", encoding="utf-8") as fh:
|
|
157
|
+
w = csv.DictWriter(fh, fieldnames=list(rows[0].keys()))
|
|
158
|
+
w.writeheader()
|
|
159
|
+
w.writerows(rows)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
if __name__ == "__main__":
|
|
163
|
+
if len(sys.argv) != 3:
|
|
164
|
+
sys.exit(__doc__)
|
|
165
|
+
extract(sys.argv[1], sys.argv[2])
|