uniweb 0.56.6 → 0.56.8

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "uniweb",
3
- "version": "0.56.6",
3
+ "version": "0.56.8",
4
4
  "description": "Create structured Vite + React sites with content/code separation",
5
5
  "type": "module",
6
6
  "bin": {
@@ -41,17 +41,17 @@
41
41
  "js-yaml": "^4.1.0",
42
42
  "prompts": "^2.4.2",
43
43
  "tar": "^7.0.0",
44
- "@uniweb/content-writer": "^0.3.4",
45
44
  "@uniweb/core": "^0.29.4",
46
45
  "@uniweb/kit": "^0.19.3",
47
- "@uniweb/semantic-parser": "^1.4.1",
48
46
  "@uniweb/schemas": "^0.3.1",
49
- "@uniweb/runtime": "^0.26.4"
47
+ "@uniweb/runtime": "^0.26.4",
48
+ "@uniweb/semantic-parser": "^1.4.1",
49
+ "@uniweb/content-writer": "^0.3.4"
50
50
  },
51
51
  "peerDependencies": {
52
- "@uniweb/semantic-parser": "^1.4.1",
53
- "@uniweb/build": "^0.52.5",
54
- "@uniweb/content-reader": "^1.2.5"
52
+ "@uniweb/build": "^0.52.6",
53
+ "@uniweb/content-reader": "^1.2.5",
54
+ "@uniweb/semantic-parser": "^1.4.1"
55
55
  },
