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 +4 -4
- package/partials/agents.md +23 -3
- package/src/backend/site-sync.js +7 -7
- package/src/commands/publish.js +10 -26
- package/src/commands/snapshot.js +178 -75
- package/src/framework-index.json +4 -4
- package/src/index.js +17 -4
- package/src/utils/snapshot-settings.js +112 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "uniweb",
|
|
3
|
-
"version": "0.56.
|
|
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.
|
|
52
|
+
"@uniweb/build": "^0.52.7",
|
|
53
53
|
"@uniweb/content-reader": "^1.2.5",
|
|
54
54
|
"@uniweb/semantic-parser": "^1.4.1"
|
|
55
55
|
},
|
package/partials/agents.md
CHANGED
|
@@ -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
|
-
|
|
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)
|
|
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
|
```
|
package/src/backend/site-sync.js
CHANGED
|
@@ -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
|
-
*
|
|
334
|
-
*
|
|
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) &&
|
package/src/commands/publish.js
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
1117
|
-
//
|
|
1118
|
-
//
|
|
1119
|
-
//
|
|
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
|
}
|
package/src/commands/snapshot.js
CHANGED
|
@@ -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,
|
|
18
|
-
*
|
|
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
|
-
|
|
47
|
-
const
|
|
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 = [...
|
|
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
|
|
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
|
|
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
|
-
|
|
105
|
+
positionals.push(...args.slice(i + 1))
|
|
68
106
|
break
|
|
69
107
|
}
|
|
70
108
|
if (!raw.startsWith('-') || raw === '-') {
|
|
71
|
-
|
|
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
|
-
|
|
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
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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 (
|
|
94
|
-
if (
|
|
95
|
-
throw new UsageError('`--
|
|
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 (
|
|
101
|
-
|
|
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
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
|
232
|
+
return `yarn ${command} --dev -W @uniweb/snapshot@latest`
|
|
181
233
|
default:
|
|
182
|
-
return
|
|
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
|
-
|
|
279
|
+
const cwd = process.cwd()
|
|
280
|
+
let parsed
|
|
228
281
|
try {
|
|
229
|
-
|
|
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 =
|
|
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 && !
|
|
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 && !
|
|
248
|
-
fail('`--out <file>` is needed outside a site: there is no
|
|
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
|
-
|
|
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 (
|
|
270
|
-
source = { url:
|
|
271
|
-
} else if (
|
|
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 (!
|
|
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 =
|
|
294
|
-
url: source.url,
|
|
295
|
-
|
|
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 (
|
|
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}`)
|
package/src/framework-index.json
CHANGED
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": 1,
|
|
3
|
-
"generatedAt": "2026-09-
|
|
3
|
+
"generatedAt": "2026-09-18T05:17:28.321Z",
|
|
4
4
|
"packages": {
|
|
5
5
|
"@uniweb/api": {
|
|
6
|
-
"version": "0.3.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
1505
|
-
|
|
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
|
+
}
|