uniweb 0.56.10 → 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.10",
3
+ "version": "0.56.12",
4
4
  "description": "Create structured Vite + React sites with content/code separation",
5
5
  "type": "module",
6
6
  "bin": {
@@ -41,16 +41,16 @@
41
41
  "js-yaml": "^4.1.0",
42
42
  "prompts": "^2.4.2",
43
43
  "tar": "^7.0.0",
44
- "@uniweb/core": "^0.29.4",
45
- "@uniweb/kit": "^0.19.3",
46
- "@uniweb/schemas": "^0.3.3",
47
44
  "@uniweb/content-writer": "^0.3.4",
45
+ "@uniweb/core": "^0.29.4",
46
+ "@uniweb/runtime": "^0.26.4",
48
47
  "@uniweb/semantic-parser": "^1.4.1",
49
- "@uniweb/runtime": "^0.26.4"
48
+ "@uniweb/kit": "^0.19.3",
49
+ "@uniweb/schemas": "^0.3.3"
50
50
  },
51
51
  "peerDependencies": {
52
+ "@uniweb/build": "^0.52.7",
52
53
  "@uniweb/content-reader": "^1.2.5",
53
- "@uniweb/build": "^0.52.6",
54
54
  "@uniweb/semantic-parser": "^1.4.1"
55
55
  },
56
56
  "peerDependenciesMeta": {
@@ -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
  ```
@@ -40,6 +40,7 @@ import { detectFoundationType, isExtensionUrl } from '@uniweb/build'
40
40
  import { computeFoundationDigest } from '../utils/code-upload.js'
41
41
  import { readFlagValue } from '../utils/args.js'
42
42
  import { isNonInteractive } from '../utils/interactive.js'
43
+ import { compareSemverPrecedence } from '../utils/semver-precedence.js'
43
44
 
44
45
  /**
45
46
  * Resolve the site's LOCAL foundation — the one publish should bring along — or
@@ -117,13 +118,19 @@ export function resolveLocalExtensions(siteDir, siteYml) {
117
118
  }
118
119
 
119
120
  // The foundation's scoped catalog name (`@org/name`) from its package.json — an
120
- // already-scoped `name`, else `uniweb.scope` + a bare `name`. Null when neither
121
+ // already-scoped name, else `uniweb.scope` + a bare one. Null when neither
121
122
  // yields a scoped name (then we can't look up the registered version, so the
122
123
  // caller treats the foundation as "release it and let register pick the scope").
124
+ //
125
+ // ⛔ The name is `uniweb.id` when set, exactly as the build reads it for the
126
+ // schema that `register` submits (`build/src/schema.js`). Reading `name` alone —
127
+ // as this did until 2026-09-17 — looked a `uniweb.id` foundation up under a name
128
+ // the catalog does not have, so every push re-released it, and pinned the site
129
+ // to that same wrong name.
123
130
  function foundationScopedName(dir) {
124
131
  try {
125
132
  const pkg = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8'))
126
- const name = pkg?.name
133
+ const name = pkg?.uniweb?.id || pkg?.name
127
134
  if (typeof name === 'string' && name.startsWith('@')) return name
128
135
  const scope = pkg?.uniweb?.scope
129
136
  if (scope && name) return `${String(scope).replace(/\/+$/, '')}/${name}`
@@ -344,8 +351,18 @@ async function bringLocalCodeAlong({
344
351
  )
345
352
  return { released: false, proceed: true, ref: registeredRef(reg) }
346
353
  }
354
+ // ⚖️ The registry takes a NEW version only when it is greater than every one
355
+ // it holds — but an older local version may be one it already has, and
356
+ // re-registering that resumes. The CLI cannot tell which from here, so it
357
+ // does not refuse: it submits, says what will decide, and `register` prints
358
+ // the registry's answer if it is a no.
359
+ const order = compareSemverPrecedence(local.version, reg.latest_version)
347
360
  say.info(
348
- `Releasing the ${kind} ${label} (new version; registered latest is ${reg.latest_version})…`
361
+ order === 1
362
+ ? `Releasing the ${kind} ${label} (new version; registered latest is ${reg.latest_version})…`
363
+ : order === null
364
+ ? `Releasing the ${kind} ${label} (registered latest is ${reg.latest_version})…`
365
+ : `Releasing the ${kind} ${label} — not newer than the registered latest ${reg.latest_version}, so the registry takes it only if ${local.version} is already registered with this code…`
349
366
  )
350
367
  return {
351
368
  released: releaseFoundation(local, args, cliBin, say),
@@ -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
  }
@@ -633,8 +633,8 @@ async function runRegister(args = []) {
633
633
  // Resume path: a registered version is immutable, so re-running after a
634
634
  // partial code delivery hits the duplicate rejection here — a STRUCTURED
635
635
  // 409 (problem+json, title "Conflict") — and proceeds to phase 2 (the
636
- // code-uploads plan authorizes against the REGISTERED version; completed
637
- // files are idempotent no-ops).
636
+ // code-uploads plan authorizes against the REGISTERED version; files it
637
+ // already stores come back `present` and are skipped).
638
638
  const isDuplicate = !standalone && res.status === 409
639
639
  if (isDuplicate) {
640
640
  alreadyRegistered = true
@@ -712,14 +712,18 @@ async function runRegister(args = []) {
712
712
  )
713
713
  }
714
714
  log(
715
- ` ${colors.dim}Re-run \`uniweb register\` to resume — completed files are safe no-ops.${colors.reset}`
715
+ ` ${colors.dim}Re-run \`uniweb register\` to resume — files already stored are skipped.${colors.reset}`
716
716
  )
