mosaic-headless 1.12.0 → 1.13.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 CHANGED
@@ -30,6 +30,7 @@ python tools/mo.py type accordion-content # 1 つの型を、全ライブス
30
30
  python tools/mo.py check div text button # 危険な型・未知の型があれば exit 1
31
31
  python tools/mo.py style --grouped # 単独設定では必ず効かない 20 個
32
32
  python tools/mo.py states --verified # 実測でコンパイルされた状態だけ
33
+ python tools/mo.py params text # 1 つの型に設定できるものすべて
33
34
  ```
34
35
 
35
36
  そのうえでページを見る。Mosaic の失敗の仕方は 4 通りあり、
@@ -60,6 +61,7 @@ URL に対応するテンプレなし HTTP 406、未ログインには空の b
60
61
  | **スタイルプロパティ** | 98 / 98 を実ページに書き、コンパイル済み CSS と照合:58 COMPILED、18 ABSENT、21 SKIPPED |
61
62
  | **ノードプロパティ** | 181 / 181 を、各プロパティ自身の validator chain から導いた値で再測定:35 APPLIED、42 NO_EFFECT、55 NO_HOST、47 SKIPPED |
62
63
  | **レスポンシブ** | 2 サイト計 688 件の `_t`/`_m` 宣言を、サイトが実際に配信したスタイルシートに対して 1 件ずつ断言 — 全件検証済み |
64
+ | **コンポーネント** | コンポーネント機構を端から端まで実行し **8 / 8**:カテゴリ配下に作成、ドキュメントを heal、書き込み可能な instance 経由でツリーを投入、読み取り専用の方は同じ書き込みを拒否(ネガティブコントロール)、最後に 2 つのインスタンスが 1 つの定義から描画 |
63
65
  | **スタイル状態** | 53 状態のうち 52 を実ページに書き、テーブルが約束するセレクタと照合:**36 件が完全一致**、12 件 NO_HOST、3 件 SKIPPED、1 件 BROKE_PAGE。どの要素にも使えるグローバル 7 状態はすべて検証済み |
64
66
  | **インタラクション** | JS アニメーション経路を、ネガティブコントロール付きで保存行を読み戻しながら検証:`propertyMetas` は**受理され保存される**。プロパティ値は依然としてバインドされないが、その境界は厳密になった |
65
67
  | **入場アニメーション** | ページ読み込み後の 10 時点をサンプリングし 7 項目を断言 — 再生されること、アニメーションする `@property` カウンタが 100 に達すること、ベールがヒットテストから外れること、ビューポート内に opacity 0 のまま取り残された要素がないこと、実際のクリックが文書に届くこと、そして `prefers-reduced-motion` ではベールがそもそも存在しないこと |
package/README.ko.md CHANGED
@@ -30,6 +30,7 @@ python tools/mo.py type accordion-content # 타입 하나를 모든 라이브
30
30
  python tools/mo.py check div text button # 위험하거나 존재하지 않는 타입이면 exit 1
31
31
  python tools/mo.py style --grouped # 단독으로 설정하면 반드시 무효인 20개
32
32
  python tools/mo.py states --verified # 실측으로 컴파일이 확인된 상태만
33
+ python tools/mo.py params text # 한 타입에 설정 가능한 모든 것
33
34
  ```
34
35
 
35
36
  그리고 페이지를 확인한다. Mosaic의 실패 방식은 네 가지이고,
@@ -60,6 +61,7 @@ URL에 맞는 템플릿 없음 HTTP 406, 로그인하지 않은 사용자
60
61
  | **스타일 속성** | 98 / 98을 실제 페이지에 쓰고 컴파일된 CSS와 대조: 58 COMPILED, 18 ABSENT, 21 SKIPPED |
61
62
  | **노드 속성** | 181 / 181을 각 속성 자신의 validator chain에서 도출한 값으로 재측정: 35 APPLIED, 42 NO_EFFECT, 55 NO_HOST, 47 SKIPPED |
62
63
  | **반응형** | 두 사이트 합쳐 688개의 `_t`/`_m` 선언을, 사이트가 실제로 내보낸 스타일시트에 대해 하나씩 단언 — 전부 검증됨 |
64
+ | **컴포넌트** | 컴포넌트 체계를 끝까지 구동해 **8 / 8**: 카테고리 아래 생성, 문서 heal, 쓰기 가능한 instance로 트리 주입, 읽기 전용 쪽은 같은 쓰기를 거부(음성 대조군), 마지막으로 인스턴스 2개가 정의 하나로 렌더링 |
63
65
  | **스타일 상태** | 53개 상태 중 52개를 실제 페이지에 쓰고 표가 약속한 셀렉터와 대조: **36개가 정확히 일치**, 12개 NO_HOST, 3개 SKIPPED, 1개 BROKE_PAGE. 어떤 요소에나 쓸 수 있는 전역 7개 상태는 전부 검증됨 |
64
66
  | **인터랙션** | JS 애니메이션 경로를 음성 대조군과 함께, 저장된 행을 되읽으며 검증: `propertyMetas`는 **수용되고 저장된다**. 속성 값은 여전히 바인딩되지 않지만 그 경계는 이제 정확하다 |
65
67
  | **인트로 애니메이션** | 페이지 로드 후 10개 시점을 샘플링해 7가지를 단언 — 재생될 것, 애니메이션되는 `@property` 카운터가 100에 도달할 것, 베일이 히트 테스트에서 빠질 것, 뷰포트 안에 opacity 0으로 갇힌 요소가 없을 것, 실제 클릭이 문서에 닿을 것, 그리고 `prefers-reduced-motion`에서는 베일이 아예 존재하지 않을 것 |