56
56
  "peerDependenciesMeta": {
57
57
  "@uniweb/build": {
@@ -2511,6 +2511,8 @@ uniweb validate # Check file-based data against declared schem
2511
2511
  npx uniweb@latest update # Align @uniweb/* deps + AGENTS.md (--dry-run, --yes)
2512
2512
  # bare `uniweb update` aligns to the CLI you ALREADY have
2513
2513
  uniweb inspect <path> # Show parsed content for a section or page (--raw for the AST)
2514
+ uniweb snapshot # Compose site/public/preview.webp from the site; sets preview: if unset
2515
+ # needs `pnpm add -D -w @uniweb/snapshot` and Chrome or Edge
2514
2516
 
2515
2517
  uniweb <command> --help # Per-command flags — no side effects. Prefer this over guessing.
2516
2518
  ```
@@ -17,6 +17,7 @@ import yaml from 'js-yaml'
17
17
  import { hasUncommittedContent } from '../utils/git.js'
18
18
  import { recordSiteBackend } from '../utils/site-identity.js'
19
19
  import { humanBytes } from '../utils/bytes.js'
20
+ import { isAuthoredPreview } from '../utils/preview.js'
20
21
  import {
21
22
  backfillEntityUuids,
22
23
  writeSiteEntityUuid,
@@ -364,11 +365,6 @@ export function dropSiteBoundValues(siteDir) {
364
365
  return dropped
365
366
  }
366
367
 
367
- // An author's preview is an address — a URL, or a site-root / relative path. Anything
368
- // else is the app's generated-image token.
369
- const isAuthoredPreview = (v) =>
370
- typeof v === 'string' && (/^https?:\/\//i.test(v) || /^\.{0,2}\//.test(v))
371
-
372
368
  export function readSyncCache(siteDir) {
373
369
  return readMap(siteDir, 'hashes')
374
370
  }
@@ -209,11 +209,6 @@ export async function add(rawArgs) {
209
209
  const nonInteractive = isNonInteractive(rawArgs)
210
210
  const args = stripNonInteractiveFlag(rawArgs)
211
211
 
212
- if (args[0] === '--help' || args[0] === '-h') {
213
- showAddHelp()
214
- return
215
- }
216
-
217
212
  const pm = detectPackageManager()
218
213
  const prefix = getCliPrefix()
219
214
 
@@ -1821,98 +1816,3 @@ function parseNodeMajor(engines) {
1821
1816
  return match ? match[1] : null
1822
1817
  }
1823
1818
 
1824
- /**
1825
- * Show help for the add command.
1826
- *
1827
- * ⚠️ CURRENTLY UNREACHABLE, and the second copy of this text is the cost.
1828
- * `src/index.js` short-circuits ANY `--help` in the args before dispatching
1829
- * (its comment says why — `deploy --help` used to open a browser), and `add`
1830
- * has a dedicated block in that file's HELP map, so `printCommandHelp('add')`
1831
- * always wins and the `args[0] === '--help'` guard above never fires.
1832
- *
1833
- * ⛔ Which means a flag documented here and not there is documented nowhere.
1834
- * Measured 2026-09-16, when `--starter` was added to both: this copy's
1835
- * unescaped backticks around \`content:\` closed the template literal and broke
1836
- * the WHOLE module — every `uniweb add` subcommand failed with "missing ) after
1837
- * argument list" — and `uniweb add --help` printed correctly throughout,
1838
- * because it never reaches this function.
1839
- */
1840
- function showAddHelp() {
1841
- log(`
1842
- ${colors.cyan}${colors.bright}Uniweb Add${colors.reset}
1843
-
1844
- Add projects, foundations, sites, extensions, section types, or CI workflows to your workspace.
1845
-
1846
- ${colors.bright}Usage:${colors.reset}
1847
- uniweb add project [name] [options]
1848
- uniweb add foundation [name] [options]
1849
- uniweb add site [name] [options]
1850
- uniweb add extension <name> [options]
1851
- uniweb add section <name> [options]
1852
- uniweb add ci [options]
1853
-
1854
- ${colors.bright}Common Options:${colors.reset}
1855
- --from <template> Apply content from a template after scaffolding
1856
- --path <dir> Custom directory for the package
1857
-
1858
- ${colors.bright}Foundation Options:${colors.reset}
1859
- --project <name> Group under a project directory (co-located layout)
1860
-
1861
- ${colors.bright}Site Options:${colors.reset}
1862
- --foundation <n> Foundation to wire to (prompted if multiple exist)
1863
- --project <name> Group under a project directory (co-located layout)
1864
-
1865
- ${colors.bright}Extension Options:${colors.reset}
1866
- --site <name> Site to wire extension URL into
1867
-
1868
- ${colors.bright}Section Options:${colors.reset}
1869
- --foundation <n> Foundation to add section to (prompted if multiple exist)
1870
- --starter Generate starter content from the section's \`content:\`
1871
- declaration — what an author would begin editing. Works on
1872
- a section that already exists (nothing is written), and on
1873
- a new one (the scaffold gets a matching declaration)
1874
- --preset <name> Frontmatter the starter content with this preset's params
1875
- --write <file> Write the starter markdown to a file instead of printing it
1876
- --json Emit the content structure and ProseMirror doc instead
1877
-
1878
- ${colors.bright}CI Options:${colors.reset}
1879
- --host <name> github-pages | cloudflare-pages | netlify | vercel
1880
- (prompted when omitted)
1881
- --target <what> site (default) | foundation
1882
- 'foundation' publishes built foundations at permanent
1883
- versioned URLs — the free alternative to the catalog
1884
- --site <name> Site the workflow builds (prompted if multiple exist)
1885
- --foundation <name> With --target foundation: publish just this one
1886
- --domain <host> Custom domain (GitHub Pages: writes CNAME, serves at root)
1887
- --project-name <name> Name to register under on the host (default: the
1888
- workspace name; Cloudflare Pages project, etc.)
1889
- --no-previews Skip the per-PR preview workflow
1890
- --force Overwrite an existing workflow file
1891
-
1892
- ${colors.dim}Hosts that support PR previews: cloudflare-pages, netlify, vercel.
1893
- GitHub Pages has no preview environment, so it scaffolds a deploy workflow only.${colors.reset}
1894
-
1895
- ${colors.bright}Examples:${colors.reset}
1896
- uniweb add project docs # Create docs/foundation/ + docs/site/
1897
- uniweb add project docs --from academic # Co-located pair + academic content
1898
- uniweb add foundation # Create ./foundation/ at root
1899
- uniweb add foundation ui # Create ./foundations/ui/
1900
- uniweb add site # Create ./site/ at root
1901
- uniweb add site blog --foundation marketing # Create ./sites/blog/ wired to marketing
1902
- uniweb add extension effects --site site # Create ./extensions/effects/
1903
- uniweb add section Hero # Create Hero section type
1904
- uniweb add section Hero --foundation ui # Target specific foundation
1905
- uniweb add section Hero --starter # Starter content for an existing Hero
1906
- uniweb add section Pricing --starter # Scaffold Pricing + content that fills it
1907
- uniweb add section Hero --starter --preset split # Frontmatter it with the 'split' preset
1908
- uniweb add section Hero --starter --json # The structure + ProseMirror, for a script
1909
- uniweb add foundation --project docs # Create ./docs/foundation/ (co-located)
1910
- uniweb add site --project docs # Create ./docs/site/ (co-located)
1911
- uniweb add ci # Pick a host, add a deploy workflow
1912
- uniweb add ci --host github-pages --site marketing # Pick host + site explicitly
1913
- uniweb add ci --host netlify # Deploy + PR-preview workflows
1914
- uniweb add ci --host vercel --no-previews # Deploy workflow only
1915
- uniweb add ci --domain mysite.com # Custom domain → writes CNAME + UNIWEB_BASE=/
1916
- uniweb add ci --target foundation # Publish foundations at versioned URLs
1917
- `)
1918
- }
@@ -0,0 +1,349 @@
1
+ /**
2
+ * Snapshot Command
3
+ *
4
+ * Captures a site in a real browser and composes a preview image from the
5
+ * captures — by default the site's card image, recorded as `preview:` in
6
+ * site.yml.
7
+ *
8
+ * The capturing and composing is `@uniweb/snapshot`, a separate package so that
9
+ * nobody who never takes a snapshot installs a browser driver. This command finds
10
+ * the site, puts it behind a URL, and records the result:
11
+ *
12
+ * uniweb snapshot build the site, serve dist/, capture it
13
+ * uniweb snapshot --dev capture the site's Vite dev server (no build)
14
+ * uniweb snapshot --url <url> capture a site that is already running
15
+ *
16
+ * The layout is chosen from the page: one that scrolls gets `split` (the first
17
+ * view in a browser window, overlapped by a long strip of the page); one that
18
+ * does not — a documentation shell, an app — gets `device` (desktop and phone).
19
+ *
20
+ * `preview:` is written only when site.yml has none, or holds the app's generated
21
+ * token. An address the author wrote is never replaced.
22
+ */
23
+
24
+ import { spawn } from 'node:child_process'
25
+ import { existsSync, readFileSync } from 'node:fs'
26
+ import { createRequire } from 'node:module'
27
+ import { isAbsolute, join, relative, resolve, sep } from 'node:path'
28
+ import { pathToFileURL } from 'node:url'
29
+ import yaml from 'js-yaml'
30
+ import { upsertYamlScalar } from '@uniweb/build/uwx'
31
+
32
+ import { didYouMean } from '../utils/args.js'
33
+ import { humanBytes } from '../utils/bytes.js'
34
+ import { discoverSites } from '../utils/discover.js'
35
+ import { detectWorkspacePm } from '../utils/pm.js'
36
+ import { isAuthoredPreview } from '../utils/preview.js'
37
+ import { findWorkspaceRoot } from '../utils/workspace.js'
38
+
39
+ const RED = '\x1b[31m'
40
+ const GREEN = '\x1b[32m'
41
+ const YELLOW = '\x1b[33m'
42
+ const CYAN = '\x1b[36m'
43
+ const DIM = '\x1b[2m'
44
+ const RESET = '\x1b[0m'
45
+
46
+ const VALUE_FLAGS = ['--site', '--url', '--route', '--layout', '--tone', '--size', '--scale', '--quality', '--out', '--hide']
47
+ const BOOLEAN_FLAGS = ['--dev', '--no-build', '--no-set-preview']
48
+ const GLOBAL_FLAGS = ['--non-interactive', '--help', '-h']
49
+ const ALL_FLAGS = [...VALUE_FLAGS, ...BOOLEAN_FLAGS, ...GLOBAL_FLAGS]
50
+
51
+ export const DEFAULT_OUTPUT = join('public', 'preview.webp')
52
+
53
+ class UsageError extends Error {}
54
+
55
+ const camel = (flag) => flag.replace(/^--/, '').replace(/-([a-z])/g, (_, c) => c.toUpperCase())
56
+
57
+ /**
58
+ * Parse `uniweb snapshot` arguments. Throws a UsageError naming the first problem.
59
+ *
60
+ * @param {string[]} args
61
+ */
62
+ export function parseSnapshotArgs(args = []) {
63
+ const options = { hide: [], positionals: [] }
64
+ for (let i = 0; i < args.length; i++) {
65
+ const raw = args[i]
66
+ if (raw === '--') {
67
+ options.positionals.push(...args.slice(i + 1))
68
+ break
69
+ }
70
+ if (!raw.startsWith('-') || raw === '-') {
71
+ options.positionals.push(raw)
72
+ continue
73
+ }
74
+ const eq = raw.indexOf('=')
75
+ const name = eq === -1 ? raw : raw.slice(0, eq)
76
+ if (VALUE_FLAGS.includes(name)) {
77
+ const value = eq === -1 ? args[++i] : raw.slice(eq + 1)
78
+ if (value === undefined || value === '' || (eq === -1 && value.startsWith('--'))) {
79
+ throw new UsageError(`\`${name}\` needs a value.`)
80
+ }
81
+ if (name === '--hide') options.hide.push(value)
82
+ else options[camel(name)] = value
83
+ } else if (BOOLEAN_FLAGS.includes(name)) {
84
+ options[camel(name)] = true
85
+ } else if (!GLOBAL_FLAGS.includes(name)) {
86
+ const suggestion = didYouMean(name, ALL_FLAGS)
87
+ throw new UsageError(
88
+ `Unknown flag \`${name}\` for \`uniweb snapshot\`.` + (suggestion ? ` Did you mean \`${suggestion}\`?` : '')
89
+ )
90
+ }
91
+ }
92
+
93
+ if (options.dev && options.url) throw new UsageError('Pass `--dev` or `--url`, not both.')
94
+ if (options.layout && !['auto', 'split', 'device'].includes(options.layout)) {
95
+ throw new UsageError('`--layout` is auto, split or device.')
96
+ }
97
+ if (options.tone && !['auto', 'light', 'deep'].includes(options.tone)) {
98
+ throw new UsageError('`--tone` is auto, light or deep.')
99
+ }
100
+ if (options.size !== undefined) {
101
+ const match = /^(\d+)x(\d+)$/.exec(options.size)
102
+ const [width, height] = match ? [Number(match[1]), Number(match[2])] : []
103
+ if (!match || width < 320 || height < 200 || width > 4096 || height > 4096) {
104
+ throw new UsageError('`--size` is WIDTHxHEIGHT, e.g. 1600x1000 (320–4096 wide, 200–4096 tall).')
105
+ }
106
+ options.canvas = { width, height }
107
+ }
108
+ if (options.scale !== undefined) {
109
+ if (!['1', '2'].includes(options.scale)) throw new UsageError('`--scale` is 1 or 2.')
110
+ options.scale = Number(options.scale)
111
+ }
112
+ if (options.quality !== undefined) {
113
+ const quality = Number(options.quality)
114
+ if (!Number.isInteger(quality) || quality < 1 || quality > 100) {
115
+ throw new UsageError('`--quality` is a whole number from 1 to 100.')
116
+ }
117
+ options.quality = quality
118
+ }
119
+ return options
120
+ }
121
+
122
+ /**
123
+ * The `preview:` value that names `output`, or null when it cannot be named — an
124
+ * image outside the site's `public/` folder has no site-root path.
125
+ */
126
+ export function previewValueFor(siteDir, output) {
127
+ const rel = relative(join(siteDir, 'public'), output)
128
+ if (!rel || rel.startsWith('..') || isAbsolute(rel)) return null
129
+ return '/' + rel.split(sep).join('/')
130
+ }
131
+
132
+ /**
133
+ * What to do with site.yml's `preview:` given the value we would write.
134
+ * @returns {'set'|'unchanged'|'replace'|'keep'}
135
+ */
136
+ export function previewDecision(current, next) {
137
+ if (current === undefined || current === null || current === '') return 'set'
138
+ if (current === next) return 'unchanged'
139
+ return isAuthoredPreview(current) ? 'keep' : 'replace'
140
+ }
141
+
142
+ /** The site the command applies to: named, else the one containing cwd, else the only one. */
143
+ export function pickSite(sites, rootDir, { requested, cwd }) {
144
+ if (requested) {
145
+ return { site: sites.find((s) => s.name === requested || s.path === requested) ?? null }
146
+ }
147
+ const containing = sites.find((s) => {
148
+ const rel = relative(join(rootDir, s.path), cwd)
149
+ return !rel.startsWith('..') && !isAbsolute(rel)
150
+ })
151
+ if (containing) return { site: containing }
152
+ return { site: sites[0] ?? null, ambiguous: sites.length > 1 }
153
+ }
154
+
155
+ /** Import `@uniweb/snapshot` from the site, the workspace, or next to this CLI. */
156
+ async function loadSnapshotPackage(dirs) {
157
+ for (const dir of dirs) {
158
+ if (!dir) continue
159
+ let entry
160
+ try {
161
+ entry = createRequire(join(dir, 'package.json')).resolve('@uniweb/snapshot')
162
+ } catch {
163
+ continue
164
+ }
165
+ return import(pathToFileURL(entry).href)
166
+ }
167
+ try {
168
+ return await import('@uniweb/snapshot')
169
+ } catch (err) {
170
+ if (err.code === 'ERR_MODULE_NOT_FOUND') return null
171
+ throw err
172
+ }
173
+ }
174
+
175
+ function installHint(rootDir) {
176
+ switch (detectWorkspacePm(rootDir)) {
177
+ case 'npm':
178
+ return 'npm install --save-dev @uniweb/snapshot'
179
+ case 'yarn':
180
+ return 'yarn add --dev -W @uniweb/snapshot'
181
+ default:
182
+ return 'pnpm add -D -w @uniweb/snapshot'
183
+ }
184
+ }
185
+
186
+ /**
187
+ * `uniweb build` in the site, with this same CLI. Its output is held back and
188
+ * shown only if it fails: on success it ends in shipping advice that has nothing
189
+ * to do with taking a snapshot.
190
+ */
191
+ function runBuild(siteDir) {
192
+ return new Promise((done, fail) => {
193
+ const child = spawn(process.execPath, [process.argv[1], 'build'], {
194
+ cwd: siteDir,
195
+ stdio: ['ignore', 'pipe', 'pipe']
196
+ })
197
+ const output = []
198
+ child.stdout.on('data', (chunk) => output.push(chunk))
199
+ child.stderr.on('data', (chunk) => output.push(chunk))
200
+ child.on('error', fail)
201
+ child.on('close', (code) => {
202
+ if (code === 0) return done()
203
+ process.stderr.write(Buffer.concat(output))
204
+ fail(new Error(`The site build failed (exit ${code}).`))
205
+ })
206
+ })
207
+ }
208
+
209
+ function readSiteYml(siteDir) {
210
+ const file = join(siteDir, 'site.yml')
211
+ if (!existsSync(file)) return { file, data: {} }
212
+ try {
213
+ return { file, data: yaml.load(readFileSync(file, 'utf8')) || {} }
214
+ } catch {
215
+ return { file, data: {} }
216
+ }
217
+ }
218
+
219
+ function fail(message, ...details) {
220
+ const [first, ...rest] = String(message).split('\n')
221
+ console.error(`${RED}✗${RESET} ${first}`)
222
+ for (const line of [...rest, ...details]) console.error(` ${line.replace(/^ {2}/, '')}`)
223
+ process.exit(1)
224
+ }
225
+
226
+ export async function snapshot(args = []) {
227
+ let options
228
+ try {
229
+ options = parseSnapshotArgs(args)
230
+ } catch (err) {
231
+ if (!(err instanceof UsageError)) throw err
232
+ fail(err.message, 'Run `uniweb snapshot --help` for the accepted flags.')
233
+ }
234
+
235
+ const cwd = process.cwd()
236
+ const rootDir = findWorkspaceRoot(cwd)
237
+ const sites = rootDir ? await discoverSites(rootDir).catch(() => []) : []
238
+ const requested = options.site ?? options.positionals[0] ?? null
239
+ const { site, ambiguous } = pickSite(sites, rootDir ?? cwd, { requested, cwd })
240
+
241
+ if (requested && !site) {
242
+ fail(`Site "${requested}" not found.`, `Available: ${sites.map((s) => s.name).join(', ') || '(none)'}`)
243
+ }
244
+ if (!site && !options.url) {
245
+ fail('No site found here.', 'Run this inside a Uniweb workspace, or pass `--url <address> --out <file>`.')
246
+ }
247
+ if (!site && !options.out) {
248
+ fail('`--out <file>` is needed outside a site: there is no public/ folder to write to.')
249
+ }
250
+ if (ambiguous) {
251
+ console.error(`${YELLOW}⚠${RESET} Multiple sites found; using ${CYAN}${site.name}${RESET}. Pick one with \`--site <name>\`.`)
252
+ }
253
+
254
+ const siteDir = site ? join(rootDir, site.path) : null
255
+ const output = options.out ? resolve(cwd, options.out) : join(siteDir, DEFAULT_OUTPUT)
256
+
257
+ const lib = await loadSnapshotPackage([siteDir, rootDir])
258
+ if (!lib) {
259
+ fail(
260
+ '`uniweb snapshot` needs `@uniweb/snapshot`, which is not installed here.',
261
+ 'Add it as a dev dependency of the workspace:',
262
+ ` ${CYAN}${installHint(rootDir)}${RESET}`
263
+ )
264
+ }
265
+
266
+ // Put the site behind a URL.
267
+ let source
268
+ try {
269
+ if (options.url) {
270
+ source = { url: options.url, label: options.url, close: async () => {} }
271
+ } else if (options.dev) {
272
+ console.error(`${DIM}→ starting the dev server for ${site.name}${RESET}`)
273
+ const dev = await lib.startDevServer(siteDir)
274
+ source = { url: dev.url, label: `dev server (${dev.url})`, close: dev.close }
275
+ } else {
276
+ if (!options.noBuild) {
277
+ console.error(`${DIM}→ building ${site.name}${RESET}`)
278
+ await runBuild(siteDir)
279
+ }
280
+ const dist = join(siteDir, 'dist')
281
+ if (!existsSync(join(dist, 'index.html'))) {
282
+ fail(`No built site at ${relative(cwd, dist) || dist}.`, 'Run without `--no-build`, or build the site first.')
283
+ }
284
+ const server = await lib.serveDirectory(dist, { base: readSiteYml(siteDir).data.base })
285
+ source = { url: server.url, label: 'the built site', close: server.close }
286
+ }
287
+ } catch (err) {
288
+ fail(err.message)
289
+ }
290
+
291
+ let result
292
+ try {
293
+ result = await lib.snapshot({
294
+ url: source.url,
295
+ route: options.route,
296
+ layout: options.layout,
297
+ tone: options.tone,
298
+ canvas: options.canvas,
299
+ scale: options.scale,
300
+ quality: options.quality,
301
+ hide: options.hide,
302
+ output,
303
+ onStep: (step) => {
304
+ if (step === 'capture') {
305
+ const page = options.route && options.route !== '/' ? ` · ${options.route}` : ''
306
+ console.error(`${DIM}→ capturing ${source.label}${page}${RESET}`)
307
+ }
308
+ if (step === 'compose') console.error(`${DIM}→ composing${RESET}`)
309
+ },
310
+ })
311
+ } catch (err) {
312
+ await source.close().catch(() => {})
313
+ fail(err.message)
314
+ }
315
+ await source.close()
316
+
317
+ const shown = relative(cwd, output) || output
318
+ console.log(
319
+ `${GREEN}✓${RESET} ${shown} ${DIM}(${result.width}×${result.height}, ${humanBytes(result.bytes)} — ${result.layout} layout, ${result.tone} background)${RESET}`
320
+ )
321
+
322
+ if (!siteDir || options.noSetPreview) return
323
+
324
+ const value = previewValueFor(siteDir, output)
325
+ const siteYml = readSiteYml(siteDir)
326
+ if (!value) {
327
+ console.log(` ${DIM}site.yml not changed: the image is outside ${join(site.path, 'public')}/, so it has no site path.${RESET}`)
328
+ return
329
+ }
330
+ const current = siteYml.data.preview
331
+ switch (previewDecision(current, value)) {
332
+ case 'set':
333
+ upsertYamlScalar(siteYml.file, 'preview', value)
334
+ console.log(` site.yml: ${CYAN}preview: ${value}${RESET}`)
335
+ break
336
+ case 'replace':
337
+ upsertYamlScalar(siteYml.file, 'preview', value)
338
+ console.log(` site.yml: ${CYAN}preview: ${value}${RESET} ${DIM}(replaces the app-generated preview)${RESET}`)
339
+ break
340
+ case 'unchanged':
341
+ console.log(` ${DIM}site.yml already has preview: ${value}${RESET}`)
342
+ break
343
+ case 'keep':
344
+ console.log(` ${YELLOW}site.yml keeps preview: ${current}${RESET} ${DIM}— set it to ${value} to use this image.${RESET}`)
345
+ break
346
+ }
347
+ }
348
+
349
+ export default snapshot
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "generatedAt": "2026-09-16T20:35:26.755Z",
3
+ "generatedAt": "2026-09-16T23:53:23.238Z",
4
4
  "packages": {
5
5
  "@uniweb/api": {
6
6
  "version": "0.3.6",
@@ -10,7 +10,7 @@
10
10
  ]
11
11
  },
