mosaic-headless 1.2.1 → 1.4.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/README.ja.md +19 -1
- package/README.ko.md +19 -1
- package/README.md +20 -1
- package/README.zh-TW.md +17 -1
- package/SKILL.md +110 -8
- package/assets/templates/platforms/claude-ai.json +1 -1
- package/assets/templates/platforms/claude-code.json +1 -1
- package/assets/templates/platforms/codex-cli.json +1 -1
- package/assets/templates/platforms/copilot.json +1 -1
- package/assets/templates/platforms/gemini-cli.json +1 -1
- package/bin/check-release.mjs +22 -0
- package/data/browser-verification.csv +2283 -0
- package/data/data-class-hierarchy.csv +122 -0
- package/data/design-audit.csv +1 -0
- package/data/interaction-verification.csv +8 -0
- package/data/node-property-verification.csv +2 -2
- package/data/node-types.csv +123 -123
- package/data/style-state-verification.csv +53 -0
- package/package.json +1 -1
- package/references/failure-modes.md +52 -0
- package/references/interactions.md +53 -8
- package/sites/_moksa.py +63 -23
- package/sites/moksa.json +130 -56
- package/tools/build_page.py +34 -1
- package/tools/extract_node_types.py +26 -1
- package/tools/mo.py +715 -0
- package/tools/sweep_interactions.py +352 -0
- package/tools/sweep_node_properties.py +41 -2
- package/tools/sweep_style_states.py +225 -0
- package/tools/verify_browser.py +589 -0
package/tools/mo.py
ADDED
|
@@ -0,0 +1,715 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""mo - the front door to the Mosaic surface.
|
|
3
|
+
|
|
4
|
+
python tools/mo.py stats
|
|
5
|
+
python tools/mo.py type accordion
|
|
6
|
+
python tools/mo.py types --edition pro --safe
|
|
7
|
+
python tools/mo.py check accordion-content text div
|
|
8
|
+
|
|
9
|
+
Why this exists rather than `grep data/*.csv`:
|
|
10
|
+
|
|
11
|
+
The skill's one rule is "never write a node type, property name, enum value or style
|
|
12
|
+
key from memory - look it up in `data/`". That rule needs something to look it up
|
|
13
|
+
WITH. Grep answers the question you typed; it does not answer the question you have.
|
|
14
|
+
Ask grep about `accordion-content` and it tells you the type exists. It does not tell
|
|
15
|
+
you that placing one committed cleanly and then reduced the entire public page to a
|
|
16
|
+
54-byte error string, which is the only thing about that type worth knowing.
|
|
17
|
+
|
|
18
|
+
So every lookup here joins the source tables to the live sweeps and leads with the
|
|
19
|
+
measured verdict. The data files are 25 CSVs that cross-reference each other through five
|
|
20
|
+
different keys; this is the query.
|
|
21
|
+
|
|
22
|
+
Reads only `data/*.csv`. No dependencies, no network.
|
|
23
|
+
"""
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import argparse
|
|
27
|
+
import csv
|
|
28
|
+
import json
|
|
29
|
+
import os
|
|
30
|
+
import re
|
|
31
|
+
import sys
|
|
32
|
+
|
|
33
|
+
DATA = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "data")
|
|
34
|
+
|
|
35
|
+
# A type whose sweep outcome is one of these must not be committed: the first two
|
|
36
|
+
# kill the request, the third kills the page for every visitor until you delete the
|
|
37
|
+
# row. `mo.py check` exits non-zero on them so a build script can refuse.
|
|
38
|
+
UNSAFE = {"COMMIT_500", "COMMIT_502", "BROKE_PAGE"}
|
|
39
|
+
|
|
40
|
+
OUTCOME_NOTE = {
|
|
41
|
+
"RENDERED": "committed and reached the delivered HTML",
|
|
42
|
+
"COMMITTED": "row exists, nothing reached the page - inert on its own",
|
|
43
|
+
"COMMIT_500": "PHP fatal during commit; the request dies",
|
|
44
|
+
"COMMIT_502": "gateway error during commit; the request dies",
|
|
45
|
+
"BROKE_PAGE": "committed, then the whole public page became an error string",
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# ── loading ───────────────────────────────────────────────────────────────────
|
|
50
|
+
|
|
51
|
+
_cache: dict[str, list[dict]] = {}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def rows(name: str) -> list[dict]:
|
|
55
|
+
"""A data file, as a list of dicts. Missing file is a hard error, not an empty
|
|
56
|
+
list: silently answering from no data is the failure this skill argues against."""
|
|
57
|
+
if name not in _cache:
|
|
58
|
+
path = os.path.join(DATA, name + ".csv")
|
|
59
|
+
if not os.path.exists(path):
|
|
60
|
+
sys.exit("missing data file: %s" % os.path.normpath(path))
|
|
61
|
+
with open(path, encoding="utf-8", newline="") as fh:
|
|
62
|
+
_cache[name] = list(csv.DictReader(fh))
|
|
63
|
+
return _cache[name]
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def index(name: str, key: str) -> dict[str, dict]:
|
|
67
|
+
return {r[key]: r for r in rows(name)}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def split(value: str) -> list[str]:
|
|
71
|
+
"""The CSVs pack multi-values with `|`."""
|
|
72
|
+
return [v for v in (value or "").split("|") if v]
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
# ── output ────────────────────────────────────────────────────────────────────
|
|
76
|
+
|
|
77
|
+
ARGS = argparse.Namespace(json=False)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def emit(payload, render):
|
|
81
|
+
if ARGS.json:
|
|
82
|
+
json.dump(payload, sys.stdout, indent=1, ensure_ascii=False)
|
|
83
|
+
sys.stdout.write("\n")
|
|
84
|
+
else:
|
|
85
|
+
render()
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def head(text):
|
|
89
|
+
print(text)
|
|
90
|
+
print("-" * len(text))
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def table(headers, body, gap=2):
|
|
94
|
+
if not body:
|
|
95
|
+
print(" (nothing matched)")
|
|
96
|
+
return
|
|
97
|
+
body = [[("" if c is None else str(c)) for c in r] for r in body]
|
|
98
|
+
width = [max(len(str(h)), *(len(r[i]) for r in body))
|
|
99
|
+
for i, h in enumerate(headers)]
|
|
100
|
+
sep = " " * gap
|
|
101
|
+
print(sep.join(str(h).ljust(width[i]) for i, h in enumerate(headers)).rstrip())
|
|
102
|
+
print(sep.join("-" * w for w in width))
|
|
103
|
+
for r in body:
|
|
104
|
+
print(sep.join(c.ljust(width[i]) for i, c in enumerate(r)).rstrip())
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def matches(text: str, needle: str | None) -> bool:
|
|
108
|
+
return needle is None or needle.lower() in (text or "").lower()
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
# ── node types ────────────────────────────────────────────────────────────────
|
|
112
|
+
|
|
113
|
+
def type_record(name: str) -> dict:
|
|
114
|
+
"""One node type, joined across every table that says something about it."""
|
|
115
|
+
t = index("node-types", "type").get(name)
|
|
116
|
+
if not t:
|
|
117
|
+
near = [r["type"] for r in rows("node-types") if name in r["type"]]
|
|
118
|
+
sys.exit("no such node type: %s%s" % (
|
|
119
|
+
name, ("\ndid you mean: " + ", ".join(near[:8])) if near else ""))
|
|
120
|
+
|
|
121
|
+
v = index("node-verification", "type").get(name, {})
|
|
122
|
+
p = index("placement-rules", "type").get(name, {})
|
|
123
|
+
d = index("default-children", "type").get(name, {})
|
|
124
|
+
|
|
125
|
+
# Properties are owned by the data class and inherited up the `extends` chain.
|
|
126
|
+
# Resolving the chain is the whole reason this is a tool: `node-properties.csv`
|
|
127
|
+
# is keyed on `owner_class` and cannot be joined to a type name by eye. The chain
|
|
128
|
+
# is walked through `data-class-hierarchy.csv` rather than through the property
|
|
129
|
+
# rows themselves, because a class that declares nothing writes no property row
|
|
130
|
+
# and would end the walk early - which is how `accordion-content` once reported
|
|
131
|
+
# zero properties while in fact carrying the nine every element has.
|
|
132
|
+
parent = {r["class"]: r["extends"] for r in rows("data-class-hierarchy")}
|
|
133
|
+
by_owner: dict[str, list[dict]] = {}
|
|
134
|
+
for r in rows("node-properties"):
|
|
135
|
+
by_owner.setdefault(r["owner_class"], []).append(r)
|
|
136
|
+
|
|
137
|
+
props, seen, cls, own_class = [], set(), t.get("data_class"), t.get("data_class")
|
|
138
|
+
while cls and cls not in seen:
|
|
139
|
+
seen.add(cls)
|
|
140
|
+
for r in by_owner.get(cls, []):
|
|
141
|
+
props.append(dict(r, inherited_from=("" if cls == own_class else cls)))
|
|
142
|
+
cls = parent.get(cls, "")
|
|
143
|
+
chain_complete = bool(own_class)
|
|
144
|
+
|
|
145
|
+
return {
|
|
146
|
+
"type": name,
|
|
147
|
+
"label": t.get("label"),
|
|
148
|
+
"edition": t.get("edition"),
|
|
149
|
+
"outcome": v.get("outcome"),
|
|
150
|
+
"outcome_note": OUTCOME_NOTE.get(v.get("outcome"), ""),
|
|
151
|
+
"safe_to_commit": v.get("outcome") not in UNSAFE,
|
|
152
|
+
"detail": v.get("detail"),
|
|
153
|
+
"rendered_tag": v.get("rendered_tag"),
|
|
154
|
+
"rendered_classes": v.get("rendered_classes"),
|
|
155
|
+
"can_be_parent": t.get("can_be_parent") == "true",
|
|
156
|
+
"placement_rule": p.get("rule"),
|
|
157
|
+
"allowed_children": split(p.get("allowed_children", "")),
|
|
158
|
+
"default_children": split(d.get("default_children", "")),
|
|
159
|
+
"data_class": t.get("data_class"),
|
|
160
|
+
"data_class_known": chain_complete,
|
|
161
|
+
"alias_types": split(t.get("alias_types", "")),
|
|
162
|
+
"file": t.get("file"),
|
|
163
|
+
"properties": [
|
|
164
|
+
{"property": r["property"],
|
|
165
|
+
"validators": split(r["validators"]),
|
|
166
|
+
"accepted_values": split(r["accepted_values"]),
|
|
167
|
+
"supports_inherit": r["supports_inherit"] == "true",
|
|
168
|
+
"inherited_from": r["inherited_from"]}
|
|
169
|
+
for r in props],
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def parents_of(name: str) -> dict:
|
|
174
|
+
"""Which types may CONTAIN this one.
|
|
175
|
+
|
|
176
|
+
Nothing stores this - it is the placement table read backwards, and it is the
|
|
177
|
+
question you actually have when a node refuses to go where you put it.
|
|
178
|
+
|
|
179
|
+
The distinction that matters: a type that NAMES this one in an allow-list is a
|
|
180
|
+
real, intended parent, and for the composite types it is usually the only one
|
|
181
|
+
that works at runtime. A container whose rule is `any` will accept the node
|
|
182
|
+
structurally and may still fatal - `accordion-content` goes into any div as far
|
|
183
|
+
as the table is concerned, and takes the whole public page down when it does.
|
|
184
|
+
Listing the eighty permissive containers alongside the one correct parent would
|
|
185
|
+
bury the answer, so they are counted, not enumerated."""
|
|
186
|
+
named, permissive = [], 0
|
|
187
|
+
for r in rows("placement-rules"):
|
|
188
|
+
allowed = split(r["allowed_children"])
|
|
189
|
+
if r["rule"] == "allow" and name in allowed:
|
|
190
|
+
named.append(r["type"])
|
|
191
|
+
elif r["rule"] == "any" or (r["rule"] == "deny" and name not in allowed):
|
|
192
|
+
permissive += 1
|
|
193
|
+
return {"named_parents": named, "permissive_containers": permissive}
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def cmd_type(a):
|
|
197
|
+
rec = type_record(a.name)
|
|
198
|
+
prop_status = {r["property"]: r["status"]
|
|
199
|
+
for r in rows("node-property-verification")}
|
|
200
|
+
|
|
201
|
+
def render():
|
|
202
|
+
head("%s%s" % (rec["type"],
|
|
203
|
+
" - " + rec["label"] if rec["label"] else ""))
|
|
204
|
+
flag = "SAFE" if rec["safe_to_commit"] else "UNSAFE - DO NOT COMMIT"
|
|
205
|
+
print("edition : %s" % rec["edition"])
|
|
206
|
+
print("swept : %s (%s)" % (rec["outcome"], rec["outcome_note"]))
|
|
207
|
+
print("verdict : %s" % flag)
|
|
208
|
+
if rec["detail"]:
|
|
209
|
+
print("failure : %s" % rec["detail"])
|
|
210
|
+
if rec["rendered_tag"]:
|
|
211
|
+
print("renders as: <%s>%s" % (
|
|
212
|
+
rec["rendered_tag"],
|
|
213
|
+
(" class=%s" % rec["rendered_classes"]) if rec["rendered_classes"]
|
|
214
|
+
else ""))
|
|
215
|
+
if rec["alias_types"]:
|
|
216
|
+
print("aliases : %s" % ", ".join(rec["alias_types"]))
|
|
217
|
+
|
|
218
|
+
print("\nchildren : rule=%s" % (rec["placement_rule"] or "n/a"))
|
|
219
|
+
if rec["allowed_children"]:
|
|
220
|
+
print(" %s" % ", ".join(rec["allowed_children"]))
|
|
221
|
+
if rec["default_children"]:
|
|
222
|
+
print(" heal() inserts on commit: %s"
|
|
223
|
+
% ", ".join(rec["default_children"]))
|
|
224
|
+
par = parents_of(rec["type"])
|
|
225
|
+
if par["named_parents"]:
|
|
226
|
+
print("goes inside: %s (named explicitly - use these)"
|
|
227
|
+
% ", ".join(par["named_parents"]))
|
|
228
|
+
else:
|
|
229
|
+
print("goes inside: no type names it; %d permissive containers accept it "
|
|
230
|
+
"structurally" % par["permissive_containers"])
|
|
231
|
+
|
|
232
|
+
print("\nproperties (%d)%s" % (
|
|
233
|
+
len(rec["properties"]),
|
|
234
|
+
"" if rec["data_class_known"]
|
|
235
|
+
else " - declares no data class; the source says nothing"))
|
|
236
|
+
table(["property", "shared", "verified", "accepted values"],
|
|
237
|
+
[[p["property"],
|
|
238
|
+
"yes" if p["inherited_from"] else "",
|
|
239
|
+
prop_status.get(p["property"], ""),
|
|
240
|
+
", ".join(p["accepted_values"])[:56]]
|
|
241
|
+
for p in rec["properties"]])
|
|
242
|
+
print("\nsource: %s" % rec["file"])
|
|
243
|
+
|
|
244
|
+
emit(rec, render)
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def cmd_types(a):
|
|
248
|
+
ver = index("node-verification", "type")
|
|
249
|
+
out = []
|
|
250
|
+
for r in rows("node-types"):
|
|
251
|
+
v = ver.get(r["type"], {})
|
|
252
|
+
if a.edition and r["edition"] != a.edition:
|
|
253
|
+
continue
|
|
254
|
+
if a.outcome and v.get("outcome") != a.outcome:
|
|
255
|
+
continue
|
|
256
|
+
if a.safe and v.get("outcome") in UNSAFE:
|
|
257
|
+
continue
|
|
258
|
+
if a.unsafe and v.get("outcome") not in UNSAFE:
|
|
259
|
+
continue
|
|
260
|
+
if not (matches(r["type"], a.grep) or matches(r["label"], a.grep)):
|
|
261
|
+
continue
|
|
262
|
+
out.append({"type": r["type"], "label": r["label"], "edition": r["edition"],
|
|
263
|
+
"outcome": v.get("outcome"),
|
|
264
|
+
"safe": v.get("outcome") not in UNSAFE,
|
|
265
|
+
"renders_as": v.get("rendered_tag", "")})
|
|
266
|
+
emit(out, lambda: (
|
|
267
|
+
table(["type", "label", "edition", "swept", "renders as"],
|
|
268
|
+
[[r["type"], r["label"], r["edition"], r["outcome"],
|
|
269
|
+
r["renders_as"] or ""] for r in out]),
|
|
270
|
+
print("\n%d types" % len(out))))
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def cmd_check(a):
|
|
274
|
+
"""Exit non-zero if any named type is measured unsafe. For build scripts."""
|
|
275
|
+
ver = index("node-verification", "type")
|
|
276
|
+
known = index("node-types", "type")
|
|
277
|
+
bad = []
|
|
278
|
+
for name in a.names:
|
|
279
|
+
if name not in known:
|
|
280
|
+
bad.append((name, "NO SUCH TYPE", "not in data/node-types.csv"))
|
|
281
|
+
continue
|
|
282
|
+
o = ver.get(name, {}).get("outcome")
|
|
283
|
+
if o in UNSAFE:
|
|
284
|
+
bad.append((name, o, ver.get(name, {}).get("detail")
|
|
285
|
+
or OUTCOME_NOTE.get(o, "")))
|
|
286
|
+
emit({"checked": a.names, "unsafe": [b[0] for b in bad],
|
|
287
|
+
"detail": [{"type": b[0], "outcome": b[1], "why": b[2]} for b in bad]},
|
|
288
|
+
lambda: (table(["type", "outcome", "why"], bad) if bad
|
|
289
|
+
else print("all %d types are safe to commit" % len(a.names))))
|
|
290
|
+
sys.exit(1 if bad else 0)
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
# ── properties ────────────────────────────────────────────────────────────────
|
|
294
|
+
|
|
295
|
+
def cmd_props(a):
|
|
296
|
+
ver = {r["property"]: r for r in rows("node-property-verification")}
|
|
297
|
+
seen, out = set(), []
|
|
298
|
+
for r in rows("node-properties"):
|
|
299
|
+
if not matches(r["property"], a.grep):
|
|
300
|
+
continue
|
|
301
|
+
if a.status and ver.get(r["property"], {}).get("status") != a.status:
|
|
302
|
+
continue
|
|
303
|
+
key = (r["property"], r["owner_class"])
|
|
304
|
+
if key in seen:
|
|
305
|
+
continue
|
|
306
|
+
seen.add(key)
|
|
307
|
+
out.append({"property": r["property"], "owner": r["owner_class"],
|
|
308
|
+
"validators": split(r["validators"]),
|
|
309
|
+
"accepted_values": split(r["accepted_values"]),
|
|
310
|
+
"verified": ver.get(r["property"], {}).get("status", ""),
|
|
311
|
+
"evidence": ver.get(r["property"], {}).get("evidence", "")})
|
|
312
|
+
if a.shared:
|
|
313
|
+
out = [r for r in out if "Abstract" in r["owner"]]
|
|
314
|
+
emit(out, lambda: (
|
|
315
|
+
table(["property", "owner", "verified", "accepted values"],
|
|
316
|
+
[[r["property"], r["owner"].replace("MResourceData", ""),
|
|
317
|
+
r["verified"], ", ".join(r["accepted_values"])[:50]] for r in out]),
|
|
318
|
+
print("\n%d rows" % len(out))))
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def cmd_prop(a):
|
|
322
|
+
decls = [r for r in rows("node-properties") if r["property"] == a.name]
|
|
323
|
+
if not decls:
|
|
324
|
+
sys.exit("no such node property: %s" % a.name)
|
|
325
|
+
v = {r["property"]: r for r in rows("node-property-verification")}.get(a.name, {})
|
|
326
|
+
classes = {r["data_class"]: r["type"] for r in rows("node-types")}
|
|
327
|
+
rec = {
|
|
328
|
+
"property": a.name,
|
|
329
|
+
"verified": v.get("status"),
|
|
330
|
+
"probed_on": v.get("probed_on"),
|
|
331
|
+
"sent": v.get("sent"),
|
|
332
|
+
"evidence": v.get("evidence"),
|
|
333
|
+
"declared_by": sorted({d["owner_class"] for d in decls}),
|
|
334
|
+
"types": sorted({classes[d["owner_class"]] for d in decls
|
|
335
|
+
if d["owner_class"] in classes}),
|
|
336
|
+
"validators": split(decls[0]["validators"]),
|
|
337
|
+
"accepted_values": split(decls[0]["accepted_values"]),
|
|
338
|
+
"supports_inherit": decls[0]["supports_inherit"] == "true",
|
|
339
|
+
"shared": any("Abstract" in d["owner_class"] for d in decls),
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
def render():
|
|
343
|
+
head(a.name)
|
|
344
|
+
print("verified : %s" % (rec["verified"] or "not probed"))
|
|
345
|
+
if rec["sent"]:
|
|
346
|
+
print("probed : sent %s on %s" % (rec["sent"], rec["probed_on"]))
|
|
347
|
+
if rec["evidence"]:
|
|
348
|
+
print("evidence : %s" % rec["evidence"])
|
|
349
|
+
print("validators: %s" % " | ".join(rec["validators"]))
|
|
350
|
+
if rec["accepted_values"]:
|
|
351
|
+
print("values : %s" % ", ".join(rec["accepted_values"]))
|
|
352
|
+
print("inherit : %s" % ("supported" if rec["supports_inherit"] else "no"))
|
|
353
|
+
if rec["shared"]:
|
|
354
|
+
print("scope : shared - every element has it")
|
|
355
|
+
else:
|
|
356
|
+
print("on types : %s" % (", ".join(rec["types"]) or "(abstract only)"))
|
|
357
|
+
|
|
358
|
+
emit(rec, render)
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
# ── style ─────────────────────────────────────────────────────────────────────
|
|
362
|
+
|
|
363
|
+
def cmd_style(a):
|
|
364
|
+
ver = index("style-verification", "property")
|
|
365
|
+
shapes = index("style-value-shapes", "property")
|
|
366
|
+
out = []
|
|
367
|
+
for r in rows("style-properties"):
|
|
368
|
+
if not matches(r["property"], a.grep):
|
|
369
|
+
continue
|
|
370
|
+
v = ver.get(r["property"], {})
|
|
371
|
+
if a.status and v.get("status") != a.status:
|
|
372
|
+
continue
|
|
373
|
+
if a.grouped and not r["group"]:
|
|
374
|
+
continue
|
|
375
|
+
out.append({"property": r["property"], "group": r["group"],
|
|
376
|
+
"css": v.get("css_property", ""),
|
|
377
|
+
"status": v.get("status", ""),
|
|
378
|
+
"compiled": v.get("compiled", ""),
|
|
379
|
+
"shape": shapes.get(r["property"], {}).get("shape", ""),
|
|
380
|
+
"tokenable": r["tokenable"] == "true",
|
|
381
|
+
"accepted_values": split(r["accepted_values"])})
|
|
382
|
+
|
|
383
|
+
def render():
|
|
384
|
+
table(["property", "group", "css", "swept", "shape"],
|
|
385
|
+
[[r["property"], r["group"], r["css"], r["status"], r["shape"][:34]]
|
|
386
|
+
for r in out])
|
|
387
|
+
n_group = sum(1 for r in out if r["group"])
|
|
388
|
+
print("\n%d properties%s" % (len(out), (
|
|
389
|
+
" - %d belong to a group and are INERT set on their own"
|
|
390
|
+
% n_group) if n_group else ""))
|
|
391
|
+
|
|
392
|
+
emit(out, render)
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def cmd_css(a):
|
|
396
|
+
"""Reverse lookup: which Mosaic style property drives this CSS property.
|
|
397
|
+
|
|
398
|
+
The names mostly transliterate, but not always, and `radius` -> `border-radius`
|
|
399
|
+
is not something to guess at 2am."""
|
|
400
|
+
want = a.property.lower()
|
|
401
|
+
out = [r for r in rows("style-verification")
|
|
402
|
+
if want in (r["css_property"] or "").lower()
|
|
403
|
+
or want in r["property"].lower()]
|
|
404
|
+
props = index("style-properties", "property")
|
|
405
|
+
emit([dict(r, group=props.get(r["property"], {}).get("group", "")) for r in out],
|
|
406
|
+
lambda: table(["css property", "set in Mosaic as", "group", "swept"],
|
|
407
|
+
[[r["css_property"], r["property"],
|
|
408
|
+
props.get(r["property"], {}).get("group", ""),
|
|
409
|
+
r["status"]] for r in out]))
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def cmd_states(a):
|
|
413
|
+
"""A state joined to whether it was ever seen to compile.
|
|
414
|
+
|
|
415
|
+
`style-states.csv` is what the source declares. `--verified` is the view that
|
|
416
|
+
answers the question you actually have when you are about to write one: a state
|
|
417
|
+
whose only host takes the whole page down is not a style you can use."""
|
|
418
|
+
ver = index("style-state-verification", "state")
|
|
419
|
+
out = []
|
|
420
|
+
for r in rows("style-states"):
|
|
421
|
+
if not (matches(r["state"], a.grep)
|
|
422
|
+
or matches(r["selector_template"], a.grep)):
|
|
423
|
+
continue
|
|
424
|
+
v = ver.get(r["state"], {})
|
|
425
|
+
status = v.get("status", "base state" if r["state"] == "&" else "")
|
|
426
|
+
if a.verified and status != "COMPILED":
|
|
427
|
+
continue
|
|
428
|
+
out.append({"state": r["state"], "selector": r["selector_template"],
|
|
429
|
+
"scope": r["scope"], "host": v.get("host", ""),
|
|
430
|
+
"status": status, "compiled_to": v.get("evidence", "")})
|
|
431
|
+
|
|
432
|
+
def render():
|
|
433
|
+
table(["state", "scope", "host", "swept", "selector"],
|
|
434
|
+
[[r["state"], r["scope"], r["host"], r["status"], r["selector"][:50]]
|
|
435
|
+
for r in out])
|
|
436
|
+
usable = [r for r in out
|
|
437
|
+
if r["scope"] == "global" and r["status"] == "COMPILED"]
|
|
438
|
+
print("\n%d states. `&` is the base state; the breakpoint axis is separate "
|
|
439
|
+
"(_ / _t / _m)." % len(out))
|
|
440
|
+
if usable:
|
|
441
|
+
print("%d go on ANY element: %s"
|
|
442
|
+
% (len(usable), ", ".join(r["state"] for r in usable)))
|
|
443
|
+
print("The pseudo-class is emitted UPPERCASE (`.M_EL9:HOVER`), so grepping a "
|
|
444
|
+
"stylesheet\nfor `:hover` finds nothing.")
|
|
445
|
+
|
|
446
|
+
emit(out, render)
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
def cmd_classes(a):
|
|
450
|
+
out = [r for r in rows("element-classes")
|
|
451
|
+
if matches(r["name"], a.grep) or matches(r["selectors"], a.grep)]
|
|
452
|
+
emit(out, lambda: (
|
|
453
|
+
table(["name", "selectors", "id"],
|
|
454
|
+
[[r["name"], r["selectors"][:40], r["id"]] for r in out]),
|
|
455
|
+
print("\n%d classes. These are THEME-GLOBAL: styling `Heading 1` restyles "
|
|
456
|
+
"every h1 on the install." % len(out))))
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
# ── dynamic content, interactions, conditions ─────────────────────────────────
|
|
460
|
+
|
|
461
|
+
def cmd_vars(a):
|
|
462
|
+
out = [r for r in rows("dynamic-variables")
|
|
463
|
+
if matches(r["expression"], a.grep)
|
|
464
|
+
and (not a.namespace or r["namespace"] == a.namespace)]
|
|
465
|
+
emit(out, lambda: (
|
|
466
|
+
table(["expression", "label"],
|
|
467
|
+
[[r["expression"], r["label"]] for r in out]),
|
|
468
|
+
print("\n%d variables. A misspelt one renders as literal text, not an error."
|
|
469
|
+
% len(out))))
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
def cmd_fns(a):
|
|
473
|
+
out = rows("evaluator-functions")
|
|
474
|
+
emit(out, lambda: table(["function", "max args", "variadic", "note"],
|
|
475
|
+
[[r["id"], r["max_args"], r["variadic"], r["note"]]
|
|
476
|
+
for r in out]))
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
def cmd_interactions(a):
|
|
480
|
+
out = rows("interaction-types")
|
|
481
|
+
emit(out, lambda: (
|
|
482
|
+
table(["id", "family", "label"],
|
|
483
|
+
[[r["id"], r["family"], r["label"]] for r in out]),
|
|
484
|
+
print("\nTrigger and timing reach the browser; per-keyframe property binding "
|
|
485
|
+
"is UNSOLVED - see references/interactions.md.")))
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
def cmd_conditions(a):
|
|
489
|
+
out = [r for r in rows("condition-subjects")
|
|
490
|
+
if (not a.context or r["context"] == a.context)
|
|
491
|
+
and matches(r["label"] + r["id"], a.grep)]
|
|
492
|
+
emit(out, lambda: (
|
|
493
|
+
table(["context", "id", "label", "group"],
|
|
494
|
+
[[r["context"], r["id"], r["label"], r["group_label"]] for r in out]),
|
|
495
|
+
print("\n%d subjects. Committing a condition has NOT been driven end to end."
|
|
496
|
+
% len(out))))
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
# ── infrastructure ────────────────────────────────────────────────────────────
|
|
500
|
+
|
|
501
|
+
def cmd_routes(a):
|
|
502
|
+
out = [r for r in rows("rest-routes") if matches(r["route"], a.grep)]
|
|
503
|
+
emit(out, lambda: (
|
|
504
|
+
table(["route", "methods"], [[r["route"], r["methods"]] for r in out]),
|
|
505
|
+
print("\n%d routes. The namespace carries the PLUGIN VERSION - read it from "
|
|
506
|
+
"mosaicOptions.rest_api_url, never hardcode." % len(out))))
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
def cmd_tables(a):
|
|
510
|
+
cols = rows("db-columns")
|
|
511
|
+
if a.name:
|
|
512
|
+
out = [r for r in cols if r["table"] == a.name or r["table"].endswith(a.name)]
|
|
513
|
+
if not out:
|
|
514
|
+
sys.exit("no such table: %s" % a.name)
|
|
515
|
+
emit(out, lambda: table(["column", "type"],
|
|
516
|
+
[[r["column"], r["type"]] for r in out]))
|
|
517
|
+
return
|
|
518
|
+
agg: dict[str, int] = {}
|
|
519
|
+
for r in cols:
|
|
520
|
+
agg[r["table"]] = agg.get(r["table"], 0) + 1
|
|
521
|
+
out = [{"table": t, "columns": n} for t, n in sorted(agg.items())]
|
|
522
|
+
emit(out, lambda: (
|
|
523
|
+
table(["table", "columns"], [[r["table"], r["columns"]] for r in out]),
|
|
524
|
+
print("\n%d tables, %d columns. A page is rows in these, not post_content."
|
|
525
|
+
% (len(out), len(cols)))))
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
def cmd_placement(a):
|
|
529
|
+
rec = type_record(a.name)
|
|
530
|
+
payload = {"type": a.name, "rule": rec["placement_rule"],
|
|
531
|
+
"allowed_children": rec["allowed_children"],
|
|
532
|
+
"default_children": rec["default_children"],
|
|
533
|
+
"safe_to_commit": rec["safe_to_commit"], **parents_of(a.name)}
|
|
534
|
+
emit(payload, lambda: (
|
|
535
|
+
head("placement: %s" % a.name),
|
|
536
|
+
print("rule : %s" % payload["rule"]),
|
|
537
|
+
print("accepts children: %s" % (", ".join(payload["allowed_children"])
|
|
538
|
+
or "none")),
|
|
539
|
+
print("heal() inserts : %s" % (", ".join(payload["default_children"])
|
|
540
|
+
or "nothing")),
|
|
541
|
+
print("named parents : %s" % (", ".join(payload["named_parents"])
|
|
542
|
+
or "none - no type asks for it by name")),
|
|
543
|
+
print("also accepted by: %d permissive containers (rule=any / deny-list)"
|
|
544
|
+
% payload["permissive_containers"]),
|
|
545
|
+
print("\nThe table is what the SOURCE declares, and it does not know that a "
|
|
546
|
+
"type\nfatals on commit: %s is %s. Ask `mo.py type %s`."
|
|
547
|
+
% (a.name, "SAFE" if payload["safe_to_commit"] else "UNSAFE TO COMMIT",
|
|
548
|
+
a.name))))
|
|
549
|
+
|
|
550
|
+
|
|
551
|
+
def cmd_skeleton(a):
|
|
552
|
+
"""A minimal spec that build_site.py accepts, using only measured-safe types."""
|
|
553
|
+
spec = {
|
|
554
|
+
"site": "example",
|
|
555
|
+
"theme": {"variables": {"--ink": {"type": "color",
|
|
556
|
+
"value": "rgb(22,24,28)"}}},
|
|
557
|
+
"pages": [{
|
|
558
|
+
"slug": "example", "title": "Example",
|
|
559
|
+
"tree": {
|
|
560
|
+
"type": "div",
|
|
561
|
+
"data": {"attrID": "root"},
|
|
562
|
+
"style": {"&": {"_": {"paddingTop": "64px",
|
|
563
|
+
"paddingBottom": "64px"},
|
|
564
|
+
"_m": {"paddingTop": "40px",
|
|
565
|
+
"paddingBottom": "40px"}}},
|
|
566
|
+
"children": [
|
|
567
|
+
{"type": "text",
|
|
568
|
+
"data": {"tagName": "h1", "attrID": "title"},
|
|
569
|
+
"style": {"&": {"_": {"fontSize": "48px",
|
|
570
|
+
"color": {"token": "--ink"}},
|
|
571
|
+
"_m": {"fontSize": "30px"}}},
|
|
572
|
+
"text": "Example"},
|
|
573
|
+
],
|
|
574
|
+
},
|
|
575
|
+
}],
|
|
576
|
+
}
|
|
577
|
+
json.dump(spec, sys.stdout, indent=1, ensure_ascii=False)
|
|
578
|
+
sys.stdout.write("\n")
|
|
579
|
+
|
|
580
|
+
|
|
581
|
+
def cmd_stats(a):
|
|
582
|
+
ver = rows("node-verification")
|
|
583
|
+
counts: dict[str, int] = {}
|
|
584
|
+
for r in ver:
|
|
585
|
+
counts[r["outcome"]] = counts.get(r["outcome"], 0) + 1
|
|
586
|
+
sv: dict[str, int] = {}
|
|
587
|
+
for r in rows("style-verification"):
|
|
588
|
+
sv[r["status"]] = sv.get(r["status"], 0) + 1
|
|
589
|
+
np: dict[str, int] = {}
|
|
590
|
+
for r in rows("node-property-verification"):
|
|
591
|
+
np[r["status"]] = np.get(r["status"], 0) + 1
|
|
592
|
+
payload = {
|
|
593
|
+
"node_types": len(rows("node-types")),
|
|
594
|
+
"node_type_outcomes": counts,
|
|
595
|
+
"unsafe_types": sorted(r["type"] for r in ver if r["outcome"] in UNSAFE),
|
|
596
|
+
"node_properties": len(rows("node-property-verification")),
|
|
597
|
+
"node_property_outcomes": np,
|
|
598
|
+
"style_properties": len(rows("style-properties")),
|
|
599
|
+
"style_outcomes": sv,
|
|
600
|
+
"style_states": len(rows("style-states")),
|
|
601
|
+
"element_classes": len(rows("element-classes")),
|
|
602
|
+
"dynamic_variables": len(rows("dynamic-variables")),
|
|
603
|
+
"rest_routes": len(rows("rest-routes")),
|
|
604
|
+
"tables": len({r["table"] for r in rows("db-columns")}),
|
|
605
|
+
"rwd_declarations": len(rows("rwd-verification")),
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
def render():
|
|
609
|
+
head("mosaic surface")
|
|
610
|
+
print("node types %3d %s" % (
|
|
611
|
+
payload["node_types"],
|
|
612
|
+
" ".join("%s %d" % (k, v) for k, v in sorted(counts.items()))))
|
|
613
|
+
print(" UNSAFE to commit: %s" % ", ".join(payload["unsafe_types"]))
|
|
614
|
+
print("node properties %3d %s" % (
|
|
615
|
+
payload["node_properties"],
|
|
616
|
+
" ".join("%s %d" % (k, v) for k, v in sorted(np.items()))))
|
|
617
|
+
print("style properties %3d %s" % (
|
|
618
|
+
payload["style_properties"],
|
|
619
|
+
" ".join("%s %d" % (k, v) for k, v in sorted(sv.items()))))
|
|
620
|
+
print("style states %3d" % payload["style_states"])
|
|
621
|
+
print("element classes %3d (theme-global)" % payload["element_classes"])
|
|
622
|
+
print("dynamic variables %3d" % payload["dynamic_variables"])
|
|
623
|
+
print("REST routes %3d" % payload["rest_routes"])
|
|
624
|
+
print("tables %3d %d columns"
|
|
625
|
+
% (payload["tables"], len(rows("db-columns"))))
|
|
626
|
+
print("rwd declarations %3d verified against the served stylesheet"
|
|
627
|
+
% payload["rwd_declarations"])
|
|
628
|
+
print("\nSKIPPED / NO_HOST / INCONCLUSIVE are blind spots, never passes.")
|
|
629
|
+
|
|
630
|
+
emit(payload, render)
|
|
631
|
+
|
|
632
|
+
|
|
633
|
+
# ── cli ───────────────────────────────────────────────────────────────────────
|
|
634
|
+
|
|
635
|
+
def main():
|
|
636
|
+
ap = argparse.ArgumentParser(
|
|
637
|
+
prog="mo.py", description="query the measured Mosaic surface")
|
|
638
|
+
ap.add_argument("--json", action="store_true", help="machine-readable output")
|
|
639
|
+
sub = ap.add_subparsers(dest="cmd", required=True)
|
|
640
|
+
|
|
641
|
+
def add(name, fn, help_):
|
|
642
|
+
p = sub.add_parser(name, help=help_)
|
|
643
|
+
p.set_defaults(fn=fn)
|
|
644
|
+
return p
|
|
645
|
+
|
|
646
|
+
add("stats", cmd_stats, "what is in here, and what is unsafe")
|
|
647
|
+
|
|
648
|
+
p = add("types", cmd_types, "list node types")
|
|
649
|
+
p.add_argument("--grep")
|
|
650
|
+
p.add_argument("--edition", choices=["free", "pro"])
|
|
651
|
+
p.add_argument("--outcome", choices=sorted(OUTCOME_NOTE))
|
|
652
|
+
p.add_argument("--safe", action="store_true", help="only types safe to commit")
|
|
653
|
+
p.add_argument("--unsafe", action="store_true", help="only the dangerous ones")
|
|
654
|
+
|
|
655
|
+
p = add("type", cmd_type, "one node type, fully joined")
|
|
656
|
+
p.add_argument("name")
|
|
657
|
+
|
|
658
|
+
p = add("check", cmd_check, "exit 1 if any named type is unsafe or unknown")
|
|
659
|
+
p.add_argument("names", nargs="+")
|
|
660
|
+
|
|
661
|
+
p = add("placement", cmd_placement, "what may go inside what")
|
|
662
|
+
p.add_argument("name")
|
|
663
|
+
|
|
664
|
+
p = add("props", cmd_props, "node properties")
|
|
665
|
+
p.add_argument("--grep")
|
|
666
|
+
p.add_argument("--status")
|
|
667
|
+
p.add_argument("--shared", action="store_true",
|
|
668
|
+
help="only the ones every element has")
|
|
669
|
+
|
|
670
|
+
p = add("prop", cmd_prop, "one node property")
|
|
671
|
+
p.add_argument("name")
|
|
672
|
+
|
|
673
|
+
p = add("style", cmd_style, "style properties and how they swept")
|
|
674
|
+
p.add_argument("--grep")
|
|
675
|
+
p.add_argument("--status")
|
|
676
|
+
p.add_argument("--grouped", action="store_true",
|
|
677
|
+
help="only grouped ones - all inert set alone")
|
|
678
|
+
|
|
679
|
+
p = add("css", cmd_css, "which style property drives this CSS property")
|
|
680
|
+
p.add_argument("property")
|
|
681
|
+
|
|
682
|
+
p = add("states", cmd_states, "style states and their selectors")
|
|
683
|
+
p.add_argument("--grep")
|
|
684
|
+
p.add_argument("--verified", action="store_true",
|
|
685
|
+
help="only states measured to compile to their promised selector")
|
|
686
|
+
|
|
687
|
+
p = add("classes", cmd_classes, "element classes (theme-global)")
|
|
688
|
+
p.add_argument("--grep")
|
|
689
|
+
|
|
690
|
+
p = add("vars", cmd_vars, "@VAR() dynamic variables")
|
|
691
|
+
p.add_argument("--grep")
|
|
692
|
+
p.add_argument("--namespace")
|
|
693
|
+
|
|
694
|
+
add("fns", cmd_fns, "evaluator functions")
|
|
695
|
+
add("interactions", cmd_interactions, "interaction trigger types")
|
|
696
|
+
|
|
697
|
+
p = add("conditions", cmd_conditions, "condition subjects")
|
|
698
|
+
p.add_argument("--grep")
|
|
699
|
+
p.add_argument("--context")
|
|
700
|
+
|
|
701
|
+
p = add("routes", cmd_routes, "REST routes")
|
|
702
|
+
p.add_argument("--grep")
|
|
703
|
+
|
|
704
|
+
p = add("tables", cmd_tables, "database tables and columns")
|
|
705
|
+
p.add_argument("name", nargs="?")
|
|
706
|
+
|
|
707
|
+
add("skeleton", cmd_skeleton, "a minimal valid page spec")
|
|
708
|
+
|
|
709
|
+
global ARGS
|
|
710
|
+
ARGS = ap.parse_args()
|
|
711
|
+
ARGS.fn(ARGS)
|
|
712
|
+
|
|
713
|
+
|
|
714
|
+
if __name__ == "__main__":
|
|
715
|
+
main()
|