mosaic-headless 1.31.3 → 1.32.1

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/SKILL.md CHANGED
@@ -5,7 +5,7 @@ description: |
5
5
  license: "MIT"
6
6
  metadata:
7
7
  author: "moksa (https://moksaweb.com)"
8
- version: "1.31.3"
8
+ version: "1.32.1"
9
9
  homepage: "https://github.com/Moksa1123/mosaic-headless"
10
10
  ---
11
11
 
@@ -529,6 +529,10 @@ so the pattern is in the data, not just in this paragraph.
529
529
  15. `references/upgrading.md` — what a plugin update does to the data and to the
530
530
  delivered page, measured on 1.0.7 -> 1.0.8; how to drive the migration and what
531
531
  to re-verify afterwards.
532
+ 16. `references/server-thumbnails.md` — Mosaic's headless-Chrome thumbnails on a host
533
+ without root: where Mosaic looks for the binary, a no-root install, and the two
534
+ things (PHP-FPM's disabled functions, a setting decided once) that make a working
535
+ install look broken.
532
536
 
533
537
  ## The data files
534
538
 
@@ -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.31.3"
23
+ "version": "1.32.1"
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.31.3"
23
+ "version": "1.32.1"
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.31.3"
23
+ "version": "1.32.1"
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.31.3"
23
+ "version": "1.32.1"
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.31.3"
23
+ "version": "1.32.1"
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,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mosaic-headless",
3
- "version": "1.31.3",
3
+ "version": "1.32.1",
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",
@@ -213,10 +213,53 @@ only the slots that differ:
213
213
  "cs-name": {"text": "…"}}}
