mosaic-headless 1.18.0 → 1.19.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
@@ -159,6 +159,7 @@ commit の成功はページが動く証拠ではないし、正しいスタイ
159
159
  | **テーマの書き出し/読み込み** | 経路は二つ、どちらも往復検証済み。`theme_export.php` は WP-CLI で行を JSON として移し、id は不変。`theme_zip.py` は Mosaic **自身**の ZIP 書き出し/読み込みを駆動 — 読み込みは `--activate` を付けない限りテストモードに入る、既定がライブサイトの切り替えだからだ — **22 項目**でコピーを元とツリー単位で突き合わせる |
160
160
  | **スキル自身** | `claude plugin eval .` — ユーザーが実際に尋ねる 5 問を各 3 回、スキルあり/なしの 2 腕、1 回ごとに LLM 審査 3 名。**あり:5 問すべて 1.00。なし:5 問すべて 0.00。** ベースラインの最善の回答は回答拒否だった |
161
161
  | **ライブ計測** | REST ルート 114、variant 152、条件サブジェクト 59、23 テーブル / 210 カラム |
162
+ | **カスタムフィールド** | ACF と Meta Box、1 ページに 40 フィールド、`@VAR` / `@LOOP` で配信 HTML から読み戻し:62 のうち 59 が解決、3 つの空は理由付き;複数値フィールドのループはフィールドの行数どおりに描画。`tools/list_fields.php` が Mosaic の実際の登録名を出す |
162
163
  | **ライブ更新** | 1.0.7 → 1.0.8 をプラグイン自身の milestone ルートで wp-admin の外から駆動:6 milestone、4 テーブル改名、出力クラス名は全部変わり、`customStyles` は全部書き換え — その結果に対して上の計測をすべて再実行 |
163
164
 
164
165
  `SKIPPED`、`NO_HOST`、`INCONCLUSIVE` は合格率に決して繰り込まない。自らの盲点を成功として
@@ -237,6 +238,7 @@ loop grid、フォーム、カウントダウン、サードパーティ addon
237
238
  | `verify_intro.py` / `verify_loop.py` | 「終わる」ロードアニメーション;ループし、何も隠さず、開く常時アニメーション |
238
239
  | `theme_export.php` / `theme_import.php` | テーマ全体を JSON 行として WP-CLI で移動、id は不変 |
239
240
  | `theme_zip.py` / `theme_zip_compare.php` / `theme_delete.php` | Mosaic 自身の ZIP 書き出し/読み込みをエディタの外から駆動、コピーを元とツリー単位で照合、ライブテーマを拒否する完全削除 |
241
+ | `list_fields.php` | ある投稿のカスタムフィールドが登録する `@VAR` / `@LOOP` 名をすべて、値付きで |
240
242
  | `data_upgrade.py` | プラグイン更新後、Mosaic のデータ移行を自身の milestone ルートで実行 — 終わるまでエディタ API は存在しない |
241
243
  | `sweep_*.py` / `probe_*.py` | 表を作った計測器そのもの |
242
244
  | `bootstrap_probe_theme.php` / `mint_session.php` | ライセンス不要の実験用テーマと、WP-CLI から作る REST セッション |
package/README.ko.md CHANGED
@@ -155,6 +155,7 @@ commit 성공은 페이지가 동작한다는 증거가 아니고, 올바른 스
155
155
  | **테마 내보내기/가져오기** | 두 경로, 모두 왕복 검증. `theme_export.php`는 WP-CLI로 행을 JSON으로 옮기며 id는 그대로. `theme_zip.py`는 Mosaic **자체**의 ZIP 내보내기/가져오기를 구동 — 가져오기는 `--activate`가 없으면 테스트 모드에 놓이는데, 기본값이 라이브 사이트 전환이기 때문이다 — **22개 검사**로 사본을 원본과 트리 단위로 대조 |
156
156
  | **스킬 자체** | `claude plugin eval .` — 사용자가 실제로 묻는 5문항을 각 3회, 스킬 있음/없음 두 팔로, 매회 LLM 심사 3명. **있음: 5문항 모두 1.00. 없음: 5문항 모두 0.00.** 베이스라인의 최선은 답변 거부였다 |
157
157
  | **라이브 측정** | REST 라우트 114, variant 152, 조건 subject 59, 23 테이블 / 210 컬럼 |
158
+ | **커스텀 필드** | ACF와 Meta Box, 한 페이지에 40개 필드, `@VAR` / `@LOOP`로 전달된 HTML에서 읽어냄: 62개 중 59개 해석, 빈 3개는 이유 있음; 다중값 필드 루프는 필드의 행 수 그대로 렌더링. `tools/list_fields.php`가 Mosaic이 실제 등록하는 이름을 출력 |
158
159
  | **라이브 업그레이드** | 1.0.7 → 1.0.8을 플러그인 자체 milestone 라우트로 wp-admin 밖에서 구동: 6 milestone, 테이블 4개 이름 변경, 출력 클래스 이름 전부 변경, `customStyles` 전부 재작성 — 그 결과 위에서 위의 측정을 전부 다시 실행 |
159
160
 