12
12
  "@uniweb/build": {
13
- "version": "0.52.5",
13
+ "version": "0.52.6",
14
14
  "path": "framework/build",
15
15
  "deps": [
16
16
  "@uniweb/content-reader",
@@ -18,6 +18,7 @@
18
18
  "@uniweb/core",
19
19
  "@uniweb/projections",
20
20
  "@uniweb/runtime",
21
+ "@uniweb/runtime",
21
22
  "@uniweb/schemas",
22
23
  "@uniweb/semantic-parser",
23
24
  "@uniweb/theming"
@@ -109,8 +110,13 @@
109
110
  "path": "framework/semantic-parser",
110
111
  "deps": []
111
112
  },
113
+ "@uniweb/snapshot": {
114
+ "version": "0.1.0",
115
+ "path": "framework/snapshot",
116
+ "deps": []
117
+ },
112
118
  "@uniweb/templates": {
113
- "version": "0.14.5",
119
+ "version": "0.14.6",
114
120
  "path": "framework/templates",
115
121
  "deps": []
116
122
  },
package/src/index.js CHANGED
@@ -731,6 +731,15 @@ async function main() {
731
731
  return
732
732
  }
733
733
 
734
+ // Handle snapshot command (dynamic import — depends on @uniweb/build; loads
735
+ // @uniweb/snapshot itself, from the workspace, and says how to add it if absent)
736
+ if (command === 'snapshot') {
737
+ const { snapshot } = await importProjectCommand('./commands/snapshot.js')
738
+ await snapshot(args.slice(1))
739
+ await showUpdateNotification()
740
+ return
741
+ }
742
+
734
743
  // Handle docs command (dynamic import — depends on @uniweb/build)
735
744
  if (command === 'docs') {
736
745
  const { docs } = await importProjectCommand('./commands/docs.js')
@@ -1479,6 +1488,42 @@ script (\`pnpm --filter <site> dev\` or \`npm -w <site> run dev\`). Picks
1479
1488
  the single site automatically; for multi-site workspaces the first
1480
1489
  site runs by default with a notice pointing at \`--site\` for explicit
1481
1490
  selection.
1491
+ `,
1492
+ snapshot: `
1493
+ ${colors.cyan}${colors.bright}uniweb snapshot${colors.reset} ${colors.dim}— Compose a preview image of a site${colors.reset}
1494
+
1495
+ ${colors.bright}Usage:${colors.reset}
1496
+ uniweb snapshot [<site>] [options]
1497
+
1498
+ Opens the site in a headless Chrome, captures it, and composes the captures into
1499
+ one image: by default ${colors.bright}site/public/preview.webp${colors.reset}, recorded as ${colors.cyan}preview:${colors.reset} in site.yml
1500
+ when site.yml has none, or only the app's generated one. A URL or image path you
1501
+ wrote is never replaced.
1502
+
1503
+ A page that scrolls gets the ${colors.bright}split${colors.reset} layout: the first view in a browser window,
1504
+ overlapped by a long strip of the page. A page that does not scroll as a page (a
1505
+ docs shell, an app) gets ${colors.bright}device${colors.reset}: a desktop window and a phone.
1506
+
1507
+ Needs ${colors.cyan}@uniweb/snapshot${colors.reset} in the workspace (\`pnpm add -D -w @uniweb/snapshot\`) and
1508
+ Google Chrome, Microsoft Edge, or a Chromium named by $UNIWEB_SNAPSHOT_BROWSER.
1509
+
1510
+ ${colors.bright}Where the site comes from:${colors.reset}
1511
+ (default) Build the site, then capture dist/
1512
+ --no-build Capture the existing dist/ as is
1513
+ --dev Capture the site's Vite dev server (no build)
1514
+ --url <address> Capture a site that is already running
1515
+
1516
+ ${colors.bright}Options:${colors.reset}
1517
+ --site <name> The site (default: the one you are in, or the only one)
1518
+ --route <path> The page to capture (default: the home page)
1519
+ --layout <name> auto (default), split, device
1520
+ --tone <name> Background: auto (default), light, deep
1521
+ --size <WxH> Image size in CSS pixels (default: 1600x1000)
1522
+ --scale <n> 1 (default) or 2 for a double-density image
1523
+ --quality <n> Encoder quality, 1–100 (default: 82)
1524
+ --out <file> Where to write it; .webp, .png, .jpg or .avif
1525
+ --hide <selector> Hide matching elements before capturing (repeatable)
1526
+ --no-set-preview Write the image without touching site.yml
1482
1527
  `,
1483
1528
  build: `
1484
1529
  ${colors.cyan}${colors.bright}uniweb build${colors.reset} ${colors.dim}— Build the current project${colors.reset}
@@ -1498,31 +1543,82 @@ ${colors.bright}Options:${colors.reset}
1498
1543
  --platform <name> (Deprecated alias for --host)
1499
1544
  `,
1500
1545
  add: `
1501
- ${colors.cyan}${colors.bright}uniweb add${colors.reset} ${colors.dim}— Add a foundation, site, or extension${colors.reset}
1546
+ ${colors.cyan}${colors.bright}Uniweb Add${colors.reset}
1502
1547
 
1503
- ${colors.bright}Subcommands:${colors.reset}
1504
- add project [name] Add a co-located foundation + site pair
1505
- add foundation [name] Add a foundation (--from, --path, --project)
1506
- add site [name] Add a site (--from, --foundation, --path, --project)
1507
- add extension <name> Add an extension (--from, --site, --path)
1508
- add section <name> Add a section type to a foundation (--foundation, --starter)
1509
- add ci Add a CI workflow so every push deploys (--host, --target)
1548
+ Add projects, foundations, sites, extensions, section types, or CI workflows to your workspace.
1549
+
1550
+ ${colors.bright}Usage:${colors.reset}
1551
+ uniweb add project [name] [options]
1552
+ uniweb add foundation [name] [options]
1553
+ uniweb add site [name] [options]
1554
+ uniweb add extension <name> [options]
1555
+ uniweb add section <name> [options]
1556
+ uniweb add ci [options]
1557
+
1558
+ ${colors.bright}Common Options:${colors.reset}
1559
+ --from <template> Apply content from a template after scaffolding
1560
+ --path <dir> Custom directory for the package
1561
+ --non-interactive Fail with usage info instead of prompting
1562
+
1563
+ ${colors.bright}Foundation Options:${colors.reset}
1564
+ --project <name> Group under a project directory (co-located layout)
1565
+
1566
+ ${colors.bright}Site Options:${colors.reset}
1567
+ --foundation <n> Foundation to wire to (prompted if multiple exist)
1568
+ --project <name> Group under a project directory (co-located layout)
1569
+
1570
+ ${colors.bright}Extension Options:${colors.reset}
1571
+ --site <name> Site to wire extension URL into
1572
+
1573
+ ${colors.bright}Section Options:${colors.reset}
1574
+ --foundation <n> Foundation to add section to (prompted if multiple exist)
1575
+ --starter Generate starter content from the section's \`content:\`
1576
+ declaration — what an author would begin editing. Works on
1577
+ a section that already exists (nothing is written), and on
1578
+ a new one (the scaffold gets a matching declaration)
1579
+ --preset <name> Frontmatter the starter content with this preset's params
1580
+ --write <file> Write the starter markdown to a file instead of printing it
1581
+ --json Emit the content structure and ProseMirror doc instead
1582
+
1583
+ ${colors.bright}CI Options:${colors.reset}
1584
+ --host <name> github-pages | cloudflare-pages | netlify | vercel
1585
+ (prompted when omitted)
1586
+ --target <what> site (default) | foundation
1587
+ 'foundation' publishes built foundations at permanent
1588
+ versioned URLs — the free alternative to the catalog
1589
+ --site <name> Site the workflow builds (prompted if multiple exist)
1590
+ --foundation <name> With --target foundation: publish just this one
1591
+ --domain <host> Custom domain (GitHub Pages: writes CNAME, serves at root)
1592
+ --project-name <name> Name to register under on the host (default: the
1593
+ workspace name; Cloudflare Pages project, etc.)
1594
+ --no-previews Skip the per-PR preview workflow
1595
+ --force Overwrite an existing workflow file
1596
+
1597
+ ${colors.dim}Hosts that support PR previews: cloudflare-pages, netlify, vercel.
1598
+ GitHub Pages has no preview environment, so it scaffolds a deploy workflow only.${colors.reset}
1510
1599
 
1511
- ${colors.bright}Common options:${colors.reset}
1512
- --from <template> Source content from a template
1513
- --path <dir> Override default folder location
1514
- --foundation <name> Wire site/extension to this foundation (CI-friendly)
1515
- --site <name> Wire extension to this site (CI-friendly)
1516
- --non-interactive Fail with usage info instead of prompting
1517
-
1518
- ${colors.bright}Starter content (add section):${colors.reset}
1519
- --starter Generate starter content from the section's
1520
- \`content:\` declaration what an author would begin
1521
- editing. Works on an existing section (writes nothing)
1522
- and on a new one (the scaffold gets a declaration)
1523
- --preset <name> Frontmatter it with this preset's params
1524
- --write <file> Write the markdown to a file instead of printing
1525
- --json Emit the structure + ProseMirror doc instead
1600
+ ${colors.bright}Examples:${colors.reset}
1601
+ uniweb add project docs # Create docs/foundation/ + docs/site/
1602
+ uniweb add project docs --from academic # Co-located pair + academic content
1603
+ uniweb add foundation # Create ./foundation/ at root
1604
+ uniweb add foundation ui # Create ./foundations/ui/
1605
+ uniweb add site # Create ./site/ at root
1606
+ uniweb add site blog --foundation marketing # Create ./sites/blog/ wired to marketing
1607
+ uniweb add extension effects --site site # Create ./extensions/effects/
1608
+ uniweb add section Hero # Create Hero section type
1609
+ uniweb add section Hero --foundation ui # Target specific foundation
1610
+ uniweb add section Hero --starter # Starter content for an existing Hero
1611
+ uniweb add section Pricing --starter # Scaffold Pricing + content that fills it
1612
+ uniweb add section Hero --starter --preset split # Frontmatter it with the 'split' preset
1613
+ uniweb add section Hero --starter --json # The structure + ProseMirror, for a script
1614
+ uniweb add foundation --project docs # Create ./docs/foundation/ (co-located)
1615
+ uniweb add site --project docs # Create ./docs/site/ (co-located)
1616
+ uniweb add ci # Pick a host, add a deploy workflow
1617
+ uniweb add ci --host github-pages --site marketing # Pick host + site explicitly
1618
+ uniweb add ci --host netlify # Deploy + PR-preview workflows
1619
+ uniweb add ci --host vercel --no-previews # Deploy workflow only
1620
+ uniweb add ci --domain mysite.com # Custom domain → writes CNAME + UNIWEB_BASE=/
1621
+ uniweb add ci --target foundation # Publish foundations at versioned URLs
1526
1622
  `,
1527
1623
  export: `
1528
1624
  ${colors.cyan}${colors.bright}uniweb export${colors.reset} ${colors.dim}— Export a self-contained site for third-party hosting${colors.reset}
@@ -1866,6 +1962,7 @@ ${colors.bright}Commands:${colors.reset}
1866
1962
  rename <type> Rename a foundation, site, or extension across the workspace
1867
1963
  dev Start a dev server for a site
1868
1964
  build Build the current project
1965
+ snapshot Compose a preview image of a site (its site.yml preview:)
1869
1966
  publish Publish a site to Uniweb hosting (smart: foundation + sync + go live)
1870
1967
  deploy Ship a site to a host (asks where, if not yet configured)
1871
1968
  export Export a self-contained site for third-party hosting
@@ -0,0 +1,20 @@
1
+ /**
2
+ * `site.yml::preview` — the site's card image — has two writers: an author, who
3
+ * writes an address (a URL, or a path to an image in the project), and the app,
4
+ * which writes a token naming an image it generated. This is the one test that
5
+ * tells them apart.
6
+ *
7
+ * It recognizes the author's shapes rather than the app's, so it does not depend
8
+ * on the token's format: a URL, a path starting `/`, `./` or `../`, or a relative
9
+ * path to an image file (`images/card.png`).
10
+ */
11
+
12
+ const IMAGE_FILE = /\.(avif|gif|jpe?g|png|svg|webp)$/i
13
+
14
+ /**
15
+ * @param {unknown} value
16
+ * @returns {boolean} true when `value` is an author's address rather than the app's token
17
+ */
18
+ export const isAuthoredPreview = (value) =>
19
+ typeof value === 'string' &&
20
+ (/^https?:\/\//i.test(value) || /^\.{0,2}\//.test(value) || IMAGE_FILE.test(value.trim()))