214
214
  ```
215
215
 
216
- **An instance's ids are prefixed with the instance's own attrID.** The definition's
217
- `cs-card` becomes `cs-0-cs-card`, `cs-1-cs-card`, and so on, so the definition's
218
- attrID is a SUFFIX in the delivered page and `getElementById` on it finds nothing.
219
- Address instances with `[id$="-cs-card"]`.
216
+ **Mosaic copies the definition's ids into every instance verbatim; `build_site.py`
217
+ is what makes them unique.** Mosaic has no id-prefixing of its own - an element's
218
+ id is its `attrID` (`ElementAttrID.php`), and every instance renders the
219
+ definition's nodes with the definition's attrIDs, so two instances put every id
220
+ into the document twice. `build_site.py`'s override pass writes an `attrID` of
221
+ `<instance attrID>-<definition attrID>` into each instance's override node: the
222
+ definition's `cs-card` becomes `cs-0-cs-card`, `cs-1-cs-card`, and so on, so the
223
+ definition's attrID is a SUFFIX in the delivered page and `getElementById` on it
224
+ finds nothing. Address instances with `[id$="-cs-card"]`.
225
+
226
+ Until 1.32.1 that pass ran only for instances that HAD overrides, so an instance
227
+ written with none - a contact block placed as-is in a footer and in a page body -
228
+ kept the definition's ids, and a client build found every one of them twice. It runs
229
+ for every instance now; measured on the demo, two override-less instances gave
230
+ `ft-a-cs-card` and `ft-b-cs-card` and no duplicates. A tool other than
231
+ `build_site.py` that writes instances has to do the same, or accept duplicate ids.
232
+
233
+ Four more things a build that leans on components runs into:
234
+
235
+ - **The instance element itself carries no id.** Its `attrID` exists only as the
236
+ prefix `build_site.py` writes, so a selector aimed at the instance's own attrID
237
+ finds nothing - aim at `<instance>-<definition id>`. Two instances given the SAME
238
+ attrID (or none) get the same prefix and so duplicate every id inside; give each a
239
+ distinct attrID.
240
+ - **A loop repeats ids too, and that one is Mosaic's.** Every `loop-item` renders
241
+ the same attrIDs, so a list of six cards carries six of each id (reported from a
242
+ client build). `ElementAttrID.php` evaluates an attrID per loop item, which is the
243
+ hook for making them unique; that has not been measured here.
244
+ - **A component's id is its name.** `build_site.py` derives it with
245
+ `uuid5(COMPONENT_NAMESPACE, name)` so rebuilds update the same row; renaming the
246
+ key in `site["components"]` therefore creates a new component and leaves the old
247
+ one behind.
248
+ - **An expression inside a definition is evaluated where the instance is.** A link
249
+ `{"v": "@concat('/contact/?ref=', @VAR('post/title'), '#consult')"}` inside a
250
+ component, placed in a single-post template, carries THAT post's title (reported
251
+ from a client build on 1.0.10).
252
+ - **The header and footer can hold instances.** `build_site.py` commits the theme's
253
+ design tokens first, builds the components, and only then writes the shell's
254
+ header and footer - with components resolved and overrides applied in the master,
255
+ as on a page. Measured: a footer instance with one override rendered with its
256
+ prefixed ids and the overridden text. (Before this order, a component in the shell
257
+ was impossible: its styles name tokens the shell commit had not made yet.)
258
+
259
+ What this is good for on a client site: anything repeated in several places that
260
+ the client should be able to change themselves - an office address block, a contact
261
+ bar, opening hours. Make it a component and it is edited once, in Mosaic's
262
+ Components panel, and every page follows, with no "site settings" plugin page.
220
263
 
221
264
  The claim worth verifying is not that three cards rendered. It is that they are the
222
265
  same definition, and the evidence is in the class: all three carry the identical
@@ -0,0 +1,84 @@
1
+ # Server-side thumbnails without root
2
+
3
+ Mosaic can capture page and component thumbnails on the server with headless
4
+ Chrome. Its documentation installs Chrome with `apt` as root, which a managed host
5
+ (Cloudways and the like) does not allow. The procedure below needs no root. It was
6
+ worked through end to end on a live client site (Debian 12, Mosaic 1.0.10) - Mosaic's
7
+ "generate thumbnails" completed for every template - and each step is checked
8
+ against the plugin source.
9
+
10
+ ## How Mosaic finds Chrome
11
+
12
+ `Mosaic/Common/ScreenshotChrome.php`:
13
+
14
+ - the binary is, in order: the constant **`MOSAIC_CHROME_BINARY`**, then whatever the
15
+ filter **`mosaic_chrome_binary_path`** returns, then chrome-php's `AutoDiscover`,
16
+ which also honours a `CHROME_PATH` server variable;
17
+ - `isChromiumAvailable()` checks only that `shell_exec`, `proc_open` and
18
+ `is_executable` exist and that the binary is executable;
19
+ - launch options pass through **`mosaic_screenshot_chrome_options`**, and Mosaic
20
+ already runs with `noSandbox`.
21
+
22
+ So anything executable at a path you name will do, including a wrapper script.
23
+
24
+ ## The procedure
25
+
26
+ **1. Check from a web request, never from WP-CLI.** PHP-FPM and the CLI have
27
+ different `disable_functions`: on the client host FPM disabled `proc_open`,
28
+ `proc_close`, `proc_terminate` and `shell_exec` while the CLI did not, so `wp eval`
29
+ said Chrome was available and every web request said it was not. Put a temporary
30
+ PHP file in the web root that loads `wp-load.php` and prints
31
+ `function_exists('proc_open')`, `function_exists('shell_exec')` and
32
+ `\Mosaic\Common\ScreenshotChrome::isChromiumAvailable()`; fetch it once; delete it
33
+ straight away.
34
+
35
+ **2. Install Chrome as the site's own user**, in a directory the app owns outside
36
+ the web root (e.g. the app's `private_html/chrome`):
37
+
38
+ - download `chrome-headless-shell` from Chrome for Testing - the version is in
39
+ `https://googlechromelabs.github.io/chrome-for-testing/LATEST_RELEASE_STABLE`, the
40
+ archive at
41
+ `https://storage.googleapis.com/chrome-for-testing-public/<version>/linux64/chrome-headless-shell-linux64.zip`;
42
+ - `ldd chrome-headless-shell | grep "not found"` - on the client host:
43
+ `libnspr4`, `libnss3`, `libnssutil3`, `libatk-1.0`, `libatk-bridge-2.0`,
44
+ `libXcomposite`, `libXdamage`, `libatspi`;
45
+ - `apt-get download libnspr4 libnss3 libatk1.0-0 libatk-bridge2.0-0 libxcomposite1
46
+ libxdamage1 libatspi2.0-0` (no root needed), unpack each with `dpkg-deb -x`, and
47
+ copy the `*.so*` files into `chrome/lib`; repeat `ldd` until nothing is missing;
48
+ - a wrapper `chrome/chrome`, mode 755, that sets `LD_LIBRARY_PATH` to `chrome/lib`
49
+ and `exec`s `chrome-headless-shell "$@"`.
50
+
51
+ CJK text needs a CJK font the system already has (`fc-list :lang=zh`); that host's
52
+ Droid Sans Fallback was enough.
53
+
54
+ **3. Point Mosaic at the wrapper**:
55
+ `wp config set MOSAIC_CHROME_BINARY /path/to/chrome/chrome --type=constant`.
56
+
57
+ **4. Let PHP-FPM run it.** Remove exactly `proc_open`, `proc_close`, `proc_terminate`
58
+ and `shell_exec` from the app's `php_admin_value[disable_functions]` and keep the rest.
59
+ On Cloudways that is Application Settings → PHP-FPM Settings, or the API's
60
+ `app_fpm_settings_update` (which takes the whole settings block, base64-encoded);
61
+ either way it changes only that app. chrome-php and symfony/process use
62
+ `proc_open` / `proc_close` / `proc_terminate` / `proc_get_status`; Mosaic's own
63
+ detection uses `shell_exec`.
64
+
65
+ **5. Repeat step 1.** `isChromiumAvailable()` from the web request must now be true.
66
+
67
+ **6. Turn the setting on by hand.** `serverSideThumbnailRender` is stored the first
68
+ time Mosaic reads its settings, as whatever `isChromiumAvailable()` said then, and is
69
+ never re-evaluated (`MosaicSettingsMResource.php`). Reload Mosaic → Settings: the
70
+ "Server-side thumbnail rendering" switch appears in the same group as "Load scripts
71
+ inline" and "WordPress emoji support" - only once Chrome is available - so switch it
72
+ on and save.
73
+
74
+ **7. Let the capture past a full-screen entrance.** A page with an entrance veil or
75
+ overlay is photographed as the veil: the client's homepage came out as a single flat
76
+ colour, a 760-byte JPEG. Have the front end drop the veil when
77
+ `/HeadlessChrome/.test(navigator.userAgent)` or `window.self !== window.top`. A
78
+ Playwright check of that entrance then needs a context whose user agent does not
79
+ contain `HeadlessChrome`, or it measures the veil-less page.
80
+
81
+ **8. Generate the thumbnails** from Mosaic. A 600x338 capture took about 2.6s.
82
+
83
+ Steps 2-4 belong to the app, not the site: a new server or a cloned app needs them
84
+ again; moving the same app to another domain does not.
@@ -109,19 +109,46 @@ def build_shell(client, cfg, site, surface):
109
109
 
