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