@uniweb/build 0.55.1 → 0.56.0
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 +3 -3
- package/src/site/deploy-config-writer.js +137 -29
- package/src/site/deploy-config.js +87 -38
- package/src/site/index.js +2 -2
- package/src/uwx/asset-map.js +26 -148
- package/src/uwx/backfill.js +29 -2
- package/src/uwx/index.js +20 -8
- package/src/uwx/records-project.js +36 -14
- package/src/uwx/records.js +26 -1
- package/src/uwx/site-project.js +31 -12
- package/src/uwx/site.js +35 -51
- package/src/uwx/sync-package.js +4 -0
- package/src/uwx/sync-store.js +351 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uniweb/build",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.56.0",
|
|
4
4
|
"description": "Build tooling for the Uniweb Component Web Platform",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -58,9 +58,9 @@
|
|
|
58
58
|
"sharp": "^0.35.3",
|
|
59
59
|
"yaml": "^2.5.0",
|
|
60
60
|
"@uniweb/schemas": "^0.3.3",
|
|
61
|
-
"@uniweb/content-reader": "^1.2.5",
|
|
62
|
-
"@uniweb/content-writer": "^0.3.4",
|
|
63
61
|
"@uniweb/projections": "^0.7.3",
|
|
62
|
+
"@uniweb/content-writer": "^0.3.4",
|
|
63
|
+
"@uniweb/content-reader": "^1.2.5",
|
|
64
64
|
"@uniweb/semantic-parser": "^1.4.1",
|
|
65
65
|
"@uniweb/theming": "^0.1.15"
|
|
66
66
|
},
|
|
@@ -1,30 +1,36 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* deploy.yml writer
|
|
3
3
|
*
|
|
4
|
-
* Updates the
|
|
4
|
+
* Updates the deploys.<target> block of a site's deploy.yml without
|
|
5
5
|
* reformatting the rest of the file. Uses the eemeli/yaml Document API
|
|
6
6
|
* because js-yaml does not preserve comments on round-trip — the writer
|
|
7
|
-
* must not destroy a developer's comments on the targets:/
|
|
7
|
+
* must not destroy a developer's comments on the targets:/saveDeploys:
|
|
8
8
|
* regions of the file.
|
|
9
9
|
*
|
|
10
10
|
* This is the only place in @uniweb/build that depends on `yaml`. The
|
|
11
11
|
* loader (deploy-config.js) stays on js-yaml for read-only ingestion.
|
|
12
12
|
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
13
|
+
* `saveDeploys: false` (or `--no-save`) makes this a no-op; otherwise it
|
|
14
|
+
* touches ONLY deploys.<targetName> — plus targets.<targetName> when the file has no
|
|
15
|
+
* such target yet (a publish to a backend no target names). ⚠️ It was `autoSave: off|lastDeploy|full`
|
|
16
|
+
* until 2026-09-20, and `full` was reserved and behaved as `lastDeploy` — a
|
|
17
|
+
* boolean wearing a tri-state.
|
|
17
18
|
*
|
|
18
19
|
* First-deploy path: when deploy.yml does not exist, writes a fresh
|
|
19
20
|
* file scaffolded with `default:`, a single entry under `targets:`, and
|
|
20
|
-
* `
|
|
21
|
+
* `saveDeploys: true`. This is the only code path that writes the
|
|
21
22
|
* config region.
|
|
23
|
+
*
|
|
24
|
+
* And two removals, for `uniweb forget`: `forgetDeploys` drops the records one
|
|
25
|
+
* backend wrote and keeps every target; `forgetDeployYml` deletes the file, for a
|
|
26
|
+
* copy that is to become a new project.
|
|
22
27
|
*/
|
|
23
28
|
|
|
24
|
-
import { readFile, writeFile } from 'node:fs/promises'
|
|
29
|
+
import { readFile, writeFile, unlink } from 'node:fs/promises'
|
|
25
30
|
import { existsSync } from 'node:fs'
|
|
26
31
|
import { join } from 'node:path'
|
|
27
32
|
import { Document, parseDocument, isMap } from 'yaml'
|
|
33
|
+
import { normalizeOrigin } from '../uwx/sync-store.js'
|
|
28
34
|
|
|
29
35
|
// The header every generated deploy.yml carries. It has to answer the question
|
|
30
36
|
// a reader has while looking AT the file — who wrote this, and may I edit it —
|
|
@@ -39,7 +45,7 @@ const SCAFFOLD_HEADER = [
|
|
|
39
45
|
' deploy.yml — written by `uniweb deploy` / `uniweb publish`.',
|
|
40
46
|
'',
|
|
41
47
|
' You do not create this file. The first successful deploy does, recording the',
|
|
42
|
-
' target you picked and what happened. Later deploys rewrite only `
|
|
48
|
+
' target you picked and what happened. Later deploys rewrite only `deploys:`',
|
|
43
49
|
' and leave the rest — including your comments — alone.',
|
|
44
50
|
'',
|
|
45
51
|
' Safe to commit; no credentials live here. Host credentials come from the',
|
|
@@ -48,29 +54,34 @@ const SCAFFOLD_HEADER = [
|
|
|
48
54
|
' default: which target is used when none is named',
|
|
49
55
|
' targets: where this site ships. Edit to change the destination, or add',
|
|
50
56
|
' a target and pick it with `--target <name>`',
|
|
51
|
-
'
|
|
52
|
-
'
|
|
53
|
-
'
|
|
57
|
+
' deploys: what each target\'s last deploy did. A record, not a setting.',
|
|
58
|
+
' `publish` reads one field back — a fingerprint of the service',
|
|
59
|
+
' request it last sent — so deleting it only means the next',
|
|
60
|
+
' publish re-sends that request',
|
|
61
|
+
' saveDeploys: false to stop recording deploys',
|
|
62
|
+
'',
|
|
63
|
+
' Which site this is on each backend lives in sync.json, not here.',
|
|
54
64
|
].join('\n')
|
|
55
65
|
|
|
56
66
|
/**
|
|
57
|
-
* Update or create deploy.yml with a fresh
|
|
67
|
+
* Update or create deploy.yml with a fresh deploys.<target> entry.
|
|
58
68
|
*
|
|
59
69
|
* @param {string} siteDir
|
|
60
70
|
* @param {object} opts
|
|
61
71
|
* @param {string} opts.targetName e.g. 'production'
|
|
62
|
-
* @param {object} [opts.targetConfig] { host, ...host-specific };
|
|
63
|
-
*
|
|
72
|
+
* @param {object} [opts.targetConfig] { host, ...host-specific }; the
|
|
73
|
+
* first-deploy scaffold's target, and a
|
|
74
|
+
* target to ADD when the file lacks it
|
|
64
75
|
* @param {object} opts.lastDeploy { at, url, foundation, runtime,
|
|
65
76
|
* artifactSha, ... }
|
|
66
|
-
* @param {
|
|
77
|
+
* @param {boolean} opts.saveDeploys - false makes this a no-op
|
|
67
78
|
* @returns {Promise<{ created: boolean, path: string } | null>}
|
|
68
|
-
* null when
|
|
79
|
+
* null when saveDeploys is false (no-op).
|
|
69
80
|
*/
|
|
70
81
|
export async function recordLastDeploy(siteDir, opts) {
|
|
71
|
-
const { targetName, targetConfig, lastDeploy,
|
|
82
|
+
const { targetName, targetConfig, lastDeploy, saveDeploys } = opts
|
|
72
83
|
|
|
73
|
-
if (
|
|
84
|
+
if (saveDeploys === false) return null
|
|
74
85
|
|
|
75
86
|
const path = join(siteDir, 'deploy.yml')
|
|
76
87
|
|
|
@@ -89,13 +100,29 @@ export async function recordLastDeploy(siteDir, opts) {
|
|
|
89
100
|
|
|
90
101
|
const text = await readFile(path, 'utf8')
|
|
91
102
|
const doc = parseDocument(text)
|
|
103
|
+
// Never rewrite a file we could not read — the caller reports it instead.
|
|
104
|
+
if (doc.errors.length) {
|
|
105
|
+
throw new Error(`deploy.yml did not parse: ${doc.errors[0].message.split('\n')[0]}`)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// A target the file does not have yet — a publish to a backend no target names
|
|
109
|
+
// (resolvePublishTarget) — is added beside the others. An existing target is never
|
|
110
|
+
// modified, and `default` / `saveDeploys` are never touched.
|
|
111
|
+
if (targetConfig?.host) {
|
|
112
|
+
const targetsNode = doc.get('targets', true)
|
|
113
|
+
if (!isMap(targetsNode)) {
|
|
114
|
+
if (targetsNode === undefined) doc.set('targets', { [targetName]: targetConfig })
|
|
115
|
+
} else if (!targetsNode.has(targetName)) {
|
|
116
|
+
targetsNode.set(targetName, targetConfig)
|
|
117
|
+
}
|
|
118
|
+
}
|
|
92
119
|
|
|
93
|
-
//
|
|
94
|
-
let
|
|
95
|
-
if (!isMap(
|
|
96
|
-
doc.set('
|
|
120
|
+
// Otherwise touch ONLY deploys.<targetName>.
|
|
121
|
+
let deploysNode = doc.get('deploys', true)
|
|
122
|
+
if (!isMap(deploysNode)) {
|
|
123
|
+
doc.set('deploys', { [targetName]: lastDeploy })
|
|
97
124
|
} else {
|
|
98
|
-
|
|
125
|
+
deploysNode.set(targetName, lastDeploy)
|
|
99
126
|
}
|
|
100
127
|
|
|
101
128
|
await writeFile(path, doc.toString(), 'utf8')
|
|
@@ -106,8 +133,8 @@ function scaffold({ targetName, targetConfig, lastDeploy }) {
|
|
|
106
133
|
const doc = new Document({
|
|
107
134
|
default: targetName,
|
|
108
135
|
targets: { [targetName]: targetConfig },
|
|
109
|
-
|
|
110
|
-
|
|
136
|
+
deploys: { [targetName]: lastDeploy },
|
|
137
|
+
saveDeploys: true,
|
|
111
138
|
})
|
|
112
139
|
doc.commentBefore = SCAFFOLD_HEADER
|
|
113
140
|
return doc
|
|
@@ -131,11 +158,11 @@ function scaffold({ targetName, targetConfig, lastDeploy }) {
|
|
|
131
158
|
*
|
|
132
159
|
* Behavior:
|
|
133
160
|
* - File missing: scaffold a fresh file with this target, set as
|
|
134
|
-
* default,
|
|
161
|
+
* default, saveDeploys: true. No deploys block (no deploy
|
|
135
162
|
* has happened yet).
|
|
136
163
|
* - File exists: merge targetConfig into targets.<targetName>
|
|
137
164
|
* (overlapping keys overwritten, other keys preserved). Never
|
|
138
|
-
* touches `default`, `
|
|
165
|
+
* touches `default`, `saveDeploys`, `deploys`, or other targets,
|
|
139
166
|
* so adding a CI workflow to a project that already deploys
|
|
140
167
|
* elsewhere doesn't change its deploy semantics.
|
|
141
168
|
*/
|
|
@@ -155,7 +182,7 @@ export async function recordTarget(siteDir, opts) {
|
|
|
155
182
|
const doc = new Document({
|
|
156
183
|
default: targetName,
|
|
157
184
|
targets: { [targetName]: targetConfig },
|
|
158
|
-
|
|
185
|
+
saveDeploys: true,
|
|
159
186
|
})
|
|
160
187
|
doc.commentBefore = SCAFFOLD_HEADER
|
|
161
188
|
await writeFile(path, doc.toString(), 'utf8')
|
|
@@ -187,3 +214,84 @@ export async function recordTarget(siteDir, opts) {
|
|
|
187
214
|
await writeFile(path, doc.toString(), 'utf8')
|
|
188
215
|
return { created: false, path, action: 'merge' }
|
|
189
216
|
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Remove the deploy records one backend wrote — `uniweb forget --backend <url>`.
|
|
220
|
+
*
|
|
221
|
+
* A record belongs to the backend it names: every publish writes
|
|
222
|
+
* `deploys.<target>.backend`. A record naming none falls back to its target's
|
|
223
|
+
* `backend:`, which only a `host: uniweb` target has — so no other host's history
|
|
224
|
+
* can match, and a record that names no backend anywhere is left alone rather than
|
|
225
|
+
* guessed at.
|
|
226
|
+
*
|
|
227
|
+
* ⛔ **`targets:` stays.** A target is a CHOICE of where to ship, not something a
|
|
228
|
+
* backend told us — the spec's own line between this file and `sync.json`. After
|
|
229
|
+
* the forget, the next publish there simply creates a new site.
|
|
230
|
+
*
|
|
231
|
+
* Why the records go at all: each names the forgotten site (`siteUuid`, `url`), and
|
|
232
|
+
* its request fingerprint describes what was sent to THAT site. Kept, they would
|
|
233
|
+
* sit beside a target that now publishes somewhere else entirely.
|
|
234
|
+
*
|
|
235
|
+
* @param {string} siteDir
|
|
236
|
+
* @param {string} origin
|
|
237
|
+
* @returns {Promise<string[]>} the target names whose record was removed, sorted
|
|
238
|
+
* @throws when deploy.yml exists and does not parse — the caller decides whether
|
|
239
|
+
* that blocks anything; this does not rewrite a file it could not read
|
|
240
|
+
*/
|
|
241
|
+
export async function forgetDeploys(siteDir, origin) {
|
|
242
|
+
const key = normalizeOrigin(origin)
|
|
243
|
+
const path = join(siteDir, 'deploy.yml')
|
|
244
|
+
if (!key || !existsSync(path)) return []
|
|
245
|
+
|
|
246
|
+
const doc = parseDocument(await readFile(path, 'utf8'))
|
|
247
|
+
if (doc.errors.length) {
|
|
248
|
+
throw new Error(`deploy.yml did not parse: ${doc.errors[0].message.split('\n')[0]}`)
|
|
249
|
+
}
|
|
250
|
+
const deploysNode = doc.get('deploys', true)
|
|
251
|
+
if (!isMap(deploysNode)) return []
|
|
252
|
+
|
|
253
|
+
const js = doc.toJS() || {}
|
|
254
|
+
const removed = []
|
|
255
|
+
for (const [name, record] of Object.entries(js.deploys || {})) {
|
|
256
|
+
const target = js.targets?.[name]
|
|
257
|
+
const named =
|
|
258
|
+
record?.backend ?? (target?.host === 'uniweb' ? target?.backend : undefined)
|
|
259
|
+
if (normalizeOrigin(named) === key) removed.push(name)
|
|
260
|
+
}
|
|
261
|
+
if (!removed.length) return []
|
|
262
|
+
|
|
263
|
+
for (const name of removed) deploysNode.delete(name)
|
|
264
|
+
// An empty `deploys: {}` says nothing and would be the only thing left of the
|
|
265
|
+
// backend in this file.
|
|
266
|
+
if (!deploysNode.items.length) doc.delete('deploys')
|
|
267
|
+
await writeFile(path, doc.toString(), 'utf8')
|
|
268
|
+
return removed.sort()
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Delete deploy.yml outright — `uniweb forget --all`, for a copy that is to become a
|
|
273
|
+
* new project.
|
|
274
|
+
*
|
|
275
|
+
* ⭐ **The targets go too, not just the records** *[Diego, 2026-09-21: "if it's for
|
|
276
|
+
* duplicating a site project and making a new site from it, … the whole deploy.yml
|
|
277
|
+
* file has to be deleted"]*. They name the ORIGINAL's destinations — its Pages
|
|
278
|
+
* project, its bucket, its domain — so a deploy from the copy would overwrite the
|
|
279
|
+
* original there. The copy's first deploy writes a fresh file.
|
|
280
|
+
*
|
|
281
|
+
* @param {string} siteDir
|
|
282
|
+
* @returns {Promise<string[]|null>} the target names it held, or null when there
|
|
283
|
+
* was no file. A file that does not parse is removed all the same and reports `[]`.
|
|
284
|
+
*/
|
|
285
|
+
export async function forgetDeployYml(siteDir) {
|
|
286
|
+
const path = join(siteDir, 'deploy.yml')
|
|
287
|
+
if (!existsSync(path)) return null
|
|
288
|
+
let names = []
|
|
289
|
+
try {
|
|
290
|
+
const doc = parseDocument(await readFile(path, 'utf8'))
|
|
291
|
+
if (!doc.errors.length) names = Object.keys(doc.toJS()?.targets || {}).sort()
|
|
292
|
+
} catch {
|
|
293
|
+
/* unreadable — still the original's, still removed */
|
|
294
|
+
}
|
|
295
|
+
await unlink(path)
|
|
296
|
+
return names
|
|
297
|
+
}
|
|
@@ -5,14 +5,21 @@
|
|
|
5
5
|
* by name. Read-only — never writes. See deploy-config-writer.js for
|
|
6
6
|
* the write path.
|
|
7
7
|
*
|
|
8
|
-
* On-disk shape:
|
|
8
|
+
* On-disk shape (spec: kb/framework/reference/deploy-yml.md):
|
|
9
9
|
* default: production
|
|
10
10
|
* targets:
|
|
11
|
-
* production: { host, ...host-specific config }
|
|
11
|
+
* production: { host, backend?, ...host-specific config }
|
|
12
12
|
* preview: { host, ...host-specific config }
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
13
|
+
* deploys:
|
|
14
|
+
* production: { at, host, url?, git?, servicesRequest? }
|
|
15
|
+
* saveDeploys: true
|
|
16
|
+
*
|
|
17
|
+
* ⚠️ `deploys` was `lastDeploy` and `saveDeploys` was `autoSave: off|lastDeploy|full`
|
|
18
|
+
* until 2026-09-20. `full` was "reserved; behaves as lastDeploy" — so the tri-state
|
|
19
|
+
* was already a boolean in practice, and the rename says so. `autoSave` also
|
|
20
|
+
* SOUNDED like it covered the whole file, which it never did.
|
|
21
|
+
*
|
|
22
|
+
* ⚠️ Nothing a backend minted lives here any more. Identity is `sync.json`'s.
|
|
16
23
|
*
|
|
17
24
|
* The CLI and the build pipeline both call resolveTarget() to turn a
|
|
18
25
|
* loaded document + a (possibly null) --target flag into a concrete
|
|
@@ -26,9 +33,9 @@ import { existsSync } from 'node:fs'
|
|
|
26
33
|
import { join } from 'node:path'
|
|
27
34
|
import yaml from 'js-yaml'
|
|
28
35
|
import { YAML_OPTIONS } from '../utils/yaml-schema.js'
|
|
36
|
+
import { normalizeOrigin } from '../uwx/sync-store.js'
|
|
29
37
|
|
|
30
38
|
const DEFAULT_TARGET_NAME = 'production'
|
|
31
|
-
const VALID_AUTOSAVE = new Set(['off', 'lastDeploy', 'full'])
|
|
32
39
|
|
|
33
40
|
/**
|
|
34
41
|
* Load and validate deploy.yml from `siteDir`. Returns null when the
|
|
@@ -57,7 +64,7 @@ export async function loadDeployYml(siteDir) {
|
|
|
57
64
|
* targetName: string,
|
|
58
65
|
* host: string,
|
|
59
66
|
* config: object,
|
|
60
|
-
*
|
|
67
|
+
* saveDeploys: boolean,
|
|
61
68
|
* fromFile: boolean,
|
|
62
69
|
* }}
|
|
63
70
|
*/
|
|
@@ -73,7 +80,7 @@ export function resolveTarget(deployYml, requestedTarget) {
|
|
|
73
80
|
targetName: DEFAULT_TARGET_NAME,
|
|
74
81
|
host: 'uniweb',
|
|
75
82
|
config: {},
|
|
76
|
-
|
|
83
|
+
saveDeploys: true,
|
|
77
84
|
fromFile: false,
|
|
78
85
|
}
|
|
79
86
|
}
|
|
@@ -99,11 +106,73 @@ export function resolveTarget(deployYml, requestedTarget) {
|
|
|
99
106
|
targetName: name,
|
|
100
107
|
host,
|
|
101
108
|
config,
|
|
102
|
-
|
|
109
|
+
saveDeploys: deployYml.saveDeploys !== false,
|
|
103
110
|
fromFile: true,
|
|
104
111
|
}
|
|
105
112
|
}
|
|
106
113
|
|
|
114
|
+
/**
|
|
115
|
+
* The target a publish to `origin` records its deploy under.
|
|
116
|
+
*
|
|
117
|
+
* ⭐ **The target follows the backend, never the reverse.** A publish goes to ONE backend,
|
|
118
|
+
* decided before this runs — `--backend`, or the backend the user is logged in to
|
|
119
|
+
* *[Diego, 2026-09-21: "publish should publish to the backend the user logged in to"]*.
|
|
120
|
+
* The default target is only a tie-break, winning when it names that backend too.
|
|
121
|
+
*
|
|
122
|
+
* 1. no deploy.yml → the scaffold: `production`, naming `origin`
|
|
123
|
+
* 2. `host: uniweb` targets naming `origin` → the default one if it is among them,
|
|
124
|
+
* else the first by name. A target with no `backend:` names `defaultBackend` —
|
|
125
|
+
* its documented meaning
|
|
126
|
+
* 3. none → a NEW target, named after the backend's host (`uniweb.app`,
|
|
127
|
+
* `localhost:8080`), `-2`, `-3`… if taken. The caller adds it when it persists.
|
|
128
|
+
*
|
|
129
|
+
* ⛔ Until 2026-09-21 publish always resolved the DEFAULT target, whatever backend it
|
|
130
|
+
* published to: a publish to a second backend recorded over the default target's deploy,
|
|
131
|
+
* and compared its service request against that target's fingerprint.
|
|
132
|
+
*
|
|
133
|
+
* @param {object|null} deployYml - from loadDeployYml
|
|
134
|
+
* @param {string} origin - the backend being published to
|
|
135
|
+
* @param {{ defaultBackend?: string }} [opts]
|
|
136
|
+
* @returns {{ targetName: string, host: 'uniweb', config: object, saveDeploys: boolean,
|
|
137
|
+
* fromFile: boolean }} `fromFile: false` means the file has no such target yet
|
|
138
|
+
*/
|
|
139
|
+
export function resolvePublishTarget(deployYml, origin, { defaultBackend } = {}) {
|
|
140
|
+
const key = normalizeOrigin(origin)
|
|
141
|
+
if (!deployYml) {
|
|
142
|
+
return {
|
|
143
|
+
targetName: DEFAULT_TARGET_NAME,
|
|
144
|
+
host: 'uniweb',
|
|
145
|
+
config: { backend: key },
|
|
146
|
+
saveDeploys: true,
|
|
147
|
+
fromFile: false,
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
const saveDeploys = deployYml.saveDeploys !== false
|
|
151
|
+
const targets = deployYml.targets || {}
|
|
152
|
+
const naming = Object.keys(targets)
|
|
153
|
+
.filter((name) => {
|
|
154
|
+
const t = targets[name]
|
|
155
|
+
return t && t.host === 'uniweb' && normalizeOrigin(t.backend || defaultBackend) === key
|
|
156
|
+
})
|
|
157
|
+
.sort()
|
|
158
|
+
if (naming.length) {
|
|
159
|
+
const targetName = naming.includes(deployYml.default) ? deployYml.default : naming[0]
|
|
160
|
+
const { host, ...config } = targets[targetName]
|
|
161
|
+
return { targetName, host, config, saveDeploys, fromFile: true }
|
|
162
|
+
}
|
|
163
|
+
let base = 'uniweb'
|
|
164
|
+
try {
|
|
165
|
+
base = new URL(key).host || base
|
|
166
|
+
} catch {
|
|
167
|
+
/* keep the fallback */
|
|
168
|
+
}
|
|
169
|
+
let targetName = base
|
|
170
|
+
for (let i = 2; Object.prototype.hasOwnProperty.call(targets, targetName); i++) {
|
|
171
|
+
targetName = `${base}-${i}`
|
|
172
|
+
}
|
|
173
|
+
return { targetName, host: 'uniweb', config: { backend: key }, saveDeploys, fromFile: false }
|
|
174
|
+
}
|
|
175
|
+
|
|
107
176
|
function validate(doc, path) {
|
|
108
177
|
if (typeof doc !== 'object' || doc === null || Array.isArray(doc)) {
|
|
109
178
|
throw new Error(`${path}: top level must be a map.`)
|
|
@@ -111,39 +180,19 @@ function validate(doc, path) {
|
|
|
111
180
|
if (doc.targets !== undefined && (typeof doc.targets !== 'object' || Array.isArray(doc.targets))) {
|
|
112
181
|
throw new Error(`${path}: \`targets\` must be a map.`)
|
|
113
182
|
}
|
|
114
|
-
//
|
|
115
|
-
//
|
|
116
|
-
// both be coherent
|
|
117
|
-
//
|
|
118
|
-
//
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
// *(Diego, 2026-08-12: multiple deploys are not expected in general, and are not
|
|
122
|
-
// allowed for Uniweb Cloud specifically — the only host that reads `$uuid` at
|
|
123
|
-
// all. Every other host is stateless from the CLI's side, so any number of those
|
|
124
|
-
// targets is fine.)*
|
|
125
|
-
if (doc.targets && typeof doc.targets === 'object' && !Array.isArray(doc.targets)) {
|
|
126
|
-
const uniwebTargets = Object.entries(doc.targets)
|
|
127
|
-
.filter(([, t]) => t && typeof t === 'object' && t.host === 'uniweb')
|
|
128
|
-
.map(([name]) => name)
|
|
129
|
-
if (uniwebTargets.length > 1) {
|
|
130
|
-
throw new Error(
|
|
131
|
-
`${path}: only one target may use \`host: uniweb\` — found ${uniwebTargets.length} (${uniwebTargets.join(', ')}). ` +
|
|
132
|
-
'A site has a single Uniweb identity (site.yml::$uuid), so a second Uniweb target cannot describe a different site. ' +
|
|
133
|
-
'Keep one, or point the others at a third-party host.'
|
|
134
|
-
)
|
|
135
|
-
}
|
|
136
|
-
}
|
|
137
|
-
if (doc.autoSave !== undefined && !VALID_AUTOSAVE.has(doc.autoSave)) {
|
|
138
|
-
throw new Error(
|
|
139
|
-
`${path}: \`autoSave\` must be one of: ${[...VALID_AUTOSAVE].join(', ')}.`
|
|
140
|
-
)
|
|
183
|
+
// ⭐ SEVERAL `host: uniweb` TARGETS ARE LEGAL (2026-09-20). This rejected a second one
|
|
184
|
+
// because a site had a single identity, `site.yml::$uuid`, so two Uniweb targets
|
|
185
|
+
// could not both be coherent. Identity is keyed by backend origin in `sync.json`
|
|
186
|
+
// now: two targets naming DIFFERENT backends are two sites, and two naming the SAME
|
|
187
|
+
// backend are one site shipped twice. The reason is gone, so the rejection is too.
|
|
188
|
+
if (doc.saveDeploys !== undefined && typeof doc.saveDeploys !== 'boolean') {
|
|
189
|
+
throw new Error(`${path}: \`saveDeploys\` must be true or false.`)
|
|
141
190
|
}
|
|
142
191
|
if (doc.default !== undefined && typeof doc.default !== 'string') {
|
|
143
192
|
throw new Error(`${path}: \`default\` must be a string.`)
|
|
144
193
|
}
|
|
145
|
-
if (doc.
|
|
146
|
-
throw new Error(`${path}: \`
|
|
194
|
+
if (doc.deploys !== undefined && (typeof doc.deploys !== 'object' || Array.isArray(doc.deploys))) {
|
|
195
|
+
throw new Error(`${path}: \`deploys\` must be a map.`)
|
|
147
196
|
}
|
|
148
197
|
return doc
|
|
149
198
|
}
|
package/src/site/index.js
CHANGED
|
@@ -50,5 +50,5 @@ export {
|
|
|
50
50
|
applyPostProcessing,
|
|
51
51
|
mergeDataIntoContent
|
|
52
52
|
} from './data-fetcher.js'
|
|
53
|
-
export { loadDeployYml, resolveTarget } from './deploy-config.js'
|
|
54
|
-
export { recordLastDeploy, recordTarget } from './deploy-config-writer.js'
|
|
53
|
+
export { loadDeployYml, resolveTarget, resolvePublishTarget } from './deploy-config.js'
|
|
54
|
+
export { recordLastDeploy, recordTarget, forgetDeploys, forgetDeployYml } from './deploy-config-writer.js'
|
package/src/uwx/asset-map.js
CHANGED
|
@@ -1,156 +1,34 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
* and the writer is a no-op when nothing changed (it does not rewrite an
|
|
29
|
-
* identical file, so `git status` stays clean on a push that moved no assets).
|
|
30
|
-
*
|
|
31
|
-
* ## What it deliberately does NOT hold
|
|
32
|
-
*
|
|
33
|
-
* ⛔ **No serve URL.** A URL is a host's route layout, and storing one here would
|
|
34
|
-
* re-create — in a committed file, on every machine — exactly the coupling that
|
|
35
|
-
* deleting `buildAssetUrl` removed from this CLI. The id plus the host's
|
|
36
|
-
* `config.assets.url` pattern is the whole address, and only the host owns the
|
|
37
|
-
* second half.
|
|
38
|
-
*
|
|
39
|
-
* ⚖️ **What it holds instead is a FINGERPRINT of the served URL** (`served`), and the
|
|
40
|
-
* difference is the point: a hash can recognize an address and cannot compose one.
|
|
41
|
-
* It exists for references that are a BARE STRING — `info.preview`, `info.favicon`,
|
|
42
|
-
* `seo.image`, a section param — where there is no object to stamp `assetId` beside,
|
|
43
|
-
* so the stored value is the serve URL alone. `pull` hashes such a string and, when
|
|
44
|
-
* it matches, puts back the path the author wrote. Should the host ever serve an
|
|
45
|
-
* asset at a new address, the fingerprint simply stops matching and the pull leaves
|
|
46
|
-
* the URL — the honest projection — until the next push records the new one.
|
|
47
|
-
*
|
|
48
|
-
* ⛔ **No mime or size.** The store validates those and they are its to change;
|
|
49
|
-
* a second copy here is a second thing to disagree.
|
|
2
|
+
* Asset reference helpers — two pure functions, no file.
|
|
3
|
+
*
|
|
4
|
+
* ⚠️ **The MAP moved to `sync.json` on 2026-09-20** (`sync-store.js`,
|
|
5
|
+
* `backends.<origin>.assets`). It lived here as `assets.json`, a single flat
|
|
6
|
+
* `local path → id` file that assumed ONE backend — which is exactly what it
|
|
7
|
+
* could not represent once a project syncs with several: an id is minted by a
|
|
8
|
+
* backend and means nothing to any other one. `readAssetMap` / `updateAssetMap` /
|
|
9
|
+
* `refForAssetId` / `ASSET_MAP_FILE` went with it.
|
|
10
|
+
*
|
|
11
|
+
* What stays here is what never touched the filesystem:
|
|
12
|
+
*
|
|
13
|
+
* - **`servedFingerprint`** — hashes a serve URL. It exists for references that are
|
|
14
|
+
* a BARE STRING (`info.preview`, `seo.image`, a section param), where there is no
|
|
15
|
+
* object to stamp an id beside, so the stored value is the address alone. ⛔ A
|
|
16
|
+
* fingerprint rather than the URL because a hash can RECOGNIZE an address and
|
|
17
|
+
* cannot COMPOSE one — route layout is the host's and never ours to rebuild.
|
|
18
|
+
* - **`restoreAssetRefs`** — puts an author's own paths back into a document pulled
|
|
19
|
+
* from a backend. Without it a push/pull cycle rewrites every image in a
|
|
20
|
+
* developer's source to a backend route: a mangling of files they own, by a round
|
|
21
|
+
* trip that changed nothing.
|
|
22
|
+
*
|
|
23
|
+
* ⭐ **`restoreAssetRefs` no-ops on an empty map**, which is what makes the store's
|
|
24
|
+
* `backend` parameter honest: a projection not tied to a backend has no known
|
|
25
|
+
* assets, so it restores nothing rather than guessing.
|
|
26
|
+
*
|
|
27
|
+
* @module
|
|
50
28
|
*/
|
|
51
29
|
|
|
52
|
-
import { existsSync, readFileSync, writeFileSync } from 'node:fs'
|
|
53
30
|
import { createHash } from 'node:crypto'
|
|
54
31
|
import { ASSET_SLOTS } from '@uniweb/semantic-parser'
|
|
55
|
-
import { join } from 'node:path'
|
|
56
|
-
|
|
57
|
-
export const ASSET_MAP_FILE = 'assets.json'
|
|
58
|
-
|
|
59
|
-
/** Current on-disk shape. Bumped only for a breaking layout change. */
|
|
60
|
-
const VERSION = 1
|
|
61
|
-
|
|
62
|
-
const mapPath = (siteDir) => join(siteDir, ASSET_MAP_FILE)
|
|
63
|
-
|
|
64
|
-
/**
|
|
65
|
-
* Read the site's asset map. A missing, unreadable or malformed file reads as
|
|
66
|
-
* empty rather than throwing: the map is an accelerator and a record, and a
|
|
67
|
-
* corrupt one must never be the reason a push fails. The next write repairs it.
|
|
68
|
-
*
|
|
69
|
-
* @param {string} siteDir
|
|
70
|
-
* @returns {Record<string, { id: string, ext: string }>} local ref → identity
|
|
71
|
-
*/
|
|
72
|
-
export function readAssetMap(siteDir) {
|
|
73
|
-
const p = mapPath(siteDir)
|
|
74
|
-
if (!existsSync(p)) return {}
|
|
75
|
-
try {
|
|
76
|
-
const parsed = JSON.parse(readFileSync(p, 'utf8'))
|
|
77
|
-
const assets = parsed?.assets
|
|
78
|
-
if (!assets || typeof assets !== 'object') return {}
|
|
79
|
-
const out = {}
|
|
80
|
-
for (const [ref, v] of Object.entries(assets)) {
|
|
81
|
-
if (v && typeof v.id === 'string' && v.id) {
|
|
82
|
-
out[ref] = { id: v.id, ext: typeof v.ext === 'string' ? v.ext : '' }
|
|
83
|
-
if (typeof v.served === 'string' && v.served) out[ref].served = v.served
|
|
84
|
-
}
|
|
85
|
-
}
|
|
86
|
-
return out
|
|
87
|
-
} catch {
|
|
88
|
-
return {}
|
|
89
|
-
}
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
/**
|
|
93
|
-
* Merge `entries` into the site's map and write it if anything changed.
|
|
94
|
-
*
|
|
95
|
-
* MERGE, not replace: a push carries only the refs that page's content touched,
|
|
96
|
-
* so replacing would drop every asset this run happened not to mention — which
|
|
97
|
-
* is most of them on a partial push. An entry whose id changed is overwritten
|
|
98
|
-
* (the bytes at that path changed); an identical entry is left alone.
|
|
99
|
-
*
|
|
100
|
-
* @param {string} siteDir
|
|
101
|
-
* @param {Record<string, { id: string, ext: string }>} entries
|
|
102
|
-
* @returns {{ added: string[], changed: string[], written: boolean }}
|
|
103
|
-
*/
|
|
104
|
-
export function updateAssetMap(siteDir, entries) {
|
|
105
|
-
const prior = readAssetMap(siteDir)
|
|
106
|
-
const added = []
|
|
107
|
-
const changed = []
|
|
108
|
-
|
|
109
|
-
for (const [ref, v] of Object.entries(entries || {})) {
|
|
110
|
-
if (!v?.id) continue
|
|
111
|
-
const was = prior[ref]
|
|
112
|
-
// A new `served` fingerprint is a change too: the host serves these bytes at
|
|
113
|
-
// another address now, and the old fingerprint would stop recognizing it. An
|
|
114
|
-
// entry that carries none — a download learns identity, not an upload's URL —
|
|
115
|
-
// keeps the one already recorded for the same bytes.
|
|
116
|
-
const served = v.served || (was && was.id === v.id ? was.served : undefined)
|
|
117
|
-
if (!was) added.push(ref)
|
|
118
|
-
else if (was.id !== v.id || (served || '') !== (was.served || '')) changed.push(ref)
|
|
119
|
-
else continue
|
|
120
|
-
prior[ref] = { id: v.id, ext: v.ext || '', ...(served ? { served } : {}) }
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
if (!added.length && !changed.length) return { added, changed, written: false }
|
|
124
|
-
|
|
125
|
-
// Sorted keys + a trailing newline: a committed file that reorders itself
|
|
126
|
-
// produces a diff on every push and teaches people to skip reading it.
|
|
127
|
-
const assets = {}
|
|
128
|
-
for (const ref of Object.keys(prior).sort()) assets[ref] = prior[ref]
|
|
129
|
-
writeFileSync(
|
|
130
|
-
mapPath(siteDir),
|
|
131
|
-
JSON.stringify({ version: VERSION, assets }, null, 2) + '\n'
|
|
132
|
-
)
|
|
133
|
-
return { added, changed, written: true }
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
/**
|
|
137
|
-
* The local ref a given asset id is known at, or null.
|
|
138
|
-
*
|
|
139
|
-
* This is the direction `pull` needs and the reason the map is worth
|
|
140
|
-
* committing: stored content carries an id, and only this can say the author
|
|
141
|
-
* called it `/images/hero.png`. Without it a pull must invent a path.
|
|
142
|
-
*
|
|
143
|
-
* @param {Record<string, { id: string, ext: string }>} map
|
|
144
|
-
* @param {string} id
|
|
145
|
-
* @returns {string|null}
|
|
146
|
-
*/
|
|
147
|
-
export function refForAssetId(map, id) {
|
|
148
|
-
if (!id) return null
|
|
149
|
-
for (const [ref, v] of Object.entries(map || {})) {
|
|
150
|
-
if (v.id === id) return ref
|
|
151
|
-
}
|
|
152
|
-
return null
|
|
153
|
-
}
|
|
154
32
|
|
|
155
33
|
/**
|
|
156
34
|
* The fingerprint `assets.json` keeps of the URL a host serves an asset at.
|