110
110
  VAR_IDS.clear()
111
111
  payload = theme_records(site, doc, surface)
112
-
113
- records = []
112
+ if payload:
113
+ commit(client, instance, doc, payload, "theme")
114
+ print("shell master=%s theme=%s" % (master_id[:8], ", ".join(sorted(payload)) or "-"))
115
+ return master_id, header_div["ID"], footer_div["ID"]
116
+
117
+
118
+ def fill_shell(client, cfg, site, surface, master_id, header_id, footer_id, comps):
119
+ """Write the header and footer into the master - AFTER the components exist.
120
+
121
+ The shell used to be one commit with the theme, which made a component in the
122
+ header or footer impossible: a component's styles name design tokens, the tokens
123
+ are committed by the shell, so components had to be built after it, and by then
124
+ the shell was already written. The theme now goes first (build_shell), the
125
+ components next, and the regions last, with components resolved and their
126
+ overrides written into the master exactly as a page's are into its template.
127
+ The workaround this replaces - commit an empty shell, then a second real one -
128
+ leaves an extra master behind on every build."""
129
+ instance = "masterDocumentInstance/%s" % master_id
130
+ key = "node/master/%s" % master_id
114
131
  shell = site.get("shell") or {}
115
- if shell.get("header"):
116
- records += flatten(shell["header"], header_div["ID"], master_id, surface, False, parent_type="div")
117
- if shell.get("footer"):
118
- records += flatten(shell["footer"], footer_div["ID"], master_id, surface, False, parent_type="div")
119
- if records:
120
- payload[key] = [{"newRevisionRecord": r, "originalRevisionRecord": None} for r in records]
121
- commit(client, instance, doc, payload, "shell")
122
- print("shell master=%s header+footer nodes=%d theme=%s" % (
123
- master_id[:8], len(records), ", ".join(sorted(payload)) or "-"))
124
- return master_id
132
+ COMPONENT_USES.clear()
133
+ records = []
134
+ for region, parent in (("header", header_id), ("footer", footer_id)):
135
+ tree = shell.get(region)
136
+ if not tree:
137
+ continue
138
+ if comps:
139
+ tree = resolve_components(tree, comps)
140
+ records += flatten(tree, parent, master_id, surface, False, parent_type="div")
141
+ if not records:
142
+ return 0
143
+ uses = list(COMPONENT_USES)
144
+ doc = unwrap(client.get(instance), "masterDocumentInstance")
145
+ commit(client, instance, doc, {key: [{"newRevisionRecord": r, "originalRevisionRecord": None}
146
+ for r in records]}, "shell regions")
147
+ done = apply_overrides(client, instance, key, uses, comps,
148
+ envelope="masterDocumentInstance") if (comps and uses) else 0
149
+ print("shell header+footer nodes=%d component instances=%d overrides=%d"
150
+ % (len(records), len(uses), done))
151
+ return len(records)
125
152
 