160
161
  `SKIPPED`, `NO_HOST`, `INCONCLUSIVE`는 결코 합격률에 섞지 않는다. 자기 사각지대를 성공으로
@@ -231,6 +232,7 @@ loop grid, 폼, 카운트다운, 서드파티 addon — 은 동적이라 될 노
231
232
  | `verify_intro.py` / `verify_loop.py` | "끝나는" 로드 애니메이션; 루프하고, 아무것도 가리지 않고, 열리는 상시 애니메이션 |
232
233
  | `theme_export.php` / `theme_import.php` | 테마 전체를 JSON 행으로 WP-CLI를 통해 이동, id 그대로 |
233
234
  | `theme_zip.py` / `theme_zip_compare.php` / `theme_delete.php` | Mosaic 자체 ZIP 내보내기/가져오기를 에디터 밖에서 구동, 사본을 원본과 트리 단위로 대조, 라이브 테마를 거부하는 깨끗한 삭제 |
235
+ | `list_fields.php` | 한 글의 커스텀 필드가 등록하는 모든 `@VAR` / `@LOOP` 이름을 값과 함께 |
234
236
  | `data_upgrade.py` | 플러그인 업데이트 후 Mosaic의 데이터 이관을 자체 milestone 라우트로 실행 — 끝나기 전까지 에디터 API는 존재하지 않는다 |
235
237
  | `sweep_*.py` / `probe_*.py` | 표를 만든 계측기 그 자체 |
236
238
  | `bootstrap_probe_theme.php` / `mint_session.php` | 라이선스 없는 실험용 테마와 WP-CLI에서 만드는 REST 세션 |
package/README.md CHANGED
@@ -164,6 +164,7 @@ factories, so Pro types register and render regardless.
164
164
  | **theme export/import** | two paths, both round-tripped. `theme_export.php` moves rows as JSON over WP-CLI, ids intact. `theme_zip.py` drives Mosaic's **own** ZIP export/import — import lands in test mode unless told `--activate`, because the default is to switch the live site — and **22 checks** hold the copy against the source tree for tree |
165
165
  | **the skill itself** | `claude plugin eval .` — five cases a user would ask, three runs each, with and without the skill loaded, three LLM judges a run. **With: 1.00 on all five. Without: 0.00 on all five.** The baseline's best answer was to refuse |
166
166
  | **measured live** | 114 REST routes, 152 variants, 59 condition subjects, 23 tables / 210 columns |
167
+ | **custom fields** | ACF and Meta Box, forty fields on a page, read back through `@VAR` / `@LOOP` off the delivered HTML: 59 of 62 resolve, 3 empties explained; loops over multi-value fields rendered exactly the field's rows. `tools/list_fields.php` prints the names Mosaic actually registers |
167
168
  | **upgraded live** | 1.0.7 -> 1.0.8 over the plugin's own milestone route, from outside wp-admin: 6 milestones, four tables renamed, every emitted class name changed, every `customStyles` rewritten - then every sweep above re-run on the result |
168
169
 
169
170
  `SKIPPED`, `NO_HOST` and `INCONCLUSIVE` are never folded into a pass rate. A sweep
@@ -246,6 +247,7 @@ and heading level — and it earned its place at once: it caught the converter l
246
247
  | `verify_intro.py` / `verify_loop.py` | a page-load sequence that ENDS; a perpetual one that loops, hides nothing, and opens |
247
248
  | `theme_export.php` / `theme_import.php` | a whole theme as JSON rows over WP-CLI, ids intact |
248
249
  | `theme_zip.py` / `theme_zip_compare.php` / `theme_delete.php` | Mosaic's own ZIP export/import from outside the editor, the copy held against the source, and a clean delete that refuses the live theme |
250
+ | `list_fields.php` | every `@VAR` / `@LOOP` name a post's custom fields register, with values |
249
251
  | `data_upgrade.py` | after a plugin update, Mosaic's data migration over its own milestone route - the editor API is gone until it runs |
250
252
  | `sweep_*.py` / `probe_*.py` | the instruments the tables were made with |
251
253
  | `bootstrap_probe_theme.php` / `mint_session.php` | a licence-free scratch theme and a REST session from WP-CLI |
package/README.zh-TW.md CHANGED
@@ -148,6 +148,7 @@ commit 成功不代表頁面能用,樣式表正確也不代表。這裡每個
148
148
  | **主題匯出/匯入** | 兩條路徑,都來回驗證過。`theme_export.php` 用 WP-CLI 把資料列搬成 JSON、ID 不變。`theme_zip.py` 驅動 Mosaic **自己**的 ZIP 匯出匯入——匯入預設進 test mode,要 `--activate` 才上線,因為它的預設是直接切換 live 站——**22 項檢查**逐表、逐樹比對副本與來源 |
149
149
  | **技能本身** | `claude plugin eval .`——五個使用者真的會問的問題,各跑三次,有載技能和沒載各一臂,每次三個 LLM 裁判。**有:五題全 1.00。沒有:五題全 0.00。** 基準線最好的回答是拒答 |
