@elytracms/cli 0.0.86 → 0.0.87

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/package.json +2 -2
  2. package/src/previews.js +225 -29
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@elytracms/cli",
3
3
  "author": "finaldream <hi@finaldream.de>",
4
- "version": "0.0.86",
4
+ "version": "0.0.87",
5
5
  "description": "The Elytra CLI — run a consuming repo's studio, frontend, and Convex backend under one command.",
6
6
  "license": "MIT",
7
7
  "type": "module",
@@ -17,7 +17,7 @@
17
17
  "node": ">=22.18.0"
18
18
  },
19
19
  "dependencies": {
20
- "@elytracms/core": "^0.0.86"
20
+ "@elytracms/core": "^0.0.87"
21
21
  },
22
22
  "devDependencies": {
23
23
  "vitest": "^4.1.10"
package/src/previews.js CHANGED
@@ -1,8 +1,8 @@
1
1
  import { createHash } from 'node:crypto'
2
2
  import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
3
- import { join } from 'node:path'
3
+ import { dirname, isAbsolute, join, relative, sep } from 'node:path'
4
4
  import { pathToFileURL } from 'node:url'
5
- import { pickPort, resolveFromDir, startConsumerVite, virtualModulesPlugin } from './config-harness.js'
5
+ import { CONFIG_FILE, pickPort, resolveFromDir, startConsumerVite, virtualModulesPlugin } from './config-harness.js'
6
6
 
7
7
  /**
8
8
  * `elytra previews` (EC-440) — build-time fixture-preview generation.
@@ -18,14 +18,19 @@ import { pickPort, resolveFromDir, startConsumerVite, virtualModulesPlugin } fro
18
18
  * the blocks-panel tile);
19
19
  * - a previews manifest to `studio/elytra-previews.json` (importable next to
20
20
  * `studio.config.tsx`) carrying per entry: componentId, fixture, URL, and
21
- * an input hash (manifest prop/slot shape + fixture props) — no
22
- * timestamps, so unchanged inputs re-serialize to the same bytes.
21
+ * an input hash (manifest prop/slot shape + fixture props) — plus ONE
22
+ * manifest-level `renderDeps` list (EC-600): a content hash per project
23
+ * source file the config imports and per stylesheet in the theme's
24
+ * `@import` chain. No timestamps, so unchanged inputs re-serialize to the
25
+ * same bytes.
23
26
  *
24
- * Idempotence: an entry whose input hash matches the existing manifest AND
25
- * whose image file exists is skipped entirely (screenshots are not guaranteed
26
- * byte-stable, so "skip unchanged" is the mechanism, not re-render-and-
27
- * compare); the manifest is only written when its bytes change. A re-run with
28
- * unchanged inputs therefore rewrites nothing.
27
+ * Idempotence: an entry is skipped entirely when its input hash matches the
28
+ * existing manifest, its image file exists, AND the render dependencies are
29
+ * unchanged (screenshots are not guaranteed byte-stable, so "skip unchanged" is
30
+ * the mechanism, not re-render-and-compare). A changed render dependency
31
+ * re-renders every entry — nothing can say which pixels it touched. The
32
+ * manifest is only written when its bytes change. A re-run with unchanged
33
+ * inputs therefore rewrites nothing.
29
34
  *
30
35
  * Harness: the shared consumer-Vite boot (`config-harness.js`, EC-495 — one
31
36
  * mechanism for this command and `elytra doctor`'s config check) plus a
@@ -57,8 +62,11 @@ export const PREVIEWS_URL_PREFIX = '/elytra-previews/'
57
62
  * `2` (EC-502): the entry hash no longer includes the engine version, so v1 hashes
58
63
  * are not comparable with v2 ones — a version mismatch is reported as itself
59
64
  * ("regenerate"), never as N bogus `stale` entries.
65
+ * `3` (EC-600): the manifest carries `renderDeps` (see {@link renderDepsOf}); a v2
66
+ * manifest has none, so its freshness cannot be judged — reported once as
67
+ * `manifest-version`, and one regeneration renders everything against the deps.
60
68
  */
61
- export const PREVIEWS_MANIFEST_VERSION = 2
69
+ export const PREVIEWS_MANIFEST_VERSION = 3
62
70
 
63
71
  /** Capture geometry: 1280×800 CSS pixels (a desktop render), shot at 0.5 → 640×400 (16/10). */
64
72
  export const CAPTURE = { width: 1280, height: 800, scale: 0.5 }
@@ -109,11 +117,22 @@ function sortValue(value) {
109
117
  }
110
118
 
111
119
  /**
112
- * The staleness input hash: sha-256 over the stable-serialized render inputs —
113
- * the manifest's prop/slot shape + the fixture's own props/slotChildren (from
114
- * the studio's `previewEntriesForConfig`). Implementation and theme drift are
115
- * deliberately NOT hashed (the ratified EC-440 split: those are caught by
116
- * regeneration runs, e.g. in the consumer's build).
120
+ * The PER-ENTRY staleness hash: sha-256 over the stable-serialized DECLARED
121
+ * inputs — the manifest's prop/slot shape + the fixture's own props/slotChildren
122
+ * (from the studio's `previewEntriesForConfig`). A change here is local to one
123
+ * entry: that entry re-renders and `--check` names exactly it.
124
+ *
125
+ * The component implementation and the theme are NOT in this hash — they have
126
+ * no per-entry identity (a config arrives evaluated; the theme is one stylesheet
127
+ * for everything). Until EC-600 this comment claimed regeneration caught drift
128
+ * there. It did not: regeneration rendered only `!fresh` jobs and `fresh` was
129
+ * decided by this hash alone, so a changed implementation or theme kept BOTH the
130
+ * check green and the images stale (measured on apps/demo 2026-10-09: an
131
+ * appended export in `hero.tsx` and a new `:root` rule in `theme.css` each left
132
+ * `--check` at exit 0; a fixture edit flipped it to `stale`). They are covered
133
+ * now by the manifest-level `renderDeps` ({@link renderDepsOf}): any change there
134
+ * re-renders everything and reports ONE `render-deps` problem — never N
135
+ * per-entry `stale` lines (the EC-502 lesson below).
117
136
  *
118
137
  * **EC-502 — the engine version was removed from this hash.** It made every
119
138
  * release invalidate every committed preview in every repo, so `elytra previews
@@ -135,11 +154,141 @@ export function previewEntryHash(hashInput) {
135
154
  return createHash('sha256').update(stableStringify({ input: hashInput }), 'utf8').digest('hex')
136
155
  }
137
156
 
157
+ // --- Render dependencies (EC-600) ------------------------------------------------
158
+
159
+ /**
160
+ * Root-relative POSIX path of an absolute file under `root` and not under a
161
+ * `node_modules` directory; `null` otherwise (virtual ids, installed packages,
162
+ * engine sources that resolve outside the project).
163
+ */
164
+ function projectPath(root, file) {
165
+ if (typeof file !== 'string' || !isAbsolute(file)) return null
166
+ const rel = relative(root, file)
167
+ if (rel.length === 0 || rel.startsWith('..') || isAbsolute(rel)) return null
168
+ const posix = rel.split(sep).join('/')
169
+ return posix.split('/').includes('node_modules') ? null : posix
170
+ }
171
+
172
+ /**
173
+ * The project source files a Vite module graph loaded: every module with a real
174
+ * file under `root` that is not under `node_modules` — exactly what
175
+ * `elytra.config.ts` transitively imports, wherever the consumer keeps it
176
+ * (components, their helpers, imported assets, `cms/`). No directory
177
+ * convention is assumed (AGENTS.md §4: capability, never structure). Virtual
178
+ * modules have no file; installed packages sit under node_modules (in this
179
+ * monorepo they resolve to workspace packages outside the project) — the
180
+ * engine category the manifest's `engine` field and the engine-drift warning
181
+ * already cover. Absolute paths, sorted, unique.
182
+ */
183
+ export function projectSourceFiles(moduleFiles, root) {
184
+ const out = new Set()
185
+ for (const file of moduleFiles) if (projectPath(root, file) !== null) out.add(file)
186
+ return [...out].sort()
187
+ }
188
+
189
+ const CSS_COMMENT = /\/\*[\s\S]*?\*\//g
190
+ const CSS_IMPORT = /@import\s+(?:url\(\s*)?(?:(['"])([^'"]+)\1|([^'")\s;]+))/g
191
+
192
+ /**
193
+ * The theme entry plus every stylesheet it reaches through RELATIVE `@import`s,
194
+ * recursively (each file once; an extension-less `./tokens` resolves to
195
+ * `./tokens.css` the way postcss-import does). Bare specifiers (`tailwindcss`,
196
+ * a package's CSS) are installed code and are not followed; a relative target
197
+ * that does not exist is skipped here — the importing file, whose text names
198
+ * it, is hashed regardless, and the render itself fails loudly on it. Absolute
199
+ * paths, in import order.
200
+ */
201
+ export function themeImportChain(entryPath, { readFile = readFileSync, exists = existsSync } = {}) {
202
+ const chain = []
203
+ const seen = new Set()
204
+ const visit = (file) => {
205
+ if (seen.has(file)) return
206
+ seen.add(file)
207
+ if (!exists(file)) return
208
+ chain.push(file)
209
+ const text = String(readFile(file)).replace(CSS_COMMENT, '')
210
+ for (const match of text.matchAll(CSS_IMPORT)) {
211
+ const spec = match[2] ?? match[3]
212
+ if (!spec || spec.startsWith('//') || /^[a-z][a-z0-9+.-]*:/i.test(spec)) continue
213
+ const target = join(dirname(file), spec)
214
+ if (exists(target)) visit(target)
215
+ else if (spec.startsWith('.') && exists(`${target}.css`)) visit(`${target}.css`)
216
+ }
217
+ }
218
+ visit(entryPath)
219
+ return chain
220
+ }
221
+
222
+ /**
223
+ * Content-hash the render dependencies — the manifest's `renderDeps`:
224
+ * `[{ path, hash }]`, root-relative POSIX paths, sha-256 of the bytes, sorted by
225
+ * path, deduped. Byte-deterministic for unchanged files.
226
+ */
227
+ export function renderDepsOf(root, files, { readFile = readFileSync } = {}) {
228
+ const byPath = new Map()
229
+ for (const file of files) {
230
+ const path = relative(root, file).split(sep).join('/')
231
+ if (byPath.has(path)) continue
232
+ byPath.set(path, createHash('sha256').update(readFile(file)).digest('hex'))
233
+ }
234
+ return [...byPath.entries()]
235
+ .map(([path, hash]) => ({ path, hash }))
236
+ .sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0))
237
+ }
238
+
239
+ /**
240
+ * The paths whose hash differs / are new / are gone between two `renderDeps`
241
+ * lists (each sorted). A missing list (a pre-EC-600 manifest) counts as empty,
242
+ * so every current dependency shows up as `added`.
243
+ */
244
+ export function diffRenderDeps(prior, current) {
245
+ const before = new Map((prior ?? []).map((d) => [d.path, d.hash]))
246
+ const after = new Map((current ?? []).map((d) => [d.path, d.hash]))
247
+ const changed = []
248
+ const added = []
249
+ const removed = []
250
+ for (const [path, hash] of after) {
251
+ if (!before.has(path)) added.push(path)
252
+ else if (before.get(path) !== hash) changed.push(path)
253
+ }
254
+ for (const path of before.keys()) if (!after.has(path)) removed.push(path)
255
+ return { changed: changed.sort(), added: added.sort(), removed: removed.sort() }
256
+ }
257
+
258
+ /** `true` when nothing changed, was added, or was removed. */
259
+ export function renderDepsEqual(prior, current) {
260
+ const { changed, added, removed } = diffRenderDeps(prior, current)
261
+ return changed.length + added.length + removed.length === 0
262
+ }
263
+
264
+ /** The changed/added/removed paths as one short list (`+new`, `-gone`), capped. */
265
+ function summarizeRenderDeps(diff, cap = 6) {
266
+ const paths = [...diff.changed, ...diff.added.map((x) => `+${x}`), ...diff.removed.map((x) => `-${x}`)]
267
+ const shown = paths.slice(0, cap).join(', ') + (paths.length > cap ? `, … +${paths.length - cap} more` : '')
268
+ return { count: paths.length, shown }
269
+ }
270
+
271
+ /**
272
+ * What the render-dependency coverage IS and IS NOT — printed on every run, so
273
+ * "fresh" is read as exactly the claim it makes: a visible caveat, not a
274
+ * comment (AGENTS.md §4).
275
+ */
276
+ export function describeRenderDeps({ sourceCount, themeCount, themeEntry }) {
277
+ return [
278
+ `Render dependencies: ${sourceCount} project source file(s) imported by ${CONFIG_FILE} ` +
279
+ `+ ${themeCount} theme stylesheet(s) (the @import chain of ${themeEntry}). ` +
280
+ 'A change in any of them re-renders every preview.',
281
+ ' Not seen: modules loaded only at render time (dynamic imports), assets referenced by URL, ' +
282
+ 'installed packages (the engine version is reported separately, as a warning).',
283
+ ]
284
+ }
285
+
138
286
  /** Serialize the previews manifest deterministically (sorted entry keys, LF, trailing newline). */
