@elytracms/cli 0.0.86 → 0.0.88
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/cli.js +3 -1
- package/src/doctor.js +74 -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.88",
|
|
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.88"
|
|
21
21
|
},
|
|
22
22
|
"devDependencies": {
|
|
23
23
|
"vitest": "^4.1.10"
|
package/src/cli.js
CHANGED
|
@@ -117,7 +117,9 @@ and are checked in the studio), and reports the generated-previews artifacts
|
|
|
117
117
|
(manifest/files present; hash staleness lives in \`elytra previews --check\`).
|
|
118
118
|
With studio/ deps installed, config validity is checked for real: doctor
|
|
119
119
|
executes elytra.config.ts through your studio's own Vite (takes a few
|
|
120
|
-
seconds); without deps it stays an honest "unknown" with the reason.
|
|
120
|
+
seconds); without deps it stays an honest "unknown" with the reason. With
|
|
121
|
+
the config resolved, it also reads the public delivery snapshot and warns
|
|
122
|
+
about published documents whose collection the config does not declare.
|
|
121
123
|
Exit non-zero if any check fails. Read-only.
|
|
122
124
|
|
|
123
125
|
Options:
|
package/src/doctor.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { readFileSync } from 'node:fs'
|
|
2
2
|
import { createRequire } from 'node:module'
|
|
3
3
|
import { dirname, join } from 'node:path'
|
|
4
|
+
import { orphanedCollections } from '@elytracms/core/cms-core'
|
|
4
5
|
import {
|
|
5
6
|
REVALIDATE_ENV_ENDPOINT,
|
|
6
7
|
REVALIDATE_ENV_SECRET,
|
|
@@ -8,6 +9,7 @@ import {
|
|
|
8
9
|
checkDeploymentReady,
|
|
9
10
|
expectedDeploymentEnv,
|
|
10
11
|
probeAuthSigningKeys,
|
|
12
|
+
probeDeliverySnapshot,
|
|
11
13
|
probeDeployedFunctionsVersion,
|
|
12
14
|
probeRevalidateEndpoint,
|
|
13
15
|
} from '@elytracms/core/export-sync/deployment-ready'
|
|
@@ -201,7 +203,7 @@ export function configFactsFromOutcome(outcome) {
|
|
|
201
203
|
issueCount: issues.length,
|
|
202
204
|
...(firstIssue ? { firstIssue: formatConfigIssue(firstIssue) } : {}),
|
|
203
205
|
}
|
|
204
|
-
const project =
|
|
206
|
+
const project = resolvedProjectOf(outcome)
|
|
205
207
|
const collections = project?.collections
|
|
206
208
|
// EC-524: the `componentIds` fact was gathered here for EC-508's presentation
|
|
207
209
|
// check, which resolved a collection's declared `presentation.wrapper` against
|
|
@@ -216,6 +218,60 @@ export function configFactsFromOutcome(outcome) {
|
|
|
216
218
|
}
|
|
217
219
|
}
|
|
218
220
|
|
|
221
|
+
/**
|
|
222
|
+
* The project a resolved config outcome describes — the first one, the same choice
|
|
223
|
+
* {@link configFactsFromOutcome} makes for `collections` — or `undefined` when the
|
|
224
|
+
* config did not resolve validly (or was never evaluated). Pure.
|
|
225
|
+
*/
|
|
226
|
+
export function resolvedProjectOf(outcome) {
|
|
227
|
+
if (!outcome || outcome.kind !== 'resolved' || outcome.result?.ok !== true) return undefined
|
|
228
|
+
return outcome.result.projects?.[0]
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// --- Published orphans (EC-606) -----------------------------------------------------
|
|
232
|
+
//
|
|
233
|
+
// A published document whose collection the config does not declare is still on the
|
|
234
|
+
// public delivery snapshot, but no route or query addresses it and the studio hides it.
|
|
235
|
+
// The doctor reads the snapshot (public, published-only — what every visitor receives)
|
|
236
|
+
// and runs core's ONE counter (`orphanedCollections`, the same one the studio's session
|
|
237
|
+
// hydration runs) against the resolved config's collection ids.
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Gather the `publishedOrphans` fact for a convex target, or `undefined` for any other
|
|
241
|
+
* target (no deployment, no snapshot). Without a declared set — the config did not
|
|
242
|
+
* resolve here — the fact is an honest `unknown` carrying why, and no request is made.
|
|
243
|
+
* `project` is {@link resolvedProjectOf}'s answer; `configFacts` is what
|
|
244
|
+
* {@link configFactsFromOutcome} returned (for the reason text).
|
|
245
|
+
*/
|
|
246
|
+
export async function gatherPublishedOrphans({
|
|
247
|
+
target,
|
|
248
|
+
project,
|
|
249
|
+
configFacts = {},
|
|
250
|
+
fetchImpl = fetch,
|
|
251
|
+
probe = probeDeliverySnapshot,
|
|
252
|
+
} = {}) {
|
|
253
|
+
if (target?.kind !== 'convex') return undefined
|
|
254
|
+
if (!project || !Array.isArray(project.collections)) {
|
|
255
|
+
const reason =
|
|
256
|
+
configFacts.config !== undefined && !configFacts.config.valid
|
|
257
|
+
? 'fix the workspace config issues first (see config-valid).'
|
|
258
|
+
: (configFacts.configUnavailableDetail ?? 'elytra.config.ts was not evaluated here.')
|
|
259
|
+
return {
|
|
260
|
+
status: 'unknown',
|
|
261
|
+
detail: `Published documents were not checked against the declared collections — ${reason}`,
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
const snapshot = await probe(target.url, project.slug, fetchImpl)
|
|
265
|
+
if (snapshot.status !== 'answered') return { status: 'unknown', detail: snapshot.detail }
|
|
266
|
+
return {
|
|
267
|
+
status: 'counted',
|
|
268
|
+
orphans: orphanedCollections(
|
|
269
|
+
snapshot.collections.map((collection) => ({ collection, published: true })),
|
|
270
|
+
project.collections.map((collection) => collection.id),
|
|
271
|
+
),
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
219
275
|
// --- Deployment env-var presence (EC-399) --------------------------------------
|
|
220
276
|
//
|
|
221
277
|
// The Convex deployment's own env (`npx convex env set …`) lives in NO file a
|
|
@@ -441,6 +497,11 @@ export function formatReport(report) {
|
|
|
441
497
|
* version and the `functions-version` check gets a real answer; otherwise the fact is
|
|
442
498
|
* omitted → an honest `unknown`. Resolution stays at the entry point so this function
|
|
443
499
|
* remains pure/injected like every other gatherer here.
|
|
500
|
+
*
|
|
501
|
+
* EC-606: against a convex target the `app` bucket also reports PUBLISHED ORPHANS —
|
|
502
|
+
* published documents whose collection the resolved config does not declare — read
|
|
503
|
+
* from the public delivery snapshot ({@link gatherPublishedOrphans}). A `warn` at most,
|
|
504
|
+
* so the exit code never changes; without a resolved config it is an honest `unknown`.
|
|
444
505
|
*/
|
|
445
506
|
export async function runDoctor({
|
|
446
507
|
env = {},
|
|
@@ -491,12 +552,23 @@ export async function runDoctor({
|
|
|
491
552
|
// (announced by the harness itself — the boot costs seconds). Merged BEFORE the
|
|
492
553
|
// contract composes the report so `config-valid`/`asset-collection` are real checks.
|
|
493
554
|
let configFacts = {}
|
|
555
|
+
let outcome
|
|
494
556
|
if (configOutcome !== undefined || root !== undefined) {
|
|
495
|
-
|
|
557
|
+
outcome = configOutcome ?? (await executeWorkspaceConfig({ root, studioDir, log }))
|
|
496
558
|
configFacts = configFactsFromOutcome(outcome)
|
|
497
559
|
Object.assign(facts, configFacts)
|
|
498
560
|
}
|
|
499
561
|
|
|
562
|
+
// EC-606: published documents of undeclared collections — one more request on the
|
|
563
|
+
// same public query API, made only when the config resolved (it needs the declared set).
|
|
564
|
+
const publishedOrphans = await gatherPublishedOrphans({
|
|
565
|
+
target,
|
|
566
|
+
project: resolvedProjectOf(outcome),
|
|
567
|
+
configFacts,
|
|
568
|
+
fetchImpl,
|
|
569
|
+
})
|
|
570
|
+
if (publishedOrphans) facts.publishedOrphans = publishedOrphans
|
|
571
|
+
|
|
500
572
|
const report = checkDeploymentReady(facts)
|
|
501
573
|
|
|
502
574
|
// EC-440: the previews bucket (repo artifacts, backend-independent). Appended to
|
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 })
|