150
150
  | **線上量測** | 114 條 REST 路由、152 個 variant、59 個條件主體、23 張表 / 210 個欄位 |
151
+ | **自訂欄位** | ACF 與 Meta Box,一頁四十個欄位,透過 `@VAR` / `@LOOP` 從送達的 HTML 讀回:62 個裡 59 個解析正確、3 個空值有解釋;多值欄位的迴圈剛好渲染出欄位的列數。`tools/list_fields.php` 列出 Mosaic 真正註冊的名字 |
151
152
  | **線上升級** | 1.0.7 → 1.0.8 走外掛自己的 milestone 路由、在 wp-admin 之外驅動:6 個 milestone、四張表改名、每個輸出 class 名稱都變、每個 `customStyles` 都重寫——然後上面每一項掃描在結果上重跑 |
152
153
 
153
154
  `SKIPPED`、`NO_HOST`、`INCONCLUSIVE` 從不折算進通過率。把自己的盲點算成成功的掃描,
@@ -220,6 +221,7 @@ button / html / icon-list / divider / image 佔了全部元素的 99.6%。長尾
220
221
  | `verify_intro.py` / `verify_loop.py` | 會「結束」的載入動畫;會循環、不遮東西、能打開的永續動畫 |
221
222
  | `theme_export.php` / `theme_import.php` | 整個主題以 JSON 資料列透過 WP-CLI 搬移,ID 不變 |
222
223
  | `theme_zip.py` / `theme_zip_compare.php` / `theme_delete.php` | 在編輯器外驅動 Mosaic 自己的 ZIP 匯出匯入、副本對來源逐樹比對、以及拒絕刪 live 主題的乾淨刪除 |
224
+ | `list_fields.php` | 一篇文章的自訂欄位註冊了哪些 `@VAR` / `@LOOP` 名字,連值一起列出 |
223
225
  | `data_upgrade.py` | 外掛更新後,走 Mosaic 自己的 milestone 路由跑資料遷移——沒跑完之前編輯器 API 是不存在的 |
224
226
  | `sweep_*.py` / `probe_*.py` | 那些表格是用這些儀器量出來的 |
225
227
  | `bootstrap_probe_theme.php` / `mint_session.php` | 免授權的實驗主題,以及從 WP-CLI 鑄出 REST session |
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, 182 properties, 98 style properties with 20 structured value shapes pinned down, 53 style states, 152 variants, 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 pages re-read in Chromium at three viewports so a rule that is present, correct and still wrong cannot pass. Drives Mosaic's own theme export/import and its plugin data upgrade from outside the editor and holds the copy against the source tree for tree. Measured on Mosaic Pro 1.0.8, with the 1.0.7 -> 1.0.8 migration (variants, universal classes, `m-` class names, customDeclarations) run and re-verified.
5
5
  license: "MIT"
6
6
  author: "moksa (https://moksaweb.com)"
7
- version: "1.18.0"
7
+ version: "1.19.0"
8
8
  ---
9
9
 
10
10
  # Headless Mosaic
@@ -171,6 +171,16 @@ DESIGN SYS variants and collection variables both verified against compiled
171
171
  :root{--brand: rgb(9, 99, 199)} with background-color:var(--brand).
172
172
  references/design-system.md
173
173
 
174
+ FIELDS ACF 6.8 and Meta Box 5.15, forty fields registered in code on a page,
175
+ read back off the delivered HTML: 59 of 62 expressions resolve to the
176
+ value expected, the 3 empties explained (select label needs ACF's
177
+ array format; oEmbed dies in a text node, lives in a code node).
178
+ Loops over relationship / checkbox / taxonomy / gallery / clone /
179
+ group fields rendered exactly the rows the field holds. The names
180
+ are not guessable (meta_k, meta_k__label, loopk, loop-k for a
181
+ group, item/value_sub) - tools/list_fields.php prints them.
182
+ data/custom-fields-verification.csv, references/custom-fields.md
183
+
174
184
  DYNAMIC the @VAR('namespace/name') language verified against rendered output -
175
185
  @VAR('post/title') produced the real post title, @concat/@substr/
176
186
  @fallback all compose over it. 74 variables in
@@ -446,7 +456,10 @@ so the pattern is in the data, not just in this paragraph.
446
456
  10. `references/interactions.md` — the JavaScript animation system, and how far it is
447
457
  verified.
448
458
  11. `references/vs-elementor-gutenberg.md` — which builder habits transfer.
449
- 12. `references/upgrading.md` — what a plugin update does to the data and to the
459
+ 12. `references/custom-fields.md` — ACF and Meta Box fields as `@VAR` / `@LOOP`:
460
+ the names, what each field type resolves to, and the loop element that walks
461
+ a multi-value field. Measured, 62 rows.
462
+ 13. `references/upgrading.md` — what a plugin update does to the data and to the
450
463
  delivered page, measured on 1.0.7 -> 1.0.8; how to drive the migration and what
451
464
  to re-verify afterwards.
452
465
 
@@ -477,6 +490,7 @@ so the pattern is in the data, not just in this paragraph.
477
490
  | `data/conversion-verification.csv` | 8 | **converted then checked live** - an Elementor page rebuilt as Mosaic and held against its source (text, images, links, heading levels), then put through rwd, browser and the design audit with every finding classified inherited-or-introduced |
