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