@elytracms/cli 0.0.85 → 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/cli.js +17 -3
- package/src/doctor.js +69 -12
- package/src/previews.js +228 -30
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/cli.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { spawnSync } from 'node:child_process'
|
|
2
2
|
import { join } from 'node:path'
|
|
3
3
|
import { stdout } from 'node:process'
|
|
4
|
-
import { runDoctor } from './doctor.js'
|
|
4
|
+
import { resolveInstalledEngineVersion, runDoctor } from './doctor.js'
|
|
5
5
|
import { runPreviews } from './previews.js'
|
|
6
6
|
import { inspectProject, resolveStudioEnv } from './project.js'
|
|
7
7
|
import { ensureAuthKeys } from './provision.js'
|
|
@@ -426,7 +426,17 @@ export async function runInit(project, { log: emit = log, fetchImpl, exec, provi
|
|
|
426
426
|
emit('')
|
|
427
427
|
emit('3. Verify —')
|
|
428
428
|
const env = resolveStudioEnv(project.root)
|
|
429
|
-
|
|
429
|
+
// EC-552: with the studio dir known, the verify step includes the version
|
|
430
|
+
// handshake — right after a provision is exactly when a stale-functions state
|
|
431
|
+
// should be loud. Resolution happens HERE (the entry), keeping runDoctor injected.
|
|
432
|
+
const verifyStudioDir = project.apps.studio ? join(project.root, 'studio') : undefined
|
|
433
|
+
const code = await runDoctor({
|
|
434
|
+
env,
|
|
435
|
+
fetchImpl,
|
|
436
|
+
log: (l) => emit(` ${l}`),
|
|
437
|
+
studioDir: verifyStudioDir,
|
|
438
|
+
installedEngineVersion: resolveInstalledEngineVersion(verifyStudioDir),
|
|
439
|
+
})
|
|
430
440
|
|
|
431
441
|
// 4. Ready checklist.
|
|
432
442
|
emit('')
|
|
@@ -470,6 +480,7 @@ export async function run(argv, { cwd = process.cwd(), env = process.env, emit =
|
|
|
470
480
|
return 1
|
|
471
481
|
}
|
|
472
482
|
const studioEnv = resolveStudioEnv(project.root, env)
|
|
483
|
+
const doctorStudioDir = project.apps.studio ? join(project.root, 'studio') : undefined
|
|
473
484
|
return runDoctor({
|
|
474
485
|
env: studioEnv,
|
|
475
486
|
fetchImpl,
|
|
@@ -477,7 +488,10 @@ export async function run(argv, { cwd = process.cwd(), env = process.env, emit =
|
|
|
477
488
|
url: flagValue(argv, '--url'),
|
|
478
489
|
// EC-399: where `convex env list` runs (name enumeration — presence only),
|
|
479
490
|
// and whether the AI provider-key expectation applies.
|
|
480
|
-
studioDir:
|
|
491
|
+
studioDir: doctorStudioDir,
|
|
492
|
+
// EC-552: the installed engine version — resolved at the entry so runDoctor
|
|
493
|
+
// stays pure/injected; unresolvable (deps not installed) → honest `unknown`.
|
|
494
|
+
installedEngineVersion: resolveInstalledEngineVersion(doctorStudioDir),
|
|
481
495
|
// EC-469: where the project's `cms/` route set is read from (code, not backend).
|
|
482
496
|
root: project.root,
|
|
483
497
|
aiRouteConfigured: project.hasAiRoute,
|
package/src/doctor.js
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs'
|
|
2
|
+
import { createRequire } from 'node:module'
|
|
3
|
+
import { dirname, join } from 'node:path'
|
|
1
4
|
import {
|
|
2
5
|
REVALIDATE_ENV_ENDPOINT,
|
|
3
6
|
REVALIDATE_ENV_SECRET,
|
|
@@ -5,6 +8,7 @@ import {
|
|
|
5
8
|
checkDeploymentReady,
|
|
6
9
|
expectedDeploymentEnv,
|
|
7
10
|
probeAuthSigningKeys,
|
|
11
|
+
probeDeployedFunctionsVersion,
|
|
8
12
|
probeRevalidateEndpoint,
|
|
9
13
|
} from '@elytracms/core/export-sync/deployment-ready'
|
|
10
14
|
import { executeWorkspaceConfig } from './config-harness.js'
|
|
@@ -68,16 +72,27 @@ export function resolveDoctorTarget(env = {}, { url } = {}) {
|
|
|
68
72
|
// --- Probes (node) -----------------------------------------------------------
|
|
69
73
|
|
|
70
74
|
/**
|
|
71
|
-
*
|
|
72
|
-
*
|
|
73
|
-
*
|
|
75
|
+
* Deployment probe (EC-542 + EC-552): ONE call to the public query API yields BOTH the
|
|
76
|
+
* reachability fact and the version-handshake probe. The studio's probe was always a
|
|
77
|
+
* real query (`adapter.status()` → `members:mine`); the CLI previously used a bare GET
|
|
78
|
+
* and called ANY HTTP response "answered" — measured false-positive: Convex's wildcard
|
|
79
|
+
* `*.convex.cloud` 404s (empty body) for deployments that DO NOT EXIST, so every
|
|
80
|
+
* nonexistent deployment passed. Reachability now means "answered AS a Convex
|
|
81
|
+
* deployment": a Convex envelope — success, missing-function, or any other Convex
|
|
82
|
+
* error — proves a deployment; a non-Convex answer (the empty 404) or a thrown fetch
|
|
83
|
+
* (DNS/connection) fails it, each with its own honest detail so "nothing there" and
|
|
84
|
+
* "there but not deployed" stay distinguishable (the latter is reachable=true + the
|
|
85
|
+
* `functions-version`/JWKS-404 checks telling the deploy story).
|
|
74
86
|
*/
|
|
75
|
-
export async function
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
87
|
+
export async function probeDeployment(url, fetchImpl = fetch) {
|
|
88
|
+
const versionProbe = await probeDeployedFunctionsVersion(url, fetchImpl)
|
|
89
|
+
const answered =
|
|
90
|
+
versionProbe.status === 'answered' ||
|
|
91
|
+
versionProbe.status === 'missing' ||
|
|
92
|
+
versionProbe.answeredAsConvex === true
|
|
93
|
+
return {
|
|
94
|
+
reachable: answered ? { available: true } : { available: false, detail: versionProbe.detail },
|
|
95
|
+
versionProbe,
|
|
81
96
|
}
|
|
82
97
|
}
|
|
83
98
|
|
|
@@ -86,24 +101,54 @@ export async function probeReachable(url, fetchImpl = fetch) {
|
|
|
86
101
|
* user facts are intentionally omitted (no auth session) → `unknown`; the app facts
|
|
87
102
|
* (config/collections) are gathered separately via the consumer-Vite harness when it
|
|
88
103
|
* is available (EC-495, {@link configFactsFromOutcome}).
|
|
104
|
+
*
|
|
105
|
+
* EC-542/EC-552: reachability and the version probe are ONE network call
|
|
106
|
+
* ({@link probeDeployment}); the `functions-version` fact is emitted only when the
|
|
107
|
+
* caller supplies `installedEngineVersion` (otherwise the probe outcome still decides
|
|
108
|
+
* reachability but the handshake stays an honest `unknown`).
|
|
89
109
|
*/
|
|
90
|
-
export async function gatherFacts(target, { fetchImpl = fetch } = {}) {
|
|
110
|
+
export async function gatherFacts(target, { fetchImpl = fetch, installedEngineVersion } = {}) {
|
|
91
111
|
if (target.kind === 'playground') return { backend: { kind: 'playground' } }
|
|
92
112
|
if (target.kind === 'unconfigured') return { backend: { kind: 'unconfigured', detail: target.detail } }
|
|
93
113
|
|
|
94
|
-
const reachable = await
|
|
114
|
+
const { reachable, versionProbe } = await probeDeployment(target.url, fetchImpl)
|
|
95
115
|
const siteUrl = backendSiteUrl(target.url, target.siteUrl)
|
|
96
116
|
const authKeys = await probeAuthSigningKeys(siteUrl, fetchImpl)
|
|
97
117
|
return {
|
|
98
118
|
backend: { kind: 'convex', url: target.url },
|
|
99
119
|
reachable,
|
|
100
120
|
authKeys,
|
|
121
|
+
...(installedEngineVersion
|
|
122
|
+
? { functionsVersion: { probe: versionProbe, expected: installedEngineVersion } }
|
|
123
|
+
: {}),
|
|
101
124
|
// A configured URL means a deployment is wired; leave `provisioned` unset (→ ok).
|
|
102
125
|
// session omitted → the user checks surface as `unknown`; config/collections are
|
|
103
126
|
// merged in by the caller from the EC-495 harness outcome (or stay omitted).
|
|
104
127
|
}
|
|
105
128
|
}
|
|
106
129
|
|
|
130
|
+
/**
|
|
131
|
+
* Resolve the engine version of the `@elytracms/studio` package INSTALLED in the
|
|
132
|
+
* consumer's studio app (EC-552) — the `expected` half of the version handshake.
|
|
133
|
+
* Deliberately resolved through the `convex/delivery` export and its adjacent
|
|
134
|
+
* `package.json`: that is the very source file the consumer's `convex deploy`
|
|
135
|
+
* bundles, so the version read here is by construction the version a redeploy
|
|
136
|
+
* would stamp. (The consumer's own `package.json` only carries a RANGE.)
|
|
137
|
+
* `undefined` when the deps aren't installed → the fact is omitted → the check
|
|
138
|
+
* reports an honest `unknown`, matching the config checks' opportunistic contract.
|
|
139
|
+
*/
|
|
140
|
+
export function resolveInstalledEngineVersion(studioDir) {
|
|
141
|
+
if (!studioDir) return undefined
|
|
142
|
+
try {
|
|
143
|
+
const req = createRequire(join(studioDir, 'package.json'))
|
|
144
|
+
const deliverySource = req.resolve('@elytracms/studio/convex/delivery')
|
|
145
|
+
const pkg = JSON.parse(readFileSync(join(dirname(deliverySource), '..', 'package.json'), 'utf8'))
|
|
146
|
+
return typeof pkg.version === 'string' ? pkg.version : undefined
|
|
147
|
+
} catch {
|
|
148
|
+
return undefined
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
107
152
|
// --- Config facts via the consumer-Vite harness (EC-495) -------------------------------
|
|
108
153
|
|
|
109
154
|
/**
|
|
@@ -389,6 +434,13 @@ export function formatReport(report) {
|
|
|
389
434
|
* works-without-node_modules contract is untouched. A resolved config also hands the
|
|
390
435
|
* route checks the same collections the studio actually mounts (superseding the
|
|
391
436
|
* `cms/index.ts` re-export). `configOutcome` is injectable for tests.
|
|
437
|
+
*
|
|
438
|
+
* EC-552: when the caller supplies `installedEngineVersion` (the CLI entry resolves it
|
|
439
|
+
* from the consumer's installed `@elytracms/studio` via {@link
|
|
440
|
+
* resolveInstalledEngineVersion}), the deployed functions are probed for their stamped
|
|
441
|
+
* version and the `functions-version` check gets a real answer; otherwise the fact is
|
|
442
|
+
* omitted → an honest `unknown`. Resolution stays at the entry point so this function
|
|
443
|
+
* remains pure/injected like every other gatherer here.
|
|
392
444
|
*/
|
|
393
445
|
export async function runDoctor({
|
|
394
446
|
env = {},
|
|
@@ -402,6 +454,7 @@ export async function runDoctor({
|
|
|
402
454
|
previewsArtifacts,
|
|
403
455
|
routeFacts,
|
|
404
456
|
configOutcome,
|
|
457
|
+
installedEngineVersion,
|
|
405
458
|
} = {}) {
|
|
406
459
|
const target = resolveDoctorTarget(env, { url })
|
|
407
460
|
|
|
@@ -417,7 +470,11 @@ export async function runDoctor({
|
|
|
417
470
|
log('No backend configured.')
|
|
418
471
|
}
|
|
419
472
|
|
|
420
|
-
|
|
473
|
+
// EC-552: the version handshake rides the same probe as reachability; it becomes a
|
|
474
|
+
// real check only when the caller supplied the installed engine version (the CLI
|
|
475
|
+
// entry resolves it via `resolveInstalledEngineVersion`; runDoctor itself never
|
|
476
|
+
// touches the filesystem for it, staying pure/injected like every gatherer here).
|
|
477
|
+
const facts = await gatherFacts(target, { fetchImpl, installedEngineVersion })
|
|
421
478
|
if (target.kind === 'convex') {
|
|
422
479
|
facts.deploymentEnv = gatherDeploymentEnvFacts({ env, target, studioDir, aiRouteConfigured, exec })
|
|
423
480
|
// EC-421: default-on signed no-op ping whenever the revalidate pair is present.
|
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
|
|
@@ -51,12 +56,17 @@ export const PREVIEWS_MANIFEST_FILE = 'elytra-previews.json'
|
|
|
51
56
|
/** Root-relative URL prefix the manifest entries carry. */
|
|
52
57
|
export const PREVIEWS_URL_PREFIX = '/elytra-previews/'
|
|
53
58
|
/**
|
|
54
|
-
* Manifest format version (
|
|
59
|
+
* Manifest format version — THE declaration (EC-514: the studio's mirror was
|
|
60
|
+
* removed; its reader is version-agnostic, and this CLI is the only writer and the
|
|
61
|
+
* only reader of the number). Bump on incompatible schema/hash changes.
|
|
55
62
|
* `2` (EC-502): the entry hash no longer includes the engine version, so v1 hashes
|
|
56
63
|
* are not comparable with v2 ones — a version mismatch is reported as itself
|
|
57
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.
|
|
58
68
|
*/
|
|
59
|
-
export const PREVIEWS_MANIFEST_VERSION =
|
|
69
|
+
export const PREVIEWS_MANIFEST_VERSION = 3
|
|
60
70
|
|
|
61
71
|
/** Capture geometry: 1280×800 CSS pixels (a desktop render), shot at 0.5 → 640×400 (16/10). */
|
|
62
72
|
export const CAPTURE = { width: 1280, height: 800, scale: 0.5 }
|
|
@@ -107,11 +117,22 @@ function sortValue(value) {
|
|
|
107
117
|
}
|
|
108
118
|
|
|
109
119
|
/**
|
|
110
|
-
* The staleness
|
|
111
|
-
* the manifest's prop/slot shape + the fixture's own props/slotChildren
|
|
112
|
-
* the studio's `previewEntriesForConfig`).
|
|
113
|
-
*
|
|
114
|
-
*
|
|
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).
|
|
115
136
|
*
|
|
116
137
|
* **EC-502 — the engine version was removed from this hash.** It made every
|
|
117
138
|
* release invalidate every committed preview in every repo, so `elytra previews
|
|
@@ -133,11 +154,141 @@ export function previewEntryHash(hashInput) {
|
|
|
133
154
|
return createHash('sha256').update(stableStringify({ input: hashInput }), 'utf8').digest('hex')
|
|
134
155
|
}
|
|
135
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
|
+
|
|
136
286
|
/** Serialize the previews manifest deterministically (sorted entry keys, LF, trailing newline). */
|
|
137
|
-
export function serializePreviewsManifest({ engine, entries }) {
|
|
287
|
+
export function serializePreviewsManifest({ engine, entries, renderDeps = [] }) {
|
|
138
288
|
const doc = {
|
|
139
289
|
version: PREVIEWS_MANIFEST_VERSION,
|
|
140
290
|
engine,
|
|
291
|
+
renderDeps: renderDeps.map((d) => ({ path: d.path, hash: d.hash })),
|
|
141
292
|
entries: entries.map((e) => ({
|
|
142
293
|
componentId: e.componentId,
|
|
143
294
|
fixture: e.fixture,
|
|
@@ -165,16 +316,19 @@ const fileOfUrl = (url) => (typeof url === 'string' ? url.split('/').pop() : und
|
|
|
165
316
|
|
|
166
317
|
/**
|
|
167
318
|
* Decide which entries need rendering. `specs` carry a computed `hash`;
|
|
168
|
-
* an entry is FRESH (skipped) when the
|
|
169
|
-
*
|
|
170
|
-
*
|
|
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).
|
|
171
324
|
*/
|
|
172
|
-
export function planPreviewRun({ specs, existing, files }) {
|
|
325
|
+
export function planPreviewRun({ specs, existing, files, renderDeps }) {
|
|
173
326
|
const priorByKey = new Map((existing?.entries ?? []).map((e) => [key(e), e]))
|
|
327
|
+
const depsFresh = renderDepsEqual(existing?.renderDeps, renderDeps)
|
|
174
328
|
return specs.map((spec) => {
|
|
175
329
|
const prior = priorByKey.get(key(spec))
|
|
176
330
|
const priorFile = prior ? fileOfUrl(prior.url) : undefined
|
|
177
|
-
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))
|
|
178
332
|
return fresh ? { ...spec, fresh: true, url: prior.url } : { ...spec, fresh: false }
|
|
179
333
|
})
|
|
180
334
|
}
|
|
@@ -186,9 +340,11 @@ export function planPreviewRun({ specs, existing, files }) {
|
|
|
186
340
|
* Two channels, and the distinction is the whole point (EC-502):
|
|
187
341
|
*
|
|
188
342
|
* - **problems** fail the gate (exit 1). Each one means an artifact does not
|
|
189
|
-
* match what the
|
|
343
|
+
* match what the code declares: a fixture with no preview, a preview whose
|
|
190
344
|
* declared inputs changed, a manifest entry whose image is gone, an entry or
|
|
191
|
-
* 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).
|
|
192
348
|
* - **warnings** are reported and do NOT fail. Today that is exactly one:
|
|
193
349
|
* `engine-drift` — these images were rendered by a different engine version.
|
|
194
350
|
* True and worth saying, but it is implementation drift, the same category
|
|
@@ -201,7 +357,7 @@ export function planPreviewRun({ specs, existing, files }) {
|
|
|
201
357
|
* `stale` line per entry. Say the real thing once, and skip the hash comparison
|
|
202
358
|
* (entry-set and file checks still run — they are version-independent).
|
|
203
359
|
*/
|
|
204
|
-
export function checkPreviews({ specs, manifest, files, engine }) {
|
|
360
|
+
export function checkPreviews({ specs, manifest, files, engine, renderDeps }) {
|
|
205
361
|
const problems = []
|
|
206
362
|
const warnings = []
|
|
207
363
|
if (!manifest) {
|
|
@@ -215,6 +371,10 @@ export function checkPreviews({ specs, manifest, files, engine }) {
|
|
|
215
371
|
const comparableHashes = version === PREVIEWS_MANIFEST_VERSION
|
|
216
372
|
if (!comparableHashes) {
|
|
217
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 })
|
|
218
378
|
}
|
|
219
379
|
if (engine !== undefined && manifest.engine !== undefined && manifest.engine !== engine) {
|
|
220
380
|
warnings.push({ kind: 'engine-drift', from: manifest.engine, to: engine })
|
|
@@ -263,6 +423,13 @@ export function formatCheckProblems(problems) {
|
|
|
263
423
|
if (p.kind === 'manifest-version') {
|
|
264
424
|
return ` ✗ manifest written in format v${p.from}, this CLI writes v${p.to} — hashes are not comparable; regenerate`
|
|
265
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
|
+
}
|
|
266
433
|
return p.kind === 'orphan-file'
|
|
267
434
|
? ` ✗ ${label[p.kind]} ${p.file}`
|
|
268
435
|
: ` ✗ ${label[p.kind]} ${p.componentId} · ${p.fixture}${p.file ? ` (${p.file})` : ''}`
|
|
@@ -507,7 +674,27 @@ async function startHarnessServer({ root, studioDir, port }) {
|
|
|
507
674
|
port,
|
|
508
675
|
plugins: [tailwind(), ...harnessPlugins({ configPath, themePath })],
|
|
509
676
|
})
|
|
510
|
-
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
|
+
}
|
|
511
698
|
}
|
|
512
699
|
|
|
513
700
|
const PLAYWRIGHT_INSTALL_HELP = [
|
|
@@ -634,7 +821,11 @@ export async function runPreviews(project, { check = false, port, log = (l) => c
|
|
|
634
821
|
fixture: entry.fixture,
|
|
635
822
|
hash: previewEntryHash(entry.hashInput),
|
|
636
823
|
}))
|
|
637
|
-
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)
|
|
638
829
|
|
|
639
830
|
const artifacts = gatherPreviewsArtifacts(studioDir)
|
|
640
831
|
const existing = parsePreviewsManifest(artifacts.manifestText)
|
|
@@ -645,12 +836,15 @@ export async function runPreviews(project, { check = false, port, log = (l) => c
|
|
|
645
836
|
manifest: existing,
|
|
646
837
|
files: artifacts.files,
|
|
647
838
|
engine,
|
|
839
|
+
renderDeps,
|
|
648
840
|
})
|
|
649
841
|
// Warnings print on BOTH verdicts — a green run that still has something to
|
|
650
842
|
// say says it, and a red run does not swallow it.
|
|
651
843
|
for (const line of formatCheckWarnings(warnings)) log(line)
|
|
652
844
|
if (ok) {
|
|
653
|
-
log(
|
|
845
|
+
log(
|
|
846
|
+
`✓ ${specs.length} preview(s) up to date (declared inputs and render dependencies unchanged, all files present).`,
|
|
847
|
+
)
|
|
654
848
|
return 0
|
|
655
849
|
}
|
|
656
850
|
log(`✗ ${problems.length} problem(s):`)
|
|
@@ -659,7 +853,11 @@ export async function runPreviews(project, { check = false, port, log = (l) => c
|
|
|
659
853
|
return 1
|
|
660
854
|
}
|
|
661
855
|
|
|
662
|
-
|
|
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 })
|
|
663
861
|
const toRender = jobs.filter((j) => !j.fresh)
|
|
664
862
|
log(`${jobs.length - toRender.length} unchanged (skipped), ${toRender.length} to render.`)
|
|
665
863
|
|
|
@@ -715,7 +913,7 @@ export async function runPreviews(project, { check = false, port, log = (l) => c
|
|
|
715
913
|
|
|
716
914
|
// Manifest: every successful entry (fresh or rendered), enumeration order.
|
|
717
915
|
const finalEntries = jobs.filter((j) => typeof j.url === 'string')
|
|
718
|
-
const manifestText = serializePreviewsManifest({ engine, entries: finalEntries })
|
|
916
|
+
const manifestText = serializePreviewsManifest({ engine, entries: finalEntries, renderDeps })
|
|
719
917
|
const manifestChanged = artifacts.manifestText !== manifestText
|
|
720
918
|
if (manifestChanged) {
|
|
721
919
|
mkdirSync(studioDir, { recursive: true })
|