478
491
  | `data/conversion-batch.csv` | 19 | **converted, built and checked live, one page after another** - every Elementor page of a production site through the converter, with per-page element and content counts |
479
492
  | `data/token-benchmark.csv` | 6 | **measured with tiktoken** - the same six lookups priced three ways: reading the plugin source, loading every table, querying `mo.py`. 71-99.5% fewer tokens than the source and 99.6%+ fewer than the tables, which total 259,539 - never load them, query them |
493
+ | `data/custom-fields-verification.csv` | 62 | **rendered live** - ACF and Meta Box fields of every common type read back through `@VAR` / `@LOOP` off the delivered page, loops included |
480
494
  | `data/theme-zip-verification.csv` | 22 | **round-tripped live** - Mosaic's own ZIP export imported in test mode and compared to its source, table by table and tree by tree |
481
495
  | `data/node-type-notes.csv` | 8 | where a sweep outcome is true but misleading on its own, why. Surfaced by `mo.py type` |
482
496
  | `data/interaction-verification.csv` | 7 | **probed live** - interaction animation shapes, with negative controls and the stored row beside the payload |
@@ -638,6 +652,13 @@ post — `build_all.py` resets first for that reason.
638
652
  the same import took under two minutes and compared 22 of 22. Prune before you
639
653
  export; `wp_mosaic_template_assigns.parentID` -> template -> `masterID` is the
640
654
  bound set, plus any `assign="auto"` template.
655
+ - **A custom field is `@VAR('post/meta_<key>')`, and a multi-value one is a LOOP.**
656
+ ACF and Meta Box both, plus bare post meta. Derived properties hang off the
657
+ name with two underscores (`meta_k__label`, `__url`, `__id`); an ACF group is
658
+ `loop-k` (hyphen) with `item/value_<sub>` rows; ACF puts the ID in a
659
+ reference's primary slot where Meta Box puts the title. Run
660
+ `tools/list_fields.php` on the post rather than guessing - a name it does not
661
+ print does not exist. references/custom-fields.md
641
662
  - **A variant row cannot be deleted, only emptied.** `status:"delete"` on a
642
663
  variant is accepted and ignored - the row is catalog-backed. Commit it back with
643
664
  `{"states": {"&": {"_": {}}}}` and the rule disappears; measured on Heading 2.
@@ -698,6 +719,7 @@ post — `build_all.py` resets first for that reason.
698
719
  | `theme_zip.py` | drive Mosaic's OWN export/import - the ZIP the editor makes, attachments included, over the milestone protocol; import lands in test mode unless told `--activate` |
699
720
  | `theme_zip_compare.php` | hold an imported copy against its source, tree for tree - every scoped table, the (parentType, type) shape, which ids survive, and orphans named rather than counted |
700
721
  | `theme_delete.php` | remove a theme completely through the plugin's own routine; refuses the live one |
722
+ | `list_fields.php` | every `@VAR` / `@LOOP` name Mosaic registers for one post - custom fields, their derived `__label` / `__url` / `__id` properties, the row variables of each loop - with the value each resolves to (`wp eval-file`) |
701
723
  | `data_upgrade.py` | after a plugin update, run Mosaic's data migration over its own milestone route - the step wp-admin does from a screen - and set the config's version when the editor API is back |
702
724
  | `copy_styles.py` | push one node's style onto others, by attrID or prefix |
703
725
  | `bootstrap_probe_theme.php` | a licence-free scratch theme |
@@ -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.18.0"
23
+ "version": "1.19.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.18.0"
23
+ "version": "1.19.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.18.0"
23
+ "version": "1.19.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.18.0"
23
+ "version": "1.19.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.18.0"
23
+ "version": "1.19.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,
@@ -83,6 +83,7 @@ const counts = {
83
83
  "data/style-state-verification.csv": 52,
84
84
  "data/interaction-verification.csv": 7,
85
85
  "data/data-class-hierarchy.csv": 121,
86
+ "data/custom-fields-verification.csv": 62,
86
87
  };