139
- export function serializePreviewsManifest({ engine, entries }) {
287
+ export function serializePreviewsManifest({ engine, entries, renderDeps = [] }) {
140
288
  const doc = {
141
289
  version: PREVIEWS_MANIFEST_VERSION,
142
290
  engine,
291
+ renderDeps: renderDeps.map((d) => ({ path: d.path, hash: d.hash })),
143
292
  entries: entries.map((e) => ({
144
293
  componentId: e.componentId,
145
294
  fixture: e.fixture,
@@ -167,16 +316,19 @@ const fileOfUrl = (url) => (typeof url === 'string' ? url.split('/').pop() : und
167
316
 
168
317
  /**
169
318
  * Decide which entries need rendering. `specs` carry a computed `hash`;
170
- * an entry is FRESH (skipped) when the existing manifest has the same hash for
171
- * it and the image file it names is present. Fresh entries keep their existing
172
- * URL verbatim (a prior PNG-fallback file stays a PNG until its inputs change).
319
+ * an entry is FRESH (skipped) when the render dependencies are unchanged
320
+ * (EC-600 — `renderDeps` against the manifest's; a changed dependency makes
321
+ * EVERY entry stale), the existing manifest has the same hash for it, and the
322
+ * image file it names is present. Fresh entries keep their existing URL
323
+ * verbatim (a prior PNG-fallback file stays a PNG until its inputs change).
173
324
  */
174
- export function planPreviewRun({ specs, existing, files }) {
325
+ export function planPreviewRun({ specs, existing, files, renderDeps }) {
175
326
  const priorByKey = new Map((existing?.entries ?? []).map((e) => [key(e), e]))
327
+ const depsFresh = renderDepsEqual(existing?.renderDeps, renderDeps)
176
328
  return specs.map((spec) => {
177
329
  const prior = priorByKey.get(key(spec))
178
330
  const priorFile = prior ? fileOfUrl(prior.url) : undefined
179
- const fresh = Boolean(prior && prior.hash === spec.hash && priorFile && files.includes(priorFile))
331
+ const fresh = Boolean(depsFresh && prior && prior.hash === spec.hash && priorFile && files.includes(priorFile))
180
332
  return fresh ? { ...spec, fresh: true, url: prior.url } : { ...spec, fresh: false }
181
333
  })
182
334
  }
@@ -188,9 +340,11 @@ export function planPreviewRun({ specs, existing, files }) {
188
340
  * Two channels, and the distinction is the whole point (EC-502):
189
341
  *
190
342
  * - **problems** fail the gate (exit 1). Each one means an artifact does not
191
- * match what the config declares: a fixture with no preview, a preview whose
343
+ * match what the code declares: a fixture with no preview, a preview whose
192
344
  * declared inputs changed, a manifest entry whose image is gone, an entry or
193
- * file nothing enumerates any more.
345
+ * file nothing enumerates any more — and, ONCE for the whole set, a render
346
+ * dependency (a config-imported source file or a theme stylesheet) whose
347
+ * content changed since the images were rendered (EC-600).
194
348
  * - **warnings** are reported and do NOT fail. Today that is exactly one:
195
349
  * `engine-drift` — these images were rendered by a different engine version.
196
350
  * True and worth saying, but it is implementation drift, the same category
@@ -203,7 +357,7 @@ export function planPreviewRun({ specs, existing, files }) {
203
357
  * `stale` line per entry. Say the real thing once, and skip the hash comparison
204
358
  * (entry-set and file checks still run — they are version-independent).
205
359
  */
206
- export function checkPreviews({ specs, manifest, files, engine }) {
360
+ export function checkPreviews({ specs, manifest, files, engine, renderDeps }) {
207
361
  const problems = []
208
362
  const warnings = []
209
363
  if (!manifest) {
@@ -217,6 +371,10 @@ export function checkPreviews({ specs, manifest, files, engine }) {
217
371
  const comparableHashes = version === PREVIEWS_MANIFEST_VERSION
218
372
  if (!comparableHashes) {
219
373
  problems.push({ kind: 'manifest-version', from: version, to: PREVIEWS_MANIFEST_VERSION })
374
+ } else {
375
+ // One problem for the whole set — which files, never N bogus per-entry `stale` rows.
376
+ const deps = diffRenderDeps(manifest.renderDeps, renderDeps)
377
+ if (!renderDepsEqual(manifest.renderDeps, renderDeps)) problems.push({ kind: 'render-deps', ...deps })
220
378
  }
221
379
  if (engine !== undefined && manifest.engine !== undefined && manifest.engine !== engine) {
222
380
  warnings.push({ kind: 'engine-drift', from: manifest.engine, to: engine })
@@ -265,6 +423,13 @@ export function formatCheckProblems(problems) {
265
423
  if (p.kind === 'manifest-version') {
266
424
  return ` ✗ manifest written in format v${p.from}, this CLI writes v${p.to} — hashes are not comparable; regenerate`
267
425
  }
426
+ if (p.kind === 'render-deps') {
427
+ const { count, shown } = summarizeRenderDeps(p)
428
+ return (
429
+ ` ✗ render deps ${count} render dependenc${count === 1 ? 'y' : 'ies'} changed since these previews ` +
430
+ `were rendered (${shown}) — every preview re-renders`
431
+ )
432
+ }
268
433
  return p.kind === 'orphan-file'
269
434
  ? ` ✗ ${label[p.kind]} ${p.file}`
270
435
  : ` ✗ ${label[p.kind]} ${p.componentId} · ${p.fixture}${p.file ? ` (${p.file})` : ''}`
@@ -509,7 +674,27 @@ async function startHarnessServer({ root, studioDir, port }) {
509
674
  port,
510
675
  plugins: [tailwind(), ...harnessPlugins({ configPath, themePath })],
511
676
  })
512
- return { server, url: `http://127.0.0.1:${port}${HARNESS_PATH}` }
677
+ return { server, url: `http://127.0.0.1:${port}${HARNESS_PATH}`, themePath }
678
+ }
679
+
680
+ /**
681
+ * The render dependencies of a booted harness (EC-600): the project source files
682
+ * the SSR module graph loaded while enumerating (what `elytra.config.ts`
683
+ * imports) plus the theme's `@import` chain, content-hashed. Call AFTER
684
+ * `ssrLoadModule` has run, or the graph is empty.
685
+ */
686
+ function collectRenderDeps(harness, root) {
687
+ const graph = harness.server.environments?.ssr?.moduleGraph ?? harness.server.moduleGraph
688
+ const sources = projectSourceFiles([...graph.idToModuleMap.values()].map((m) => m.file), root)
689
+ const theme = themeImportChain(harness.themePath)
690
+ return {
691
+ renderDeps: renderDepsOf(root, [...sources, ...theme]),
692
+ description: describeRenderDeps({
693
+ sourceCount: sources.length,
694
+ themeCount: theme.length,
695
+ themeEntry: relative(root, harness.themePath).split(sep).join('/'),
696
+ }),
697
+ }
513
698
  }
514
699
 
515
700
  const PLAYWRIGHT_INSTALL_HELP = [
@@ -636,7 +821,11 @@ export async function runPreviews(project, { check = false, port, log = (l) => c
636
821
  fixture: entry.fixture,
637
822
  hash: previewEntryHash(entry.hashInput),
638
823
  }))
639
- log(`Enumerated ${specs.length} fixture preview(s) from elytra.config.ts.`)
824
+ log(`Enumerated ${specs.length} fixture preview(s) from ${CONFIG_FILE}.`)
825
+ // EC-600: what the config imported while enumerating + the theme chain — and,
826
+ // visibly, what that does NOT cover.
827
+ const { renderDeps, description } = collectRenderDeps(harness, root)
828
+ for (const line of description) log(line)
640
829
 
641
830
  const artifacts = gatherPreviewsArtifacts(studioDir)
642
831
  const existing = parsePreviewsManifest(artifacts.manifestText)
@@ -647,12 +836,15 @@ export async function runPreviews(project, { check = false, port, log = (l) => c
647
836
  manifest: existing,
648
837
  files: artifacts.files,
649
838
  engine,
839
+ renderDeps,
650
840
  })
651
841
  // Warnings print on BOTH verdicts — a green run that still has something to
652
842
  // say says it, and a red run does not swallow it.
653
843
  for (const line of formatCheckWarnings(warnings)) log(line)
654
844
  if (ok) {
655
- log(`✓ ${specs.length} preview(s) up to date (hashes match, all files present).`)
845
+ log(
846
+ `✓ ${specs.length} preview(s) up to date (declared inputs and render dependencies unchanged, all files present).`,
847
+ )
656
848
  return 0
657
849
  }
658
850
  log(`✗ ${problems.length} problem(s):`)
@@ -661,7 +853,11 @@ export async function runPreviews(project, { check = false, port, log = (l) => c
661
853
  return 1
662
854
  }
663
855
 
664
- const jobs = planPreviewRun({ specs, existing, files: artifacts.files })
856
+ if (existing && !renderDepsEqual(existing.renderDeps, renderDeps)) {
857
+ const { shown } = summarizeRenderDeps(diffRenderDeps(existing.renderDeps, renderDeps))
858
+ log(`Render dependencies changed (${shown}) — every preview re-renders.`)
859
+ }
860
+ const jobs = planPreviewRun({ specs, existing, files: artifacts.files, renderDeps })
665
861
  const toRender = jobs.filter((j) => !j.fresh)
666
862
  log(`${jobs.length - toRender.length} unchanged (skipped), ${toRender.length} to render.`)
667
863
 
@@ -717,7 +913,7 @@ export async function runPreviews(project, { check = false, port, log = (l) => c
717
913
 
718
914
  // Manifest: every successful entry (fresh or rendered), enumeration order.
719
915
  const finalEntries = jobs.filter((j) => typeof j.url === 'string')
720
- const manifestText = serializePreviewsManifest({ engine, entries: finalEntries })
916
+ const manifestText = serializePreviewsManifest({ engine, entries: finalEntries, renderDeps })
721
917
  const manifestChanged = artifacts.manifestText !== manifestText
722
918
  if (manifestChanged) {
723
919
  mkdirSync(studioDir, { recursive: true })