uniweb 0.56.11 → 0.56.12

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.11",
3
+ "version": "0.56.12",
4
4
  "description": "Create structured Vite + React sites with content/code separation",
5
5
  "type": "module",
6
6
  "bin": {
@@ -44,12 +44,12 @@
44
44
  "@uniweb/content-writer": "^0.3.4",
45
45
  "@uniweb/core": "^0.29.4",
46
46
  "@uniweb/runtime": "^0.26.4",
47
- "@uniweb/schemas": "^0.3.3",
48
47
  "@uniweb/semantic-parser": "^1.4.1",
49
- "@uniweb/kit": "^0.19.3"
48
+ "@uniweb/kit": "^0.19.3",
49
+ "@uniweb/schemas": "^0.3.3"
50
50
  },
51
51
  "peerDependencies": {
52
- "@uniweb/build": "^0.52.6",
52
+ "@uniweb/build": "^0.52.7",
53
53
  "@uniweb/content-reader": "^1.2.5",
54
54
  "@uniweb/semantic-parser": "^1.4.1"
55
55
  },
@@ -661,18 +661,37 @@ Decimals insert between: `2.5-testimonials.md` goes between `2-` and `3-`. **Ign
661
661
  title: About Us
662
662
  id: about # Stable identity (for page: links, survives moves)
663
663
  order: 2 # Navigation sort position
664
- pages: [team, history, ...] # Child page order (... = rest). Without ... = strict (hides unlisted)
664
+ sections: [hero, team, ...] # Section order within this page (same ... rule)
665
+ pages: [team, history, ...] # Child page order, when this page has child folders
665
666
  redirect: academic # Redirect to a child page (relative/absolute path, or URL)
666
667
  slug: { fr: a-propos } # Localized URL segment per language
667
668
 
669
+ # folder.yml — a folder OF PAGES, where page.yml means a page built from sections
670
+ title: Getting Started
671
+ pages: [quickstart, app-tour, ...] # Child page order (... = rest)
672
+
668
673
  # site.yml
669
674
  index: home # Just set the homepage
670
- pages: [home, about, ...] # Order pages (... = rest, first = homepage); without ... = strict
675
+ pages: [home, about, ...] # Order pages (... = rest, first = homepage)
671
676
  foundation: '@acme/ui@1.2.0' # The component system (see Part 2, step 1)
672
677
  extensions: ['@acme/fx@0.3.1'] # Secondary foundations — same shapes as foundation:
673
- runtime: 0.9.6 # Optional runtime pin; omit and the host chooses
674
678
  ```
675
679
 
680
+ **The trailing `...` is not decoration — dropping it changes behaviour.** `pages:`
681
+ in `folder.yml` and `site.yml`, and `sections:` in `page.yml`, all read it the same way:
682
+
683
+ | written | means |
684
+ |---|---|
685
+ | `[a, b, ...]` | **inclusive** — `a` and `b` pinned in that order, **everything else follows** |
686
+ | `['...']` | identical to omitting the key |
687
+ | `[a, b]` — no `...` | **strict** — and every unlisted sibling is dropped from **every** menu |
688
+
689
+ ⚠️ **Strict is a navigation filter, not a delete, which is what makes it easy to
690
+ miss.** Unlisted pages stay routed, stay in `dist/`, stay in `llms.txt` and still
691
+ resolve by URL — only the links to them disappear. Your route count does not
692
+ change and nothing warns. **If you list pages in order and mean "these first",
693
+ end the list with `...`.**
694
+
676
695
  **Configuration cascades: `page.yml` → `folder.yml` → `site.yml` → foundation defaults.** Each level inherits from the one above and overrides specific values, the way CSS specificity works. This is what makes bulk assignment natural — put `layout: marketing` in a `folder.yml` and every page in that folder inherits it, while one page can still override with its own `page.yml`. Reach for `folder.yml` before editing the same key into a dozen `page.yml` files.
677
696
 
678
697
  **Route mapping:** folder structure maps 1:1 to routes. Every folder keeps its natural route — `pages:` controls **order only**, not which child "becomes" the parent. The only exception is the site root, where `index:` (or first in `pages:`) sets `/`.
@@ -2513,6 +2532,7 @@ npx uniweb@latest update # Align @uniweb/* deps + AGENTS.md (--dry-run,
2513
2532
  uniweb inspect <path> # Show parsed content for a section or page (--raw for the AST)
2514
2533
  uniweb snapshot # Compose site/public/preview.webp from the site; sets preview: if unset
2515
2534
  # needs `pnpm add -D -w @uniweb/snapshot` and Chrome or Edge
2535
+ uniweb snapshot --compare # Several looks on one sheet; take one with its flags + --save (site/snapshot.yml)
2516
2536
 
2517
2537
  uniweb <command> --help # Per-command flags — no side effects. Prefer this over guessing.
2518
2538
  ```
