@elytracms/cli 0.0.70 → 0.0.72
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/README.md +20 -1
- package/package.json +2 -2
- package/src/cli.js +32 -2
- package/src/doctor.js +20 -1
- package/src/previews.js +715 -0
package/README.md
CHANGED
|
@@ -29,7 +29,26 @@ elytra doctor Diagnose the active deployment's readiness and print each fix.
|
|
|
29
29
|
by NAME only — values are never read into the report or printed.
|
|
30
30
|
When the publish revalidate pair is set, also sends a signed
|
|
31
31
|
no-op ping to the endpoint to prove the publish → live chain
|
|
32
|
-
end to end.
|
|
32
|
+
end to end. Also reports the generated-previews artifacts
|
|
33
|
+
(manifest present, image files present, no strays — warnings
|
|
34
|
+
only; previews are optional). Read-only; exits non-zero if any
|
|
35
|
+
check fails.
|
|
36
|
+
|
|
37
|
+
elytra previews Render every canvas-eligible component's fixtures against your
|
|
38
|
+
theme and write committed insert-menu preview artifacts:
|
|
39
|
+
640×400 WebP images in studio/public/elytra-previews/ plus an
|
|
40
|
+
importable studio/elytra-previews.json manifest (per-entry
|
|
41
|
+
input hashes — re-running with unchanged inputs rewrites
|
|
42
|
+
nothing; removed components' images are pruned). Wire it with
|
|
43
|
+
`generatedPreviewsFromManifest` from
|
|
44
|
+
`@elytracms/studio/customization` (two lines — see the docs).
|
|
45
|
+
Rendering needs Playwright once (optional, on demand):
|
|
46
|
+
pnpm add -D playwright && pnpm exec playwright install chromium
|
|
47
|
+
Options:
|
|
48
|
+
--check verify the input hashes without rendering (no
|
|
49
|
+
Playwright needed); exits non-zero when previews
|
|
50
|
+
are missing/stale/orphaned. Wire into CI/builds.
|
|
51
|
+
--port <n> harness dev-server port (default: first free 5440+)
|
|
33
52
|
|
|
34
53
|
elytra help Show help
|
|
35
54
|
elytra version Show version
|
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.72",
|
|
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": ">=20"
|
|
18
18
|
},
|
|
19
19
|
"dependencies": {
|
|
20
|
-
"@elytracms/core": "^0.0.
|
|
20
|
+
"@elytracms/core": "^0.0.72"
|
|
21
21
|
},
|
|
22
22
|
"devDependencies": {
|
|
23
23
|
"vitest": "^4.1.10"
|
package/src/cli.js
CHANGED
|
@@ -2,6 +2,7 @@ import { spawnSync } from 'node:child_process'
|
|
|
2
2
|
import { join } from 'node:path'
|
|
3
3
|
import { stdout } from 'node:process'
|
|
4
4
|
import { runDoctor } from './doctor.js'
|
|
5
|
+
import { runPreviews } from './previews.js'
|
|
5
6
|
import { inspectProject, resolveStudioEnv } from './project.js'
|
|
6
7
|
import { ensureAuthKeys } from './provision.js'
|
|
7
8
|
import { runGroup, runSequential } from './run.js'
|
|
@@ -27,8 +28,18 @@ Commands:
|
|
|
27
28
|
signing keys, expected deployment env vars — set/missing by NAME,
|
|
28
29
|
values never shown — config, membership) and print each fix. When the
|
|
29
30
|
instant-publish revalidate pair is set, also sends a signed no-op ping
|
|
30
|
-
to the endpoint to prove the publish → live chain end to end.
|
|
31
|
-
|
|
31
|
+
to the endpoint to prove the publish → live chain end to end. Also
|
|
32
|
+
reports the generated-previews artifacts (manifest/files present;
|
|
33
|
+
hash staleness lives in \`elytra previews --check\`). Exit non-zero
|
|
34
|
+
if any check fails. Read-only. Optional: --url <convex url>.
|
|
35
|
+
previews Render every canvas-eligible component's fixtures against YOUR theme
|
|
36
|
+
(headless Chromium via Playwright — an optional install) and write
|
|
37
|
+
committed preview images (studio/public/elytra-previews/) plus an
|
|
38
|
+
importable manifest (studio/elytra-previews.json) for the insert
|
|
39
|
+
menu. Re-running with unchanged inputs rewrites nothing.
|
|
40
|
+
Options: --check verify freshness (input hashes) without rendering,
|
|
41
|
+
exit non-zero when previews are missing/stale;
|
|
42
|
+
--port <n> harness dev-server port (default: first free 5440+).
|
|
32
43
|
help Show this help
|
|
33
44
|
version Show version
|
|
34
45
|
|
|
@@ -342,6 +353,25 @@ export async function run(argv, { cwd = process.cwd(), env = process.env, emit =
|
|
|
342
353
|
return runInit(project, { log: emit, fetchImpl, exec })
|
|
343
354
|
}
|
|
344
355
|
|
|
356
|
+
// `elytra previews [--check] [--port <n>]` (EC-440): build-time fixture-preview
|
|
357
|
+
// generation through the studio app's own Vite + on-demand Playwright.
|
|
358
|
+
if (command === 'previews') {
|
|
359
|
+
const project = inspectProject(cwd, env)
|
|
360
|
+
if (project.issues.length > 0) {
|
|
361
|
+
reportIssues(project.issues)
|
|
362
|
+
return 1
|
|
363
|
+
}
|
|
364
|
+
if (!project.apps.studio) {
|
|
365
|
+
log('No studio/ app found — `elytra previews` renders through the studio app’s Vite.')
|
|
366
|
+
return 1
|
|
367
|
+
}
|
|
368
|
+
return runPreviews(project, {
|
|
369
|
+
check: argv.includes('--check'),
|
|
370
|
+
port: flagValue(argv, '--port'),
|
|
371
|
+
log: emit,
|
|
372
|
+
})
|
|
373
|
+
}
|
|
374
|
+
|
|
345
375
|
if (command !== 'dev' && command !== 'build') {
|
|
346
376
|
log(`Unknown command: ${command}\n`)
|
|
347
377
|
log(HELP)
|
package/src/doctor.js
CHANGED
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
probeRevalidateEndpoint,
|
|
9
9
|
} from '@elytracms/core/export-sync/deployment-ready'
|
|
10
10
|
import { convexEnvGet, defaultConvexExec } from './provision.js'
|
|
11
|
+
import { gatherPreviewsArtifacts, previewsDoctorChecks } from './previews.js'
|
|
11
12
|
|
|
12
13
|
/**
|
|
13
14
|
* `elytra doctor` (EC-259) — the pre-boot counterpart to the studio's boot
|
|
@@ -232,6 +233,7 @@ const BUCKET_TITLE = {
|
|
|
232
233
|
'deployment-env': 'Deployment env',
|
|
233
234
|
app: 'App',
|
|
234
235
|
user: 'Per-user',
|
|
236
|
+
previews: 'Previews',
|
|
235
237
|
}
|
|
236
238
|
|
|
237
239
|
/**
|
|
@@ -275,6 +277,13 @@ export function formatReport(report) {
|
|
|
275
277
|
* studio dir the env checks stay honest `unknown`s. Playground stays untouched — no
|
|
276
278
|
* deployment, no env bucket. When the instant-publish revalidate pair is present, a
|
|
277
279
|
* signed no-op ping proves the publish → live chain end to end (EC-421, default-on).
|
|
280
|
+
*
|
|
281
|
+
* EC-440: when a studio dir is known, a `previews` bucket reports the generated
|
|
282
|
+
* fixture-preview artifacts — the cheap honest file checks a bundler-less CLI can
|
|
283
|
+
* make (manifest present + parses, every entry's image present, no strays). All
|
|
284
|
+
* findings there are warnings (previews are optional); input-hash STALENESS needs
|
|
285
|
+
* the component registry and lives in `elytra previews --check` (the ratified
|
|
286
|
+
* split). `previewsArtifacts` is injectable for tests.
|
|
278
287
|
*/
|
|
279
288
|
export async function runDoctor({
|
|
280
289
|
env = {},
|
|
@@ -284,6 +293,7 @@ export async function runDoctor({
|
|
|
284
293
|
studioDir,
|
|
285
294
|
aiRouteConfigured = false,
|
|
286
295
|
exec,
|
|
296
|
+
previewsArtifacts,
|
|
287
297
|
} = {}) {
|
|
288
298
|
const target = resolveDoctorTarget(env, { url })
|
|
289
299
|
|
|
@@ -312,6 +322,15 @@ export async function runDoctor({
|
|
|
312
322
|
if (revalidateProbe) facts.revalidateProbe = revalidateProbe
|
|
313
323
|
}
|
|
314
324
|
const report = checkDeploymentReady(facts)
|
|
315
|
-
|
|
325
|
+
|
|
326
|
+
// EC-440: the previews bucket (repo artifacts, backend-independent). Appended to
|
|
327
|
+
// the shared report before formatting so it wears the same glyph/fix conventions;
|
|
328
|
+
// its checks are ok/warn/unknown only, so `report.ok` (the exit code) is unchanged.
|
|
329
|
+
const artifacts = previewsArtifacts ?? (studioDir ? gatherPreviewsArtifacts(studioDir) : undefined)
|
|
330
|
+
const merged = artifacts
|
|
331
|
+
? { ...report, checks: [...report.checks, ...previewsDoctorChecks(artifacts)] }
|
|
332
|
+
: report
|
|
333
|
+
|
|
334
|
+
log(formatReport(merged))
|
|
316
335
|
return report.ok ? 0 : 1
|
|
317
336
|
}
|
package/src/previews.js
ADDED
|
@@ -0,0 +1,715 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
|
3
|
+
import { createRequire } from 'node:module'
|
|
4
|
+
import { createServer as createNetServer } from 'node:net'
|
|
5
|
+
import { join } from 'node:path'
|
|
6
|
+
import { pathToFileURL } from 'node:url'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* `elytra previews` (EC-440) — build-time fixture-preview generation.
|
|
10
|
+
*
|
|
11
|
+
* Renders every canvas-eligible component's fixtures (each declared fixture;
|
|
12
|
+
* fixture-less components once as `Default`) against the CONSUMER theme — the
|
|
13
|
+
* same standalone Tailwind entry the canvas iframe and delivery load (EC-414)
|
|
14
|
+
* — and writes:
|
|
15
|
+
*
|
|
16
|
+
* - images to `studio/public/elytra-previews/` (served at
|
|
17
|
+
* `/elytra-previews/...`), deterministically named
|
|
18
|
+
* `<componentId>__<fixture>.webp` (PNG fallback), 640×400 (16/10 — @2x of
|
|
19
|
+
* the blocks-panel tile);
|
|
20
|
+
* - a previews manifest to `studio/elytra-previews.json` (importable next to
|
|
21
|
+
* `studio.config.tsx`) carrying per entry: componentId, fixture, URL, and
|
|
22
|
+
* an input hash (manifest prop/slot shape + fixture props + engine
|
|
23
|
+
* version) — no timestamps, so unchanged inputs re-serialize to the same
|
|
24
|
+
* bytes.
|
|
25
|
+
*
|
|
26
|
+
* Idempotence: an entry whose input hash matches the existing manifest AND
|
|
27
|
+
* whose image file exists is skipped entirely (screenshots are not guaranteed
|
|
28
|
+
* byte-stable, so "skip unchanged" is the mechanism, not re-render-and-
|
|
29
|
+
* compare); the manifest is only written when its bytes change. A re-run with
|
|
30
|
+
* unchanged inputs therefore rewrites nothing.
|
|
31
|
+
*
|
|
32
|
+
* Harness: the CLI boots the consumer studio app's own Vite PROGRAMMATICALLY
|
|
33
|
+
* (resolved from the studio dir; a minimal inline config — the studio's
|
|
34
|
+
* `vite.config.ts`, with its app plugins/env, is deliberately NOT loaded) and
|
|
35
|
+
* serves a DATA-FREE render page that imports exactly the consumer registry
|
|
36
|
+
* (`elytra.config.ts`) and the consumer theme. Enumeration + hashing run
|
|
37
|
+
* node-side through the same Vite (`ssrLoadModule`), so `--check` needs no
|
|
38
|
+
* browser; rendering drives headless Chromium via Playwright — an ON-DEMAND
|
|
39
|
+
* dependency (dynamic import + install instructions), never a hard dep of
|
|
40
|
+
* `@elytracms/cli`.
|
|
41
|
+
*
|
|
42
|
+
* Staleness split (the ratified EC-440 design): `elytra previews --check`
|
|
43
|
+
* owns full input-hash staleness (it has the registry); `elytra doctor`'s
|
|
44
|
+
* previews bucket does the cheap honest file checks (manifest present/parses,
|
|
45
|
+
* every entry's image present, no orphans) — see {@link previewsDoctorChecks}.
|
|
46
|
+
*/
|
|
47
|
+
|
|
48
|
+
// --- Conventions ---------------------------------------------------------------
|
|
49
|
+
|
|
50
|
+
/** Where the images live inside the studio app (served at `/elytra-previews/...`). */
|
|
51
|
+
export const PREVIEWS_DIR = join('public', 'elytra-previews')
|
|
52
|
+
/** The manifest file, next to `studio.config.tsx` so it imports as `./elytra-previews.json`. */
|
|
53
|
+
export const PREVIEWS_MANIFEST_FILE = 'elytra-previews.json'
|
|
54
|
+
/** Root-relative URL prefix the manifest entries carry. */
|
|
55
|
+
export const PREVIEWS_URL_PREFIX = '/elytra-previews/'
|
|
56
|
+
/** Manifest format version (mirrors `@elytracms/studio`'s PREVIEWS_MANIFEST_VERSION). */
|
|
57
|
+
export const PREVIEWS_MANIFEST_VERSION = 1
|
|
58
|
+
|
|
59
|
+
/** Capture geometry: 1280×800 CSS pixels (a desktop render), shot at 0.5 → 640×400 (16/10). */
|
|
60
|
+
export const CAPTURE = { width: 1280, height: 800, scale: 0.5 }
|
|
61
|
+
/** WebP quality — modest; typical fixture renders land well under 100 KB. */
|
|
62
|
+
export const WEBP_QUALITY = 80
|
|
63
|
+
/** Per-fixture budget: a render that hasn't settled in 30s fails that entry. */
|
|
64
|
+
export const RENDER_TIMEOUT_MS = 30_000
|
|
65
|
+
|
|
66
|
+
const HARNESS_PATH = '/__elytra-previews__/'
|
|
67
|
+
const ENTRY_ID = 'virtual:elytra-previews-entry'
|
|
68
|
+
const ENUMERATE_ID = 'virtual:elytra-previews-enumerate'
|
|
69
|
+
|
|
70
|
+
// --- Pure helpers ----------------------------------------------------------------
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Deterministic, filename-safe slug for a component id / fixture name.
|
|
74
|
+
* Path-hostile characters become `-`; whenever the cleaned form differs from
|
|
75
|
+
* the raw name (or overflows the length cap) an 8-hex sha-256 suffix of the
|
|
76
|
+
* RAW name keeps distinct names distinct ("Startseite v2" vs "Startseite-v2").
|
|
77
|
+
*/
|
|
78
|
+
export function sanitizePreviewSlug(raw) {
|
|
79
|
+
const MAX = 60
|
|
80
|
+
const cleaned = String(raw).replace(/[^A-Za-z0-9._-]/g, '-')
|
|
81
|
+
if (cleaned === raw && raw.length > 0 && raw.length <= MAX) return raw
|
|
82
|
+
const suffix = createHash('sha256').update(String(raw), 'utf8').digest('hex').slice(0, 8)
|
|
83
|
+
const base = cleaned.slice(0, MAX)
|
|
84
|
+
return `${base.length > 0 ? base : 'x'}-${suffix}`
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** The deterministic image filename for an entry: `<componentId>__<fixture>.<ext>`. */
|
|
88
|
+
export function previewFileName(componentId, fixture, ext) {
|
|
89
|
+
return `${sanitizePreviewSlug(componentId)}__${sanitizePreviewSlug(fixture)}.${ext}`
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** JSON.stringify with recursively sorted object keys — stable across runs. */
|
|
93
|
+
export function stableStringify(value) {
|
|
94
|
+
return JSON.stringify(sortValue(value))
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function sortValue(value) {
|
|
98
|
+
if (Array.isArray(value)) return value.map(sortValue)
|
|
99
|
+
if (value && typeof value === 'object') {
|
|
100
|
+
const out = {}
|
|
101
|
+
for (const key of Object.keys(value).sort()) out[key] = sortValue(value[key])
|
|
102
|
+
return out
|
|
103
|
+
}
|
|
104
|
+
return value
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* The staleness input hash: sha-256 over the stable-serialized render inputs —
|
|
109
|
+
* the manifest's prop/slot shape + the fixture's own props/slotChildren (from
|
|
110
|
+
* the studio's `previewEntriesForConfig`) + the installed engine version.
|
|
111
|
+
* Implementation and theme drift are deliberately NOT hashed (the ratified
|
|
112
|
+
* split: those are caught by regeneration runs, e.g. in the consumer's build).
|
|
113
|
+
*/
|
|
114
|
+
export function previewEntryHash(hashInput, engineVersion) {
|
|
115
|
+
return createHash('sha256')
|
|
116
|
+
.update(stableStringify({ engine: engineVersion, input: hashInput }), 'utf8')
|
|
117
|
+
.digest('hex')
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Serialize the previews manifest deterministically (sorted entry keys, LF, trailing newline). */
|
|
121
|
+
export function serializePreviewsManifest({ engine, entries }) {
|
|
122
|
+
const doc = {
|
|
123
|
+
version: PREVIEWS_MANIFEST_VERSION,
|
|
124
|
+
engine,
|
|
125
|
+
entries: entries.map((e) => ({
|
|
126
|
+
componentId: e.componentId,
|
|
127
|
+
fixture: e.fixture,
|
|
128
|
+
url: e.url,
|
|
129
|
+
hash: e.hash,
|
|
130
|
+
})),
|
|
131
|
+
}
|
|
132
|
+
return `${JSON.stringify(doc, null, 2)}\n`
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Parse a previews manifest; `null` when missing/unparsable/mis-shaped (tolerant). */
|
|
136
|
+
export function parsePreviewsManifest(text) {
|
|
137
|
+
if (typeof text !== 'string') return null
|
|
138
|
+
try {
|
|
139
|
+
const doc = JSON.parse(text)
|
|
140
|
+
if (typeof doc !== 'object' || doc === null || !Array.isArray(doc.entries)) return null
|
|
141
|
+
return doc
|
|
142
|
+
} catch {
|
|
143
|
+
return null
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const key = (e) => `${e.componentId}\u0000${e.fixture}`
|
|
148
|
+
const fileOfUrl = (url) => (typeof url === 'string' ? url.split('/').pop() : undefined)
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Decide which entries need rendering. `specs` carry a computed `hash`;
|
|
152
|
+
* an entry is FRESH (skipped) when the existing manifest has the same hash for
|
|
153
|
+
* it and the image file it names is present. Fresh entries keep their existing
|
|
154
|
+
* URL verbatim (a prior PNG-fallback file stays a PNG until its inputs change).
|
|
155
|
+
*/
|
|
156
|
+
export function planPreviewRun({ specs, existing, files }) {
|
|
157
|
+
const priorByKey = new Map((existing?.entries ?? []).map((e) => [key(e), e]))
|
|
158
|
+
return specs.map((spec) => {
|
|
159
|
+
const prior = priorByKey.get(key(spec))
|
|
160
|
+
const priorFile = prior ? fileOfUrl(prior.url) : undefined
|
|
161
|
+
const fresh = Boolean(prior && prior.hash === spec.hash && priorFile && files.includes(priorFile))
|
|
162
|
+
return fresh ? { ...spec, fresh: true, url: prior.url } : { ...spec, fresh: false }
|
|
163
|
+
})
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* `--check` verdicts: compare the enumerated entry set (+ fresh hashes)
|
|
168
|
+
* against the committed manifest + image files. Every problem names its
|
|
169
|
+
* entry; an empty list is green.
|
|
170
|
+
*/
|
|
171
|
+
export function checkPreviews({ specs, manifest, files }) {
|
|
172
|
+
const problems = []
|
|
173
|
+
if (!manifest) {
|
|
174
|
+
for (const spec of specs) {
|
|
175
|
+
problems.push({ kind: 'missing', componentId: spec.componentId, fixture: spec.fixture })
|
|
176
|
+
}
|
|
177
|
+
return { ok: specs.length === 0, problems }
|
|
178
|
+
}
|
|
179
|
+
const entryByKey = new Map(manifest.entries.map((e) => [key(e), e]))
|
|
180
|
+
const specKeys = new Set(specs.map((s) => key(s)))
|
|
181
|
+
|
|
182
|
+
for (const spec of specs) {
|
|
183
|
+
const entry = entryByKey.get(key(spec))
|
|
184
|
+
if (!entry) {
|
|
185
|
+
problems.push({ kind: 'missing', componentId: spec.componentId, fixture: spec.fixture })
|
|
186
|
+
continue
|
|
187
|
+
}
|
|
188
|
+
const file = fileOfUrl(entry.url)
|
|
189
|
+
if (entry.hash !== spec.hash) {
|
|
190
|
+
problems.push({ kind: 'stale', componentId: spec.componentId, fixture: spec.fixture })
|
|
191
|
+
} else if (!file || !files.includes(file)) {
|
|
192
|
+
problems.push({ kind: 'missing-file', componentId: spec.componentId, fixture: spec.fixture, file })
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
for (const entry of manifest.entries) {
|
|
196
|
+
if (!specKeys.has(key(entry))) {
|
|
197
|
+
problems.push({ kind: 'orphan-entry', componentId: entry.componentId, fixture: entry.fixture })
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
// A file is stray when NO manifest entry references it at all.
|
|
201
|
+
for (const file of files) {
|
|
202
|
+
if (!manifest.entries.some((e) => fileOfUrl(e.url) === file)) {
|
|
203
|
+
problems.push({ kind: 'orphan-file', file })
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
return { ok: problems.length === 0, problems }
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** One line per problem, stable order, for `--check` output. */
|
|
210
|
+
export function formatCheckProblems(problems) {
|
|
211
|
+
const label = {
|
|
212
|
+
missing: 'missing ',
|
|
213
|
+
stale: 'stale ',
|
|
214
|
+
'missing-file': 'file gone ',
|
|
215
|
+
'orphan-entry': 'orphaned ',
|
|
216
|
+
'orphan-file': 'stray file',
|
|
217
|
+
}
|
|
218
|
+
return problems.map((p) =>
|
|
219
|
+
p.kind === 'orphan-file'
|
|
220
|
+
? ` ✗ ${label[p.kind]} ${p.file}`
|
|
221
|
+
: ` ✗ ${label[p.kind]} ${p.componentId} · ${p.fixture}${p.file ? ` (${p.file})` : ''}`,
|
|
222
|
+
)
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// --- Doctor bucket (cheap, honest, registry-free) ---------------------------------
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Gather the previews artifacts a plain-node doctor can see: the manifest file
|
|
229
|
+
* and the images directory. No config execution, no registry — those checks
|
|
230
|
+
* live in `elytra previews --check`.
|
|
231
|
+
*/
|
|
232
|
+
export function gatherPreviewsArtifacts(studioDir) {
|
|
233
|
+
const manifestPath = join(studioDir, PREVIEWS_MANIFEST_FILE)
|
|
234
|
+
const imagesDir = join(studioDir, PREVIEWS_DIR)
|
|
235
|
+
const manifestExists = existsSync(manifestPath)
|
|
236
|
+
const dirExists = existsSync(imagesDir)
|
|
237
|
+
return {
|
|
238
|
+
manifestExists,
|
|
239
|
+
manifestText: manifestExists ? readFileSync(manifestPath, 'utf8') : undefined,
|
|
240
|
+
dirExists,
|
|
241
|
+
files: dirExists ? readdirSync(imagesDir).filter((f) => !f.startsWith('.')) : [],
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* The doctor's `previews` bucket (EC-440): the cheap honest file checks a
|
|
247
|
+
* bundler-less CLI can make. Previews are OPTIONAL — every finding here is a
|
|
248
|
+
* `warn` (named + remediated, never fails the exit code, the EC-399
|
|
249
|
+
* convention); full input-hash staleness is `elytra previews --check`'s job
|
|
250
|
+
* and is pointed to with an honest `unknown`.
|
|
251
|
+
*/
|
|
252
|
+
export function previewsDoctorChecks(artifacts) {
|
|
253
|
+
const { manifestExists, manifestText, dirExists, files } = artifacts
|
|
254
|
+
const rerun = 'Run `elytra previews` to (re)generate the fixture preview images.'
|
|
255
|
+
|
|
256
|
+
if (!manifestExists && !dirExists) {
|
|
257
|
+
return [
|
|
258
|
+
{
|
|
259
|
+
id: 'previews-artifacts',
|
|
260
|
+
bucket: 'previews',
|
|
261
|
+
status: 'ok',
|
|
262
|
+
detail: 'No generated previews (optional — `elytra previews` renders fixture preview images).',
|
|
263
|
+
},
|
|
264
|
+
]
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const checks = []
|
|
268
|
+
if (!manifestExists) {
|
|
269
|
+
checks.push({
|
|
270
|
+
id: 'previews-manifest',
|
|
271
|
+
bucket: 'previews',
|
|
272
|
+
status: 'warn',
|
|
273
|
+
detail: `${files.length} preview image(s) exist but studio/${PREVIEWS_MANIFEST_FILE} is missing.`,
|
|
274
|
+
fix: rerun,
|
|
275
|
+
})
|
|
276
|
+
return checks
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const manifest = parsePreviewsManifest(manifestText)
|
|
280
|
+
if (!manifest) {
|
|
281
|
+
checks.push({
|
|
282
|
+
id: 'previews-manifest',
|
|
283
|
+
bucket: 'previews',
|
|
284
|
+
status: 'warn',
|
|
285
|
+
detail: `studio/${PREVIEWS_MANIFEST_FILE} exists but is not a valid previews manifest.`,
|
|
286
|
+
fix: rerun,
|
|
287
|
+
})
|
|
288
|
+
return checks
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const missingFiles = manifest.entries.filter((e) => {
|
|
292
|
+
const file = fileOfUrl(e.url)
|
|
293
|
+
return !file || !files.includes(file)
|
|
294
|
+
})
|
|
295
|
+
if (missingFiles.length > 0) {
|
|
296
|
+
checks.push({
|
|
297
|
+
id: 'previews-files',
|
|
298
|
+
bucket: 'previews',
|
|
299
|
+
status: 'warn',
|
|
300
|
+
detail: `${missingFiles.length} of ${manifest.entries.length} manifest entr${manifest.entries.length === 1 ? 'y' : 'ies'} name image files that are missing (first: ${fileOfUrl(missingFiles[0].url) ?? '?'}).`,
|
|
301
|
+
fix: rerun,
|
|
302
|
+
})
|
|
303
|
+
} else {
|
|
304
|
+
checks.push({
|
|
305
|
+
id: 'previews-files',
|
|
306
|
+
bucket: 'previews',
|
|
307
|
+
status: 'ok',
|
|
308
|
+
detail: `${manifest.entries.length} generated preview(s), every image file present.`,
|
|
309
|
+
})
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const referenced = new Set(manifest.entries.map((e) => fileOfUrl(e.url)))
|
|
313
|
+
const orphans = files.filter((f) => !referenced.has(f))
|
|
314
|
+
if (orphans.length > 0) {
|
|
315
|
+
checks.push({
|
|
316
|
+
id: 'previews-orphans',
|
|
317
|
+
bucket: 'previews',
|
|
318
|
+
status: 'warn',
|
|
319
|
+
detail: `${orphans.length} image file(s) are not referenced by the manifest (first: ${orphans[0]}).`,
|
|
320
|
+
fix: rerun,
|
|
321
|
+
})
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
checks.push({
|
|
325
|
+
id: 'previews-staleness',
|
|
326
|
+
bucket: 'previews',
|
|
327
|
+
status: 'unknown',
|
|
328
|
+
detail: 'Input-hash staleness needs the component registry — run `elytra previews --check`.',
|
|
329
|
+
})
|
|
330
|
+
return checks
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// --- Impure plumbing ---------------------------------------------------------------
|
|
334
|
+
|
|
335
|
+
/** Resolve a module specifier from a directory's own node_modules world. */
|
|
336
|
+
function resolveFromDir(dir, specifier) {
|
|
337
|
+
return createRequire(join(dir, 'package.json')).resolve(specifier)
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* The engine version for the input hash — read from the `@elytracms/studio`
|
|
342
|
+
* (fallback `@elytracms/core`) package the CONSUMER has installed, never
|
|
343
|
+
* hardcoded. Direct node_modules paths (pnpm keeps symlinks for direct deps).
|
|
344
|
+
*/
|
|
345
|
+
export function resolveEngineVersion(root) {
|
|
346
|
+
const bases = [join(root, 'studio'), root]
|
|
347
|
+
for (const pkg of ['@elytracms/studio', '@elytracms/core']) {
|
|
348
|
+
for (const base of bases) {
|
|
349
|
+
const path = join(base, 'node_modules', ...pkg.split('/'), 'package.json')
|
|
350
|
+
if (!existsSync(path)) continue
|
|
351
|
+
try {
|
|
352
|
+
const version = JSON.parse(readFileSync(path, 'utf8')).version
|
|
353
|
+
if (typeof version === 'string' && version.length > 0) return version
|
|
354
|
+
} catch {
|
|
355
|
+
// fall through to the next candidate
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
return 'unknown'
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* The consumer theme entry the harness page imports: the studio's canvas
|
|
364
|
+
* stylesheet (`studio/src/styles/canvas.css` — the EC-414 shape: the site
|
|
365
|
+
* theme plus any engine-required `@source` lines) when present, else the
|
|
366
|
+
* shared theme entry itself (`components/theme.css`). `null` → no theme, the
|
|
367
|
+
* command refuses (an unstyled preview would be a lying artifact).
|
|
368
|
+
*/
|
|
369
|
+
export function themeStylesheetPath(root) {
|
|
370
|
+
for (const candidate of [
|
|
371
|
+
join(root, 'studio', 'src', 'styles', 'canvas.css'),
|
|
372
|
+
join(root, 'components', 'theme.css'),
|
|
373
|
+
]) {
|
|
374
|
+
if (existsSync(candidate)) return candidate
|
|
375
|
+
}
|
|
376
|
+
return null
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
const HARNESS_HTML = `<!doctype html>
|
|
380
|
+
<html>
|
|
381
|
+
<head>
|
|
382
|
+
<meta charset="utf-8" />
|
|
383
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
384
|
+
<title>Elytra previews harness</title>
|
|
385
|
+
</head>
|
|
386
|
+
<body>
|
|
387
|
+
<script type="module" src="/@id/${ENTRY_ID}"></script>
|
|
388
|
+
</body>
|
|
389
|
+
</html>
|
|
390
|
+
`
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* The inline Vite plugin serving the DATA-FREE harness: two virtual modules
|
|
394
|
+
* (the browser render entry; the node-side enumeration) that import exactly
|
|
395
|
+
* the consumer config + theme + the studio previews modules — no studio app
|
|
396
|
+
* code, no persistence, no auth, no env.
|
|
397
|
+
*/
|
|
398
|
+
function harnessPlugin({ configPath, themePath }) {
|
|
399
|
+
const configImport = `/@fs/${configPath}`
|
|
400
|
+
const themeImport = `/@fs/${themePath}`
|
|
401
|
+
return {
|
|
402
|
+
name: 'elytra-previews-harness',
|
|
403
|
+
resolveId(id) {
|
|
404
|
+
return id === ENTRY_ID || id === ENUMERATE_ID ? id : null
|
|
405
|
+
},
|
|
406
|
+
load(id) {
|
|
407
|
+
if (id === ENTRY_ID) {
|
|
408
|
+
return [
|
|
409
|
+
`import '${themeImport}'`,
|
|
410
|
+
`import config from '${configImport}'`,
|
|
411
|
+
`import { mountPreviewHarness } from '@elytracms/studio/previews/harness'`,
|
|
412
|
+
`mountPreviewHarness(config)`,
|
|
413
|
+
].join('\n')
|
|
414
|
+
}
|
|
415
|
+
if (id === ENUMERATE_ID) {
|
|
416
|
+
return [
|
|
417
|
+
`import config from '${configImport}'`,
|
|
418
|
+
`import { previewEntriesForConfig } from '@elytracms/studio/previews/enumerate'`,
|
|
419
|
+
`export const entries = previewEntriesForConfig(config)`,
|
|
420
|
+
].join('\n')
|
|
421
|
+
}
|
|
422
|
+
return null
|
|
423
|
+
},
|
|
424
|
+
configureServer(server) {
|
|
425
|
+
server.middlewares.use((req, res, next) => {
|
|
426
|
+
if (!req.url || req.url.split('?')[0] !== HARNESS_PATH) return next()
|
|
427
|
+
server
|
|
428
|
+
.transformIndexHtml(req.url, HARNESS_HTML)
|
|
429
|
+
.then((html) => {
|
|
430
|
+
res.setHeader('content-type', 'text/html')
|
|
431
|
+
res.end(html)
|
|
432
|
+
})
|
|
433
|
+
.catch(next)
|
|
434
|
+
})
|
|
435
|
+
},
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/** First free port in [start, start+span) — never a hardcoded single port. */
|
|
440
|
+
async function pickPort(start = 5440, span = 30) {
|
|
441
|
+
for (let port = start; port < start + span; port += 1) {
|
|
442
|
+
const free = await new Promise((resolve) => {
|
|
443
|
+
const probe = createNetServer()
|
|
444
|
+
probe.once('error', () => resolve(false))
|
|
445
|
+
probe.listen(port, '127.0.0.1', () => probe.close(() => resolve(true)))
|
|
446
|
+
})
|
|
447
|
+
if (free) return port
|
|
448
|
+
}
|
|
449
|
+
throw new Error(`No free port in ${start}–${start + span - 1}.`)
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/** Boot the consumer studio's Vite with the minimal inline harness config. */
|
|
453
|
+
async function startHarnessServer({ root, studioDir, port }) {
|
|
454
|
+
const vite = await import(pathToFileURL(resolveFromDir(studioDir, 'vite')).href)
|
|
455
|
+
const tailwindEntry = resolveFromDir(studioDir, '@tailwindcss/vite')
|
|
456
|
+
const tailwind = (await import(pathToFileURL(tailwindEntry).href)).default
|
|
457
|
+
|
|
458
|
+
const configPath = join(root, 'elytra.config.ts')
|
|
459
|
+
const themePath = themeStylesheetPath(root)
|
|
460
|
+
if (!themePath) {
|
|
461
|
+
throw new Error(
|
|
462
|
+
'No theme stylesheet found (looked for studio/src/styles/canvas.css and components/theme.css) — ' +
|
|
463
|
+
'previews must render against the consumer theme.',
|
|
464
|
+
)
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
const server = await vite.createServer({
|
|
468
|
+
configFile: false,
|
|
469
|
+
root: studioDir,
|
|
470
|
+
mode: 'development',
|
|
471
|
+
appType: 'custom',
|
|
472
|
+
logLevel: 'warn',
|
|
473
|
+
plugins: [tailwind(), harnessPlugin({ configPath, themePath })],
|
|
474
|
+
server: {
|
|
475
|
+
port,
|
|
476
|
+
strictPort: true,
|
|
477
|
+
host: '127.0.0.1',
|
|
478
|
+
fs: { allow: [vite.searchForWorkspaceRoot(studioDir), root] },
|
|
479
|
+
},
|
|
480
|
+
esbuild: { jsx: 'automatic' },
|
|
481
|
+
})
|
|
482
|
+
await server.listen()
|
|
483
|
+
return { server, url: `http://127.0.0.1:${port}${HARNESS_PATH}` }
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
const PLAYWRIGHT_INSTALL_HELP = [
|
|
487
|
+
'`elytra previews` renders fixtures in a headless browser via Playwright, which is an',
|
|
488
|
+
'optional, on-demand dependency (never installed with the CLI). Install it in your repo:',
|
|
489
|
+
'',
|
|
490
|
+
' pnpm add -D playwright',
|
|
491
|
+
' pnpm exec playwright install chromium',
|
|
492
|
+
'',
|
|
493
|
+
'then re-run `elytra previews`.',
|
|
494
|
+
].join('\n')
|
|
495
|
+
|
|
496
|
+
/** Dynamic, on-demand Playwright load from the consumer repo. `null` = not installed. */
|
|
497
|
+
async function loadPlaywright(root) {
|
|
498
|
+
for (const base of [join(root, 'studio'), root]) {
|
|
499
|
+
let resolved
|
|
500
|
+
try {
|
|
501
|
+
resolved = resolveFromDir(base, 'playwright')
|
|
502
|
+
} catch {
|
|
503
|
+
continue
|
|
504
|
+
}
|
|
505
|
+
const mod = await import(pathToFileURL(resolved).href)
|
|
506
|
+
return mod.default ?? mod
|
|
507
|
+
}
|
|
508
|
+
return null
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
/** Wait until the harness page reports ready (surviving dep-optimizer reloads). */
|
|
512
|
+
async function waitForHarness(page) {
|
|
513
|
+
await page.waitForFunction(() => window.__ELYTRA_PREVIEWS__?.ready === true, undefined, {
|
|
514
|
+
timeout: 60_000,
|
|
515
|
+
})
|
|
516
|
+
// Vite may discover + optimize deps on first load and trigger one full
|
|
517
|
+
// reload; give it a beat, then re-confirm (the entry re-mounts on reload).
|
|
518
|
+
await page.waitForTimeout(750)
|
|
519
|
+
await page.waitForFunction(() => window.__ELYTRA_PREVIEWS__?.ready === true, undefined, {
|
|
520
|
+
timeout: 60_000,
|
|
521
|
+
})
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
/** Render one entry in-page (with the per-fixture budget) and report the outcome. */
|
|
525
|
+
async function renderEntryInPage(page, spec) {
|
|
526
|
+
const attempt = () =>
|
|
527
|
+
page.evaluate(
|
|
528
|
+
([componentId, fixture, timeout]) =>
|
|
529
|
+
Promise.race([
|
|
530
|
+
window.__ELYTRA_PREVIEWS__.render(componentId, fixture),
|
|
531
|
+
new Promise((resolve) =>
|
|
532
|
+
setTimeout(() => resolve({ ok: false, error: `render timed out after ${timeout}ms` }), timeout),
|
|
533
|
+
),
|
|
534
|
+
]),
|
|
535
|
+
[spec.componentId, spec.fixture, RENDER_TIMEOUT_MS],
|
|
536
|
+
)
|
|
537
|
+
try {
|
|
538
|
+
return await attempt()
|
|
539
|
+
} catch (error) {
|
|
540
|
+
// One retry after a mid-run reload (dep optimizer) destroyed the context.
|
|
541
|
+
if (String(error).includes('Execution context was destroyed')) {
|
|
542
|
+
await waitForHarness(page)
|
|
543
|
+
return attempt()
|
|
544
|
+
}
|
|
545
|
+
throw error
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/**
|
|
550
|
+
* Screenshot the settled render: WebP via CDP (Chromium encodes natively;
|
|
551
|
+
* Playwright's own screenshot() has no WebP), PNG via Playwright as fallback.
|
|
552
|
+
*/
|
|
553
|
+
async function captureViewport(page, cdp) {
|
|
554
|
+
const clip = { x: 0, y: 0, width: CAPTURE.width, height: CAPTURE.height, scale: CAPTURE.scale }
|
|
555
|
+
if (cdp) {
|
|
556
|
+
try {
|
|
557
|
+
const shot = await cdp.send('Page.captureScreenshot', {
|
|
558
|
+
format: 'webp',
|
|
559
|
+
quality: WEBP_QUALITY,
|
|
560
|
+
clip,
|
|
561
|
+
})
|
|
562
|
+
return { buffer: Buffer.from(shot.data, 'base64'), ext: 'webp' }
|
|
563
|
+
} catch {
|
|
564
|
+
// fall through to PNG
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
const buffer = await page.screenshot({
|
|
568
|
+
clip: { x: 0, y: 0, width: CAPTURE.width, height: CAPTURE.height },
|
|
569
|
+
type: 'png',
|
|
570
|
+
})
|
|
571
|
+
return { buffer, ext: 'png' }
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
// --- The command -------------------------------------------------------------------
|
|
575
|
+
|
|
576
|
+
/**
|
|
577
|
+
* Run `elytra previews` / `elytra previews --check` for an inspected project.
|
|
578
|
+
* Returns the process exit code. `log` is injectable; everything else uses the
|
|
579
|
+
* consumer repo's own toolchain (vite, tailwind, playwright) resolved on
|
|
580
|
+
* demand.
|
|
581
|
+
*/
|
|
582
|
+
export async function runPreviews(project, { check = false, port, log = (l) => console.log(l) } = {}) {
|
|
583
|
+
const root = project.root
|
|
584
|
+
const studioDir = join(root, 'studio')
|
|
585
|
+
const engine = resolveEngineVersion(root)
|
|
586
|
+
const manifestPath = join(studioDir, PREVIEWS_MANIFEST_FILE)
|
|
587
|
+
const imagesDir = join(studioDir, PREVIEWS_DIR)
|
|
588
|
+
|
|
589
|
+
log(`Elytra previews — fixture preview ${check ? 'staleness check' : 'generation'}`)
|
|
590
|
+
log(`Engine version (hash input): ${engine}`)
|
|
591
|
+
|
|
592
|
+
const chosenPort = port ? Number(port) : await pickPort()
|
|
593
|
+
let harness
|
|
594
|
+
try {
|
|
595
|
+
harness = await startHarnessServer({ root, studioDir, port: chosenPort })
|
|
596
|
+
} catch (error) {
|
|
597
|
+
log(`Could not start the render harness: ${error.message}`)
|
|
598
|
+
return 1
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
try {
|
|
602
|
+
// Enumerate + hash node-side through the same Vite (no browser needed).
|
|
603
|
+
const { entries } = await harness.server.ssrLoadModule(ENUMERATE_ID)
|
|
604
|
+
const specs = entries.map((entry) => ({
|
|
605
|
+
componentId: entry.componentId,
|
|
606
|
+
fixture: entry.fixture,
|
|
607
|
+
hash: previewEntryHash(entry.hashInput, engine),
|
|
608
|
+
}))
|
|
609
|
+
log(`Enumerated ${specs.length} fixture preview(s) from elytra.config.ts.`)
|
|
610
|
+
|
|
611
|
+
const artifacts = gatherPreviewsArtifacts(studioDir)
|
|
612
|
+
const existing = parsePreviewsManifest(artifacts.manifestText)
|
|
613
|
+
|
|
614
|
+
if (check) {
|
|
615
|
+
const { ok, problems } = checkPreviews({ specs, manifest: existing, files: artifacts.files })
|
|
616
|
+
if (ok) {
|
|
617
|
+
log(`✓ ${specs.length} preview(s) up to date (hashes match, all files present).`)
|
|
618
|
+
return 0
|
|
619
|
+
}
|
|
620
|
+
log(`✗ ${problems.length} problem(s):`)
|
|
621
|
+
for (const line of formatCheckProblems(problems)) log(line)
|
|
622
|
+
log('Run `elytra previews` to regenerate.')
|
|
623
|
+
return 1
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
const jobs = planPreviewRun({ specs, existing, files: artifacts.files })
|
|
627
|
+
const toRender = jobs.filter((j) => !j.fresh)
|
|
628
|
+
log(`${jobs.length - toRender.length} unchanged (skipped), ${toRender.length} to render.`)
|
|
629
|
+
|
|
630
|
+
const failures = []
|
|
631
|
+
if (toRender.length > 0) {
|
|
632
|
+
const playwright = await loadPlaywright(root)
|
|
633
|
+
if (!playwright) {
|
|
634
|
+
log(PLAYWRIGHT_INSTALL_HELP)
|
|
635
|
+
return 1
|
|
636
|
+
}
|
|
637
|
+
let browser
|
|
638
|
+
try {
|
|
639
|
+
browser = await playwright.chromium.launch()
|
|
640
|
+
} catch (error) {
|
|
641
|
+
log(`Could not launch Chromium: ${error.message}`)
|
|
642
|
+
log('If the browser is not installed yet: pnpm exec playwright install chromium')
|
|
643
|
+
return 1
|
|
644
|
+
}
|
|
645
|
+
try {
|
|
646
|
+
const context = await browser.newContext({
|
|
647
|
+
viewport: { width: CAPTURE.width, height: CAPTURE.height },
|
|
648
|
+
deviceScaleFactor: 1,
|
|
649
|
+
reducedMotion: 'reduce',
|
|
650
|
+
})
|
|
651
|
+
const page = await context.newPage()
|
|
652
|
+
await page.goto(harness.url)
|
|
653
|
+
await waitForHarness(page)
|
|
654
|
+
let cdp = null
|
|
655
|
+
try {
|
|
656
|
+
cdp = await context.newCDPSession(page)
|
|
657
|
+
} catch {
|
|
658
|
+
cdp = null // non-CDP browser — PNG fallback below
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
mkdirSync(imagesDir, { recursive: true })
|
|
662
|
+
for (const job of toRender) {
|
|
663
|
+
const result = await renderEntryInPage(page, job)
|
|
664
|
+
if (!result || result.ok !== true) {
|
|
665
|
+
failures.push({ ...job, error: result?.error ?? 'unknown render failure' })
|
|
666
|
+
log(` ✗ ${job.componentId} · ${job.fixture} — ${result?.error ?? 'render failed'}`)
|
|
667
|
+
continue
|
|
668
|
+
}
|
|
669
|
+
const { buffer, ext } = await captureViewport(page, cdp)
|
|
670
|
+
const fileName = previewFileName(job.componentId, job.fixture, ext)
|
|
671
|
+
writeFileSync(join(imagesDir, fileName), buffer)
|
|
672
|
+
job.url = `${PREVIEWS_URL_PREFIX}${fileName}`
|
|
673
|
+
log(` ✓ ${job.componentId} · ${job.fixture} → ${fileName} (${(buffer.length / 1024).toFixed(1)} KB)`)
|
|
674
|
+
}
|
|
675
|
+
} finally {
|
|
676
|
+
await browser.close()
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
// Manifest: every successful entry (fresh or rendered), enumeration order.
|
|
681
|
+
const finalEntries = jobs.filter((j) => typeof j.url === 'string')
|
|
682
|
+
const manifestText = serializePreviewsManifest({ engine, entries: finalEntries })
|
|
683
|
+
const manifestChanged = artifacts.manifestText !== manifestText
|
|
684
|
+
if (manifestChanged) {
|
|
685
|
+
mkdirSync(studioDir, { recursive: true })
|
|
686
|
+
writeFileSync(manifestPath, manifestText)
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
// Prune orphaned images — the directory is CLI-owned and mirrors the manifest.
|
|
690
|
+
const referenced = new Set(finalEntries.map((e) => fileOfUrl(e.url)))
|
|
691
|
+
const currentFiles = existsSync(imagesDir)
|
|
692
|
+
? readdirSync(imagesDir).filter((f) => !f.startsWith('.'))
|
|
693
|
+
: []
|
|
694
|
+
const orphans = currentFiles.filter((f) => !referenced.has(f))
|
|
695
|
+
for (const orphan of orphans) rmSync(join(imagesDir, orphan))
|
|
696
|
+
|
|
697
|
+
const rendered = toRender.length - failures.length
|
|
698
|
+
log('')
|
|
699
|
+
log(
|
|
700
|
+
`Done: ${rendered} rendered, ${jobs.length - toRender.length} unchanged, ` +
|
|
701
|
+
`${orphans.length} pruned, manifest ${manifestChanged ? 'written' : 'unchanged'} ` +
|
|
702
|
+
`(studio/${PREVIEWS_MANIFEST_FILE}).`,
|
|
703
|
+
)
|
|
704
|
+
if (failures.length > 0) {
|
|
705
|
+
log(`✗ ${failures.length} fixture(s) failed to render — their entries were left out of the manifest.`)
|
|
706
|
+
return 1
|
|
707
|
+
}
|
|
708
|
+
return 0
|
|
709
|
+
} catch (error) {
|
|
710
|
+
log(`previews failed: ${error?.stack ?? error}`)
|
|
711
|
+
return 1
|
|
712
|
+
} finally {
|
|
713
|
+
await harness.server.close()
|
|
714
|
+
}
|
|
715
|
+
}
|