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,135 @@
1
+ #!/usr/bin/env python3
2
+ """Extract Mosaic's parent -> child placement rules from the node type factories.
3
+
4
+ python extract_placement.py <path-to-mosaic-plugin-root> <out-data-dir>
5
+
6
+ Writes placement-rules.csv: one row per node type, saying which children it accepts.
7
+
8
+ ## Why this table matters more than it looks
9
+
10
+ `canBeParentFor()` is the rule the *editor* uses to stop you dropping an element
11
+ somewhere impossible. The REST commit path does not consult it. A node committed under
12
+ an illegal parent is stored happily, HTTP 200, and the public page then dies with a
13
+ bare error string (see references/failure-modes.md).
14
+
15
+ So for anything writing Mosaic headlessly this is not documentation - it is the
16
+ guardrail the API declines to enforce. Check the parent before you commit the child.
17
+
18
+ ## Reading the output
19
+
20
+ rule = any canBeParentFor returns true unconditionally - takes any child
21
+ rule = none returns false (the default on NodeTypeFactoryAbstract) - a leaf
22
+ rule = allow accepts only the types in allowed_children
23
+ rule = complex the method body is not a simple instanceof chain; read the file
24
+
25
+ `nested_rule` flags types that also implement `canBeNestedChildFor`, which adds a
26
+ runtime ancestry condition a static parse cannot resolve (e.g. "only inside an
27
+ accordion, and not under an accordion title"). Those need the source read, and the
28
+ column names the file.
29
+ """
30
+ import csv
31
+ import os
32
+ import re
33
+ import sys
34
+
35
+ RE_CLASS = re.compile(r"^\s*(?:final\s+)?(?:abstract\s+)?class\s+(\w+)\s+extends\s+(\w+)", re.M)
36
+ RE_CAN_BE_PARENT = re.compile(
37
+ r"function\s+canBeParentFor\s*\([^)]*\)\s*:\s*bool\s*\{(.*?)\n \}", re.S
38
+ )
39
+ RE_INSTANCEOF = re.compile(r"instanceof\s+(\w+)")
40
+ RE_CTOR = re.compile(r"parent::__construct\s*\(\s*\$\w+\s*,\s*'([^']+)'", re.S)
41
+
42
+
43
+ def read(path):
44
+ with open(path, encoding="utf-8", errors="replace") as fh:
45
+ return fh.read()
46
+
47
+
48
+ def walk(root, suffix):
49
+ for dirpath, _dirs, files in os.walk(root):
50
+ for fn in sorted(files):
51
+ if fn.endswith(suffix):
52
+ yield os.path.join(dirpath, fn)
53
+
54
+
55
+ def extract(plugin_root, out_dir):
56
+ node_root = os.path.join(plugin_root, "Mosaic", "NodeTypes")
57
+ if not os.path.isdir(node_root):
58
+ sys.exit("no Mosaic/NodeTypes under %s" % plugin_root)
59
+
60
+ # factory class name -> type slug, so instanceof targets can be named as types
61
+ class_to_type, sources = {}, {}
62
+ for path in walk(node_root, "TypeFactory.php"):
63
+ src = read(path)
64
+ cls = RE_CLASS.search(src)
65
+ if not cls:
66
+ continue
67
+ sources[cls.group(1)] = (path, src, cls.group(2))
68
+ ctor = RE_CTOR.search(src)
69
+ if ctor:
70
+ class_to_type[cls.group(1)] = ctor.group(1)
71
+
72
+ def resolve(cls_name):
73
+ """A factory may inherit canBeParentFor from an abstract ancestor."""
74
+ seen = set()
75
+ while cls_name in sources and cls_name not in seen:
76
+ seen.add(cls_name)
77
+ path, src, parent = sources[cls_name]
78
+ m = RE_CAN_BE_PARENT.search(src)
79
+ if m:
80
+ return m.group(1), path
81
+ cls_name = parent
82
+ return None, None
83
+
84
+ rows = []
85
+ for cls_name, slug in sorted(class_to_type.items(), key=lambda kv: kv[1]):
86
+ body, defined_in = resolve(cls_name)
87
+ path, src, _parent = sources[cls_name]
88
+ nested = "yes" if "function canBeNestedChildFor" in src else ""
89
+
90
+ if body is None:
91
+ rule, allowed = "none", [] # inherited default on the abstract base
92
+ else:
93
+ stripped = re.sub(r"//.*|/\*.*?\*/", "", body, flags=re.S).strip()
94
+ if re.fullmatch(r"return\s+true\s*;", stripped):
95
+ rule, allowed = "any", []
96
+ elif re.fullmatch(r"return\s+false\s*;", stripped):
97
+ rule, allowed = "none", []
98
+ else:
99
+ targets = RE_INSTANCEOF.findall(stripped)
100
+ mapped = [class_to_type[t] for t in targets if t in class_to_type]
101
+ if mapped and re.fullmatch(
102
+ r"return\s+(\$\w+\s+instanceof\s+\w+\s*(\|\|\s*)?)+;", stripped
103
+ ):
104
+ rule, allowed = "allow", sorted(set(mapped))
105
+ else:
106
+ rule, allowed = "complex", sorted(set(mapped))
107
+
108
+ rows.append(
109
+ {
110
+ "type": slug,
111
+ "rule": rule,
112
+ "allowed_children": "|".join(allowed),
113
+ "nested_rule": nested,
114
+ "declared_in": os.path.relpath(defined_in or path, plugin_root).replace(os.sep, "/"),
115
+ }
116
+ )
117
+
118
+ os.makedirs(out_dir, exist_ok=True)
119
+ with open(os.path.join(out_dir, "placement-rules.csv"), "w", newline="", encoding="utf-8") as fh:
120
+ w = csv.DictWriter(fh, fieldnames=["type", "rule", "allowed_children", "nested_rule", "declared_in"])
121
+ w.writeheader()
122
+ w.writerows(rows)
123
+
124
+ tally = {}
125
+ for r in rows:
126
+ tally[r["rule"]] = tally.get(r["rule"], 0) + 1
127
+ print("placement rules: %d types %s" % (len(rows), tally))
128
+ print("with a runtime ancestry rule (canBeNestedChildFor): %d"
129
+ % sum(1 for r in rows if r["nested_rule"]))
130
+
131
+
132
+ if __name__ == "__main__":
133
+ if len(sys.argv) != 3:
134
+ sys.exit(__doc__)
135
+ extract(sys.argv[1], sys.argv[2])
@@ -0,0 +1,90 @@
1
+ #!/usr/bin/env python3
2
+ """Extract Mosaic's pluggable registries (the extension points a headless caller writes against).
3
+
4
+ Usage:
5
+ python extract_pluggables.py <path-to-mosaic-plugin-root> <out-data-dir>
6
+
7
+ Writes:
8
+ pluggables.csv one row per registered pluggable ID, keyed by the registry it belongs to
9
+
10
+ Mosaic registers every extension point through a writer object whose `setID('...')`
11
+ call carries the stable string that appears in stored node data. Grouping those by
12
+ the Mosaic/Plugins/<Registry> directory they live in reproduces the registry list the
13
+ editor UI shows, without needing a licensed install to read it back over REST.
14
+ """
15
+ import csv
16
+ import os
17
+ import re
18
+ import sys
19
+
20
+ RE_SET_ID = re.compile(r"->setID\(\s*'([^']+)'\s*\)")
21
+ RE_LABEL = re.compile(r"->setLabel\(\s*(?:esc_html__|__)\(\s*'([^']*)'")
22
+ RE_NAMESPACE = re.compile(r"^namespace\s+([^;]+);", re.M)
23
+
24
+
25
+ def read(path):
26
+ with open(path, encoding="utf-8", errors="replace") as fh:
27
+ return fh.read()
28
+
29
+
30
+ def extract(plugin_root, out_dir):
31
+ plugins_root = os.path.join(plugin_root, "Mosaic", "Plugins")
32
+ if not os.path.isdir(plugins_root):
33
+ sys.exit("no Mosaic/Plugins under %s" % plugin_root)
34
+
35
+ rows = []
36
+ for dirpath, _dirs, files in os.walk(plugins_root):
37
+ for fn in sorted(files):
38
+ if not fn.endswith(".php"):
39
+ continue
40
+ path = os.path.join(dirpath, fn)
41
+ relpath = os.path.relpath(path, plugin_root).replace(os.sep, "/")
42
+ parts = relpath.split("/")
43
+ registry = parts[2] if len(parts) > 2 else ""
44
+ src = read(path)
45
+ ns = RE_NAMESPACE.search(src)
46
+ for m in RE_SET_ID.finditer(src):
47
+ # the nearest setLabel after this setID belongs to the same writer chain
48
+ tail = src[m.end() : m.end() + 600]
49
+ nxt = RE_SET_ID.search(tail)
50
+ if nxt:
51
+ tail = tail[: nxt.start()]
52
+ label = RE_LABEL.search(tail)
53
+ rows.append(
54
+ {
55
+ "registry": registry,
56
+ "id": m.group(1),
57
+ "label": label.group(1) if label else "",
58
+ "namespace": ns.group(1) if ns else "",
59
+ "file": relpath,
60
+ }
61
+ )
62
+
63
+ # de-duplicate: the same ID can be registered once per context (element/template/...)
64
+ seen = set()
65
+ unique = []
66
+ for r in rows:
67
+ key = (r["registry"], r["id"], r["file"])
68
+ if key in seen:
69
+ continue
70
+ seen.add(key)
71
+ unique.append(r)
72
+
73
+ os.makedirs(out_dir, exist_ok=True)
74
+ with open(os.path.join(out_dir, "pluggables.csv"), "w", newline="", encoding="utf-8") as fh:
75
+ w = csv.DictWriter(fh, fieldnames=["registry", "id", "label", "namespace", "file"])
76
+ w.writeheader()
77
+ w.writerows(unique)
78
+
79
+ by_registry = {}
80
+ for r in unique:
81
+ by_registry.setdefault(r["registry"], set()).add(r["id"])
82
+ for reg in sorted(by_registry):
83
+ print("%-22s %d" % (reg, len(by_registry[reg])))
84
+ print("total rows: %d" % len(unique))
85
+
86
+
87
+ if __name__ == "__main__":
88
+ if len(sys.argv) != 3:
89
+ sys.exit(__doc__)
90
+ extract(sys.argv[1], sys.argv[2])
@@ -0,0 +1,163 @@
1
+ #!/usr/bin/env python3
2
+ """Extract Mosaic's supported style-property and style-state surface from the source.
3
+
4
+ python extract_style_properties.py <path-to-mosaic-plugin-root> <out-data-dir>
5
+
6
+ Writes style-properties.csv and style-states.csv.
7
+
8
+ A style value in Mosaic is addressed by (state, breakpoint, property). The two tables
9
+ here cover the first and third axis; breakpoints are per-theme data, not source
10
+ constants, and live in the `mosaic_breakpoints` table.
11
+
12
+ `Mosaic/Builder/Style/SupportedStyleProperties.php` is the single registry of every
13
+ CSS property the builder can emit. It is the closest thing Mosaic has to Elementor's
14
+ control list, and it is the authoritative answer to "can I set X?" - a property that
15
+ is not in this file cannot be set through the style system at all (only through the
16
+ `customStyles` escape hatch, which is itself one of the entries).
17
+
18
+ The factory class each property is registered with determines the VALUE SHAPE you must
19
+ write, which matters more than the name:
20
+
21
+ CSSPropertyFactory a plain CSS keyword/value string
22
+ CSSCollectionVariablePropertyFactory a length that may instead reference a
23
+ collection variable (the design-token layer)
24
+ CSSColorPropertyFactory a colour, likewise token-referencable
25
+ CSSGrouppedPropertyFactory one leg of a compound property - the group
26
+ name is what the data is keyed under, the
27
+ member is the individual CSS property
28
+ everything else a purpose-built shape (shadow, transform,
29
+ gradient, filter ...) - read the factory
30
+ """
31
+ import csv
32
+ import os
33
+ import re
34
+ import sys
35
+
36
+ # StyleStateDataMeta declares each style property's validator chain, and many of the
37
+ # ones registered as a plain CSSPropertyFactory are in fact restricted to an enum.
38
+ # Without this, style-properties.csv reads as "any CSS string" and a legal-looking
39
+ # value like white-space:pre-line is accepted, dropped, and never compiled.
40
+ RE_STYLE_PROP = re.compile(
41
+ r"createDataSimple\(\s*'([^']+)'.*?(?=createDataSimple\(|createDataGroup\(|createDataArray\(|\Z)",
42
+ re.S)
43
+ RE_ACCEPTED = re.compile(r"createValidatorAcceptedValues\(\s*\[(.*?)\]", re.S)
44
+
45
+ RE_ADD = re.compile(
46
+ r'addCSSProperty\(\s*new\s+(\w+)\s*\(\s*"([^"]+)"' # factory, first arg
47
+ r'(?:\s*,\s*"([^"]+)")?' # optional member name
48
+ r'(?:\s*,\s*([\w\\]+)::class)?', # optional value class
49
+ re.S,
50
+ )
51
+
52
+
53
+ # createMeta('<stateID>', '<selector template>' [, order]) -- & stands for the element's
54
+ # own selector, so the template is exactly what the compiled CSS rule will look like
55
+ RE_STATE = re.compile(
56
+ r"createMeta\(\s*'([^']*)'\s*,\s*'((?:[^'\\]|\\.)*)'\s*(?:,\s*(\d+))?\s*\)", re.S
57
+ )
58
+
59
+
60
+ def extract_states(plugin_root, out_dir):
61
+ rows, seen = [], set()
62
+ for dirpath, _dirs, files in os.walk(os.path.join(plugin_root, "Mosaic")):
63
+ for fn in sorted(files):
64
+ if not fn.endswith(".php"):
65
+ continue
66
+ path = os.path.join(dirpath, fn)
67
+ with open(path, encoding="utf-8", errors="replace") as fh:
68
+ src = fh.read()
69
+ for state_id, selector, order in RE_STATE.findall(src):
70
+ if state_id in seen:
71
+ continue
72
+ seen.add(state_id)
73
+ rows.append(
74
+ {
75
+ "state": state_id,
76
+ "selector_template": selector.replace("\\'", "'"),
77
+ "order": order,
78
+ # states beginning with underscores are registered by a node type
79
+ # and are only meaningful on that type's elements
80
+ "scope": "global" if not state_id.startswith("_") else "node-type",
81
+ "declared_in": os.path.relpath(path, plugin_root).replace(os.sep, "/"),
82
+ }
83
+ )
84
+
85
+ rows.sort(key=lambda r: (r["scope"] != "global", r["state"]))
86
+ out = os.path.join(out_dir, "style-states.csv")
87
+ with open(out, "w", newline="", encoding="utf-8") as fh:
88
+ w = csv.DictWriter(fh, fieldnames=["state", "selector_template", "order", "scope", "declared_in"])
89
+ w.writeheader()
90
+ w.writerows(rows)
91
+ print("style states: %d (%d global, %d node-type scoped)"
92
+ % (len(rows), sum(1 for r in rows if r["scope"] == "global"),
93
+ sum(1 for r in rows if r["scope"] == "node-type")))
94
+
95
+
96
+ def style_enums(plugin_root):
97
+ """property -> its accepted values, for the style properties restricted to an enum."""
98
+ path = os.path.join(plugin_root, "Mosaic", "Data", "StyleData", "StyleStateDataMeta.php")
99
+ if not os.path.exists(path):
100
+ return {}
101
+ with open(path, encoding="utf-8", errors="replace") as fh:
102
+ src = fh.read()
103
+ out = {}
104
+ for m in RE_STYLE_PROP.finditer(src):
105
+ acc = RE_ACCEPTED.search(m.group(0))
106
+ if acc:
107
+ values = re.findall(r"'([^']+)'", acc.group(1))
108
+ if values:
109
+ out[m.group(1)] = values
110
+ return out
111
+
112
+
113
+ def extract(plugin_root, out_dir):
114
+ enums = style_enums(plugin_root)
115
+ path = os.path.join(plugin_root, "Mosaic", "Builder", "Style", "SupportedStyleProperties.php")
116
+ if not os.path.exists(path):
117
+ sys.exit("no SupportedStyleProperties.php under %s" % plugin_root)
118
+ with open(path, encoding="utf-8", errors="replace") as fh:
119
+ src = fh.read()
120
+
121
+ rows = []
122
+ for factory, first, member, value_class in RE_ADD.findall(src):
123
+ grouped = factory == "CSSGrouppedPropertyFactory"
124
+ rows.append(
125
+ {
126
+ "property": member if grouped else first,
127
+ "group": first if grouped else "",
128
+ "factory": factory,
129
+ "value_class": value_class.split("\\")[-1] if value_class else "",
130
+ "accepted_values": "|".join(enums.get(member if grouped else first, [])),
131
+ "tokenable": "yes" if (
132
+ "CollectionVariable" in factory or "Color" in factory
133
+ or "CollectionVariable" in (value_class or "")
134
+ ) else "",
135
+ }
136
+ )
137
+
138
+ os.makedirs(out_dir, exist_ok=True)
139
+ out = os.path.join(out_dir, "style-properties.csv")
140
+ with open(out, "w", newline="", encoding="utf-8") as fh:
141
+ w = csv.DictWriter(fh, fieldnames=["property", "group", "factory", "value_class",
142
+ "accepted_values", "tokenable"])
143
+ w.writeheader()
144
+ w.writerows(rows)
145
+
146
+ groups = {r["group"] for r in rows if r["group"]}
147
+ print("style properties: %d (%d in %d compound groups, %d token-referencable, "
148
+ "%d restricted to an enum)"
149
+ % (len(rows), sum(1 for r in rows if r["group"]), len(groups),
150
+ sum(1 for r in rows if r["tokenable"]),
151
+ sum(1 for r in rows if r["accepted_values"])))
152
+ by_factory = {}
153
+ for r in rows:
154
+ by_factory[r["factory"]] = by_factory.get(r["factory"], 0) + 1
155
+ for f, n in sorted(by_factory.items(), key=lambda kv: -kv[1]):
156
+ print(" %-42s %d" % (f, n))
157
+
158
+
159
+ if __name__ == "__main__":
160
+ if len(sys.argv) != 3:
161
+ sys.exit(__doc__)
162
+ extract(sys.argv[1], sys.argv[2])
163
+ extract_states(sys.argv[1], sys.argv[2])
@@ -0,0 +1,52 @@
1
+ <?php
2
+ /**
3
+ * Mint a logged-in cookie and a matching wp_rest nonce, without a browser.
4
+ *
5
+ * wp eval-file mint_session.php # first administrator
6
+ * wp eval-file mint_session.php admin@site.com # a specific account
7
+ *
8
+ * The catch: wp_create_nonce() mixes in wp_get_session_token(), which reads the
9
+ * session token out of the CURRENT REQUEST's logged-in cookie. Under WP-CLI there is
10
+ * no such cookie, so a nonce minted the obvious way is tied to an empty token while
11
+ * the cookie carries a real one, and the REST API rejects the pair. So: create the
12
+ * session explicitly, build the cookie around that token, then plant the cookie in
13
+ * $_COOKIE before creating the nonce, so both sides agree on the same session.
14
+ */
15
+ $args = isset($args) ? $args : [];
16
+
17
+ // Takes a login or email; falls back to the first administrator on the site. Never
18
+ // hardcode an account here - this file ships.
19
+ $who = isset($args[0]) ? $args[0] : '';
20
+ $user = null;
21
+ if ($who !== '') {
22
+ $user = strpos($who, '@') !== false ? get_user_by('email', $who)
23
+ : get_user_by('login', $who);
24
+ if (!$user) {
25
+ echo "NO_SUCH_USER
26
+ ";
27
+ return;
28
+ }
29
+ }
30
+ if (!$user) {
31
+ $admins = get_users(array('role' => 'administrator', 'number' => 1));
32
+ $user = $admins ? $admins[0] : null;
33
+ }
34
+ if (!$user) {
35
+ echo "NO_ADMIN\n";
36
+ return;
37
+ }
38
+
39
+ $expiration = time() + 12 * HOUR_IN_SECONDS;
40
+
41
+ $manager = WP_Session_Tokens::get_instance($user->ID);
42
+ $token = $manager->create($expiration);
43
+
44
+ $cookie = wp_generate_auth_cookie($user->ID, $expiration, 'logged_in', $token);
45
+
46
+ $_COOKIE[LOGGED_IN_COOKIE] = $cookie;
47
+ wp_set_current_user($user->ID);
48
+
49
+ echo 'COOKIE=' . LOGGED_IN_COOKIE . '=' . $cookie . "\n";
50
+ echo 'NONCE=' . wp_create_nonce('wp_rest') . "\n";
51
+ echo 'SITEURL=' . get_option('siteurl') . "\n";
52
+ echo 'USER=' . $user->user_login . "\n";
package/tools/probe.py ADDED
@@ -0,0 +1,144 @@
1
+ #!/usr/bin/env python3
2
+ """Put arbitrary node data on a live Mosaic page and report exactly what came out.
3
+
4
+ python probe.py --config lab.json --cases cases.json [--out results.csv]
5
+
6
+ This is the instrument the rest of the skill's measurements are taken with. Reading
7
+ Mosaic's source tells you what a value is *validated* against; it does not tell you
8
+ what the compiler emits, and the two disagree often enough that guessing is not an
9
+ option - a value can be accepted, stored, and produce no CSS at all.
10
+
11
+ ## Case format
12
+
13
+ A case is one node committed alone onto the probe master, rendered, then deleted:
14
+
15
+ {"label": "boxShadow outside",
16
+ "type": "div", default "div"
17
+ "data": {"style": {...}}, merged into the node's data
18
+ "children": [...], optional child nodes, same shape
19
+ "expect": ["box-shadow", "8px"]} substrings to look for in the output
20
+
21
+ Results report, per case: whether the commit was accepted, whether the page survived,
22
+ and the compiled CSS rule and rendered element that the probe produced - so a case
23
+ that "worked" can be checked against what it actually emitted rather than trusted.
24
+
25
+ ## Reading the outcome
26
+
27
+ OK committed, page healthy, every `expect` substring found
28
+ PARTIAL committed and rendered, but some expectation missing
29
+ NO_OUTPUT committed, page healthy, produced no CSS rule and no element
30
+ REJECTED validator refused - HTTP 200 with an `exceptions` body
31
+ COMMIT_5xx PHP fatal during commit
32
+ BROKE_PAGE committed, and the page stopped rendering
33
+ """
34
+ import argparse
35
+ import csv
36
+ import json
37
+ import os
38
+ import re
39
+ import sys
40
+ import uuid
41
+
42
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
43
+ from sweep_node_types import ( # noqa: E402
44
+ MIN_HEALTHY_BYTES,
45
+ Client,
46
+ delete_subtree,
47
+ envelopes,
48
+ exceptions_of,
49
+ unwrap,
50
+ )
51
+ from sweep_properties import split_markup_and_css # noqa: E402
52
+
53
+ PROBE_ID = "probe-target"
54
+
55
+
56
+ def records(node, parent_id, master_id, ordering="a0", out=None):
57
+ out = out if out is not None else []
58
+ nid = str(uuid.uuid4())
59
+ data = dict(node.get("data") or {})
60
+ if not out: # the outermost probe node carries the marker id
61
+ data.setdefault("attrID", PROBE_ID)
62
+ out.append({
63
+ "ID": nid, "parentType": "node", "parentID": parent_id, "ordering": ordering,
64
+ "status": "publish", "revision": "", "version": "",
65
+ "type": node.get("type", "div"), "data": data,
66
+ "documentType": "master", "documentID": master_id,
67
+ })
68
+ alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"
69
+ for i, child in enumerate(node.get("children") or []):
70
+ records(child, nid, master_id, "a" + alphabet[i % len(alphabet)], out)
71
+ return out
72
+
73
+
74
+ def rule_for(css, class_name):
75
+ """Every rule mentioning a class, including inside @media blocks."""
76
+ return re.findall(r"[^{}@]*\.%s\b[^{}]*\{[^{}]*\}" % re.escape(class_name), css)
77
+
78
+
79
+ def run(client, cfg, cases, out_path):
80
+ master = cfg["masterID"]
81
+ key = "node/master/%s" % master
82
+ doc = unwrap(client.get("masterDocumentInstance/%s" % master), "masterDocumentInstance")
83
+ baseline = {n["ID"] for n in doc[key]}
84
+ base_html = client.page(cfg.get("path", ""))
85
+ if len(base_html) < MIN_HEALTHY_BYTES:
86
+ sys.exit("probe page already broken (%d bytes)" % len(base_html))
87
+
88
+ rows = []
89
+ for case in cases:
90
+ doc = unwrap(client.get("masterDocumentInstance/%s" % master), "masterDocumentInstance")
91
+ recs = records(case, cfg["parentNodeID"], master)
92
+ resp = client.commit("masterDocumentInstance/%s" % master, envelopes(doc),
93
+ {key: [{"newRevisionRecord": r, "originalRevisionRecord": None}
94
+ for r in recs]})
95
+ err = exceptions_of(resp)
96
+ css_rule = element = ""
97
+ if err:
98
+ code = resp.get("_httperror")
99
+ outcome = "COMMIT_%d" % code if code and code >= 500 else "REJECTED"
100
+ detail = "PHP fatal during commit" if code else err[:220]
101
+ else:
102
+ html = client.page(cfg.get("path", ""))
103
+ if len(html) < MIN_HEALTHY_BYTES:
104
+ outcome, detail = "BROKE_PAGE", html.strip()[:160]
105
+ else:
106
+ markup, css = split_markup_and_css(html)
107
+ m = re.search(r'<([a-zA-Z0-9-]+)[^>]*\bid="%s"[^>]*>' % PROBE_ID, markup)
108
+ element = m.group(0)[:160] if m else ""
109
+ cls = re.search(r'class="(M_EL\d+)', element or "")
110
+ rules = rule_for(css, cls.group(1)) if cls else []
111
+ css_rule = " ".join(r.strip() for r in rules)[:400]
112
+ missing = [e for e in (case.get("expect") or []) if e not in css_rule + element]
113
+ if not (css_rule or element):
114
+ outcome, detail = "NO_OUTPUT", ""
115
+ elif missing:
116
+ outcome, detail = "PARTIAL", "missing: " + ", ".join(missing)
117
+ else:
118
+ outcome, detail = "OK", ""
119
+ rows.append({"label": case.get("label", case.get("type", "?")), "outcome": outcome,
120
+ "css": css_rule, "element": element, "detail": detail})
121
+ print("%-34s %-11s %s" % (rows[-1]["label"], outcome, (css_rule or detail)[:96]), flush=True)
122
+ delete_subtree(client, master, baseline, cfg["parentNodeID"])
123
+
124
+ if out_path:
125
+ with open(out_path, "w", newline="", encoding="utf-8") as fh:
126
+ w = csv.DictWriter(fh, fieldnames=list(rows[0].keys()))
127
+ w.writeheader()
128
+ w.writerows(rows)
129
+ tally = {}
130
+ for r in rows:
131
+ tally[r["outcome"]] = tally.get(r["outcome"], 0) + 1
132
+ print("\n" + " ".join("%s=%d" % kv for kv in sorted(tally.items())))
133
+ return rows
134
+
135
+
136
+ if __name__ == "__main__":
137
+ ap = argparse.ArgumentParser()
138
+ ap.add_argument("--config", required=True)
139
+ ap.add_argument("--cases", required=True)
140
+ ap.add_argument("--out")
141
+ a = ap.parse_args()
142
+ cfg = json.load(open(a.config, encoding="utf-8"))
143
+ cases = json.load(open(a.cases, encoding="utf-8"))
144
+ run(Client(cfg), cfg, cases, a.out)