@@ -328,16 +328,17 @@ export function clearRemoteSyncStateIfUnbound(siteDir) {
328
328
  /**
329
329
  * Drop the `site.yml` values that describe ONE PARTICULAR backend site, when this
330
330
  * project is bound to none (no `$uuid`) — a brand-new site, or the state our own
331
- * "clear `$uuid` to re-publish as a new site" recovery puts you in:
332
- *
333
- * · `$url` where the PREVIOUS site was live. The new one is not live anywhere
334
- * yet; its first publish records its own.
335
- * · `preview` in the APP's form — a timestamp naming the previous site's generated
336
- * card image, which is keyed by that site's uuid and would dangle on this one.
331
+ * "clear `$uuid` to re-publish as a new site" recovery puts you in. Today that is
332
+ * one value: `preview` in the APP's form — a timestamp naming the previous site's
333
+ * generated card image, which is keyed by that site's uuid and would dangle on this
334
+ * one.
337
335
  *
338
336
  * ⛔ An AUTHOR's preview is theirs and stays: a URL, or a path to an image in the
339
337
  * project, is as good for the new site as for the old.
340
338
  *
339
+ * (`$url`, where the previous site was live, was dropped here too until it was
340
+ * retired on 2026-09-17. A leftover line is inert — nothing reads or sends it.)
341
+ *
341
342
  * The site.yml sibling of `clearRemoteSyncStateIfUnbound`, called beside it for the
342
343
  * same reason — before the create mints a uuid and makes the project look bound.
343
344
  *
@@ -354,7 +355,6 @@ export function dropSiteBoundValues(siteDir) {
354
355
  }
355
356
  if (!y || typeof y !== 'object' || typeof y.$uuid === 'string') return []
356
357
  const dropped = []
357
- if (y.$url !== undefined && removeYamlScalar(file, '$url')) dropped.push('$url')
358
358
  if (
359
359
  y.preview !== undefined &&
360
360
  !isAuthoredPreview(y.preview) &&
@@ -124,26 +124,6 @@ const say = {
124
124
  dim: (m) => console.log(` ${c.dim}${m}${c.reset}`)
125
125
  }
126
126
 
127
- // Origin-relative serve path → clickable absolute URL.
128
- //
129
- // ⭐ THE TWO SHAPES ARE A CONTRACT, NOT AN INCONSISTENCY — ratified 2026-08-29 and
130
- // documented in the backend's `wire-layer.md` rather than merely observed. A publish
131
- // returns an ABSOLUTE url when Cloudflare hosts the site (another origin entirely)
132
- // and an ORIGIN-RELATIVE path when the backend serves it itself, where its own
133
- // external origin is not reliably self-reportable from behind an ALB.
134
- //
135
- // ⇒ So this branch is implementing the contract, not defending against drift. I
136
- // reported the two shapes as a violation of "finished values only" in collab
137
- // framework↔backend; the backend checked, found the adjacent ruling that
138
- // explains the relative arm, and ratified both. Do not "fix" it by demanding one
139
- // shape — the caller's own origin is the missing half on the relative arm, and we
140
- // are the caller.
141
- function absolutizeServeUrl(origin, url) {
142
- if (!url || typeof url !== 'string') return null
143
- if (/^https?:\/\//.test(url)) return url
144
- return `${origin.replace(/\/$/, '')}${url.startsWith('/') ? '' : '/'}${url}`
145
- }
146
-
147
127
  function readSiteYml(path) {
148
128
  if (!existsSync(path)) return {}
149
129
  try {
@@ -1020,7 +1000,12 @@ export async function publish(args = []) {
1020
1000
  } catch {
1021
1001
  result = {}
1022
1002
  }
1023
- const serveUrl = absolutizeServeUrl(client.origin, result.url)
1003
+ // Where the site went live — a finished, absolute address, taken verbatim: shown,
1004
+ // and recorded in deploy.yml, never composed onto. Until 2026-09-17 a backend
1005
+ // serving the site itself answered with an origin-relative path, which this
1006
+ // prefixed with our own origin; the reply is absolute now, so a path from an
1007
+ // older backend is simply shown as one.
1008
+ const serveUrl = typeof result.url === 'string' && result.url ? result.url : null
1024
1009
 
1025
1010
  // 8. Persist deploy.yml memory — a record of what went live (and so a re-run
1026
1011
  // reuses the resolved target without re-asking). One identity:
@@ -1113,11 +1098,10 @@ export async function publish(args = []) {
1113
1098
  say.dim(` site.yml lists ${unserved.join(', ')}, and ${unserved.length === 1 ? 'it was' : 'they were'} not published.`)
1114
1099
  }
1115
1100
  if (serveUrl) console.log(` ${c.cyan}${serveUrl}${c.reset}`)
1116
- // ⛔ No site.yml write for where it went live. The backend records the reply's
1117
- // `url` on `info.url` at every publish, and pull brings it into `site.yml::$url`
1118
- // like any other key. A copy written here was a second writer that had to match
1119
- // the backend's byte for byte (the address printed above is made absolute, so it
1120
- // did not), and it left site.yml modified — which a plain `pull` refuses over.
1101
+ // ⛔ No site.yml write for where it went live. That is a fact about this deploy,
1102
+ // not about the site, and it is recorded where deploy facts live: deploy.yml's
1103
+ // `lastDeploy.<target>.url` (step 8 above). site.yml carried it as `$url`
1104
+ // `info.url` from 2026-09-10 until the field was retired on 2026-09-17.
1121
1105
  if (result.deploy_uuid) say.dim(`deploy: ${result.deploy_uuid}`)
1122
1106
  return { exitCode: 0 }
1123
1107
  }
@@ -12,10 +12,16 @@
12
12
  * uniweb snapshot build the site, serve dist/, capture it
13
13
  * uniweb snapshot --dev capture the site's Vite dev server (no build)
14
14
  * uniweb snapshot --url <url> capture a site that is already running
15
+ * uniweb snapshot --compare one capture, several looks, on one sheet
15
16
  *
16
17
  * 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).
18
+ * view in a browser window, beside a long strip of the page); one that does not
19
+ * — a documentation shell, an app — gets `device` (desktop and phone). How the
20
+ * two frames sit together is the look: `--gap`/`--overlap`, `--strip`, `--side`,
21
+ * `--frame`, `--tone`.
22
+ *
23
+ * A site keeps its chosen look in `site/snapshot.yml` (utils/snapshot-settings.js):
24
+ * the command's defaults for that site, which flags override and `--save` writes.
19
25
  *
20
26
  * `preview:` is written only when site.yml has none, or holds the app's generated
21
27
  * token. An address the author wrote is never replaced.
@@ -34,6 +40,13 @@ import { humanBytes } from '../utils/bytes.js'
34
40
  import { discoverSites } from '../utils/discover.js'
35
41
  import { detectWorkspacePm } from '../utils/pm.js'
36
42
  import { isAuthoredPreview } from '../utils/preview.js'
43
+ import {
44
+ SETTINGS_FILE,
45
+ SettingsError,
46
+ mergeSettings,
47
+ readSnapshotSettings,
48
+ saveSnapshotSettings
49
+ } from '../utils/snapshot-settings.js'
37
50
  import { findWorkspaceRoot } from '../utils/workspace.js'
38
51
 
39
52
  const RED = '\x1b[31m'
@@ -43,45 +56,78 @@ const CYAN = '\x1b[36m'
43
56
  const DIM = '\x1b[2m'
44
57
  const RESET = '\x1b[0m'
45
58
 
46
- const VALUE_FLAGS = ['--site', '--url', '--route', '--layout', '--tone', '--size', '--scale', '--quality', '--out', '--hide']
47
- const BOOLEAN_FLAGS = ['--dev', '--no-build', '--no-set-preview']
59
+ /** Flags that choose the image, by the `snapshot.yml` setting each one sets. */
60
+ const SETTING_FLAGS = {
61
+ '--route': 'route',
62
+ '--layout': 'layout',
63
+ '--tone': 'tone',
64
+ '--gap': 'gap',
65
+ '--overlap': 'overlap',
66
+ '--strip': 'strip',
67
+ '--side': 'side',
68
+ '--frame': 'frame',
69
+ '--size': 'size',
70
+ '--scale': 'scale',
71
+ '--quality': 'quality',
72
+ '--hide': 'hide',
73
+ '--out': 'out'
74
+ }
75
+ const NUMBER_SETTINGS = ['gap', 'overlap', 'scale', 'quality']
76
+ /** Flags that choose where the site comes from and what the run does. */
77
+ const CONTROL_VALUE_FLAGS = ['--site', '--url']
78
+ const CONTROL_BOOLEAN_FLAGS = ['--dev', '--no-build', '--no-set-preview', '--compare', '--save']
48
79
  const GLOBAL_FLAGS = ['--non-interactive', '--help', '-h']
49
- const ALL_FLAGS = [...VALUE_FLAGS, ...BOOLEAN_FLAGS, ...GLOBAL_FLAGS]
80
+ const ALL_FLAGS = [...Object.keys(SETTING_FLAGS), ...CONTROL_VALUE_FLAGS, ...CONTROL_BOOLEAN_FLAGS, ...GLOBAL_FLAGS]
81
+ const LOOK_SETTINGS = ['gap', 'overlap', 'strip', 'side', 'frame']
50
82
 
51
83
  export const DEFAULT_OUTPUT = join('public', 'preview.webp')
84
+ export const COMPARE_OUTPUT = join('.uniweb', 'snapshot', 'compare.webp')
52
85
 
53
86
  class UsageError extends Error {}
54
87
 
55
88
  const camel = (flag) => flag.replace(/^--/, '').replace(/-([a-z])/g, (_, c) => c.toUpperCase())
56
89
 
57
90
  /**
58
- * Parse `uniweb snapshot` arguments. Throws a UsageError naming the first problem.
91
+ * Parse `uniweb snapshot` arguments into the image settings they choose and the
92
+ * run controls. Checks the shape of each value; what a value may be (a layout
93
+ * name, a range) is checked by the package, before anything is built.
59
94
  *
60
95
  * @param {string[]} args
96
+ * @param {string} [cwd] - `--out` is relative to it
61
97
  */
62
- export function parseSnapshotArgs(args = []) {
63
- const options = { hide: [], positionals: [] }
98
+ export function parseSnapshotArgs(args = [], cwd = process.cwd()) {
99
+ const settings = {}
100
+ const control = {}
101
+ const positionals = []
64
102
  for (let i = 0; i < args.length; i++) {
65
103
  const raw = args[i]
66
104
  if (raw === '--') {
67
- options.positionals.push(...args.slice(i + 1))
105
+ positionals.push(...args.slice(i + 1))
68
106
  break
69
107
  }
70
108
  if (!raw.startsWith('-') || raw === '-') {
71
- options.positionals.push(raw)
109
+ positionals.push(raw)
72
110
  continue
73
111
  }
74
112
  const eq = raw.indexOf('=')
75
113
  const name = eq === -1 ? raw : raw.slice(0, eq)
76
- if (VALUE_FLAGS.includes(name)) {
114
+ const takesValue = name in SETTING_FLAGS || CONTROL_VALUE_FLAGS.includes(name)
115
+ if (takesValue) {
77
116
  const value = eq === -1 ? args[++i] : raw.slice(eq + 1)
78
117
  if (value === undefined || value === '' || (eq === -1 && value.startsWith('--'))) {
79
118
  throw new UsageError(`\`${name}\` needs a value.`)
80
119
  }
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
120
+ const key = SETTING_FLAGS[name]
121
+ if (!key) control[camel(name)] = value
122
+ else if (key === 'hide') settings.hide = [...(settings.hide ?? []), value]
123
+ else if (key === 'out') settings.out = resolve(cwd, value)
124
+ else if (NUMBER_SETTINGS.includes(key)) {
125
+ const number = Number(value)
126
+ if (!Number.isFinite(number)) throw new UsageError(`\`${name}\` needs a number.`)
127
+ settings[key] = number
128
+ } else settings[key] = value
129
+ } else if (CONTROL_BOOLEAN_FLAGS.includes(name)) {
130
+ control[camel(name)] = true
85
131
  } else if (!GLOBAL_FLAGS.includes(name)) {
86
132
  const suggestion = didYouMean(name, ALL_FLAGS)
87
133
  throw new UsageError(
@@ -90,35 +136,41 @@ export function parseSnapshotArgs(args = []) {
90
136
  }
91
137
  }
92
138
 
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.')
139
+ if (control.dev && control.url) throw new UsageError('Pass `--dev` or `--url`, not both.')
140
+ if (settings.gap !== undefined && settings.overlap !== undefined) {
141
+ throw new UsageError('Pass `--gap` or `--overlap`, not both.')
99
142
  }
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)
143
+ if (control.compare && control.save) {
144
+ throw new UsageError('`--save` keeps the look of a snapshot; choose a look from the sheet, then save that.')
111
145
  }
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
146
+ return { settings, control, positionals }
147
+ }
148
+
149
+ /**
150
+ * The package's options for a set of settings. `size` is the one setting whose
151
+ * shape the package does not take as written.
152
+ */
153
+ export function libraryOptions({ size, out, scale, quality, ...rest }) {
154
+ const options = { ...rest }
155
+ if (size !== undefined) {
156
+ const match = /^(\d+)x(\d+)$/.exec(String(size))
157
+ if (!match) throw new UsageError('`size` is WIDTHxHEIGHT, e.g. 1600x1000.')
158
+ options.canvas = { width: Number(match[1]), height: Number(match[2]) }
118
159
  }
160
+ if (scale !== undefined) options.scale = Number(scale)
161
+ if (quality !== undefined) options.quality = Number(quality)
162
+ if (out !== undefined) options.output = out
119
163
  return options
120
164
  }
121
165
 
166
+ /** A variant's changes as the flags that apply them; `current` for none. */
167
+ export function flagsFor(changes) {
168
+ const parts = Object.entries(changes)
169
+ .filter(([, value]) => value !== undefined)
170
+ .map(([key, value]) => `--${key} ${value}`)
171
+ return parts.length ? parts.join(' ') : 'current'
172
+ }
173
+
122
174
  /**
123
175
  * The `preview:` value that names `output`, or null when it cannot be named — an
124
176
  * image outside the site's `public/` folder has no site-root path.
@@ -172,14 +224,14 @@ async function loadSnapshotPackage(dirs) {
172
224
  }
173
225
  }
174
226
 
175
- function installHint(rootDir) {
227
+ function installHint(rootDir, command = 'add') {
176
228
  switch (detectWorkspacePm(rootDir)) {
177
229
  case 'npm':
178
- return 'npm install --save-dev @uniweb/snapshot'
230
+ return 'npm install --save-dev @uniweb/snapshot@latest'
179
231
  case 'yarn':
180
- return 'yarn add --dev -W @uniweb/snapshot'
232
+ return `yarn ${command} --dev -W @uniweb/snapshot@latest`
181
233
  default:
182
- return 'pnpm add -D -w @uniweb/snapshot'
234
+ return `pnpm ${command} -D -w @uniweb/snapshot@latest`
183
235
  }
184
236
  }
185
237
 
@@ -224,35 +276,59 @@ function fail(message, ...details) {
224
276
  }
225
277
 
226
278
  export async function snapshot(args = []) {
227
- let options
279
+ const cwd = process.cwd()
280
+ let parsed
228
281
  try {
229
- options = parseSnapshotArgs(args)
282
+ parsed = parseSnapshotArgs(args, cwd)
230
283
  } catch (err) {
231
284
  if (!(err instanceof UsageError)) throw err
232
285
  fail(err.message, 'Run `uniweb snapshot --help` for the accepted flags.')
233
286
  }
287
+ const { settings: flagSettings, control, positionals } = parsed
234
288
 
235
- const cwd = process.cwd()
236
289
  const rootDir = findWorkspaceRoot(cwd)
237
290
  const sites = rootDir ? await discoverSites(rootDir).catch(() => []) : []
238
- const requested = options.site ?? options.positionals[0] ?? null
291
+ const requested = control.site ?? positionals[0] ?? null
239
292
  const { site, ambiguous } = pickSite(sites, rootDir ?? cwd, { requested, cwd })
240
293
 
241
294
  if (requested && !site) {
242
295
  fail(`Site "${requested}" not found.`, `Available: ${sites.map((s) => s.name).join(', ') || '(none)'}`)
243
296
  }
244
- if (!site && !options.url) {
297
+ if (!site && !control.url) {
245
298
  fail('No site found here.', 'Run this inside a Uniweb workspace, or pass `--url <address> --out <file>`.')
246
299
  }
247
- if (!site && !options.out) {
248
- fail('`--out <file>` is needed outside a site: there is no public/ folder to write to.')
300
+ if (!site && !flagSettings.out) {
301
+ fail('`--out <file>` is needed outside a site: there is no site folder to write to.')
249
302
  }
303
+ if (!site && control.save) fail(`\`--save\` writes ${SETTINGS_FILE} into a site, and there is none here.`)
250
304
  if (ambiguous) {
251
305
  console.error(`${YELLOW}⚠${RESET} Multiple sites found; using ${CYAN}${site.name}${RESET}. Pick one with \`--site <name>\`.`)
252
306
  }
253
307
 
254
308
  const siteDir = site ? join(rootDir, site.path) : null
255
- const output = options.out ? resolve(cwd, options.out) : join(siteDir, DEFAULT_OUTPUT)
309
+
310
+ // The site's saved look, under this run's flags. For a comparison, the file's
311
+ // `out` names the preview image, not the sheet.
312
+ let fileSettings = {}
313
+ if (siteDir) {
314
+ try {
315
+ fileSettings = readSnapshotSettings(siteDir).settings
316
+ } catch (err) {
317
+ if (!(err instanceof SettingsError)) throw err
318
+ fail(err.message)
319
+ }
320
+ }
321
+ const base = { ...fileSettings }
322
+ if (control.compare) delete base.out
323
+ const merged = mergeSettings(base, flagSettings)
324
+ let options
325
+ try {
326
+ options = libraryOptions(merged)
327
+ } catch (err) {
328
+ if (!(err instanceof UsageError)) throw err
329
+ fail(err.message)
330
+ }
331
+ options.output ??= join(siteDir, control.compare ? COMPARE_OUTPUT : DEFAULT_OUTPUT)
256
332
 
257
333
  const lib = await loadSnapshotPackage([siteDir, rootDir])
258
334
  if (!lib) {
@@ -262,18 +338,33 @@ export async function snapshot(args = []) {
262
338
  ` ${CYAN}${installHint(rootDir)}${RESET}`
263
339
  )
264
340
  }
341
+ const chosesLook = LOOK_SETTINGS.some((key) => options[key] !== undefined)
342
+ if ((control.compare || chosesLook) && typeof lib.compare !== 'function') {
343
+ fail(
344
+ 'The installed `@uniweb/snapshot` predates looks and comparisons.',
345
+ 'Update it:',
346
+ ` ${CYAN}${installHint(rootDir)}${RESET}`
347
+ )
348
+ }
349
+ if (typeof lib.normalizeOptions === 'function') {
350
+ try {
351
+ lib.normalizeOptions(options)
352
+ } catch (err) {
353
+ fail(err.message, `Check the flags${Object.keys(fileSettings).length ? ` and ${SETTINGS_FILE}` : ''}.`)
354
+ }
355
+ }
265
356
 
266
357
  // Put the site behind a URL.
267
358
  let source
268
359
  try {
269
- if (options.url) {
270
- source = { url: options.url, label: options.url, close: async () => {} }
271
- } else if (options.dev) {
360
+ if (control.url) {
361
+ source = { url: control.url, label: control.url, close: async () => {} }
362
+ } else if (control.dev) {
272
363
  console.error(`${DIM}→ starting the dev server for ${site.name}${RESET}`)
273
364
  const dev = await lib.startDevServer(siteDir)
274
365
  source = { url: dev.url, label: `dev server (${dev.url})`, close: dev.close }
275
366
  } else {
276
- if (!options.noBuild) {
367
+ if (!control.noBuild) {
277
368
  console.error(`${DIM}→ building ${site.name}${RESET}`)
278
369
  await runBuild(siteDir)
279
370
  }
@@ -288,40 +379,52 @@ export async function snapshot(args = []) {
288
379
  fail(err.message)
289
380
  }
290
381
 
382
+ const onStep = (step) => {
383
+ if (step === 'capture') {
384
+ const page = options.route && options.route !== '/' ? ` · ${options.route}` : ''
385
+ console.error(`${DIM}→ capturing ${source.label}${page}${RESET}`)
386
+ }
387
+ if (step === 'compose') console.error(`${DIM}→ composing${RESET}`)
388
+ }
389
+
291
390
  let result
292
391
  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
- })
392
+ result = control.compare
393
+ ? await lib.compare({ ...options, url: source.url, label: flagsFor, onStep })
394
+ : await lib.snapshot({ ...options, url: source.url, onStep })
311
395
  } catch (err) {
312
396
  await source.close().catch(() => {})
313
397
  fail(err.message)
314
398
  }
315
399
  await source.close()
316
400
 
317
- const shown = relative(cwd, output) || output
401
+ const shown = relative(cwd, options.output) || options.output
402
+
403
+ if (control.compare) {
404
+ console.log(
405
+ `${GREEN}✓${RESET} ${shown} ${DIM}(${result.width}×${result.height}, ${result.variants.length} looks from one capture)${RESET}`
406
+ )
407
+ result.variants.forEach((variant, i) => {
408
+ console.log(` ${String(i + 1).padStart(2)} ${i === 0 ? `${DIM}current${RESET}` : `${CYAN}${variant.label}${RESET}`}`)
409
+ })
410
+ console.log(` ${DIM}Take one with its flags, and add --save to keep it: uniweb snapshot ${result.variants[1]?.label ?? ''} --save${RESET}`)
411
+ return
412
+ }
413
+
318
414
  console.log(
319
415
  `${GREEN}✓${RESET} ${shown} ${DIM}(${result.width}×${result.height}, ${humanBytes(result.bytes)} — ${result.layout} layout, ${result.tone} background)${RESET}`
320
416
  )
321
417
 
322
- if (!siteDir || options.noSetPreview) return
418
+ if (control.save) {
419
+ const { saved } = saveSnapshotSettings(siteDir, flagSettings, fileSettings)
420
+ const where = relative(cwd, join(siteDir, SETTINGS_FILE))
421
+ if (saved.length) console.log(` ${where}: ${CYAN}saved ${saved.join(', ')}${RESET}`)
422
+ else console.log(` ${DIM}${where}: nothing to save — pass the flags you want to keep.${RESET}`)
423
+ }
424
+
425
+ if (!siteDir || control.noSetPreview) return
323
426
 
324
- const value = previewValueFor(siteDir, output)
427
+ const value = previewValueFor(siteDir, options.output)
325
428
  const siteYml = readSiteYml(siteDir)
326
429
  if (!value) {
327
430
  console.log(` ${DIM}site.yml not changed: the image is outside ${join(site.path, 'public')}/, so it has no site path.${RESET}`)
@@ -1,16 +1,16 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "generatedAt": "2026-09-17T03:53:59.364Z",
3
+ "generatedAt": "2026-09-18T05:17:28.321Z",
4
4
  "packages": {
5
5
  "@uniweb/api": {
6
- "version": "0.3.6",
6
+ "version": "0.3.7",
7
7
  "path": "framework/api",
8
8
  "deps": [
9
9
  "@uniweb/core"
10
10
  ]
11
11
  },
12
12
  "@uniweb/build": {
13
- "version": "0.52.6",
13
+ "version": "0.52.7",
14
14
  "path": "framework/build",
15
15
  "deps": [
16
16
  "@uniweb/content-reader",
@@ -111,7 +111,7 @@
111
111
  "deps": []
112
112
  },
113
113
  "@uniweb/snapshot": {
114
- "version": "0.1.1",
114
+ "version": "0.1.2",
115
115
  "path": "framework/snapshot",
116
116
  "deps": []
117
117
  },
package/src/index.js CHANGED
@@ -1494,6 +1494,7 @@ ${colors.cyan}${colors.bright}uniweb snapshot${colors.reset} ${colors.dim}— Co
1494
1494
 
1495
1495
  ${colors.bright}Usage:${colors.reset}
1496
1496
  uniweb snapshot [<site>] [options]
1497
+ uniweb snapshot --compare [options]
1497
1498
 
1498
1499
  Opens the site in a headless Chrome, captures it, and composes the captures into
1499
1500
  one image: by default ${colors.bright}site/public/preview.webp${colors.reset}, recorded as ${colors.cyan}preview:${colors.reset} in site.yml
@@ -1501,8 +1502,8 @@ when site.yml has none, or only the app's generated one. A URL or image path you
1501
1502
  wrote is never replaced.
1502
1503
 
1503
1504
  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.
1505
+ beside a long strip of the page. A page that does not scroll as a page (a docs
1506
+ shell, an app) gets ${colors.bright}device${colors.reset}: a desktop window and a phone.
1506
1507
 
1507
1508
  Needs ${colors.cyan}@uniweb/snapshot${colors.reset} in the workspace (\`pnpm add -D -w @uniweb/snapshot\`) and
1508
1509
  Google Chrome, Microsoft Edge, or a Chromium named by $UNIWEB_SNAPSHOT_BROWSER.
@@ -1513,11 +1514,23 @@ ${colors.bright}Where the site comes from:${colors.reset}
1513
1514
  --dev Capture the site's Vite dev server (no build)
1514
1515
  --url <address> Capture a site that is already running
1515
1516
 
1517
+ ${colors.bright}The look:${colors.reset}
1518
+ --layout <name> auto (default), split, device
1519
+ --gap <px> Space between the two frames (split default: 48)
1520
+ --overlap <px> Overlap them instead (device default: 31)
1521
+ --strip <width> split: fit (default), or 1:N — narrower shows more of a long page
1522
+ --side <side> Where the strip or phone goes: right (default), left
1523
+ --frame <style> browser (default, with a title bar), plain
1524
+ --tone <name> Background: auto (default), light, deep
1525
+
1526
+ ${colors.bright}Choosing and keeping a look:${colors.reset}
1527
+ --compare One capture, several looks on one sheet, each captioned with
1528
+ its flags (site/.uniweb/snapshot/compare.webp)
1529
+ --save Keep this run's flags in site/snapshot.yml, the site's defaults
1530
+
1516
1531
  ${colors.bright}Options:${colors.reset}
1517
1532
  --site <name> The site (default: the one you are in, or the only one)
1518
1533
  --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
1534
  --size <WxH> Image size in CSS pixels (default: 1600x1000)
1522
1535
  --scale <n> 1 (default) or 2 for a double-density image
1523
1536
  --quality <n> Encoder quality, 1–100 (default: 82)
@@ -0,0 +1,112 @@
1
+ /**
2
+ * `site/snapshot.yml` — how `uniweb snapshot` composes a site's preview image,
3
+ * kept with the site so the next run reproduces the same look.
4
+ *
5
+ * It is the command's defaults for this site, and flags override it. Nothing else
6
+ * reads it: the build, `push` and `pull` all leave a site's root files alone
7
+ * unless they name them.
8
+ */
9
+
10
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs'
11
+ import { isAbsolute, join, relative, resolve, sep } from 'node:path'
12
+ import yaml from 'js-yaml'
13
+
14
+ import { didYouMean } from './args.js'
15
+
16
+ export const SETTINGS_FILE = 'snapshot.yml'
17
+
18
+ /** In the order they are written. */
19
+ export const SETTING_KEYS = [
20
+ 'route',
21
+ 'layout',
22
+ 'tone',
23
+ 'gap',
24
+ 'overlap',
25
+ 'strip',
26
+ 'side',
27
+ 'frame',
28
+ 'size',
29
+ 'scale',
30
+ 'quality',
31
+ 'hide',
32
+ 'out'
33
+ ]
34
+
35
+ const HEADER = [
36
+ '# How `uniweb snapshot` composes this site\'s preview image.',
37
+ '# Flags override these; `uniweb snapshot <flags> --save` writes them here.',
38
+ ''
39
+ ].join('\n')
40
+
41
+ export class SettingsError extends Error {}
42
+
43
+ /**
44
+ * The settings in `<siteDir>/snapshot.yml`, or `{}` when there is none. `out` comes
45
+ * back absolute, resolved against the site folder.
46
+ */
47
+ export function readSnapshotSettings(siteDir) {
48
+ const file = join(siteDir, SETTINGS_FILE)
49
+ if (!existsSync(file)) return { file, settings: {} }
50
+
51
+ let data
52
+ try {
53
+ data = yaml.load(readFileSync(file, 'utf8'))
54
+ } catch (err) {
55
+ throw new SettingsError(`${SETTINGS_FILE} is not valid YAML: ${err.message.split('\n')[0]}`)
56
+ }
57
+ if (data === undefined || data === null) return { file, settings: {} }
58
+ if (typeof data !== 'object' || Array.isArray(data)) {
59
+ throw new SettingsError(`${SETTINGS_FILE} should be a list of settings, like \`gap: 48\`.`)
60
+ }
61
+
62
+ const settings = {}
63
+ for (const [key, value] of Object.entries(data)) {
64
+ if (!SETTING_KEYS.includes(key)) {
65
+ const suggestion = didYouMean(key, SETTING_KEYS)
66
+ throw new SettingsError(
67
+ `${SETTINGS_FILE} has an unknown setting \`${key}\`.` + (suggestion ? ` Did you mean \`${suggestion}\`?` : '')
68
+ )
69
+ }
70
+ if (value === null || value === undefined) continue
71
+ if (key === 'hide') settings.hide = Array.isArray(value) ? value.map(String) : [String(value)]
72
+ else if (key === 'out') settings.out = resolve(siteDir, String(value))
73
+ else settings[key] = value
74
+ }
75
+ return { file, settings }
76
+ }
77
+
78
+ /** Flags over the file. A gap or an overlap from the flags replaces either from the file. */
79
+ export function mergeSettings(fromFile = {}, fromFlags = {}) {
80
+ const merged = { ...fromFile }
81
+ if (fromFlags.gap !== undefined || fromFlags.overlap !== undefined) {
82
+ delete merged.gap
83
+ delete merged.overlap
84
+ }
85
+ for (const [key, value] of Object.entries(fromFlags)) {
86
+ if (value !== undefined) merged[key] = value
87
+ }
88
+ return merged
89
+ }
90
+
91
+ /**
92
+ * Write the flags of this run into `<siteDir>/snapshot.yml`, over what it already
93
+ * holds. The file is rewritten in full, in `SETTING_KEYS` order, under a header.
94
+ *
95
+ * @returns {{ file: string, saved: string[] }} the keys this run set
96
+ */
97
+ export function saveSnapshotSettings(siteDir, fromFlags, fromFile = readSnapshotSettings(siteDir).settings) {
98
+ const file = join(siteDir, SETTINGS_FILE)
99
+ const merged = mergeSettings(fromFile, fromFlags)
100
+ const ordered = {}
101
+ for (const key of SETTING_KEYS) {
102
+ if (merged[key] === undefined) continue
103
+ ordered[key] = key === 'out' ? toSitePath(siteDir, merged.out) : merged[key]
104
+ }
105
+ writeFileSync(file, HEADER + yaml.dump(ordered, { lineWidth: -1 }))
106
+ return { file, saved: SETTING_KEYS.filter((key) => fromFlags[key] !== undefined) }
107
+ }
108
+
109
+ function toSitePath(siteDir, path) {
110
+ const rel = relative(siteDir, path)
111
+ return isAbsolute(rel) ? path : rel.split(sep).join('/')
112
+ }