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,221 @@
1
+ #!/usr/bin/env python3
2
+ """Assemble the Mosaic verification report as a single self-contained HTML page."""
3
+ import io
4
+ import json
5
+ import os
6
+
7
+ ROOT = r"C:/Users/yun19/OneDrive/桌面/mosaic"
8
+ SHOTS = json.load(io.open(os.path.join(ROOT, "shots/web/_embed.json"), encoding="utf-8"))
9
+ BASE = "https://woocommerce-1469814-6656199.cloudwaysapps.com"
10
+
11
+ PAGES = [
12
+ dict(slug="brutalist", shot="01-brutalist", name="Brutalist Grid", nodes=21, bytes=31366,
13
+ palette=["#f5f3ee", "#101010"],
14
+ style="紙白底、墨黑字,唯一字族是 Courier New 等寬體。84px 標題 letter-spacing -4px 壓到幾乎相黏,line-height 0.88 讓兩行咬在一起。三張卡片用 3px 實心黑框直接相鄰、gap 為 0,邊框自己變成分隔線——沒有圓角、沒有陰影、沒有第三個顏色。",
15
+ motion="hover 時整格背景由紙白翻成墨黑(<code>background-color</code>,200ms ease)。只有一個動畫,因為這個風格的主張就是「除了結構什麼都不要」。",
16
+ components="<code>section</code> ×1、<code>div</code> ×4、<code>text</code> ×8(h1/p/h3)、<code>wysiwyg-text</code> ×8",
17
+ props="20 個樣式屬性;<code>borderStyle</code> 物件(12 個 border 長寫)、<code>textTransform</code>、<code>letterSpacing</code>、<code>fontFamily</code>"),
18
+ dict(slug="glass", shot="02-glass", name="Glassmorphism", nodes=21, bytes=31221,
19
+ palette=["#2b1055", "#7597de", "#f2a1c4"],
20
+ style="140° 三段線性漸層(紫→藍→粉)鋪滿整個 viewport,卡片是 14% 白 + <code>backdrop-filter: blur(16px)</code> 的霧面板,配 1px 半透明白邊界當邊緣光。深度來自模糊,不是陰影。標題用 300 字重的細體置中,跟厚重的漸層形成反差。",
21
+ motion="hover:卡片上浮 10px(<code>translateY</code>),同時背景不透明度 0.14→0.24、模糊 16px→22px、邊界亮度提高,320ms ease 一起過渡。折射感是靠模糊半徑變化做出來的,不是靠位移。",
22
+ components="<code>section</code> ×1、<code>div</code> ×4、<code>text</code> ×8、<code>wysiwyg-text</code> ×8",
23
+ props="22 個;漸層與 backdrop-filter 走 <code>customStyles</code>,圓角走 <code>borderRadius</code> 結構化值"),
24
+ dict(slug="editorial", shot="03-editorial", name="Editorial Serif", nodes=24, bytes=30758,
25
+ palette=["#fcfbf7", "#1a1816", "#b03a2e"],
26
+ style="雜誌內頁。Georgia 襯線體撐全場,72px 標題 400 字重(不是粗體)置中,副標用斜體。單一磚紅色只出現在 eyebrow 標籤和三個欄位小標,總共四次。三欄 46px 間距、17px 內文配 1.75 行高——是為了「讀」而不是「掃」而排的。",
27
+ motion="沒有動畫。這是刻意的:編輯風格的權威感來自靜止與留白,加 hover 效果反而會削弱它。八頁裡有兩頁沒有任何動態,這也是設計決策的一部分。",
28
+ components="<code>section</code> ×1、<code>div</code> ×5(含一條 1px 分隔線)、<code>text</code> ×9、<code>wysiwyg-text</code> ×9",
29
+ props="23 個;<code>fontStyle: italic</code>、<code>letterSpacing</code> 3px 的全大寫標籤、<code>maxWidth</code> + auto margin 控制行長"),
30
+ dict(slug="neobrutal", shot="04-neobrutal", name="Neo Brutalist", nodes=21, bytes=31865,
31
+ palette=["#ffde59", "#ff6b6b", "#6ce0a8", "#96b0ff"],
32
+ style="飽和到近乎刺眼的黃底,三張卡片分別是珊瑚紅、薄荷綠、藍紫。每張都有 3px 純黑外框、14px 圓角,以及 8px 位移、0 模糊的硬陰影——陰影是實心色塊而不是柔光,這是這個風格的簽名。78px 800 字重標題把版面壓住。",
33
+ motion="hover:卡片上移 6px,同時硬陰影從 8px 推到 14px,180ms ease。位移和陰影同時變大,讓卡片看起來真的離開了頁面。這是 <code>boxShadow</code> 結構化陣列 + <code>transform</code> 陣列一起動的例子。",
34
+ components="<code>section</code> ×1、<code>div</code> ×4、<code>text</code> ×8、<code>wysiwyg-text</code> ×8",
35
+ props="22 個;<code>boxShadow</code>(type: outside)、<code>borderStyle</code>、<code>borderRadius</code>、<code>transform</code>、<code>transition</code> 五種結構化值全在這頁"),
36
+ dict(slug="darkglow", shot="05-darkglow", name="Dark Glow", nodes=23, bytes=31114,
37
+ palette=["#090b18", "#1b2a5e", "#6ee7ff"],
38
+ style="1100×600 的徑向漸層從頂端中央往外散,深海軍藍收到近黑——一個漸層取代了整張 hero 圖。訊號色只有一個高彩度青色,用在 eyebrow 和三個卡片標題。卡片本身是 6%→2% 的白色線性漸層加 22% 藍紫邊界,在暗底上幾乎只看得到輪廓。",
39
+ motion="hover:卡片上移 8px,同時長出 40px 模糊、-6px 內縮、55% 不透明度的藍色光暈,300ms ease。因為模糊半徑夠大而位移夠小,讀起來是「發光」不是「投影」。",
40
+ components="<code>section</code> ×1、<code>div</code> ×4、<code>text</code> ×9、<code>wysiwyg-text</code> ×9",
41
+ props="25 個;徑向漸層與卡片漸層走 <code>customStyles</code>,光暈走 <code>boxShadow</code> 結構化陣列"),
42
+ dict(slug="swiss", shot="06-swiss", name="Swiss Grid", nodes=33, bytes=30403,
43
+ palette=["#ffffff", "#0c0c0c", "#de1a1a"],
44
+ style="純白、Helvetica、四欄 32px 溝槽。開頭一條 120×8px 的紅色實心橫槓,接著 90px 500 字重(中等,不是粗體)標題、letter-spacing -4px。四個欄位各自頂著一條 2px 黑色上緣線。紅色出現三次,全部是文字或那條槓,從不當背景。右側整欄留空是刻意的。",
45
+ motion="沒有動畫。瑞士風格的秩序感建立在絕對靜止上——任何 hover 位移都會破壞網格的權威。",
46
+ components="<code>section</code> ×1、<code>div</code> ×6(含紅槓與四個欄位)、<code>text</code> ×13、<code>wysiwyg-text</code> ×13",
47
+ props="20 個;上緣線用 <code>customStyles: border-top</code>(因為只要單邊,用 borderStyle 物件要寫十二個鍵)"),
48
+ dict(slug="motion", shot="07-motion", name="Motion Matrix", nodes=46, bytes=33615,
49
+ palette=["#0e0e10", "#1c1c22", "#78b4ff"], shot2="07b-motion-hover-glow",
50
+ style="近黑底上的 4×2 深灰磚陣列,這頁的目的就是把八種動畫並排放在同一個視覺條件下比較,所以靜態設計刻意壓到最低——同樣的圓角、同樣的內距、同樣的字級,只有動畫不同。",
51
+ motion="八格各自動一種屬性:<b>Lift</b> translateY −14px、<b>Tilt</b> rotateZ 4deg、<b>Grow</b> scaleX 1.06、<b>Glow</b> 46px 模糊藍色光暈、<b>Skew</b> skewX 6deg、<b>Fade</b> opacity 1→0.45、<b>Round</b> 圓角 14px→40px、<b>Blur</b> filter blur 3px。全部走 <code>transition</code> 陣列,260–320ms ease。右側附圖是用真實指標 hover 在 Glow 格上截下來的。",
52
+ components="<code>section</code> ×1、<code>div</code> ×9、<code>text</code> ×18、<code>wysiwyg-text</code> ×18",
53
+ props="26 個;<code>transform</code> 四種型別(translateY / rotateZ / scaleX / skewX)、<code>boxShadow</code>、<code>opacity</code>、<code>borderRadius</code>、<code>filter</code>(走 customStyles)"),
54
+ dict(slug="tokens", shot="09-tokens", name="Design Tokens", nodes=29, bytes=33740,
55
+ palette=["#12141c", "#0d6c66", "#666e70", "#ffffff", "#f7f6f3"],
56
+ style="這頁是唯一一個「照真實網站該有的方式」建的。整頁沒有任何寫死的顏色——五個 collection variable 編譯成 <code>:root</code> 上的 <code>--ink</code>/<code>--paper</code>/<code>--brand</code>/<code>--muted</code>/<code>--surface</code>,樹裡每一處顏色都是 <code>var()</code> 引用(全頁 16 次)。h1/h2/h3/段落的排版也不在節點上,而是提交給內建的 element class meta,所以是全站生效的規則。",
57
+ motion="卡片 hover 上移 6px 並長出 28px 模糊陰影,220ms ease。動態部分不是動畫而是內容:標題與說明文字在渲染當下才讀取。",
58
+ components="<code>section</code> ×1、<code>div</code> ×9(3 卡片 + 5 色票 + 網格)、<code>text</code> ×9、<code>wysiwyg-text</code> ×7、<code>wysiwyg-variable</code> ×2",
59
+ props="theme 層:5 個 collectionVariable + 4 個 elementClass(Heading 1/2/3、Paragraph);節點層只剩排版與間距"),
60
+ dict(slug="components", shot="08-components", name="Composite Components", nodes=58, bytes=35378,
61
+ palette=["#f8f8fa", "#14141a"],
62
+ style="安靜的淺灰底白卡,因為這頁的主體不是視覺而是結構——它測的是那些「巢狀寫錯就會把整頁弄死」的複合元件能不能正確組出來。三個區塊:手風琴、分頁、清單。",
63
+ motion="手風琴 <code>animation: slide</code>、<code>openMode: single</code>、<code>firstOpened: 1</code>——展開收合由 Mosaic 自己的 Accordion.js 驅動,不是 CSS。分頁標籤 hover 有 180ms 背景過渡,active 分頁由 <code>___tab--active</code> 這個節點型別專屬 state 上色。",
64
+ components="<code>accordion</code> ×1 →<code>accordion-item</code> ×3 →(<code>accordion-title</code> + <code>accordion-content</code>)、<code>tabs</code> ×1 →<code>tabs-menu</code>→<code>tabs-tab</code> ×2 / <code>tabs-content</code>→<code>tabs-tab-pane</code> ×2、<code>list</code>→<code>list-item</code> ×3",
65
+ props="18 個;另用到 <code>openMode</code>、<code>animation</code>、<code>firstOpened</code>、<code>defaultTabIndex</code>、<code>listType</code> 五個 enum 節點屬性"),
66
+ ]
67
+
68
+ CARDS = "\n".join("""
69
+ <article class="spec" id="{slug}">
70
+ <div class="shot"><img src="{img}" alt="{name} 測試頁截圖" loading="lazy">{img2}</div>
71
+ <div class="body">
72
+ <header>
73
+ <div class="swatches">{sw}</div>
74
+ <h3>{name}</h3>
75
+ <a class="live" href="{base}/probe-{slug}/" target="_blank" rel="noopener">probe-{slug} ↗</a>
76
+ </header>
77
+ <dl>
78
+ <dt>設計風格</dt><dd>{style}</dd>
79
+ <dt>動畫</dt><dd>{motion}</dd>
80
+ <dt>元件結構</dt><dd class="mono-ish">{components}</dd>
81
+ <dt>樣式屬性</dt><dd class="mono-ish">{props}</dd>
82
+ </dl>
83
+ <p class="verified"><span>{nodes}</span> 個節點提交 · 交付 <span>{bytes:,}</span> bytes · 瀏覽器實拍</p>
84
+ </div>
85
+ </article>""".format(
86
+ img=SHOTS[p["shot"]], base=BASE,
87
+ img2=('<img src="%s" alt="Glow 格 hover 狀態,藍色光暈已展開" loading="lazy">'
88
+ '<figcaption>hover:Glow 格的 46px 模糊光暈</figcaption>' % SHOTS[p["shot2"]])
89
+ if p.get("shot2") else "",
90
+ sw="".join('<i style="background:%s"></i>' % c for c in p["palette"]),
91
+ **p) for p in PAGES)
92
+
93
+ HTML = """<title>Mosaic 無頭建站驗證</title>
94
+ <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Archivo:wght@500;600;700&family=Newsreader:ital,opsz,wght@0,6..72,400;0,6..72,500;1,6..72,400&family=JetBrains+Mono:wght@400;500&display=swap">
95
+ <style>
96
+ :root {
97
+ --paper:#f5f6f4; --card:#ffffff; --ink:#14171a; --muted:#5c6668; --faint:#8a9294;
98
+ --rule:#dfe3df; --accent:#0d6c66; --accent-soft:#e3efed; --warn:#a8442a;
99
+ --display:'Archivo','Helvetica Neue',Arial,sans-serif;
100
+ --body:'Newsreader',Georgia,'Times New Roman',serif;
101
+ --mono:'JetBrains Mono',ui-monospace,'SF Mono',Menlo,monospace;
102
+ --shadow:0 1px 2px rgba(20,23,26,.05), 0 8px 24px -12px rgba(20,23,26,.14);
103
+ }
104
+ @media (prefers-color-scheme: dark) {
105
+ :root:not([data-theme="light"]) {
106
+ --paper:#0f1213; --card:#171b1c; --ink:#e6ebe9; --muted:#9aa5a4; --faint:#6d7877;
107
+ --rule:#262c2d; --accent:#4cc4b8; --accent-soft:#12302e; --warn:#e08a6c;
108
+ --shadow:0 1px 2px rgba(0,0,0,.4), 0 10px 28px -14px rgba(0,0,0,.7);
109
+ }
110
+ }
111
+ :root[data-theme="dark"] {
112
+ --paper:#0f1213; --card:#171b1c; --ink:#e6ebe9; --muted:#9aa5a4; --faint:#6d7877;
113
+ --rule:#262c2d; --accent:#4cc4b8; --accent-soft:#12302e; --warn:#e08a6c;
114
+ --shadow:0 1px 2px rgba(0,0,0,.4), 0 10px 28px -14px rgba(0,0,0,.7);
115
+ }
116
+ * { box-sizing:border-box; }
117
+ body { margin:0; background:var(--paper); color:var(--ink); font-family:var(--body);
118
+ font-size:17px; line-height:1.7; -webkit-font-smoothing:antialiased; }
119
+ .wrap { max-width:1080px; margin:0 auto; padding:56px 28px 96px; }
120
+ h1,h2,h3 { font-family:var(--display); font-weight:600; letter-spacing:-.02em;
121
+ text-wrap:balance; margin:0; }
122
+ h1 { font-size:clamp(34px,5vw,52px); line-height:1.06; letter-spacing:-.035em; }
123
+ .eyebrow { font-family:var(--mono); font-size:11.5px; letter-spacing:.18em;
124
+ text-transform:uppercase; color:var(--accent); margin:0 0 14px; }
125
+ .lede { font-size:19px; color:var(--muted); max-width:60ch; margin:18px 0 0; }
126
+
127
+ .tally { display:grid; grid-template-columns:repeat(auto-fit,minmax(132px,1fr));
128
+ gap:1px; background:var(--rule); border:1px solid var(--rule);
129
+ border-radius:3px; margin:40px 0 8px; overflow:hidden; }
130
+ .tally div { background:var(--card); padding:16px 18px; }
131
+ .tally b { display:block; font-family:var(--display); font-size:27px; font-weight:700;
132
+ letter-spacing:-.02em; font-variant-numeric:tabular-nums; }
133
+ .tally span { font-family:var(--mono); font-size:10.5px; letter-spacing:.1em;
134
+ text-transform:uppercase; color:var(--faint); }
135
+ .tally .bad b { color:var(--warn); }
136
+ .tally-note { font-size:14.5px; color:var(--faint); margin:12px 0 0; }
137
+
138
+ h2 { font-size:15px; letter-spacing:.14em; text-transform:uppercase;
139
+ font-family:var(--mono); font-weight:500; color:var(--faint);
140
+ margin:64px 0 22px; padding-bottom:10px; border-bottom:1px solid var(--rule); }
141
+
142
+ .spec { display:grid; grid-template-columns:minmax(0,420px) minmax(0,1fr); gap:30px;
143
+ background:var(--card); border:1px solid var(--rule); border-radius:4px;
144
+ padding:22px; margin-bottom:20px; box-shadow:var(--shadow); }
145
+ .shot { display:flex; flex-direction:column; gap:8px; }
146
+ .shot img { display:block; width:100%; height:auto; border-radius:2px;
147
+ border:1px solid var(--rule); }
148
+ .shot figcaption { font-family:var(--mono); font-size:10.5px; color:var(--faint);
149
+ letter-spacing:.06em; }
150
+ .body header { display:flex; flex-wrap:wrap; align-items:baseline; gap:10px 14px;
151
+ margin-bottom:14px; }
152
+ .body h3 { font-size:23px; }
153
+ .swatches { display:flex; gap:4px; width:100%; }
154
+ .swatches i { width:26px; height:7px; border-radius:1px;
155
+ box-shadow:inset 0 0 0 1px rgba(128,128,128,.28); }
156
+ .live { font-family:var(--mono); font-size:12px; color:var(--accent);
157
+ text-decoration:none; border-bottom:1px solid transparent; }
158
+ .live:hover, .live:focus-visible { border-bottom-color:currentColor; }
159
+ dl { margin:0; display:grid; grid-template-columns:auto minmax(0,1fr);
160
+ gap:9px 18px; align-items:baseline; }
161
+ dt { font-family:var(--mono); font-size:11px; letter-spacing:.1em;
162
+ text-transform:uppercase; color:var(--faint); white-space:nowrap; }
163
+ dd { margin:0; font-size:15.5px; line-height:1.62; }
164
+ dd.mono-ish { font-size:14px; color:var(--muted); }
165
+ code { font-family:var(--mono); font-size:.86em; background:var(--accent-soft);
166
+ color:var(--accent); padding:1px 5px; border-radius:2px; }
167
+ dd.mono-ish code { background:none; padding:0; color:var(--ink); }
168
+ .verified { font-family:var(--mono); font-size:11.5px; color:var(--faint);
169
+ margin:16px 0 0; padding-top:12px; border-top:1px solid var(--rule);
170
+ font-variant-numeric:tabular-nums; }
171
+ .verified span { color:var(--ink); font-weight:500; }
172
+
173
+ .notes { margin-top:16px; }
174
+ .notes li { margin-bottom:11px; color:var(--muted); font-size:16px; }
175
+ .notes b { color:var(--ink); font-weight:500; }
176
+ .foot { margin-top:60px; padding-top:20px; border-top:1px solid var(--rule);
177
+ font-family:var(--mono); font-size:12px; color:var(--faint); line-height:1.9; }
178
+ a { color:var(--accent); }
179
+ @media (max-width:820px) { .spec { grid-template-columns:1fr; } .wrap { padding:36px 18px 64px; } }
180
+ @media (prefers-reduced-motion:reduce) { * { transition:none !important; animation:none !important; } }
181
+ </style>
182
+
183
+ <div class="wrap">
184
+ <p class="eyebrow">Mosaic Pro 1.0.7 · 無授權 · WP 7.1 / Woo 11.1</p>
185
+ <h1>九個測試頁,九種設計語言</h1>
186
+ <p class="lede">每一頁都只靠 <code>mosaic-headless</code> skill 的資料表建成——查表決定用哪個節點型別、什麼可以放進什麼、樣式值該寫成什麼形狀,然後走 REST 提交,最後在真實瀏覽器裡拍下來。建得出來的地方證明表是對的;建不出來的地方就是表的破洞,都列在最後。最後一頁是唯一按照「真實網站該有的方式」建的:設計 token、全域排版、渲染當下讀取的動態內容。</p>
187
+
188
+ <div class="tally">
189
+ <div><b>122</b><span>節點型別掃描</span></div>
190
+ <div><b>70</b><span>渲染成功</span></div>
191
+ <div class="bad"><b>22</b><span>放錯位置會炸</span></div>
192
+ <div><b>98</b><span>樣式屬性</span></div>
193
+ <div><b>53</b><span>樣式 state</span></div>
194
+ <div><b>74</b><span>動態變數</span></div>
195
+ <div><b>9</b><span>設計頁全數通過</span></div>
196
+ </div>
197
+ <p class="tally-note">節點掃描是逐型別單獨放上真站 → 提交 → 渲染 → 比對 attrID → 刪除,122 型跑滿。九頁共 276 個節點。</p>
198
+
199
+ <h2>測試頁</h2>
200
+ {cards}
201
+
202
+ <h2>建構過程揭露的問題</h2>
203
+ <ul class="notes">
204
+ <li><b>網格靜默失效。</b><code>gridTemplateColumns</code> 接受 <code>"repeat(3, 1fr)"</code>、存進資料庫、然後編譯出<em>空</em>——三欄卡片全部塌成單欄。八頁都建完、第一張截圖出來才發現。這類「值的形狀錯了」不會有任何錯誤訊息。</li>
205
+ <li><b>五種結構化值已破解。</b><code>borderRadius</code>、<code>transform</code>、<code>boxShadow</code>、<code>transition</code>、<code>borderStyle</code> 都不吃字串。<code>boxShadow</code> 的 <code>type</code> 必須是 <code>outside</code>/<code>inside</code>,寫 CSS 慣用的 <code>outset</code> 會靜默編譯成 <code>box-shadow:none</code>。</li>
206
+ <li><b><code>canBeParentFor</code> 不是複合元件的權威。</b><code>accordion-item</code> 宣告只收 accordion-item,但它的 <code>getAccordionItemDefaultData()</code> 說要 title + content——而 title 正是 Accordion.js 綁點擊事件的元素。少了它,伺服器渲染乾淨無誤,瀏覽器 console 直接拋 <code>Cannot read properties of null</code>。</li>
207
+ <li><b>Pro 型別不需要授權。</b><code>isLicenseActive: false</code> 但 48 個 Pro 型別照常註冊、照常渲染。授權擋的是主題庫和更新,不是 node factory。</li>
208
+ <li><b>動態內容的語法猜不到。</b>裸識別字完全沒實作——<code>Evaluator</code> 對 Identifier 節點回傳的是字串 <code>"Identifiers are not used currently"</code>。所以 <code>@post.title</code> 存得下、渲染成文字 <code>@post.title</code>,看起來像你打錯字而不是功能壞了。正確寫法是函式呼叫 <code>@VAR('post/title')</code>,而且解析不到的名稱回空字串、不報錯。</li>
209
+ <li><b>無效的巢狀結構會被靜默剪掉。</b>提交完整的 interaction 回 200、無 exception、正常 create 信封,存進去的卻只剩 <code>{type, name, uuid}</code>。在這個資料模型上,把 row 讀回來跟送出的做 diff,那個 diff 就是 Mosaic 從來不給你的錯誤訊息。</li>
210
+ </ul>
211
+
212
+ <p class="foot">
213
+ 測試站 woocommerce-1469814-6656199.cloudwaysapps.com · 主題為腳本生成的免授權 fixture,可整體重置重建<br>
214
+ 建構工具 tools/build_page.py + build_all.py · 設計規格 designs/*.json · 截圖 Playwright 1440×900 full-page
215
+ </p>
216
+ </div>
217
+ """.replace("{cards}", CARDS)
218
+
219
+ path = os.path.join(ROOT, "report.html")
220
+ io.open(path, "w", encoding="utf-8").write(HTML)
221
+ print("wrote %s (%d KB)" % (path, os.path.getsize(path)//1024))
@@ -0,0 +1,174 @@
1
+ #!/usr/bin/env python3
2
+ """Build a whole Mosaic site - shared shell, theme layer, and one document per page.
3
+
4
+ python build_site.py --config sweep.json --site sites/zidanna.json
5
+
6
+ `build_page.py` puts a tree in a master's body, which is fine for a one-off page and
7
+ wrong for a site: every page would carry its own copy of the header and footer. Mosaic
8
+ already has the right structure and this tool uses it:
9
+
10
+ master the shell - header, a `template-external` slot, footer. One master,
11
+ shared by every template.
12
+ template its own document, rooted at a `template-internal` node whose
13
+ parentType/documentType are "template" rather than "master". This is
14
+ what renders into the slot.
15
+
16
+ So the header exists once. Editing it changes every page, which is the whole point of
17
+ a theme.
18
+
19
+ ## Site spec
20
+
21
+ {
22
+ "theme": {"variables": {...}, "elementClasses": {...}}, as build_page.py
23
+ "master": "Zidanna shell",
24
+ "shell": {"header": <node>, "footer": <node>},
25
+ "pages": [{"slug": "...", "title": "...", "tree": <node>}, ...]
26
+ }
27
+
28
+ Pages are bound to WordPress posts by slug, so the posts must already exist. The theme
29
+ is committed once, before anything references a token.
30
+ """
31
+ import argparse
32
+ import json
33
+ import os
34
+ import sys
35
+ import urllib.parse
36
+ import uuid
37
+
38
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
39
+ from build_page import VAR_IDS, Surface, flatten, theme_records # noqa: E402
40
+ from sweep_node_types import ( # noqa: E402
41
+ MIN_HEALTHY_BYTES,
42
+ Client,
43
+ envelopes,
44
+ exceptions_of,
45
+ unwrap,
46
+ )
47
+
48
+
49
+ def commit(client, instance, doc, payload, what):
50
+ resp = client.commit(instance, envelopes(doc), payload)
51
+ err = exceptions_of(resp)
52
+ if err:
53
+ sys.exit("%s failed: %s" % (what, err[:400]))
54
+ return resp
55
+
56
+
57
+ def build_shell(client, cfg, site, surface):
58
+ """Create the master, apply the theme, and wrap the slot in header and footer."""
59
+ inst = unwrap(client.get("adminMasterEditorInstance"), "adminMasterEditorInstance")
60
+ master_id = str(uuid.uuid4())
61
+ commit(client, "adminMasterEditorInstance", inst, {"master": [{
62
+ "newRevisionRecord": {"ID": master_id, "parentType": "", "parentID": "", "ordering": "a0",
63
+ "status": "publish", "revision": "", "version": "",
64
+ "name": site.get("master", "Site shell")},
65
+ "originalRevisionRecord": None}]}, "master")
66
+
67
+ instance = "masterDocumentInstance/%s" % master_id
68
+ key = "node/master/%s" % master_id
69
+
70
+ # A fresh theme has no collection, mode or skin until heal() runs, and heal() runs
71
+ # inside a commit - so a token cannot be declared before something has been
72
+ # committed. Push one throwaway node to force the heal, then drop it.
73
+ doc = unwrap(client.get(instance), "masterDocumentInstance")
74
+ if not doc.get("collection"):
75
+ seed = {"ID": str(uuid.uuid4()), "parentType": "node",
76
+ "parentID": next(n for n in doc[key] if n["type"] == "body")["ID"],
77
+ "ordering": "z0", "status": "publish", "revision": "", "version": "",
78
+ "type": "div", "data": {}, "documentType": "master", "documentID": master_id}
79
+ commit(client, instance, doc, {key: [{"newRevisionRecord": seed,
80
+ "originalRevisionRecord": None}]}, "heal seed")
81
+ doc = unwrap(client.get(instance), "masterDocumentInstance")
82
+ planted = next((n for n in doc[key] if n["ID"] == seed["ID"]), None)
83
+ if planted:
84
+ commit(client, instance, doc, {key: [{"newRevisionRecord": dict(planted, status="delete"),
85
+ "originalRevisionRecord": planted}]}, "heal cleanup")
86
+ doc = unwrap(client.get(instance), "masterDocumentInstance")
87
+
88
+ nodes = doc[key]
89
+
90
+ body = next(n for n in nodes if n["type"] == "body")
91
+ slots = sorted([n for n in nodes if n["parentID"] == body["ID"]], key=lambda n: n["ordering"])
92
+ # heal() seeds the body with three divs and drops a template-external in the middle
93
+ # one; that div is the slot the template renders into, so keep it and use the divs
94
+ # either side for the header and the footer
95
+ slot_ids = {n["ID"] for n in nodes if n["type"] == "template-external"}
96
+ middle = next((d for d in slots if any(n["parentID"] == d["ID"] for n in nodes if n["ID"] in slot_ids)),
97
+ slots[1] if len(slots) > 2 else None)
98
+ if middle is None:
99
+ sys.exit("healed master has no template-external slot to build around")
100
+ header_div, footer_div = slots[0], slots[-1]
101
+
102
+ VAR_IDS.clear()
103
+ payload = theme_records(site, doc, surface)
104
+
105
+ records = []
106
+ shell = site.get("shell") or {}
107
+ if shell.get("header"):
108
+ records += flatten(shell["header"], header_div["ID"], master_id, surface, False, parent_type="div")
109
+ if shell.get("footer"):
110
+ records += flatten(shell["footer"], footer_div["ID"], master_id, surface, False, parent_type="div")
111
+ if records:
112
+ payload[key] = [{"newRevisionRecord": r, "originalRevisionRecord": None} for r in records]
113
+ commit(client, instance, doc, payload, "shell")
114
+ print("shell master=%s header+footer nodes=%d theme=%s" % (
115
+ master_id[:8], len(records), ", ".join(sorted(payload)) or "-"))
116
+ return master_id
117
+
118
+
119
+ def bind_page(client, cfg, master_id, slug, post_id):
120
+ """Create the manual template for a post, then find the row it made."""
121
+ resp = client._call("%s/templateAssign/createManualTemplate" % client.api,
122
+ {"resourceQuery": "post/%d" % post_id, "masterID": master_id})
123
+ if "_httperror" in resp:
124
+ sys.exit("template assign failed for %s: %s" % (slug, resp["_body"][:200]))
125
+ # the endpoint does not return the id, so read it back: newest template on this master
126
+ inst = unwrap(client.get("adminTemplateEditorInstance"), "adminTemplateEditorInstance")
127
+ mine = [t for t in inst.get("template", []) if t.get("masterID") == master_id]
128
+ if not mine:
129
+ sys.exit("no template appeared for %s" % slug)
130
+ return sorted(mine, key=lambda t: t.get("modified_gmt", ""))[-1]["ID"]
131
+
132
+
133
+ def build_page_document(client, cfg, master_id, template_id, tree, surface):
134
+ """Commit a page's tree into its own template document, under template-internal."""
135
+ instance = "templateDocumentInstance/%s/%s" % (master_id, template_id)
136
+ doc = unwrap(client.get(instance), "templateDocumentInstance")
137
+ key = "node/template/%s" % template_id
138
+ root = next((n for n in doc.get(key, []) if n["type"] == "template-internal"), None)
139
+ if root is None:
140
+ sys.exit("template %s has no template-internal root" % template_id[:8])
141
+
142
+ records = flatten(tree, root["ID"], template_id, surface, False, parent_type="template-internal")
143
+ for r in records:
144
+ r["documentType"] = "template" # these rows belong to the template,
145
+ r["documentID"] = template_id # not to the master that frames them
146
+ commit(client, instance, doc, {key: [{"newRevisionRecord": r, "originalRevisionRecord": None}
147
+ for r in records]}, "page document")
148
+ return len(records)
149
+
150
+
151
+ def main():
152
+ ap = argparse.ArgumentParser()
153
+ ap.add_argument("--config", required=True)
154
+ ap.add_argument("--site", required=True)
155
+ a = ap.parse_args()
156
+ cfg = json.load(open(a.config, encoding="utf-8"))
157
+ site = json.load(open(a.site, encoding="utf-8"))
158
+ client = Client(cfg)
159
+ surface = Surface()
160
+
161
+ master_id = build_shell(client, cfg, site, surface)
162
+
163
+ for page in site["pages"]:
164
+ template_id = bind_page(client, cfg, master_id, page["slug"], page["post_id"])
165
+ n = build_page_document(client, cfg, master_id, template_id, page["tree"], surface)
166
+ url = "%s/%s/" % (cfg["base"].rstrip("/"), page["slug"])
167
+ body = client.page(page["slug"])
168
+ ok = len(body) >= MIN_HEALTHY_BYTES
169
+ print(" %-16s %-7s %6d bytes %3d nodes %s" % (
170
+ page["slug"], "OK" if ok else "BROKEN", len(body), n, url))
171
+
172
+
173
+ if __name__ == "__main__":
174
+ main()
@@ -0,0 +1,130 @@
1
+ #!/usr/bin/env python3
2
+ """Turn a Mosaic site's live REST responses into the skill's measured CSVs.
3
+
4
+ Usage:
5
+ python capture_live.py <data-dir>
6
+
7
+ Reads <data-dir>/raw/*.json (captured from a real install, see references/measuring.md)
8
+ and writes rest-routes.csv, element-classes.csv and condition-subjects.csv beside them.
9
+
10
+ Everything here is measured rather than inferred: the route table comes from the
11
+ site's own `/wp-json/mosaic/v<version>` index, so it reflects what that install
12
+ actually exposes, including routes a different edition or version would not have.
13
+ """
14
+ import csv
15
+ import json
16
+ import os
17
+ import re
18
+ import sys
19
+
20
+ # the namespace prefix is a nested group, so it cannot be matched with a [^)]* body
21
+ VERSION_RE = re.compile(r"^/mosaic/v\(\?P<mosaicVersion>\(\\d\+\\\.\)\?\(\\d\+\\\.\)\*\(\\d\+\)\)")
22
+
23
+
24
+ def load(data_dir, name):
25
+ path = os.path.join(data_dir, "raw", name)
26
+ if not os.path.exists(path):
27
+ return None
28
+ with open(path, encoding="utf-8") as fh:
29
+ return json.load(fh)
30
+
31
+
32
+ def write_csv(path, fields, rows):
33
+ with open(path, "w", newline="", encoding="utf-8") as fh:
34
+ w = csv.DictWriter(fh, fieldnames=fields)
35
+ w.writeheader()
36
+ w.writerows(rows)
37
+ print("%-26s %d rows" % (os.path.basename(path), len(rows)))
38
+
39
+
40
+ def tidy_route(route):
41
+ """Strip the version-matching prefix so routes read the way you call them."""
42
+ route = VERSION_RE.sub("/mosaic/v<version>", route)
43
+ # collapse the repeated UUID matcher into something a human can scan
44
+ return re.sub(r"\(\?P<(\w+)>\[0-9a-fA-F\]\{8\}[^)]*\)", r"<\1>", route)
45
+
46
+
47
+ def rest_routes(data_dir, out_dir):
48
+ d = load(data_dir, "rest-index.json")
49
+ if not d:
50
+ return
51
+ rows = []
52
+ for route, spec in sorted(d.get("routes", {}).items()):
53
+ methods = set()
54
+ args = set()
55
+ for ep in spec.get("endpoints", []):
56
+ methods |= set(ep.get("methods", []))
57
+ args |= set(ep.get("args", {}) or {})
58
+ rows.append(
59
+ {
60
+ "route": tidy_route(route),
61
+ "methods": ",".join(sorted(methods)),
62
+ "args": "|".join(sorted(args)),
63
+ "raw_route": route,
64
+ }
65
+ )
66
+ write_csv(os.path.join(out_dir, "rest-routes.csv"), ["route", "methods", "args", "raw_route"], rows)
67
+
68
+
69
+ def element_classes(data_dir, out_dir):
70
+ d = load(data_dir, "elementClassMeta.json")
71
+ if not d:
72
+ return
73
+ rows = []
74
+ for c in d.get("response", []):
75
+ rows.append(
76
+ {
77
+ "id": c.get("ID", ""),
78
+ "name": c.get("metaName", ""),
79
+ "type": c.get("metaType", ""),
80
+ "selectors": "|".join(c.get("metaSelectors") or []),
81
+ "parent": c.get("metaParent", ""),
82
+ "is_group": c.get("metaIsGroup", ""),
83
+ "is_editable": c.get("metaIsEditable", ""),
84
+ "is_required": c.get("isRequired", ""),
85
+ "ordering": c.get("ordering", ""),
86
+ }
87
+ )
88
+ write_csv(
89
+ os.path.join(out_dir, "element-classes.csv"),
90
+ ["id", "name", "type", "selectors", "parent", "is_group", "is_editable", "is_required", "ordering"],
91
+ rows,
92
+ )
93
+
94
+
95
+ def condition_subjects(data_dir, out_dir):
96
+ rows = []
97
+ for context in ("element", "template", "interaction", "formAction"):
98
+ d = load(data_dir, "evaluatorEngineMetas-%s.json" % context)
99
+ if not d:
100
+ continue
101
+ resp = d.get("response", {})
102
+ groups = {g.get("name"): g.get("label", "") for g in resp.get("groups", [])}
103
+ for rule_type, spec in (resp.get("ruleTypes") or {}).items():
104
+ for subj in spec.get("subjects", []):
105
+ fields = subj.get("settingsFields") or []
106
+ rows.append(
107
+ {
108
+ "context": context,
109
+ "rule_type": rule_type,
110
+ "id": subj.get("ID", ""),
111
+ "label": subj.get("label", ""),
112
+ "group": subj.get("group", ""),
113
+ "group_label": groups.get(subj.get("group"), ""),
114
+ "settings_fields": "|".join(f.get("name", "") for f in fields),
115
+ "field_types": "|".join(f.get("type", "") for f in fields),
116
+ "default_comparator": (subj.get("defaultComparison") or {}).get("comparatorID", ""),
117
+ "default_operator": (subj.get("defaultComparison") or {}).get("operatorID", ""),
118
+ }
119
+ )
120
+ if rows:
121
+ write_csv(os.path.join(out_dir, "condition-subjects.csv"), list(rows[0].keys()), rows)
122
+
123
+
124
+ if __name__ == "__main__":
125
+ if len(sys.argv) != 2:
126
+ sys.exit(__doc__)
127
+ data_dir = sys.argv[1]
128
+ rest_routes(data_dir, data_dir)
129
+ element_classes(data_dir, data_dir)
130
+ condition_subjects(data_dir, data_dir)
@@ -0,0 +1,72 @@
1
+ #!/usr/bin/env python3
2
+ """Score the source-extracted placement rules against what the live sweep measured.
3
+
4
+ python check_placement_predicts.py
5
+
6
+ The sweep hangs every node type off a `div`, and `div` accepts any child, so the
7
+ parent side never objects. Whatever goes wrong is the child refusing that context.
8
+ The question this script answers is whether the static rules can tell you *in advance*
9
+ which types those are.
10
+
11
+ The answer is no, and that is the point of keeping this script in the repo: it stops
12
+ anyone (including a future pass of this skill) from quietly promoting the extracted
13
+ rules into a safety guarantee they do not earn. Run it after any re-sweep; if a
14
+ predictor ever gets good, the numbers will say so.
15
+ """
16
+ import csv
17
+ import os
18
+
19
+ HERE = os.path.dirname(os.path.abspath(__file__))
20
+ DATA = os.path.join(HERE, "..", "data")
21
+
22
+
23
+ def load(name):
24
+ with open(os.path.join(DATA, name), encoding="utf-8") as fh:
25
+ return list(csv.DictReader(fh))
26
+
27
+
28
+ def is_unsafe(outcome):
29
+ return outcome == "BROKE_PAGE" or outcome.startswith("COMMIT_5")
30
+
31
+
32
+ PREDICTORS = [
33
+ ("nested_rule == yes", lambda ru: ru["nested_rule"] == "yes"),
34
+ ("rule == allow", lambda ru: ru["rule"] == "allow"),
35
+ ("rule in (allow, complex)", lambda ru: ru["rule"] in ("allow", "complex")),
36
+ ("rule == allow AND nested_rule", lambda ru: ru["rule"] == "allow" and ru["nested_rule"] == "yes"),
37
+ ("rule == allow OR nested_rule", lambda ru: ru["rule"] == "allow" or ru["nested_rule"] == "yes"),
38
+ ]
39
+
40
+
41
+ def main():
42
+ rules = {r["type"]: r for r in load("placement-rules.csv")}
43
+ swept = [r for r in load("node-verification.csv") if r["type"] in rules]
44
+ unsafe = [r for r in swept if is_unsafe(r["outcome"])]
45
+
46
+ print("%d types swept, %d unsafe under a bare div\n" % (len(swept), len(unsafe)))
47
+ print(" %-34s %-22s %s" % ("predictor", "tp / fp / fn", "precision recall"))
48
+ for name, pred in PREDICTORS:
49
+ tp = fp = fn = 0
50
+ for r in swept:
51
+ p, a = pred(rules[r["type"]]), is_unsafe(r["outcome"])
52
+ tp, fp, fn = tp + (p and a), fp + (p and not a), fn + (a and not p)
53
+ prec = tp / (tp + fp) if tp + fp else 0.0
54
+ rec = tp / (tp + fn) if tp + fn else 0.0
55
+ print(" %-34s %-22s %.2f %.2f" % (name, "%d / %d / %d" % (tp, fp, fn), prec, rec))
56
+
57
+ by_rule = {}
58
+ for r in unsafe:
59
+ by_rule.setdefault(rules[r["type"]]["rule"], []).append(r["type"])
60
+ print("\nthe unsafe types spread across every rule value, which is why no")
61
+ print("single flag separates them:")
62
+ for rule, types in sorted(by_rule.items()):
63
+ print(" rule=%-8s %2d %s" % (rule, len(types), ", ".join(sorted(types))))
64
+
65
+ print("\nCONCLUSION: use data/node-verification.csv - the measured table - to answer")
66
+ print("'is this type safe under a plain container'. Use data/placement-rules.csv to")
67
+ print("answer 'which children does this parent accept', which it does reliably.")
68
+ print("They are different questions and only the second one is settled by source.")
69
+
70
+
71
+ if __name__ == "__main__":
72
+ main()