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 +6 -6
- package/partials/agents.md +23 -3
- package/src/backend/foundation-bring-along.js +20 -3
- package/src/backend/site-sync.js +7 -7
- package/src/commands/publish.js +10 -26
- package/src/commands/register.js +19 -7
- package/src/commands/snapshot.js +178 -75
- package/src/framework-index.json +4 -4
- package/src/index.js +17 -4
- package/src/utils/code-upload.js +48 -8
- package/src/utils/semver-precedence.js +57 -0
- 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": {
|
|
@@ -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/
|
|
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": {
|
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
|
```
|
|
@@ -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
|
|
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
|
-
|
|
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),
|
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/register.js
CHANGED
|
@@ -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;
|
|
637
|
-
//
|
|
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 —
|
|
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 (${
|
|
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}`
|
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)
|
package/src/utils/code-upload.js
CHANGED
|
@@ -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
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
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
|
|
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
|
+
}
|