@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.
- package/package.json +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.
|
|
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.
|
|
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) —
|
|
22
|
-
*
|
|
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
|
|
25
|
-
*
|
|
26
|
-
* byte-stable, so "skip unchanged" is
|
|
27
|
-
*
|
|
28
|
-
*
|
|
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 =
|
|
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
|
|
113
|
-
* the manifest's prop/slot shape + the fixture's own props/slotChildren
|
|
114
|
-
* the studio's `previewEntriesForConfig`).
|
|
115
|
-
*
|
|
116
|
-
*
|
|
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
|
|
171
|
-
*
|
|
172
|
-
*
|
|
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
|
|
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
|
|
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(
|
|
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
|
-
|
|
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 })
|