87
88
  for (const [file, expected] of Object.entries(counts)) {
88
89
  if (!fs.existsSync(path.join(ROOT, file))) { fail(`${file} missing`); continue; }
@@ -0,0 +1,63 @@
1
+ provider,field,expression,expected,rendered,result
2
+ ACF,text,@VAR('post/meta_acf_text'),ACF plain text,ACF plain text,PASS
3
+ ACF,"textarea (newline kept, no <br>)",@VAR('post/meta_acf_area'),line one\nline two,line one\nline two,PASS
4
+ ACF,number,@VAR('post/meta_acf_num'),42,42,PASS
5
+ ACF,true_false,@VAR('post/meta_acf_bool'),1,1,PASS
6
+ ACF,url,@VAR('post/meta_acf_url'),https://example.com/acf,https://example.com/acf,PASS
7
+ ACF,email,@VAR('post/meta_acf_email'),acf@example.com,acf@example.com,PASS
8
+ ACF,color_picker,@VAR('post/meta_acf_color'),#ff5a36,#ff5a36,PASS
9
+ ACF,date_picker (return Y-m-d),@VAR('post/meta_acf_date'),2026-09-15,2026-09-15,PASS
10
+ ACF,wysiwyg,@VAR_RAW('post/meta_acf_wys'),Rich text,Rich text,PASS
11
+ ACF,select (return value),@VAR('post/meta_acf_select'),b,b,PASS
12
+ ACF,select (return value) __label,@VAR('post/meta_acf_select__label'),,,EMPTY
13
+ ACF,radio,@VAR('post/meta_acf_radio'),r2,r2,PASS
14
+ ACF,image (return array),@VAR('post/meta_acf_img'),https://mosaic.moksaweb.com/wp-content/uploads/woocommerce-placeholder.webp,https://mosaic.moksaweb.com/wp-content/uploads/woocommerce-placeholder.webp,PASS
15
+ ACF,image (return array) __id,@VAR('post/meta_acf_img__id'),5,5,PASS
16
+ ACF,image (return id),@VAR('post/meta_acf_img_id'),https://mosaic.moksaweb.com/wp-content/uploads/2026/09/richmenu-a.png,https://mosaic.moksaweb.com/wp-content/uploads/2026/09/richmenu-a.png,PASS
17
+ ACF,image (return id) __id,@VAR('post/meta_acf_img_id__id'),37,37,PASS
18
+ ACF,file (return url),@VAR('post/meta_acf_file'),https://mosaic.moksaweb.com/wp-content/uploads/woocommerce-placeholder.webp,https://mosaic.moksaweb.com/wp-content/uploads/woocommerce-placeholder.webp,PASS
19
+ ACF,link (return array),@VAR('post/meta_acf_link'),https://example.com/link,https://example.com/link,PASS
20
+ ACF,link __title,@VAR('post/meta_acf_link__title'),Link title,Link title,PASS
21
+ ACF,link __target,@VAR('post/meta_acf_link__target'),_blank,_blank,PASS
22
+ ACF,page_link,@VAR('post/meta_acf_pl'),https://mosaic.moksaweb.com/sample-page/,https://mosaic.moksaweb.com/sample-page/,PASS
23
+ ACF,post_object,@VAR('post/meta_acf_po'),6,6,PASS
24
+ ACF,post_object __label,@VAR('post/meta_acf_po__label'),Shop,Shop,PASS
25
+ ACF,post_object __url,@VAR('post/meta_acf_po__url'),https://mosaic.moksaweb.com/shop/,https://mosaic.moksaweb.com/shop/,PASS
26
+ ACF,user,@VAR('post/meta_acf_user'),1,1,PASS
27
+ ACF,user __label,@VAR('post/meta_acf_user__label'),Cheng-Chun Chang,Cheng-Chun Chang,PASS
28
+ ACF,user __url,@VAR('post/meta_acf_user__url'),https://mosaic.moksaweb.com/author/moksa/,https://mosaic.moksaweb.com/author/moksa/,PASS
29
+ ACF,oembed in a text node,@VAR_RAW('post/meta_acf_oembed'),,,EMPTY
30
+ Meta Box,text,@VAR('post/meta_mb_text'),MB plain text,MB plain text,PASS
31
+ Meta Box,number,@VAR('post/meta_mb_num'),17,17,PASS
32
+ Meta Box,checkbox,@VAR('post/meta_mb_check'),1,1,PASS
33
+ Meta Box,select,@VAR('post/meta_mb_select'),a,a,PASS
34
+ Meta Box,select __label,@VAR('post/meta_mb_select__label'),Alpha,Alpha,PASS
35
+ Meta Box,date,@VAR('post/meta_mb_date'),2026-09-15,2026-09-15,PASS
36
+ Meta Box,color,@VAR('post/meta_mb_color'),#0a84ff,#0a84ff,PASS
37
+ Meta Box,url,@VAR('post/meta_mb_url'),https://example.com/mb,https://example.com/mb,PASS
38
+ Meta Box,wysiwyg,@VAR('post/meta_mb_wys'),MB rich,MB rich,PASS
39
+ Meta Box,single_image,@VAR('post/meta_mb_single'),https://mosaic.moksaweb.com/wp-content/uploads/woocommerce-placeholder.webp,https://mosaic.moksaweb.com/wp-content/uploads/woocommerce-placeholder.webp,PASS
40
+ Meta Box,single_image __id,@VAR('post/meta_mb_single__id'),5,5,PASS
41
+ Meta Box,user,@VAR('post/meta_mb_user'),Cheng-Chun Chang,Cheng-Chun Chang,PASS
42
+ Meta Box,user __id,@VAR('post/meta_mb_user__id'),1,1,PASS
43
+ Meta Box,user __url,@VAR('post/meta_mb_user__url'),https://mosaic.moksaweb.com/author/moksa/,https://mosaic.moksaweb.com/author/moksa/,PASS
44
+ Meta Box,oembed in a text node,@VAR_RAW('post/meta_mb_oembed'),,,EMPTY
45
+ Meta Box,oembed __url,@VAR('post/meta_mb_oembed__url'),https://www.youtube.com/watch?v=dQw4w9WgXcQ,https://www.youtube.com/watch?v=dQw4w9WgXcQ,PASS
46
+ ACF,"relationship, one row inline (index 1)","@LOOP('post/loopacf_rel', 1, 'value__url')",https://mosaic.moksaweb.com/sample-page/,https://mosaic.moksaweb.com/sample-page/,PASS
47
+ ACF,"relationship, every row inline (index 0)","@LOOP('post/loopacf_rel', 0, 'value__label')","Sample Page, Shop","Sample Page, Shop",PASS
48
+ ACF,"group, one row inline","@LOOP('post/loop-acf_group', 1, 'value_ga')",group text,group text,PASS
49
+ ACF,relationship loop row value (= ID),loop post/loopacf_rel -> @VAR('item/value'),2 | 6,2 | 6,PASS
50
+ ACF,relationship loop row __label,loop post/loopacf_rel -> @VAR('item/value__label'),Sample Page | Shop,Sample Page | Shop,PASS
51
+ ACF,relationship loop row __url,loop post/loopacf_rel -> @VAR('item/value__url'),https://mosaic.moksaweb.com/sample-page/ | https://mosaic.moksaweb.com/shop/,https://mosaic.moksaweb.com/sample-page/ | https://mosaic.moksaweb.com/shop/,PASS
52
+ ACF,relationship loop row index,loop post/loopacf_rel -> @VAR('item/index'),1 | 2,1 | 2,PASS
53
+ ACF,checkbox loop row value,loop post/loopacf_check -> @VAR('item/value'),x | z,x | z,PASS
54
+ ACF,taxonomy loop row __label,loop post/loopacf_tax -> @VAR('item/value__label'),Uncategorized,Uncategorized,PASS
55
+ ACF,taxonomy loop row __url,loop post/loopacf_tax -> @VAR('item/value__url'),https://mosaic.moksaweb.com/category/uncategorized/,https://mosaic.moksaweb.com/category/uncategorized/,PASS
56
+ ACF,group loop row sub-field,loop post/loop-acf_group -> @VAR('item/value_ga'),group text,group text,PASS
57
+ ACF,group loop row sub-field (number),loop post/loop-acf_group -> @VAR('item/value_gb'),7,7,PASS
58
+ Meta Box,image_advanced loop row __id,loop post/loopmb_img -> @VAR('item/value__id'),5 | 37,5 | 37,PASS
59
+ Meta Box,checkbox_list loop row value,loop post/loopmb_multi -> @VAR('item/value'),x | y,x | y,PASS
60
+ Meta Box,checkbox_list loop row __label,loop post/loopmb_multi -> @VAR('item/value__label'),Ex | Why,Ex | Why,PASS
61
+ Meta Box,cloneable text loop row value,loop post/loopmb_text_clone -> @VAR('item/value'),clone one | clone two,clone one | clone two,PASS
62
+ ACF,oembed through a code node,code: @VAR_RAW('post/meta_acf_oembed'),<iframe,"<iframe title=""Rick Astley - Never Gonna Give You Up (Offici",PASS
63
+ Meta Box,wysiwyg through a code node,code: @VAR_RAW('post/meta_mb_wys'),<p>MB <em>rich</em></p>,"<div class=""wp-site-blocks""><div class=""entry-content alignf",PASS
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mosaic-headless",
3
- "version": "1.18.0",
3
+ "version": "1.19.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",
@@ -0,0 +1,112 @@
1
+ # Custom fields: ACF and Meta Box through `@VAR` and `@LOOP`, measured
2
+
3
+ Mosaic 1.0.8 reads custom fields through one provider-neutral core with two
4
+ providers wired in: **ACF** (free or Pro) and **Meta Box** (free; Group,
5
+ Relationships, Term/User/Settings add-ons have handlers too). Plain `post_meta`
6
+ without either plugin is read as well, as a bare string. There is no public hook to
7
+ add a provider - `CustomFields/AUTHORING.md` in the plugin says so in its first
8
+ paragraph.
9
+
10
+ Everything below was measured on a live 1.0.8 install: ACF 6.8.10 and Meta Box
11
+ 5.15.0, forty fields registered in code on a page, values written with
12
+ `update_field()` / `rwmb_set_meta()`, then read back **off the delivered HTML** of
13
+ a Mosaic page built through the tables. `data/custom-fields-verification.csv`, 62
14
+ rows: 59 resolve to the value expected, 3 are empty for a reason given below.
15
+
16
+ ## The names
17
+
18
+ A field with meta key `k` on the current post is
19
+
20
+ ```
21
+ @VAR('post/meta_k') the field's primary value
22
+ @VAR('post/meta_k__label') derived properties, when the field type has them
23
+ @VAR('post/meta_k__url')
24
+ @VAR('post/meta_k__id')
25
+ @VAR('post/meta_k__title') ACF link
26
+ @VAR('post/meta_k__target') ACF link
27
+ ```
28
+
29
+ A field that holds MANY values (checkbox, relationship, taxonomy, gallery,
30
+ image_advanced, cloneable, group, repeater) is not a variable at all. It is a
31
+ **loop**, named `loop` + key - `@LOOP('post/loopk')` - and its rows carry the same
32
+ properties under the loop node's namespace (`item` unless `loopNamespace` says
33
+ otherwise): `@VAR('item/value')`, `@VAR('item/value__label')`, `@VAR('item/index')`.
34
+ An ACF **group** is the exception in spelling: `loop-k` with a hyphen, one row, and
35
+ its sub-fields are `item/value_<subfield>` with a single underscore.
36
+
37
+ Do not guess any of this. `wp eval-file tools/list_fields.php <post_id>` prints the
38
+ exact variable and loop names Mosaic registers for that post with the value each
39
+ resolves to and the row variables of every loop - the same schema the frontend
40
+ evaluates, so a name it does not print does not exist:
41
+
42
+ ```
43
+ variable post/meta_acf_po ACF / Post object / ID 6
44
+ variable post/meta_acf_po__label ACF / Post object / 標題 Shop
45
+ variable post/meta_acf_po__url ACF / Post object / 網址 https://…/shop/
46
+ loop post/loopacf_rel ACF / Relationship 2 items; first row: value=2 | value__label=Sample Page | value__url=… | index=1
47
+ loop post/loop-acf_group ACF / Group 1 items; first row: value_ga=group text | value_gb=7 | index=1
48
+ ```
49
+
50
+ ## What each field type resolves to
51
+
52
+ | field | `meta_k` | derived |
53
+ |---|---|---|
54
+ | text, textarea, number, url, email, color, date, select, radio, true_false | the stored value, as a string; a textarea keeps its newline and gets no `<br>`; a date honours ACF's `return_format` | select/radio: `__label` **only when ACF's return format is `array`** - with the default `value` format the label variable is not registered (measured empty) |
55
+ | image, file | the URL, whatever ACF's return format says (`array` and `id` both rendered the URL) | `__id` |
56
+ | link (ACF, array) | the URL | `__title`, `__target` |
57
+ | page_link | the permalink | - |
58
+ | post_object (ACF) | **the ID** | `__label` = title, `__url` = permalink |
59
+ | post (Meta Box) | **the title** | `__id`, `__url` - the two providers put opposite things in the primary slot |
60
+ | user (ACF) | the ID | `__label` = display name, `__url` = author archive |
61
+ | user (Meta Box) | the display name | `__id`, `__url` |
62
+ | oembed | the `<iframe>` HTML - **but see below** | Meta Box adds `__url`, the source URL |
63
+ | wysiwyg | the HTML; Meta Box's arrives wrapped in Mosaic's own `wp-site-blocks` / `entry-content` divs because its value runs through `the_content` | - |
64
+ | checkbox (ACF), checkbox_list (Meta Box), relationship, taxonomy, image_advanced, file_advanced, post (multiple), cloneable | a loop | rows: `value` (ACF reference rows: the ID; Meta Box: the title / URL), `value__label`, `value__url`, `value__id`, `index` |
65
+ | group (ACF) | a loop of one row, named `loop-k` | `value_<sub>` per sub-field |
66
+
67
+ ## Inline access to a loop
68
+
69
+ `@LOOP` takes `(loop, index, row-variable)` pairs and works inside a text node
70
+ without a loop element:
71
+
72
+ ```
73
+ @LOOP('post/loopacf_rel', 1, 'value__label') -> Sample Page row 1 (1-based)
74
+ @LOOP('post/loopacf_rel', 0, 'value__label') -> Sample Page, Shop index 0 = every row, joined ", "
75
+ @LOOP('post/loop-acf_group', 1, 'value_ga') -> group text
76
+ ```
77
+
78
+ A **loop element** is the real thing: `loop > loop-items > loop-item > …`, with
79
+ the source on the loop node:
80
+
81
+ ```json
82
+ {"type": "loop",
83
+ "data": {"loopType": "localContext",
84
+ "localContextOptions": {"loopSource": {"v": "@LOOP('post/loopacf_rel')"}}},
85
+ "children": [{"type": "loop-items", "children": [{"type": "loop-item", "children": [
86
+ {"type": "text", "data": {"tagName": "p"},
87
+ "children": [{"type": "wysiwyg-variable", "data": {"dynamicCode": "@VAR('item/value__label')"}}]}
88
+ ]}]}]}
89
+ ```
90
+
91
+ `loopType:"localContext"` is the built-in source that evaluates an expression in the
92
+ page's context; the sweep's `loop` COMMIT_500 is what happens without it. The loop
93
+ rendered exactly the rows the field holds for every family above (2, 2, 1, 1, 2, 2,
94
+ 2 rows), and an empty field renders `m-loop--no-result` with the `loop-no-result`
95
+ child rather than nothing.
96
+
97
+ ## The three empties, explained
98
+
99
+ - `@VAR('post/meta_acf_select__label')` with ACF's default `value` return format: the
100
+ handler registers `__label` only for `return_format: array`. Switch the field or
101
+ use `@VAR('post/meta_k')` and map it yourself.
102
+ - **oEmbed inside a text node is empty**, with `@VAR` and with `@VAR_RAW` alike - the
103
+ wysiwyg text pipeline strips the `<iframe>`. Put it in a `code` node instead:
104
+ `<div>@VAR_RAW('post/meta_acf_oembed')</div>` delivered the full YouTube iframe.
105
+ The same route carries Meta Box's wysiwyg HTML intact.
106
+
107
+ ## What did not need a field plugin
108
+
109
+ `meta_footnotes` appeared in the list on a page with neither ACF nor Meta Box
110
+ registering it: every non-underscore `post_meta` key present on the post type is
111
+ exposed as `meta_<key>` (`MetaFieldUnknown`, a plain string). A site that stores
112
+ its data with `update_post_meta()` and nothing else is already readable.
@@ -0,0 +1,88 @@
1
+ <?php
2
+ /**
3
+ * Every dynamic variable and loop Mosaic exposes for one post - the exact names
4
+ * `@VAR('post/<name>')` and `@LOOP('post/<name>')` take - with the value each
5
+ * resolves to on that post. Custom fields (ACF, Meta Box, plain post meta) included,
6
+ * with the derived properties a structured field grows (`__label`, `__url`, `__id`,
7
+ * `__title`, `__target`) and the row variables inside each loop.
8
+ *
9
+ * wp eval-file tools/list_fields.php <post_id> [csv]
10
+ *
11
+ * Reads the same schema the frontend evaluates (VariableProviderPost), so a name that
12
+ * is not printed here does not exist for that post, whatever the field plugin calls it.
13
+ * Loop names are the plugin's own: `loop<key>` for most fields, `loop-<key>` for an ACF
14
+ * group. Row variables live under the loop node's `loopNamespace` (default `item`).
15
+ */
16
+
17
+ use Mosaic\Compatibility\VariableProvider\AbstractVariableProvider;
18
+ use Mosaic\Compatibility\VariableProvider\VariableProvider;
19
+ use Mosaic\Compatibility\WordPress\PathContext\VariableProviderPost;
20
+
21
+ $postID = (int)($args[0] ?? 0);
22
+ $asCsv = ($args[1] ?? '') === 'csv';
23
+ $post = get_post($postID);
24
+ if (!$post) {
25
+ WP_CLI::error("no post $postID");
26
+ }
27
+
28
+ $provider = new VariableProvider(VariableProviderPost::getVariableProviderDefinition(get_post_type_object($post->post_type), $postID));
29
+ $ref = new ReflectionClass(AbstractVariableProvider::class);
30
+ $vars = $ref->getProperty('schemaVariables');
31
+ $vars->setAccessible(true);
32
+ $loops = $ref->getProperty('schemaLoops');
33
+ $loops->setAccessible(true);
34
+ $record = $ref->getProperty('currentRecord');
35
+ $record->setAccessible(true);
36
+ $record->setValue($provider, $post);
37
+
38
+ $rows = [];
39
+ foreach ($vars->getValue($provider) as $name => $variable) {
40
+ $value = '';
41
+ try {
42
+ $value = (string)$variable->getValue($post);
43
+ } catch (Throwable $e) {
44
+ $value = 'ERR ' . $e->getMessage();
45
+ }
46
+ $rows[] = ['variable', "post/$name", $variable->getLabel(), $value];
47
+ }
48
+ foreach ($loops->getValue($provider) as $name => $loop) {
49
+ $count = 0;
50
+ $inner = [];
51
+ try {
52
+ $lp = $loop->toLoop($post);
53
+ if ($lp) {
54
+ $count = $lp->getTotalItems();
55
+ if ($count > 0) {
56
+ // read row 1 the way the evaluator does: seek, then getVariable per name
57
+ $lp->seek(0);
58
+ $lv = $ref->getProperty('schemaVariables');
59
+ $lv->setAccessible(true);
60
+ foreach (array_keys($lv->getValue($lp)) as $n) {
61
+ $val = '';
62
+ try {
63
+ $val = (string)$lp->getVariable($n);
64
+ } catch (Throwable $e) {
65
+ $val = '?';
66
+ }
67
+ $inner[] = "$n=" . mb_substr(str_replace(PHP_EOL, ' ', $val), 0, 30);
68
+ }
69
+ }
70
+ }
71
+ } catch (Throwable $e) {
72
+ $inner = ['ERR ' . $e->getMessage()];
73
+ }
74
+ $rows[] = ['loop', "post/$name", $loop->getLabel(), $count . ' items; first row: ' . implode(' | ', $inner)];
75
+ }
76
+
77
+ if ($asCsv) {
78
+ $out = fopen('php://output', 'w');
79
+ fputcsv($out, ['kind', 'name', 'label', 'value']);
80
+ foreach ($rows as $r) {
81
+ fputcsv($out, $r);
82
+ }
83
+ fclose($out);
84
+ } else {
85
+ foreach ($rows as [$kind, $name, $label, $value]) {
86
+ printf("%-8s %-34s %-28s %s\n", $kind, $name, mb_substr($label, 0, 28), mb_substr(str_replace("\n", ' ', $value), 0, 80));
87
+ }
88
+ }