package/README.md CHANGED
@@ -30,6 +30,7 @@ python tools/mo.py type accordion-content # one type, joined to every live swe
30
30
  python tools/mo.py check div text button # exits 1 on an unsafe or unknown type
31
31
  python tools/mo.py style --grouped # the 20 that are inert set on their own
32
32
  python tools/mo.py states --verified # the states measured to compile
33
+ python tools/mo.py params text # everything settable on one type
33
34
  ```
34
35
 
35
36
  Then check the page. Mosaic has four failure modes and **only one of them changes the
@@ -61,6 +62,7 @@ factories, so Pro types register and render regardless.
61
62
  | **style properties** | 98 / 98 written to a live page and checked against the compiled CSS: 58 COMPILED, 18 ABSENT, 21 SKIPPED |
62
63
  | **node properties** | 181 / 181 re-probed with a value shaped by each property's own validator chain: 35 APPLIED, 42 NO_EFFECT, 55 NO_HOST, 47 SKIPPED |
63
64
  | **responsive** | 688 `_t`/`_m` declarations across two sites asserted against the stylesheet the site actually served — all verified |
65
+ | **components** | the component system driven end to end, **8 of 8**: created under a category, document healed, tree filled through the writable instance, the read-only one refused the same write as a negative control, and two instances on a page rendering one definition twice |
64
66
  | **style states** | 52 of the 53 states written to a live page and matched against the selector the table promises: **36 compiled exactly**, 12 NO_HOST, 3 SKIPPED, 1 BROKE_PAGE. All seven globally usable states verified |
65
67
  | **interactions** | the JS animation path probed with negative controls and the row read back: `propertyMetas` **is** accepted and stored; the property values still do not bind, and the boundary is now exact |
66
68
  | **entrance animation** | the page-load sequence sampled at ten timestamps and asserted on seven counts — it plays, its animated `@property` counter reaches 100, the veil leaves hit-testing, nothing in the viewport is stranded at opacity 0, a real click reaches the document, and under `prefers-reduced-motion` the veil never exists at all |
package/README.zh-TW.md CHANGED
@@ -26,6 +26,7 @@ python tools/mo.py type accordion-content # 一個型別,接上所有線上
26
26
  python tools/mo.py check div text button # 型別不安全或不存在就 exit 1
27
27
  python tools/mo.py style --grouped # 那 20 個單獨設定必然無效的屬性
28
28
  python tools/mo.py states --verified # 實測會編譯出來的狀態
29
+ python tools/mo.py params text # 一個型別上所有能設的東西
29
30
  ```
30
31
 
31
32
  然後去看頁面。Mosaic 有四種失效模式,**其中只有一種會改變 HTTP 狀態碼**:
@@ -53,6 +54,7 @@ commit 時 PHP fatal HTTP 500 (122 種型別裡有 15 種,光放在 div
53
54
  | **樣式屬性** | 98 / 98 寫進真實頁面並對照編譯後的 CSS:58 COMPILED、18 ABSENT、21 SKIPPED |
54
55
  | **節點屬性** | 181 / 181,用每個屬性自己的 validator chain 推導出的值重測:35 APPLIED、42 NO_EFFECT、55 NO_HOST、47 SKIPPED |
55
56
  | **響應式** | 兩個站共 688 條 `_t`/`_m` 宣告,對照網站實際送出的樣式表逐條斷言——全數通過 |
57
+ | **元件系統** | 完整驅動過一遍,**8 / 8**:在分類下建立、文件 heal、透過可寫的 instance 填入內容、唯讀的那個作為負對照組確實拒絕同一個寫入,最後兩個實例在頁面上由同一份定義渲染兩次 |
56
58
  | **樣式狀態** | 53 個狀態中的 52 個寫進真實頁面,對照表格承諾的選擇器逐一比對:**36 個完全吻合**、12 個 NO_HOST、3 個 SKIPPED、1 個 BROKE_PAGE。七個可用於任何元素的狀態全數驗證 |
57
59
  | **互動動畫** | 帶負對照組並讀回儲存列來探測 JS 動畫路徑:`propertyMetas` **確實**會被接受並儲存;屬性值仍然無法綁定,但界線現在很精確 |
58
60
  | **入口動畫** | 在載入後十個時間點取樣並做七項斷言——它有播、被動畫的 `@property` 計數器跑到 100、遮罩退出點擊判定、視窗內沒有任何內容卡在 opacity 0、真實點擊落在文件上,而在 `prefers-reduced-motion` 下遮罩根本不存在 |
package/SKILL.md CHANGED
@@ -4,7 +4,7 @@ description: |
4
4
  Build and modify Mosaic Pro (Nextend) sites by writing the underlying data model directly - no visual editor, no DOM. Query the real surface with `mo.py`, which joins every source table to the live sweeps so a lookup leads with the measured verdict rather than the declaration (122 node types, 181 properties, 98 style properties with 20 structured value shapes pinned down, 53 style states, 151 element classes, 74 dynamic variables, 12 interaction triggers, 114 REST routes, 23 tables) instead of guessing, with every node type placed on a live site one at a time and asserted against the delivered HTML, the design-token and element-class layers verified against compiled CSS, the @VAR() dynamic language verified against rendered output, nine designed pages built through the tables themselves, and the delivered page re-read in Chromium at three viewports so a rule that is present, correct and still wrong cannot pass.
5
5
  license: "MIT"
6
6
  author: "moksa (https://moksaweb.com)"
7
- version: "1.12.0"
7
+ version: "1.13.0"
8
8
  ---
9
9
 
10
10
  # Headless Mosaic
@@ -30,6 +30,9 @@ thing about that type worth knowing.
30
30
  ```bash
31
31
  python tools/mo.py stats # the surface, and what is unsafe
32
32
  python tools/mo.py type accordion-content # ONE type, joined to every sweep
33
+ python tools/mo.py params text # EVERYTHING settable on a type:
34
+ # data properties, the states that
35
+ # reach it, the style surface, placement
33
36
  python tools/mo.py types --edition pro --safe
34
37
  python tools/mo.py check div text button # exits 1 on an unsafe or unknown type
35
38
  python tools/mo.py placement accordion-item # what goes inside what, both directions
@@ -147,6 +150,12 @@ STATES 52 of the 53 style states written to a live page and matched agains
147
150
  pseudo-classes are emitted UPPERCASE (`.M_EL9:HOVER`), so grepping a
148
151
  stylesheet for `:hover` finds nothing. data/style-state-verification.csv
149
152
 
153
+ COMPONENTS the component system driven end to end, 8 of 8 checks: a component
154
+ created under a category, its document healed, its tree filled through
155
+ the WRITABLE instance, the read-only one refused the same write as a
156
+ negative control, and two instances placed on a page rendering the one
157
+ definition twice. data/component-verification.csv
158
+
150
159
  INTERACTION the JS animation path, probed with negative controls and the row read
151
160
  back: `propertyMetas` IS accepted and stored (the earlier claim that it
152
161
  never survived was wrong), `uuid` per item is optional on create and
@@ -320,6 +329,8 @@ so the pattern is in the data, not just in this paragraph.
320
329
  | `data/design-audit.csv` | 24 | **computed in Chromium** - contrast, font fallback, CJK tracking, overflow, measure. Empty means it ran and found nothing |
321
330
  | `data/data-class-hierarchy.csv` | 121 | source - every data class and its parent, so a type's inherited properties can be resolved |
322
331
  | `data/style-state-verification.csv` | 52 | **swept live** - each state written on a host of its own type and matched against its promised selector |
332
+ | `data/component-verification.csv` | 8 | **driven live** - the component lifecycle, each step asserted against the row or the delivered HTML |
333
+ | `data/node-type-notes.csv` | 4 | where a sweep outcome is true but misleading on its own, why. Surfaced by `mo.py type` |
323
334
  | `data/interaction-verification.csv` | 7 | **probed live** - interaction animation shapes, with negative controls and the stored row beside the payload |
324
335
  | `data/intro-verification.csv` | 7 + 10 | **sampled live** - the entrance sequence over ten timestamps, plus the seven assertions about it |
325
336
  | `data/element-classes.csv` | 151 | **live** — the built-in class metas; their IDs are what an `elementClass` record must use |
@@ -433,6 +444,7 @@ post — `build_all.py` resets first for that reason.
433
444
  | `sweep_style_properties.py` | write every style property and check the compiled CSS |
434
445
  | `sweep_node_properties.py` | probe every node property with a value from its own validator chain |
435
446
  | `sweep_style_states.py` | write every style state and check the selector it compiled to |
447
+ | `sweep_components.py` | build a component, instance it twice, and prove one definition served both |
436
448
  | `sweep_interactions.py` | which interaction animation shapes survive to the frontend payload |
437
449
  | `theme_export.php` / `theme_import.php` | move a whole theme across installs, ids intact |
438
450
  | `copy_styles.py` | push one node's style onto others, by attrID or prefix |
@@ -20,7 +20,7 @@
20
20
  "description": "Build and modify Mosaic Pro (Nextend) sites by writing the underlying data model directly - no visual editor, no DOM. Query the real surface (122 node types, 181 node properties, 98 style properties, 53 style states, 151 element classes, 74 dynamic variables, 114 REST routes, 23 tables) instead of guessing, with every node type placed on a live site one at a time and asserted against the delivered HTML, every style and node property swept against the compiled CSS and markup, and 569 responsive declarations checked against the stylesheet the site actually served.",
21
21
  "license": "MIT",
22
22
  "author": "moksa (https://moksaweb.com)",
23
- "version": "1.12.0"
23
+ "version": "1.13.0"
24
24
  },
25
25
  "loaderBehaviour": "Upload via Settings -> Skills -> Upload. Claude.ai parses SKILL.md frontmatter and surfaces the skill in your library. The extraction tool (extract-block-schema.php) needs a live WP-CLI connection and won't run in the sandbox; use it from a local terminal against your own site instead.",
26
26
  "uploadSteps": [
@@ -20,7 +20,7 @@
20
20
  "description": "Build and modify Mosaic Pro (Nextend) sites by writing the underlying data model directly - no visual editor, no DOM. Query the real surface (122 node types, 181 node properties, 98 style properties, 53 style states, 151 element classes, 74 dynamic variables, 114 REST routes, 23 tables) instead of guessing, with every node type placed on a live site one at a time and asserted against the delivered HTML, every style and node property swept against the compiled CSS and markup, and 569 responsive declarations checked against the stylesheet the site actually served.",
21
21
  "license": "MIT",
22
22
  "author": "moksa (https://moksaweb.com)",
23
- "version": "1.12.0"
23
+ "version": "1.13.0"
24
24
  },
25
25
  "loaderBehaviour": "Auto-loads on session start when SKILL.md frontmatter parses successfully.",
26
26
  "verified": true,
@@ -20,7 +20,7 @@
20
20
  "description": "Build and modify Mosaic Pro (Nextend) sites by writing the underlying data model directly - no visual editor, no DOM. Query the real surface (122 node types, 181 node properties, 98 style properties, 53 style states, 151 element classes, 74 dynamic variables, 114 REST routes, 23 tables) instead of guessing, with every node type placed on a live site one at a time and asserted against the delivered HTML, every style and node property swept against the compiled CSS and markup, and 569 responsive declarations checked against the stylesheet the site actually served.",
21
21
  "license": "MIT",
22
22
  "author": "moksa (https://moksaweb.com)",
23
- "version": "1.12.0"
23
+ "version": "1.13.0"
24
24
  },
25
25
  "loaderBehaviour": "Confirmed (2026-07-11): Codex CLI natively supports the SKILL.md spec. Place SKILL.md under .codex/skills/<name>/ (project) or ~/.codex/skills/<name>/ (personal) and Codex loads the name+description at session start, then the full body on demand. A parallel, broader convention .agents/skills/ (searched from cwd up to repo root, then ~/.agents/skills/) also exists across multiple tools - if your Codex CLI version prioritizes that path instead, mirror the same SKILL.md there.",
26
26
  "verified": true,
@@ -20,7 +20,7 @@
20
20
  "description": "Build and modify Mosaic Pro (Nextend) sites by writing the underlying data model directly - no visual editor, no DOM. Query the real surface (122 node types, 181 node properties, 98 style properties, 53 style states, 151 element classes, 74 dynamic variables, 114 REST routes, 23 tables) instead of guessing, with every node type placed on a live site one at a time and asserted against the delivered HTML, every style and node property swept against the compiled CSS and markup, and 569 responsive declarations checked against the stylesheet the site actually served.",
21
21
  "license": "MIT",
22
22
  "author": "moksa (https://moksaweb.com)",
23
- "version": "1.12.0"
23
+ "version": "1.13.0"
24
24
  },
25
25
  "loaderBehaviour": "CHANGED as of 2026-07-11: GitHub Copilot added a proper '.github/skills/' Agent Skills directory (December 2025), alongside the older single-file .github/copilot-instructions.md convention. This config targets the new skills-directory form. If your Copilot version predates this (pre Dec 2025), use the instructions-append fallback instead (see fallback below).",
26
26
  "fallback": {
@@ -20,7 +20,7 @@
20
20
  "description": "Build and modify Mosaic Pro (Nextend) sites by writing the underlying data model directly - no visual editor, no DOM. Query the real surface (122 node types, 181 node properties, 98 style properties, 53 style states, 151 element classes, 74 dynamic variables, 114 REST routes, 23 tables) instead of guessing, with every node type placed on a live site one at a time and asserted against the delivered HTML, every style and node property swept against the compiled CSS and markup, and 569 responsive declarations checked against the stylesheet the site actually served.",
21
21
  "license": "MIT",
22
22
  "author": "moksa (https://moksaweb.com)",
23
- "version": "1.12.0"
23
+ "version": "1.13.0"
24
24
  },
25
25
  "loaderBehaviour": "CHANGED as of 2026-07-11: Gemini CLI now natively supports the same SKILL.md standard as Claude Code and Codex CLI - the same directory-based skill works unmodified. Gemini CLI discovers skills in this precedence order: built-in, extension skills, ~/.gemini/skills/ (personal), .gemini/skills/ (project, shared via version control). At session start Gemini injects each discovered skill's name+description into the system prompt and calls activate_skill when a task matches.",
26
26
  "verified": true,
@@ -0,0 +1,9 @@
1
+ step,result,detail
2
+ categories exist,PASS,"Page, Block, Part"
3
+ component created,PASS,parented to 'Page'
4
+ document healed,PASS,"first GET 404'd, second gave component-internal"
5
+ component filled,PASS,"tree is component-internal, div, text, wysiwyg-text"
6
+ read-only instance refuses,PASS,componentNodeEditorInstance rejected the same commit
7
+ instances committed,PASS,type carries the component id
8
+ both instances rendered,PASS,2 instance elements in the delivered HTML
9
+ component content reused,PASS,the component's own text appears 2 times from ONE definition
@@ -0,0 +1,5 @@
1
+ type,note
2
+ component-instance,"Swept as COMMIT_500, which is what a BARE one does. The node's type is not `component-instance` but `component-instance/<componentID>` - the factory splits on that slash and a bare one has no id to read. With a real component it commits and renders; see sweep_components.py."
3
+ component-internal,"The component's content hangs under THIS node, in the `node/component/<id>` key of a componentDocumentInstance. `component-root` looks like the parent and accepts no children."
4
+ component-root,"Accepts no children. It frames the component's preview document, not its content."
5
+ loop,"COMMIT_500 on a bare page: the loop needs a query context that a plain document does not provide."
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mosaic-headless",
3
- "version": "1.12.0",
3
+ "version": "1.13.0",
4
4
  "description": "AI-agent skill: build Mosaic Pro (Nextend) WordPress sites by writing the underlying data model directly - 23 custom tables, no visual editor, no DOM. Every node type, style property and node property swept against a live install and asserted on the delivered HTML and compiled CSS. Installs into Claude Code, Cursor, Codex CLI, Gemini CLI, Copilot, Continue, Windsurf and Claude.ai.",
5
5
  "keywords": [
6
6
  "wordpress",
@@ -93,3 +93,41 @@ takes variations from there.
93
93
  That is why the same visual change can be made in two very different places, and
94
94
  why writing a style onto a node directly is usually the wrong move: the class is
95
95
  the unit of reuse, and the collection/variable tables are the token layer beneath it.
96
+
97
+
98
+ ## Components: one definition, many places
99
+
100
+ Three tables (`components`, `component_documents`, `component_categories`), four
101
+ node types and eighteen REST routes. Driven end to end by
102
+ `tools/sweep_components.py`; every claim here was measured, and none of it is
103
+ guessable from the route list.
104
+
105
+ ```
106
+ 1 create adminComponentsEditorInstance, commit a `component` record
107
+ parentType MUST be "componentCategory" - Page, Block and Part exist
108
+ out of the box, and no other parent type is accepted at all.
109
+ parentType:"" gives HTTP 500, "Parent type not supported"
110
+
111
+ 2 heal GET componentDocumentInstance/<id>
112
+ the FIRST call 404s and creates the document; the second returns it,
113
+ healed into body / component-external / component-root / document
114
+
115
+ 3 fill commit into the SAME instance, under the `component-internal` node
116
+ in its `node/component/<id>` key.
117
+ NOT componentNodeEditorInstance - `isCommitAllowed()` returns false
118
+ there and the commit is refused with "Not allowed!" and a 500, even
119
+ though that is the instance which shows you the tree.
120
+ NOT component-root either - it accepts no children
121
+
122
+ 4 instance on a page, a node whose TYPE carries the component's id:
123
+ type: "component-instance/<componentID>"
124
+ `ComponentInstanceElementTypeFactory` splits on that slash. A bare
125
+ `component-instance` has no id for `$flags[0]` and fatals, which is
126
+ the entire reason it is COMMIT_500 in node-verification.csv
127
+ ```
128
+
129
+ Verified on a live page: two instances of one component, both rendered, the
130
+ component's own text present twice from a single definition. Edit the component and
131
+ every instance changes - which is the whole point, and the reason a real site should
132
+ use these rather than repeating a tree.
133
+
package/tools/mo.py CHANGED
@@ -104,6 +104,21 @@ def table(headers, body, gap=2):
104
104
  print(sep.join(c.ljust(width[i]) for i, c in enumerate(r)).rstrip())
105
105
 
106
106
 
107
+ def wrap(text: str, width: int) -> list[str]:
108
+ """Break on spaces. Slicing every N characters splits words in half, which in a
109
+ note about `component-instance/<componentID>` is actively misleading."""
110
+ out, line = [], ""
111
+ for word in (text or "").split():
112
+ if line and len(line) + 1 + len(word) > width:
113
+ out.append(line)
114
+ line = word
115
+ else:
116
+ line = (line + " " + word) if line else word
117
+ if line:
118
+ out.append(line)
119
+ return out
120
+
121
+
107
122
  def matches(text: str, needle: str | None) -> bool:
108
123
  return needle is None or needle.lower() in (text or "").lower()
109
124
 
@@ -119,6 +134,14 @@ def type_record(name: str) -> dict:
119
134
  name, ("\ndid you mean: " + ", ".join(near[:8])) if near else ""))
120
135
 
121
136
  v = index("node-verification", "type").get(name, {})
137
+ # A sweep outcome is what happened to ONE probe. Where that number is true but
138
+ # misleading on its own, the note says why - `component-instance` is COMMIT_500
139
+ # only because a bare one has no component id in its type.
140
+ note = ""
141
+ try:
142
+ note = index("node-type-notes", "type").get(name, {}).get("note", "")
143
+ except SystemExit:
144
+ note = ""
122
145
  p = index("placement-rules", "type").get(name, {})
123
146
  d = index("default-children", "type").get(name, {})
124
147
 
@@ -148,6 +171,7 @@ def type_record(name: str) -> dict:
148
171
  "edition": t.get("edition"),
149
172
  "outcome": v.get("outcome"),
150
173
  "outcome_note": OUTCOME_NOTE.get(v.get("outcome"), ""),
174
+ "note": note,
151
175
  "safe_to_commit": v.get("outcome") not in UNSAFE,
152
176
  "detail": v.get("detail"),
153
177
  "rendered_tag": v.get("rendered_tag"),
@@ -207,6 +231,9 @@ def cmd_type(a):
207
231
  print("verdict : %s" % flag)
208
232
  if rec["detail"]:
209
233
  print("failure : %s" % rec["detail"])
234
+ if rec["note"]:
235
+ for i, line in enumerate(wrap(rec["note"], 74)):
236
+ print("%-11s %s" % ("note :" if i == 0 else "", line))
210
237
  if rec["rendered_tag"]:
211
238
  print("renders as: <%s>%s" % (
212
239
  rec["rendered_tag"],
@@ -244,6 +271,96 @@ def cmd_type(a):
244
271
  emit(rec, render)
245
272
 
246
273
 
274
+ def cmd_params(a):
275
+ """Everything that can be SET on one node type, in one answer.
276
+
277
+ This is the question you actually have in front of an editor: not "does this
278
+ type exist" but "what may I put on it, in what shape, and which of those have
279
+ been seen to work". It is four tables joined - node properties by the data
280
+ class, the universal style surface, the style states that reach this type, and
281
+ the placement rule - because the answer is not in any one of them.
282
+
283
+ Mosaic differs from a widget-based builder here in a way worth stating: the
284
+ STYLE surface is universal. Every element takes the same 98 style properties;
285
+ what varies per type is the DATA properties and which node-type-scoped states
286
+ apply. So the style half of this output is the same for every type, and that
287
+ is a fact about the platform rather than a shortcut taken here."""
288
+ rec = type_record(a.name)
289
+ prop_status = {r["property"]: r for r in rows("node-property-verification")}
290
+ sv = index("style-verification", "property")
291
+ shapes = index("style-value-shapes", "property")
292
+ ver = index("style-state-verification", "state")
293
+
294
+ states = []
295
+ for r in rows("style-states"):
296
+ v = ver.get(r["state"], {})
297
+ if r["scope"] == "global" or v.get("host") == a.name:
298
+ states.append({"state": r["state"], "scope": r["scope"],
299
+ "selector": r["selector_template"],
300
+ "status": v.get("status", "base state"
301
+ if r["state"] == "&" else "")})
302
+
303
+ style = []
304
+ for r in rows("style-properties"):
305
+ v = sv.get(r["property"], {})
306
+ style.append({"property": r["property"], "group": r["group"],
307
+ "css": v.get("css_property", ""),
308
+ "status": v.get("status", ""),
309
+ "shape": shapes.get(r["property"], {}).get("shape", ""),
310
+ "accepted_values": split(r["accepted_values"])})
311
+
312
+ payload = {"type": a.name, "safe_to_commit": rec["safe_to_commit"],
313
+ "note": rec["note"], "data_properties": rec["properties"],
314
+ "style_properties": style, "states": states,
315
+ "placement_rule": rec["placement_rule"],
316
+ "allowed_children": rec["allowed_children"]}
317
+
318
+ def render():
319
+ head("%s - everything settable" % a.name)
320
+ print("verdict : %s%s"
321
+ % ("SAFE" if rec["safe_to_commit"] else "UNSAFE TO COMMIT",
322
+ " (%s)" % rec["outcome"] if rec["outcome"] else ""))
323
+ if rec["note"]:
324
+ for i, line in enumerate(wrap(rec["note"], 74)):
325
+ print("%-11s %s" % ("note :" if i == 0 else "", line))
326
+
327
+ print("\nDATA properties (%d) - these vary by type"
328
+ % len(rec["properties"]))
329
+ table(["property", "shared", "verified", "accepted values"],
330
+ [[p["property"], "yes" if p["inherited_from"] else "",
331
+ (prop_status.get(p["property"]) or {}).get("status", ""),
332
+ ", ".join(p["accepted_values"])[:44]]
333
+ for p in rec["properties"]])
334
+
335
+ usable = [s for s in states if s["status"] in ("COMPILED", "base state")]
336
+ print("\nSTATES reaching this type (%d usable of %d)"
337
+ % (len(usable), len(states)))
338
+ table(["state", "scope", "swept", "selector"],
339
+ [[s["state"], s["scope"], s["status"], s["selector"][:44]]
340
+ for s in states])
341
+
342
+ ok = [p for p in style if p["status"] == "COMPILED"]
343
+ grouped = [p for p in style if p["group"]]
344
+ print("\nSTYLE properties: %d in the surface, %d measured COMPILED,"
345
+ " %d belong to a group and are INERT set on their own."
346
+ "\nThe style surface is UNIVERSAL in Mosaic - it is the same "
347
+ "for every type."
348
+ % (len(style), len(ok), len(grouped)))
349
+ if a.style:
350
+ table(["property", "group", "css", "swept", "shape"],
351
+ [[p["property"], p["group"], p["css"], p["status"],
352
+ p["shape"][:30]] for p in style])
353
+ else:
354
+ print("(pass --style to list them, or `mo.py style` for the same "
355
+ "table on its own)")
356
+
357
+ print("\nCHILDREN : rule=%s%s" % (rec["placement_rule"],
358
+ (" " + ", ".join(rec["allowed_children"]))
359
+ if rec["allowed_children"] else ""))
360
+
361
+ emit(payload, render)
362
+
363
+
247
364
  def cmd_types(a):
248
365
  ver = index("node-verification", "type")
249
366
  out = []
@@ -655,6 +772,12 @@ def main():
655
772
  p = add("type", cmd_type, "one node type, fully joined")
656
773
  p.add_argument("name")
657
774
 
775
+ p = add("params", cmd_params,
776
+ "EVERYTHING settable on one type: data, style, states, placement")
777
+ p.add_argument("name")
778
+ p.add_argument("--style", action="store_true",
779
+ help="list all 98 style properties too, not just count them")
780
+
658
781
  p = add("check", cmd_check, "exit 1 if any named type is unsafe or unknown")
659
782
  p.add_argument("names", nargs="+")
660
783
 
@@ -0,0 +1,215 @@
1
+ #!/usr/bin/env python3
2
+ """Drive Mosaic's component system end to end, and check every step.
3
+
4
+ python tools/sweep_components.py --config c.json --post 20 --slug probe-lab
5
+ python tools/sweep_components.py --config c.json --post 20 --slug probe-lab \
6
+ --csv data/component-verification.csv
7
+
8
+ Components are how a Mosaic site stops repeating itself: build a card once, place it
9
+ in twenty documents, edit the one and all twenty change. Three tables, four node
10
+ types and eighteen REST routes serve it, and this skill had never touched any of
11
+ them - `component-instance` sat in `node-verification.csv` as COMMIT_500, which is
12
+ true and useless, because it only says what happens when you commit one WRONG.
13
+
14
+ Four things had to be found out, and none is guessable:
15
+
16
+ 1. **A component must hang off a `componentCategory`.** `parentType:""` gives
17
+ `Uncaught Exception: Parent type not supported` and an HTTP 500. Three
18
+ categories exist out of the box - Page, Block, Part - and the manager accepts
19
+ no other parent type at all.
20
+
21
+ 2. **The component's document is created lazily by the first GET.** Ask for
22
+ `componentDocumentInstance/<id>` immediately after creating the component and it
23
+ is a 404; ask again and it is there, healed into body / component-external /
24
+ component-root / document.
25
+
26
+ 3. **`componentNodeEditorInstance` is READ ONLY.** Its `isCommitAllowed()` returns
27
+ `false`, so a commit there is refused with `Not allowed!` and an HTTP 500 - even
28
+ though it is the instance that shows you the tree you want to edit. The writable
29
+ one is `componentDocumentInstance`, and the content goes under the
30
+ `component-internal` node in its `node/component/<id>` key. `component-root`
31
+ looks like the obvious parent and accepts no children at all.
32
+
33
+ 4. **An instance's node type carries the component's ID.** Not
34
+ `type: "component-instance"` but `type: "component-instance/<componentID>"` -
35
+ `ComponentInstanceElementTypeFactory` splits on that slash, which is why a bare
36
+ one fatals: there is no id for `$flags[0]` to read.
37
+
38
+ Every step below is asserted against the row that came back or the HTML the site
39
+ served, and the run ends by placing two instances and counting the component's own
40
+ text in the delivered page - because one instance rendering proves less than two.
41
+ """
42
+ from __future__ import annotations
43
+
44
+ import argparse
45
+ import csv
46
+ import json
47
+ import os
48
+ import sys
49
+ import time
50
+ import urllib.error
51
+ import urllib.request
52
+ import uuid
53
+
54
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
55
+ from build_page import Surface, flatten # noqa: E402
56
+ from build_site import bind_page, build_shell # noqa: E402
57
+ from sweep_node_types import Client, envelopes, exceptions_of, unwrap # noqa: E402
58
+
59
+ MARKER = "COMPONENT PROBE — REUSED"
60
+
61
+
62
+ def card(attr):
63
+ """What the component contains. Deliberately something with a border and a
64
+ string, so both the CSS and the text can be looked for in the delivered page."""
65
+ return {"type": "div", "data": {"attrID": attr},
66
+ "style": {"&": {"_": {"paddingTop": "18px", "paddingBottom": "18px",
67
+ "paddingLeft": "20px", "paddingRight": "20px",
68
+ "customStyles":
69
+ "border:1px solid rgb(214,214,206);"}}},
70
+ "children": [
71
+ {"type": "text",
72
+ "data": {"tagName": "p", "attrID": attr + "-t"},
73
+ "style": {"&": {"_": {"fontSize": "13px",
74
+ "letterSpacing": "0.16em"}}},
75
+ "text": MARKER}]}
76
+
77
+
78
+ def fetch(url):
79
+ sep = "&" if "?" in url else "?"
80
+ req = urllib.request.Request(
81
+ "%s%s_v=%d" % (url, sep, int(time.time() * 1000)),
82
+ headers={"User-Agent": "Mozilla/5.0", "Cache-Control": "no-cache"})
83
+ try:
84
+ with urllib.request.urlopen(req, timeout=90) as r:
85
+ return r.read().decode("utf-8", "replace")
86
+ except urllib.error.HTTPError as e:
87
+ return e.read().decode("utf-8", "replace")
88
+
89
+
90
+ def main():
91
+ ap = argparse.ArgumentParser()
92
+ ap.add_argument("--config", required=True)
93
+ ap.add_argument("--post", type=int, required=True)
94
+ ap.add_argument("--slug", required=True)
95
+ ap.add_argument("--csv")
96
+ a = ap.parse_args()
97
+
98
+ cfg = json.load(open(a.config, encoding="utf-8"))
99
+ client, surface = Client(cfg), Surface()
100
+ rows, failed = [], 0
101
+
102
+ def step(name, ok, detail):
103
+ nonlocal failed
104
+ rows.append([name, "PASS" if ok else "FAIL", detail])
105
+ print(" %-28s %-5s %s" % (name, "PASS" if ok else "FAIL", detail))
106
+ if not ok:
107
+ failed += 1
108
+
109
+ # ── 1. the categories a component may hang from ──────────────────────────
110
+ inst = unwrap(client.get("adminComponentsEditorInstance"),
111
+ "adminComponentsEditorInstance")
112
+ cats = inst.get("componentCategory") or []
113
+ step("categories exist", bool(cats),
114
+ ", ".join((r.get("data") or {}).get("name", "?") for r in cats)
115
+ or "none - a component has nothing to hang from")
116
+ if not cats:
117
+ sys.exit(1)
118
+
119
+ # ── 2. creating one, correctly parented ──────────────────────────────────
120
+ cid = str(uuid.uuid4())
121
+ resp = client.commit("adminComponentsEditorInstance", envelopes(inst),
122
+ {"component": [{"newRevisionRecord": {
123
+ "ID": cid, "parentType": "componentCategory",
124
+ "parentID": cats[0]["ID"], "ordering": "a0",
125
+ "status": "publish", "revision": "", "version": "",
126
+ "name": "Probe Card", "path": ""},
127
+ "originalRevisionRecord": None}]})
128
+ err = exceptions_of(resp)
129
+ inst2 = unwrap(client.get("adminComponentsEditorInstance"),
130
+ "adminComponentsEditorInstance")
131
+ mine = [r for r in (inst2.get("component") or []) if r["ID"] == cid]
132
+ step("component created", bool(mine) and not err,
133
+ "parented to %r" % (cats[0].get("data") or {}).get("name")
134
+ if mine else "rejected: %s" % (err or "row never appeared"))
135
+
136
+ # ── 3. the document heals on first read ──────────────────────────────────
137
+ instance = "componentDocumentInstance/%s" % cid
138
+ first = client.get(instance)
139
+ doc = unwrap(client.get(instance), "componentDocumentInstance")
140
+ ckey = "node/component/%s" % cid
141
+ internal = next((n for n in doc.get(ckey, [])
142
+ if n["type"] == "component-internal"), None)
143
+ step("document healed", internal is not None,
144
+ "first GET %s, second gave component-internal"
145
+ % ("404'd" if "_httperror" in first else "succeeded"))
146
+ if internal is None:
147
+ sys.exit(1)
148
+
149
+ # ── 4. content goes under component-internal, via the WRITABLE instance ──
150
+ recs = flatten(card("cmp-card"), internal["ID"], cid, surface, False,
151
+ parent_type="component-internal")
152
+ for r in recs:
153
+ r["documentType"], r["documentID"] = "component", cid
154
+ resp = client.commit(instance, envelopes(doc),
155
+ {ckey: [{"newRevisionRecord": r,
156
+ "originalRevisionRecord": None} for r in recs]})
157
+ err = exceptions_of(resp)
158
+ doc2 = unwrap(client.get(instance), "componentDocumentInstance")
159
+ types = sorted({n["type"] for n in doc2.get(ckey, [])})
160
+ step("component filled", "div" in types and not err,
161
+ "tree is %s" % ", ".join(types))
162
+
163
+ # ── 5. the read-only instance refuses the same write ─────────────────────
164
+ # A negative control: this is the endpoint that LOOKS like the right one, and
165
+ # the difference between the two is not visible from the route list.
166
+ ro = "componentNodeEditorInstance/%s" % cid
167
+ rodoc = unwrap(client.get(ro), "componentNodeEditorInstance")
168
+ ro_resp = client.commit(ro, envelopes(rodoc), {ckey: [
169
+ {"newRevisionRecord": dict(recs[0], ID=str(uuid.uuid4())),
170
+ "originalRevisionRecord": None}]})
171
+ refused = "_httperror" in ro_resp or bool(exceptions_of(ro_resp))
172
+ step("read-only instance refuses", refused,
173
+ "componentNodeEditorInstance rejected the same commit"
174
+ if refused else "it ACCEPTED a write it documents as not allowed")
175
+
176
+ # ── 6. two instances on a real page, and the page must show both ─────────
177
+ master = build_shell(client, cfg, {"pages": [], "shell": {}}, surface)
178
+ template = bind_page(client, cfg, master, a.slug, a.post)
179
+ tinst = "templateDocumentInstance/%s/%s" % (master, template)
180
+ tdoc = unwrap(client.get(tinst), "templateDocumentInstance")
181
+ tkey = "node/template/%s" % template
182
+ root = next(n for n in tdoc[tkey] if n["type"] == "template-internal")
183
+ base = {"parentType": "node", "parentID": root["ID"], "status": "publish",
184
+ "revision": "", "version": "", "documentType": "template",
185
+ "documentID": template}
186
+ place = [{"newRevisionRecord": dict(base, ID=str(uuid.uuid4()),
187
+ ordering="a%d" % i,
188
+ type="component-instance/%s" % cid,
189
+ data={"attrID": "cmp-use-%d" % i}),
190
+ "originalRevisionRecord": None} for i in range(2)]
191
+ resp = client.commit(tinst, envelopes(tdoc), {tkey: place})
192
+ err = exceptions_of(resp)
193
+ step("instances committed", not err, err or "type carries the component id")
194
+
195
+ html = fetch("%s/%s/" % (cfg["base"].rstrip("/"), a.slug))
196
+ ids = html.count('id="cmp-use-')
197
+ body = html.count(MARKER)
198
+ step("both instances rendered", ids == 2,
199
+ "%d instance elements in the delivered HTML" % ids)
200
+ step("component content reused", body == 2,
201
+ "the component's own text appears %d times from ONE definition" % body)
202
+
203
+ print("\n%d of %d checks passed" % (len(rows) - failed, len(rows)))
204
+ print("COMPONENT_ID=%s" % cid)
205
+ if a.csv:
206
+ with open(a.csv, "w", newline="", encoding="utf-8") as fh:
207
+ w = csv.writer(fh)
208
+ w.writerow(["step", "result", "detail"])
209
+ w.writerows(rows)
210
+ print("wrote", a.csv)
211
+ sys.exit(1 if failed else 0)
212
+
213
+
214
+ if __name__ == "__main__":
215
+ main()