126
153
 
127
154
  # A component's row id is derived from its NAME, for the same reason a design
@@ -224,7 +251,7 @@ def ensure_components(client, cfg, site, surface):
224
251
  return out
225
252
 
226
253
 
227
- def apply_overrides(client, instance, key, uses, comps):
254
+ def apply_overrides(client, instance, key, uses, comps, envelope="templateDocumentInstance"):
228
255
  """Second pass: write each instance's own content into the override nodes.
229
256
 
230
257
  Mosaic materialises one override node per component node per instance - node,
@@ -235,10 +262,14 @@ def apply_overrides(client, instance, key, uses, comps):
235
262
  `strpos($type, 'component-instance')`, and strpos returns 0 for a needle at
236
263
  offset 0, so the condition is false for every instance there will ever be.
237
264
  """
238
- wanted = [u for u in uses if u.get("overrides")]
265
+ # EVERY instance, not only the ones with overrides: this pass is also what gives
266
+ # each instance's inner nodes their own ids (below). Filtering on overrides left
267
+ # an instance without any as a verbatim copy of the definition's ids - a client
268
+ # build put the same component in a footer and a page body and got every id twice.
269
+ wanted = list(uses)
239
270
  if not wanted:
240
271
  return 0
241
- doc = unwrap(client.get(instance), "templateDocumentInstance")
272
+ doc = unwrap(client.get(instance), envelope)
242
273
  by_attr = {(n.get("data") or {}).get("attrID"): n for n in doc[key]}
243
274
  revisions = []
244
275
  for use in wanted:
@@ -387,8 +418,9 @@ def main():
387
418
  # AFTER the shell, not before: build_shell is what commits the theme's design
388
419
  # tokens and fills VAR_IDS, and a component that uses one cannot be flattened
389
420
  # until the token it names exists.
390
- master_id = build_shell(client, cfg, site, surface)
421
+ master_id, header_id, footer_id = build_shell(client, cfg, site, surface)
391
422
  comps = ensure_components(client, cfg, site, surface)
423
+ fill_shell(client, cfg, site, surface, master_id, header_id, footer_id, comps)
392
424
 
393
425
  for page in site["pages"]:
394
426
  template_id = bind_page(client, cfg, master_id, page["slug"], page["post_id"])