@elytracms/cli 0.0.69 → 0.0.71

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 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. Read-only; exits non-zero if any check fails.
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.69",
4
+ "version": "0.0.71",
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.69"
20
+ "@elytracms/core": "^0.0.71"
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. Exit
31
- non-zero if any check fails. Read-only. Optional: --url <convex url>.
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
- log(formatReport(report))
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
  }
@@ -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
+ }