717
717
  return { exitCode: 1 }
718
718
  }
719
+ // "4 files" on a first upload; "1 uploaded, 3 already stored" on a resume.
720
+ const delivered = result.stored?.length
721
+ ? `${result.uploaded.length} uploaded, ${result.stored.length} already stored`
722
+ : `${result.uploaded.length} files`
719
723
  if (result.verified === true) {
720
724
  // serveBase is guaranteed here — it is what gated the check.
721
725
  success(
722
- `Code delivered (${result.uploaded.length} files) — entry verified live at ${colors.dim}${result.serveBase}${colors.reset}`
726
+ `Code delivered (${delivered}) — entry verified live at ${colors.dim}${result.serveBase}${colors.reset}`
723
727
  )
724
728
  } else if (result.verified === false) {
725
729
  error(
@@ -727,9 +731,7 @@ async function runRegister(args = []) {
727
731
  )
728
732
  return { exitCode: 1 }
729
733
  } else {
730
- success(
731
- `Code delivered (${result.uploaded.length} files, ${result.mode} mode)`
732
- )
734
+ success(`Code delivered (${delivered}, ${result.mode} mode)`)
733
735
  // Say WHY nothing was checked, rather than skipping in silence. The CLI
734
736
  // never reconstructs a serve URL (see utils/code-upload.js), so a plan
735
737
  // without `serve_base` means the location is not ours to know — name it
@@ -741,6 +743,16 @@ async function runRegister(args = []) {
741
743
  }
742
744
  }
743
745
  } catch (err) {
746
+ // ⚖️ A 4xx is the registry REFUSING the plan — e.g. a stored file
747
+ // declared at a different size (`version_content_changed`). Running the
748
+ // same command again gets the same answer, so do not suggest it: print
749
+ // the refusal's own sentence, which says what does help.
750
+ if (err.status >= 400 && err.status < 500 && err.detail) {
751
+ error(`Code delivery refused: HTTP ${err.status}`)
752
+ log(` ${err.detail}`)
753
+ if (err.code) log(` ${colors.dim}(${err.code})${colors.reset}`)
754
+ return { exitCode: 1 }
755
+ }
744
756
  error(`Code delivery failed: ${err.message}`)
745
757
  log(
746
758
  ` ${colors.dim}The schema registration above succeeded; re-run \`uniweb register\` to deliver the code.${colors.reset}`
@@ -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)
@@ -7,10 +7,12 @@
7
7
  *
8
8
  * 1. PLAN — POST {apiBase}/dev/registry/code-uploads with the file list
9
9
  * ({ path, content_type, size, sha256? }). The response carries
10
- * one upload target per file ({ path, method, url, headers })
11
- * plus mode: 'direct' (dev URLs point back at the backend) or
12
- * 'presigned' (prod storage PUTs; bytes never transit the
13
- * backend). The CLI never branches on the mode.
10
+ * one entry per file: `present: true` for a file the backend
11
+ * already stores for this version (no URL it is skipped), or an
12
+ * upload target ({ path, method, url, headers }). Plus mode:
13
+ * 'direct' (dev URLs point back at the backend) or 'presigned'
14
+ * (prod — storage PUTs; bytes never transit the backend). The CLI
15
+ * never branches on the mode.
14
16
  * 2. UPLOAD — PUT each file's raw bytes to its URL with the given headers.
15
17
  * The ENTRY uploads LAST: a partial upload never yields a
16
18
  * loadable version (practical atomicity — there is no server
@@ -35,7 +37,8 @@
35
37
  * plan step's per-version file cap — the cap is an abuse guard, the maps
36
38
  * simply don't belong on the CDN.)
37
39
  * - a registered version is immutable, code included — changed bytes mean
38
- * a new version (re-PUTting identical bytes is a safe no-op)
40
+ * a new version. A stored file is never re-sent (the plan marks it
41
+ * `present`), and one declared at a different size is refused (422)
39
42
  */
40
43
 
41
44
  import { createHash } from 'node:crypto'
@@ -215,7 +218,10 @@ export function computeFoundationDigest(distDir) {
215
218
  * @param {string} opts.distDir - the built dist/ directory
216
219
  * @param {Array} [opts.files] - pre-collected file list (default: collect)
217
220
  * @param {(msg: string) => void} [opts.onProgress]
218
- * @returns {Promise<{ mode: string, uploaded: string[], failed: Array<{path, status, detail}>, verified: boolean|null, serveBase: string|null }>}
221
+ * @returns {Promise<{ mode: string, uploaded: string[], stored: string[], failed: Array<{path, status, detail}>, verified: boolean|null, serveBase: string|null }>}
222
+ * `stored` — files the plan reported as already stored (`present: true`), not re-sent.
223
+ * @throws {Error} when the plan is refused — with `status`, and the problem+json
224
+ * `detail` and `code` when the body carries them
219
225
  */
220
226
  export async function uploadFoundationCode({
221
227
  apiBase,
@@ -259,10 +265,22 @@ export async function uploadFoundationCode({
259
265
  })
260
266
  if (!planRes.ok) {
261
267
  const body = await planRes.text().catch(() => '')
268
+ // A refusal is problem+json, and its `detail` is a sentence written for the
269
+ // person reading — for a changed file it says what to do. Carry it (and the
270
+ // `code`) instead of a raw body the reader has to dig the sentence out of.
271
+ let problem = null
272
+ try {
273
+ problem = JSON.parse(body)
274
+ } catch {
275
+ problem = null
276
+ }
277
+ const detail = typeof problem?.detail === 'string' ? problem.detail : null
262
278
  const err = new Error(
263
- `code-uploads plan rejected: HTTP ${planRes.status}${body ? ` — ${body.slice(0, 300)}` : ''}`
279
+ `code-uploads plan rejected: HTTP ${planRes.status}${detail ? ` — ${detail}` : body ? ` — ${body.slice(0, 300)}` : ''}`
264
280
  )
265
281
  err.status = planRes.status
282
+ err.detail = detail
283
+ err.code = typeof problem?.code === 'string' ? problem.code : null
266
284
  throw err
267
285
  }
268
286
  const plan = await planRes.json()
@@ -275,6 +293,7 @@ export async function uploadFoundationCode({
275
293
  plan.mode === 'direct' ? { Authorization: `Bearer ${token}` } : {}
276
294
 
277
295
  const uploaded = []
296
+ const stored = []
278
297
  const failed = []
279
298
  for (const file of uploadOrder(list)) {
280
299
  const target = targets.get(file.path)
@@ -286,6 +305,27 @@ export async function uploadFoundationCode({
286
305
  })
287
306
  continue
288
307
  }
308
+ // ⭐ A file the backend already stores for this version comes back
309
+ // `present: true`, with no URL — a stored file of a registered version is
310
+ // never overwritten. Skip it. This is what lets an interrupted upload
311
+ // resume, and a re-run on a fully uploaded version pass.
312
+ //
313
+ // ⛔ Before this branch existed, a present entry was PUT to
314
+ // `new URL(undefined, origin)`, which does not throw — it resolves to
315
+ // `<origin>/undefined` — and came back as a failed upload.
316
+ if (target.present === true) {
317
+ stored.push(file.path)
318
+ onProgress(`${file.path} (already stored)`)
319
+ continue
320
+ }
321
+ if (!target.url) {
322
+ failed.push({
323
+ path: file.path,
324
+ status: 0,
325
+ detail: 'the plan gave no upload URL'
326
+ })
327
+ continue
328
+ }
289
329
  const bytes = readFileSync(join(distDir, file.path))
290
330
  try {
291
331
  // ⭐ **Retried.** A single connection-level failure used to fail the whole
@@ -350,5 +390,5 @@ export async function uploadFoundationCode({
350
390
  }
351
391
  }
352
392
 
353
- return { mode: plan.mode || 'direct', uploaded, failed, verified, serveBase }
393
+ return { mode: plan.mode || 'direct', uploaded, stored, failed, verified, serveBase }
354
394
  }
@@ -0,0 +1,57 @@
1
+ /**
2
+ * SemVer 2.0.0 precedence — how a registry orders a foundation's versions.
3
+ *
4
+ * A pre-release sorts below its release (`1.0.0-rc.1` < `1.0.0`), and build
5
+ * metadata carries no order (`1.2.0+b` equals `1.2.0`).
6
+ *
7
+ * ⚖️ Not `compareSemver` in `dep-survey.js`: that one reads dependency specs
8
+ * (`^1.2.3`) for `uniweb update` and compares major.minor.patch only. This one
9
+ * is strict — anything that is not a SemVer version is `null`, never a guess.
10
+ */
11
+
12
+ // The regex published with the SemVer 2.0.0 specification (semver.org, §FAQ).
13
+ const SEMVER =
14
+ /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/
15
+
16
+ /**
17
+ * @param {unknown} version
18
+ * @returns {{ major: number, minor: number, patch: number, pre: string[] }|null}
19
+ */
20
+ export function parseSemver(version) {
21
+ if (typeof version !== 'string') return null
22
+ const m = SEMVER.exec(version)
23
+ if (!m) return null
24
+ return { major: +m[1], minor: +m[2], patch: +m[3], pre: m[4] ? m[4].split('.') : [] }
25
+ }
26
+
27
+ /**
28
+ * @param {string} a
29
+ * @param {string} b
30
+ * @returns {-1|0|1|null} how `a` sorts against `b`; null when either is not SemVer
31
+ */
32
+ export function compareSemverPrecedence(a, b) {
33
+ const x = parseSemver(a)
34
+ const y = parseSemver(b)
35
+ if (!x || !y) return null
36
+ for (const part of ['major', 'minor', 'patch']) {
37
+ if (x[part] !== y[part]) return x[part] > y[part] ? 1 : -1
38
+ }
39
+ // A version with a pre-release sorts below the same version without one.
40
+ if (!x.pre.length || !y.pre.length) {
41
+ return x.pre.length === y.pre.length ? 0 : x.pre.length ? -1 : 1
42
+ }
43
+ for (let i = 0; i < Math.max(x.pre.length, y.pre.length); i++) {
44
+ const p = x.pre[i]
45
+ const q = y.pre[i]
46
+ if (p === undefined) return -1
47
+ if (q === undefined) return 1
48
+ if (p === q) continue
49
+ const pNum = /^\d+$/.test(p)
50
+ const qNum = /^\d+$/.test(q)
51
+ // Numeric identifiers compare numerically and sort below alphanumeric ones.
52
+ if (pNum && qNum) return BigInt(p) > BigInt(q) ? 1 : -1
53
+ if (pNum !== qNum) return pNum ? -1 : 1
54
+ return p > q ? 1 : -1
55
+ }
56
+ return 0
57
+ }
@@ -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
+ }