@uniweb/build 0.78.0 → 0.79.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 +6 -6
- package/src/dev/api-mount.js +11 -9
- package/src/foundation/derive-supports.js +3 -2
- package/src/resolve-data-schema.js +2 -1
- package/src/runtime-schema.js +30 -12
- package/src/schema.js +93 -1
- package/src/site/content-collector.js +2 -1
- package/src/site/deploy-config-writer.js +4 -2
- package/src/site/plugin.js +5 -5
- package/src/site/query-processor.js +2 -2
- package/src/uwx/index.js +4 -1
- package/src/uwx/project-writer.js +31 -21
- package/src/uwx/services-request.js +148 -27
- package/src/uwx/site-project.js +1 -2
- package/src/uwx/site.js +10 -8
- package/src/uwx/yaml-edit.js +288 -0
- package/src/validate-data.js +18 -11
- package/src/uwx/yaml-upsert.js +0 -79
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* exclude: { routes: [/legal] } # an option the site's runtime reads
|
|
9
9
|
* submit: https://forms.example.com/f/abc # an address: a provider the site brings
|
|
10
10
|
* tracking: { consent: required }
|
|
11
|
-
*
|
|
11
|
+
* backend: { grade: pro } # a setting for the host's service
|
|
12
12
|
* ```
|
|
13
13
|
*
|
|
14
14
|
* ⭐ ONE PLACE [Diego, 2026-10-06]. Until then the site's own provider and options
|
|
@@ -19,8 +19,8 @@
|
|
|
19
19
|
* Each entry goes ONE place per lane, decided here and nowhere else:
|
|
20
20
|
*
|
|
21
21
|
* - **a built site** reads it as its own config (`config.<name>` in the payload) —
|
|
22
|
-
* the switch, an address, the options. ⛔ For `
|
|
23
|
-
* its settings are for the host that provisions it. Never a credential.
|
|
22
|
+
* the switch, an address, the options. ⛔ For `backend` only its switch and
|
|
23
|
+
* address: its settings are for the host that provisions it. Never a credential.
|
|
24
24
|
* - **a push** sends it as the request: ONE row per entry in the `services` Section,
|
|
25
25
|
* `{ name, enabled?: false, config? }`, whose `config` is the whole entry — an
|
|
26
26
|
* address, the options, the host's settings — never a credential. What reaches a
|
|
@@ -65,9 +65,27 @@ export const RUNTIME_KEYS = Object.freeze({
|
|
|
65
65
|
submit: Object.freeze(['enabled', 'endpoint']),
|
|
66
66
|
assistant: Object.freeze(['enabled', 'endpoint']),
|
|
67
67
|
tracking: Object.freeze(['enabled', 'endpoint', 'emit', 'consent', 'scripts', 'flushIntervalMs', 'debug']),
|
|
68
|
-
|
|
68
|
+
backend: Object.freeze(['enabled', 'endpoint'])
|
|
69
69
|
})
|
|
70
70
|
|
|
71
|
+
/**
|
|
72
|
+
* Services renamed, by their old name. The old name is refused wherever a service is
|
|
73
|
+
* named — an entry here, and a foundation's `uniweb.supports` — naming the new one.
|
|
74
|
+
*
|
|
75
|
+
* ⛔ No alias, and the reason is the runtime: a name no host offers resolves to
|
|
76
|
+
* nothing, so a site or a foundation still saying `api` would simply lose the service
|
|
77
|
+
* on every page, with nothing anywhere saying why. Here it stops where it is written.
|
|
78
|
+
*
|
|
79
|
+
* ⭐ `api` → `backend`, 2026-10-07 [Diego: "name the actual service, and not the
|
|
80
|
+
* interface of the service, which is a route named /api"]. The site's own backend is
|
|
81
|
+
* named for what it is; `/_api` stays the route it answers on, and `@uniweb/api` is
|
|
82
|
+
* named for that interface.
|
|
83
|
+
*/
|
|
84
|
+
export const RENAMED_SERVICES = Object.freeze({ api: 'backend' })
|
|
85
|
+
|
|
86
|
+
/** `site.yml` keys renamed with a service — refused by the old name, like the services. */
|
|
87
|
+
const RENAMED_SITE_KEYS = Object.freeze({ $devApi: '$devBackend' })
|
|
88
|
+
|
|
71
89
|
/**
|
|
72
90
|
* Services only a host provides, which a site never answers itself: no address, no
|
|
73
91
|
* runtime part. A site does not declare its own record provider — a third-party
|
|
@@ -92,6 +110,106 @@ function stable(value) {
|
|
|
92
110
|
)
|
|
93
111
|
}
|
|
94
112
|
|
|
113
|
+
/**
|
|
114
|
+
* YAML 1.1's words for a switch, which js-yaml — YAML 1.2 — reads as TEXT, and `true` /
|
|
115
|
+
* `false` written in quotes. ⛔ As text, `search: yes` was an address: the site's own
|
|
116
|
+
* search provider at `yes`, with the host's search asked OFF, while the CLI said
|
|
117
|
+
* "site.yml turns on search" (measured on 0.85.0, 2026-10-07 — F14).
|
|
118
|
+
*/
|
|
119
|
+
const SWITCH_WORDS = /^(y|yes|on|true|n|no|off|false)$/i
|
|
120
|
+
const ON_WORDS = /^(y|yes|on|true)$/i
|
|
121
|
+
|
|
122
|
+
/** What a value is, in words — for a message that says what was found. */
|
|
123
|
+
function described(value) {
|
|
124
|
+
if (value === null || value === undefined) return 'empty'
|
|
125
|
+
if (Array.isArray(value)) return 'a list'
|
|
126
|
+
if (typeof value === 'string') return value.trim() ? `the text \`${value}\`` : 'empty'
|
|
127
|
+
return `\`${JSON.stringify(value)}\``
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** A switch written as a word: what it says, and what to write instead. */
|
|
131
|
+
function switchWord(path, key, value) {
|
|
132
|
+
const on = ON_WORDS.test(value.trim())
|
|
133
|
+
return (
|
|
134
|
+
`\`${path}\` is the text \`${value}\`, not a switch — YAML reads yes, no, on and off as words. ` +
|
|
135
|
+
`Write \`${key}: ${on}\` to turn it ${on ? 'on' : 'off'}.`
|
|
136
|
+
)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* What is wrong with one entry, or null when it is `true`, `false`, an address or a map —
|
|
141
|
+
* the one judgement both `readServicesRequest` and `refuseUnreadableServices` make.
|
|
142
|
+
*/
|
|
143
|
+
function entryProblem(name, value) {
|
|
144
|
+
if (RENAMED_SERVICES[name]) {
|
|
145
|
+
const now = RENAMED_SERVICES[name]
|
|
146
|
+
return (
|
|
147
|
+
`\`services.${name}\` is now \`services.${now}\` — the site's own backend is the \`${now}\` service. ` +
|
|
148
|
+
`Rename the entry; what it says stays. On a site you push, \`uniweb pull\` brings it renamed.`
|
|
149
|
+
)
|
|
150
|
+
}
|
|
151
|
+
if (typeof value === 'boolean') return null
|
|
152
|
+
if (typeof value === 'string') {
|
|
153
|
+
if (SWITCH_WORDS.test(value.trim())) return switchWord(`services.${name}`, name, value)
|
|
154
|
+
if (!value.trim()) return `\`services.${name}\` is empty — write true, false, an address, or a map.`
|
|
155
|
+
if (HOST_ONLY.has(name)) return `\`services.${name}\` has no address of its own — your host provides it. Write \`${name}: true\`.`
|
|
156
|
+
return null
|
|
157
|
+
}
|
|
158
|
+
if (!isMap(value)) return `\`services.${name}\` is true, false, an address, or a map — not ${described(value)}.`
|
|
159
|
+
if (value.enabled !== undefined && typeof value.enabled !== 'boolean') {
|
|
160
|
+
return typeof value.enabled === 'string' && SWITCH_WORDS.test(value.enabled.trim())
|
|
161
|
+
? switchWord(`services.${name}.enabled`, 'enabled', value.enabled)
|
|
162
|
+
: `\`services.${name}.enabled\` is true or false — not ${described(value.enabled)}.`
|
|
163
|
+
}
|
|
164
|
+
if (typeof value.endpoint === 'string' && SWITCH_WORDS.test(value.endpoint.trim())) {
|
|
165
|
+
return `\`services.${name}.endpoint\` is the text \`${value.endpoint}\`, not an address — an address is a URL or a path. To turn the service on, leave \`endpoint\` out.`
|
|
166
|
+
}
|
|
167
|
+
if (HOST_ONLY.has(name) && addressOf(value)) {
|
|
168
|
+
return `\`services.${name}\` has no address of its own — your host provides it. Remove \`endpoint\`.`
|
|
169
|
+
}
|
|
170
|
+
return null
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Every entry of `site.yml::services` that neither a build nor a push can read, each said
|
|
175
|
+
* with its fix — [] when there is none.
|
|
176
|
+
*
|
|
177
|
+
* @param {*} declared - the raw `services:` value
|
|
178
|
+
* @returns {string[]}
|
|
179
|
+
*/
|
|
180
|
+
export function unreadableServices(declared) {
|
|
181
|
+
if (declared === undefined || declared === null) return []
|
|
182
|
+
if (!isMap(declared)) return ['`services:` is a map of service names — `services: { search: true }`.']
|
|
183
|
+
return Object.entries(declared)
|
|
184
|
+
.map(([name, value]) => entryProblem(name, value))
|
|
185
|
+
.filter(Boolean)
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Stop on a `services:` entry neither lane can read — beside `refuseRetiredServiceKeys`,
|
|
190
|
+
* in the static build and in the push.
|
|
191
|
+
*
|
|
192
|
+
* ⛔ NOT "warn and ignore it". A push states every service the file lists and OFF for each
|
|
193
|
+
* one this copy holds that it does not (`statedServices`), so an entry it skipped turned
|
|
194
|
+
* that service off — `search: 7`, or `records: /_query`, switched off on the site under a
|
|
195
|
+
* warning that said "Ignoring it" (until 2026-10-07). And a word for a switch was not
|
|
196
|
+
* skipped but read as an address, which asks the host to leave its own off (F14).
|
|
197
|
+
*
|
|
198
|
+
* @param {object} siteYml
|
|
199
|
+
* @param {string} [where]
|
|
200
|
+
* @throws {Error} naming each entry and what to write
|
|
201
|
+
*/
|
|
202
|
+
export function refuseUnreadableServices(siteYml, where = 'site.yml') {
|
|
203
|
+
if (!isMap(siteYml)) return
|
|
204
|
+
const found = unreadableServices(siteYml.services)
|
|
205
|
+
if (!found.length) return
|
|
206
|
+
throw new Error(
|
|
207
|
+
found.length === 1
|
|
208
|
+
? `[uniweb] ${where}: ${found[0]}`
|
|
209
|
+
: `[uniweb] ${where}: \`services:\` has entries that cannot be read:\n${found.map((f) => ` - ${f}`).join('\n')}`
|
|
210
|
+
)
|
|
211
|
+
}
|
|
212
|
+
|
|
95
213
|
/** The address an entry names, or null — a string, or a map's `endpoint`. */
|
|
96
214
|
function addressOf(value) {
|
|
97
215
|
if (typeof value === 'string') return value.trim() || null
|
|
@@ -132,12 +250,20 @@ function sayCredentials(name, found, warn) {
|
|
|
132
250
|
*/
|
|
133
251
|
export function refuseRetiredServiceKeys(siteYml, where = 'site.yml') {
|
|
134
252
|
if (!isMap(siteYml)) return
|
|
253
|
+
const renamed = Object.keys(RENAMED_SITE_KEYS).filter((k) => siteYml[k] !== undefined)
|
|
254
|
+
if (renamed.length) {
|
|
255
|
+
throw new Error(
|
|
256
|
+
`[uniweb] ${where}: ${renamed.map((k) => `\`${k}:\` is now \`${RENAMED_SITE_KEYS[k]}:\``).join(', ')} — ` +
|
|
257
|
+
"it names what answers the site's `backend` service in `uniweb dev`. Rename the key; its value stays."
|
|
258
|
+
)
|
|
259
|
+
}
|
|
135
260
|
const found = RETIRED_SERVICE_KEYS.filter((k) => siteYml[k] !== undefined)
|
|
136
261
|
if (!found.length) return
|
|
137
|
-
|
|
262
|
+
// The block to move them into names each service as `services:` does now.
|
|
263
|
+
const lines = found.map((k) => ` ${RENAMED_SERVICES[k] || k}: …`).join('\n')
|
|
138
264
|
const api = found.includes('api')
|
|
139
|
-
? '\n `api:` named the address a local mock answered on — in `uniweb dev`, `$
|
|
140
|
-
|
|
265
|
+
? '\n `api:` named the address a local mock answered on — in `uniweb dev`, `$devBackend` now ' +
|
|
266
|
+
"supplies it. Asking your host for the site's own backend is `backend: true` under `services:`."
|
|
141
267
|
: ''
|
|
142
268
|
throw new Error(
|
|
143
269
|
`[uniweb] ${where}: ${found.map((k) => `\`${k}:\``).join(', ')} ` +
|
|
@@ -148,10 +274,10 @@ export function refuseRetiredServiceKeys(siteYml, where = 'site.yml') {
|
|
|
148
274
|
|
|
149
275
|
/**
|
|
150
276
|
* Services whose settings are never published: only the switch and the address reach
|
|
151
|
-
* the site's config. `
|
|
277
|
+
* the site's config. `backend`'s settings are for the host that provisions it (a grade,
|
|
152
278
|
* sign-in providers, billing) and no page reads them.
|
|
153
279
|
*/
|
|
154
|
-
const UNPUBLISHED_SETTINGS = new Set(['
|
|
280
|
+
const UNPUBLISHED_SETTINGS = new Set(['backend'])
|
|
155
281
|
|
|
156
282
|
/**
|
|
157
283
|
* One entry → what the site's config carries: `false`, an address, or the entry's
|
|
@@ -217,6 +343,14 @@ export function readServicesRequest(declared, { warn = () => {} } = {}) {
|
|
|
217
343
|
}
|
|
218
344
|
const asks = []
|
|
219
345
|
for (const [name, value] of Object.entries(declared)) {
|
|
346
|
+
// ⚠️ Skipped here for a caller that only reads; the build and the push stop on it
|
|
347
|
+
// first (`refuseUnreadableServices`), since a push would state a skipped one off.
|
|
348
|
+
// First, so a renamed service is skipped whatever its value — `true` included.
|
|
349
|
+
const problem = entryProblem(name, value)
|
|
350
|
+
if (problem) {
|
|
351
|
+
warn(`${problem} Ignoring \`${name}\`.`)
|
|
352
|
+
continue
|
|
353
|
+
}
|
|
220
354
|
if (value === true) {
|
|
221
355
|
asks.push({ name })
|
|
222
356
|
continue
|
|
@@ -225,31 +359,18 @@ export function readServicesRequest(declared, { warn = () => {} } = {}) {
|
|
|
225
359
|
asks.push({ name, enabled: false })
|
|
226
360
|
continue
|
|
227
361
|
}
|
|
228
|
-
if (typeof value !== 'string' && !isMap(value)) {
|
|
229
|
-
warn(`\`services.${name}\` is true, false, an address, or a map. Ignoring it.`)
|
|
230
|
-
continue
|
|
231
|
-
}
|
|
232
|
-
if (isMap(value) && value.enabled !== undefined && typeof value.enabled !== 'boolean') {
|
|
233
|
-
warn(`\`services.${name}.enabled\` is true or false. Ignoring \`${name}\`.`)
|
|
234
|
-
continue
|
|
235
|
-
}
|
|
236
362
|
const address = addressOf(value)
|
|
237
|
-
if (typeof value === 'string' && !address) continue // an empty string asks nothing
|
|
238
|
-
if (address && HOST_ONLY.has(name)) {
|
|
239
|
-
warn(`\`services.${name}\` has no address of its own — your host provides it. Ignoring \`${name}\`.`)
|
|
240
|
-
continue
|
|
241
|
-
}
|
|
242
363
|
const { enabled, ...entry } = isMap(value) ? value : { endpoint: address }
|
|
243
364
|
const { value: config, found } = withoutCredentials(entry)
|
|
244
365
|
sayCredentials(name, found, warn)
|
|
245
366
|
if (address) {
|
|
246
367
|
config.endpoint = address
|
|
247
|
-
// ⚠️ Said for `
|
|
248
|
-
// local mock answered, and under `services:` an address turns the host's off.
|
|
249
|
-
if (name === '
|
|
368
|
+
// ⚠️ Said for `backend` because its old spelling invites it: `api: /_api` was where
|
|
369
|
+
// a local mock answered, and under `services:` an address turns the host's off.
|
|
370
|
+
if (name === 'backend') {
|
|
250
371
|
warn(
|
|
251
|
-
"`services.
|
|
252
|
-
"For your host's, write `
|
|
372
|
+
"`services.backend` is an address: it asks your host to leave its own `backend` off, so the site uses yours. " +
|
|
373
|
+
"For your host's, write `backend: true`; in `uniweb dev`, `$devBackend` answers it."
|
|
253
374
|
)
|
|
254
375
|
}
|
|
255
376
|
}
|
package/src/uwx/site-project.js
CHANGED
|
@@ -56,7 +56,6 @@ import { siteContentDirs } from './site-dirs.js'
|
|
|
56
56
|
import { layoutAreaRoute } from '../site/layout-folder.js'
|
|
57
57
|
import { orderFolders, rootOrderConfig, parseNumericPrefix, parseWildcardArray, composeLocalizedRoute, stripAtPrefix, compareFilenames } from '../site/content-collector.js'
|
|
58
58
|
import { parseFrontmatter } from '../utils/frontmatter.js'
|
|
59
|
-
import { upsertYamlScalar } from './yaml-upsert.js'
|
|
60
59
|
|
|
61
60
|
// The pull-side identity index: a per-clone, GITIGNORED `uuid → relative path`
|
|
62
61
|
// map under `.uniweb/`, the home for the backend's per-item identity so that
|
|
@@ -1155,7 +1154,7 @@ export function siteContentDocumentToProject({ document, siteRoot, backend = nul
|
|
|
1155
1154
|
const listed = Array.isArray(root.pages) ? parseWildcardArray(root.pages) : null
|
|
1156
1155
|
// A list that names the homepage first beats `index:`, so it opens with `...` instead.
|
|
1157
1156
|
if (listed && listed.before.length > 0) root.write(['...', ...root.pages.filter((e) => e !== '...')])
|
|
1158
|
-
|
|
1157
|
+
writeSiteConfig(siteRoot, { index: ctx.homepage })
|
|
1159
1158
|
}
|
|
1160
1159
|
}
|
|
1161
1160
|
projectLayout(document?.layout_sections, layoutBaseDir, report, prune, ctx)
|
package/src/uwx/site.js
CHANGED
|
@@ -83,9 +83,9 @@ import { updateBackendState, readBackendState } from './sync-store.js'
|
|
|
83
83
|
import {
|
|
84
84
|
readServicesRequest,
|
|
85
85
|
statedServices,
|
|
86
|
-
refuseRetiredServiceKeys
|
|
86
|
+
refuseRetiredServiceKeys,
|
|
87
|
+
refuseUnreadableServices
|
|
87
88
|
} from './services-request.js'
|
|
88
|
-
import { upsertYamlScalar } from './yaml-upsert.js'
|
|
89
89
|
import { resolveQueriesConfig } from './queries-config.js'
|
|
90
90
|
import { resolveSelfScope, siteSelfScope, refuseOrgOption } from './self-scope.js'
|
|
91
91
|
import { siteContentDirs } from './site-dirs.js'
|
|
@@ -1372,6 +1372,7 @@ export async function siteProjectToDocument(siteRoot, opts = {}) {
|
|
|
1372
1372
|
refuseOrgOption(opts, 'uwx/site')
|
|
1373
1373
|
const siteYml = await readYamlFile(join(siteRoot, 'site.yml'))
|
|
1374
1374
|
refuseRetiredServiceKeys(siteYml)
|
|
1375
|
+
refuseUnreadableServices(siteYml)
|
|
1375
1376
|
const sourceLocale = opts.sourceLocale || resolveDefaultLocale(siteYml)
|
|
1376
1377
|
if (!siteYml.name) {
|
|
1377
1378
|
throw new Error('uwx/site: site.yml::name is required')
|
|
@@ -1477,12 +1478,13 @@ export async function siteProjectToDocument(siteRoot, opts = {}) {
|
|
|
1477
1478
|
// KEYED FIELD: one embedded in an endpoint URL (`https://collector/e?key=…`) is
|
|
1478
1479
|
// invisible there and is disclosed. The host's secret store is the only right home.
|
|
1479
1480
|
//
|
|
1480
|
-
// ⛔ `
|
|
1481
|
-
// `@uniweb/api` (`resolveBase`). Asking for it is `services: {
|
|
1482
|
-
// of the host's settings; an address of the site's own asks the host to leave its
|
|
1483
|
-
// off, and `readServicesRequest` says so, because
|
|
1484
|
-
// used to answer — reads that way. In `uniweb dev`, `$
|
|
1485
|
-
// address (`dev/api-mount.js`).
|
|
1481
|
+
// ⛔ `backend`'S ADDRESS IS THE HOST'S TO SUPPLY — `config.services.backend`, read by
|
|
1482
|
+
// `@uniweb/api` (`resolveBase`). Asking for it is `services: { backend: true }`, or a
|
|
1483
|
+
// map of the host's settings; an address of the site's own asks the host to leave its
|
|
1484
|
+
// own off, and `readServicesRequest` says so, because `/_api` — where a local mock
|
|
1485
|
+
// used to answer — reads that way. In `uniweb dev`, `$devBackend` supplies the mock's
|
|
1486
|
+
// address (`dev/api-mount.js`). *(The service was `api` until 2026-10-07; that name is
|
|
1487
|
+
// refused — `RENAMED_SERVICES`.)*
|
|
1486
1488
|
// ⛔ THE CONFIGURATION KEYS ARE NOT HERE — they ride the `settings` Section
|
|
1487
1489
|
// (`settingsNested` above). `info` is the BRIEF: what a card or a select dropdown
|
|
1488
1490
|
// renders, plus what a listing can filter on. Eighteen keys moved off it on
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
// Edits to a YAML file an author wrote — ONE engine, `editYamlText`, for everything
|
|
2
|
+
// in the framework that changes such a file.
|
|
3
|
+
//
|
|
4
|
+
// `site.yml`, `theme.yml`, `page.yml`, `queries.yml` are hand-authored — they carry
|
|
5
|
+
// comments, blank lines and an author-chosen layout. Round-tripping them through
|
|
6
|
+
// js-yaml (load → dump) discards every comment and re-flows the file. So nothing
|
|
7
|
+
// writes one whole: the engine rewrites only the entries that change.
|
|
8
|
+
//
|
|
9
|
+
// - `editYamlText(text, before, after)` — the engine: text in, text out.
|
|
10
|
+
// - `setYamlKey` / `replaceInYamlList` — the two edits a command decides on before
|
|
11
|
+
// it writes anything (`uniweb rename`), as text, so a file it cannot edit stops
|
|
12
|
+
// the command while nothing has moved.
|
|
13
|
+
// - Writing a file goes through `project-writer.js` (`writeSiteConfig`,
|
|
14
|
+
// `writeThemeFile`, …), which reads it, merges, and hands the engine both values.
|
|
15
|
+
//
|
|
16
|
+
// ⛔ *Until 2026-10-07 there were three: this module's line-level `upsertYamlScalar`
|
|
17
|
+
// and `removeYamlScalar` (one top-level scalar line), the CLI's `utils/yaml-edit.js`
|
|
18
|
+
// (`setTopLevelScalar`, `replaceInTopLevelList`), and the engine. The two line-level
|
|
19
|
+
// ones refused or misplaced what the engine edits — a block scalar, a list entry
|
|
20
|
+
// beside a comment, a new key PREPENDED above a file's header comments.*
|
|
21
|
+
|
|
22
|
+
import yaml from 'js-yaml'
|
|
23
|
+
import { parseDocument, isMap, isSeq, isScalar, Scalar, visit } from 'yaml'
|
|
24
|
+
import { YAML_OPTIONS } from '../utils/yaml-schema.js'
|
|
25
|
+
|
|
26
|
+
/** js-yaml's options for every value the framework writes into YAML, so output is byte-stable. */
|
|
27
|
+
export const YAML_DUMP_OPTS = { lineWidth: -1, quotingType: "'", forceQuotes: false, noRefs: true }
|
|
28
|
+
|
|
29
|
+
const isPlainObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v)
|
|
30
|
+
|
|
31
|
+
/** Deep equality of two values read from YAML: sorted-key JSON. */
|
|
32
|
+
const sameValue = (a, b) => {
|
|
33
|
+
const canonical = (v) =>
|
|
34
|
+
JSON.stringify(v, (_k, x) => (isPlainObject(x) ? Object.fromEntries(Object.keys(x).sort().map((k) => [k, x[k]])) : x))
|
|
35
|
+
return canonical(a) === canonical(b)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Offset of the start of the line holding `pos`. */
|
|
39
|
+
const lineStart = (text, pos) => text.lastIndexOf('\n', pos - 1) + 1
|
|
40
|
+
|
|
41
|
+
/** The column a block map's key starts at — null when anything but spaces precedes it (`- key:`). */
|
|
42
|
+
function columnOf(text, key) {
|
|
43
|
+
const start = lineStart(text, key.range[0])
|
|
44
|
+
const lead = text.slice(start, key.range[0])
|
|
45
|
+
return /^ *$/.test(lead) ? lead.length : null
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Where a pair's text ends: after its value, its trailing comment and its newline. */
|
|
49
|
+
function pairEnd(text, pair) {
|
|
50
|
+
let end = (pair.value ?? pair.key).range[2]
|
|
51
|
+
if (text[end - 1] !== '\n') {
|
|
52
|
+
const nl = text.indexOf('\n', end)
|
|
53
|
+
const rest = nl === -1 ? text.slice(end) : text.slice(end, nl)
|
|
54
|
+
if (/^\s*(#.*)?$/.test(rest)) end = nl === -1 ? text.length : nl + 1
|
|
55
|
+
}
|
|
56
|
+
return end
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Every line but the first — or every line, for text that starts a line — indented to `col`. */
|
|
60
|
+
function indented(text, col, first) {
|
|
61
|
+
if (!col) return text
|
|
62
|
+
const pad = ' '.repeat(col)
|
|
63
|
+
return text
|
|
64
|
+
.split('\n')
|
|
65
|
+
.map((line, i) => (line && (i > 0 || first) ? pad + line : line))
|
|
66
|
+
.join('\n')
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** An alias or a `<<` merge key: one edit could change what another place says. */
|
|
70
|
+
function usesReferences(doc) {
|
|
71
|
+
let found = false
|
|
72
|
+
visit(doc, {
|
|
73
|
+
Alias() {
|
|
74
|
+
found = true
|
|
75
|
+
return visit.BREAK
|
|
76
|
+
},
|
|
77
|
+
Pair(_, pair) {
|
|
78
|
+
if (isScalar(pair.key) && pair.key.value === '<<') {
|
|
79
|
+
found = true
|
|
80
|
+
return visit.BREAK
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
})
|
|
84
|
+
return found
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* A scalar written where `node` stood — in the node's quotes when it was quoted and the
|
|
89
|
+
* new value is text (`"…"` stays `"…"`), else as js-yaml writes it; inside a flow
|
|
90
|
+
* collection, in a form that cannot run into the next entry. Null when it does not fit
|
|
91
|
+
* on one line, or `node` spans lines.
|
|
92
|
+
*/
|
|
93
|
+
function scalarText(text, node, value, dump, inFlow) {
|
|
94
|
+
if (!node.range || node.range[1] <= node.range[0]) return null
|
|
95
|
+
if (text.slice(node.range[0], node.range[1]).includes('\n')) return null
|
|
96
|
+
if (value !== null && typeof value === 'object') return null
|
|
97
|
+
let out
|
|
98
|
+
if (typeof value === 'string' && node.type === Scalar.QUOTE_DOUBLE) out = JSON.stringify(value)
|
|
99
|
+
else if (typeof value === 'string' && node.type === Scalar.QUOTE_SINGLE) out = `'${value.replace(/'/g, "''")}'`
|
|
100
|
+
else if (inFlow) {
|
|
101
|
+
const list = dump([value], { flowLevel: 0 }).trim()
|
|
102
|
+
if (!list.startsWith('[') || !list.endsWith(']')) return null
|
|
103
|
+
out = list.slice(1, -1)
|
|
104
|
+
} else out = dump(value).replace(/\n$/, '')
|
|
105
|
+
return out.includes('\n') ? null : out
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Make one list say `after` — `before` is what it says now, the same length — by
|
|
110
|
+
* rewriting each changed entry where it stands. False for anything else (a list that
|
|
111
|
+
* grew or shrank, an entry that is not a one-line scalar): the caller writes it whole.
|
|
112
|
+
*/
|
|
113
|
+
function editSeq(text, seq, before, after, edits, dump) {
|
|
114
|
+
if (seq.items.length !== before.length || before.length !== after.length) return false
|
|
115
|
+
const inner = []
|
|
116
|
+
for (let i = 0; i < after.length; i++) {
|
|
117
|
+
if (sameValue(before[i], after[i])) continue
|
|
118
|
+
const node = seq.items[i]
|
|
119
|
+
const now = isScalar(node) ? scalarText(text, node, after[i], dump, seq.flow) : null
|
|
120
|
+
if (now === null) return false
|
|
121
|
+
inner.push({ start: node.range[0], end: node.range[1], text: now })
|
|
122
|
+
}
|
|
123
|
+
edits.push(...inner)
|
|
124
|
+
return true
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Make one block map say `after` — `before` is what it says now — by edits to its own
|
|
129
|
+
* entries: a removed key's lines go, a changed scalar is rewritten where it stands, a
|
|
130
|
+
* changed map or same-length list is edited the same way one level down, anything else
|
|
131
|
+
* is written whole in its place, and a new key goes after the last one. False when the
|
|
132
|
+
* map is not one this can edit (a key that is not a plain scalar, or not first on its line).
|
|
133
|
+
*/
|
|
134
|
+
function editMap(text, map, before, after, edits, dump) {
|
|
135
|
+
if (!map.items.length || !map.items[0].key?.range) return false
|
|
136
|
+
const col = columnOf(text, map.items[0].key)
|
|
137
|
+
if (col === null) return false
|
|
138
|
+
const seen = new Set()
|
|
139
|
+
let end = 0
|
|
140
|
+
for (const pair of map.items) {
|
|
141
|
+
if (!isScalar(pair.key) || !pair.key.range || columnOf(text, pair.key) !== col) return false
|
|
142
|
+
const key = String(pair.key.value)
|
|
143
|
+
if (seen.has(key)) return false
|
|
144
|
+
seen.add(key)
|
|
145
|
+
const value = pair.value
|
|
146
|
+
const stop = pairEnd(text, pair)
|
|
147
|
+
end = Math.max(end, stop)
|
|
148
|
+
if (!Object.hasOwn(after, key) || after[key] === undefined) {
|
|
149
|
+
edits.push({ start: lineStart(text, pair.key.range[0]), end: stop, text: '' })
|
|
150
|
+
continue
|
|
151
|
+
}
|
|
152
|
+
if (Object.hasOwn(before, key) && sameValue(before[key], after[key])) continue
|
|
153
|
+
const next = after[key]
|
|
154
|
+
const inner = []
|
|
155
|
+
if (isMap(value) && !value.flow && isPlainObject(before[key]) && isPlainObject(next)) {
|
|
156
|
+
if (editMap(text, value, before[key], next, inner, dump)) {
|
|
157
|
+
edits.push(...inner)
|
|
158
|
+
continue
|
|
159
|
+
}
|
|
160
|
+
} else if (isSeq(value) && Array.isArray(before[key]) && Array.isArray(next)) {
|
|
161
|
+
if (editSeq(text, value, before[key], next, inner, dump)) {
|
|
162
|
+
edits.push(...inner)
|
|
163
|
+
continue
|
|
164
|
+
}
|
|
165
|
+
} else if (isScalar(value)) {
|
|
166
|
+
const now = scalarText(text, value, next, dump, false)
|
|
167
|
+
if (now !== null) {
|
|
168
|
+
edits.push({ start: value.range[0], end: value.range[1], text: now })
|
|
169
|
+
continue
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
// Written whole — in flow style when the author wrote it that way (`tags: [a, b]`).
|
|
173
|
+
const flow = (isMap(value) || isSeq(value)) && value.flow
|
|
174
|
+
edits.push({
|
|
175
|
+
start: pair.key.range[0],
|
|
176
|
+
end: stop,
|
|
177
|
+
text: indented(dump({ [key]: next }, flow ? { flowLevel: 1 } : {}), col, false)
|
|
178
|
+
})
|
|
179
|
+
}
|
|
180
|
+
for (const key of Object.keys(after)) {
|
|
181
|
+
if (seen.has(key) || after[key] === undefined) continue
|
|
182
|
+
const lead = end > 0 && text[end - 1] !== '\n' ? '\n' : ''
|
|
183
|
+
edits.push({ start: end, end, text: lead + indented(dump({ [key]: after[key] }), col, true) })
|
|
184
|
+
}
|
|
185
|
+
return true
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Edit the YAML `text` so it says `after` — `before` is what it says now, as the build
|
|
190
|
+
* reads it — changing only the entries that differ, and leaving every other byte as its
|
|
191
|
+
* author wrote it: comments, blank lines, key order, quoting, a list written on one line.
|
|
192
|
+
*
|
|
193
|
+
* ⭐ `yaml` LOCATES, js-yaml WRITES AND READS. The comment-preserving library parses the
|
|
194
|
+
* text for where each entry sits; a changed entry is rendered by js-yaml, as the writers
|
|
195
|
+
* render a whole file — keeping the quotes it had, where it had them; and the result is
|
|
196
|
+
* read back by the build's own reader, and kept only if it says exactly `after`. ⛔ Not
|
|
197
|
+
* `yaml`'s Document API, edited and printed: it prints the WHOLE file again, and with no
|
|
198
|
+
* edit at all it changed 16 of the 17 template `site.yml` files — long lines folded,
|
|
199
|
+
* `[a, b]` padded to `[ a, b ]` — and with the best options still 4 of 17, comments moved
|
|
200
|
+
* onto a key's line or indented under the map before them (measured 2026-10-07). Every
|
|
201
|
+
* such line is one a pull did not change, and a conflict waiting in a merge.
|
|
202
|
+
*
|
|
203
|
+
* ⛔ *Until 2026-10-07 the writers dumped the whole file, and a pull that changed one key
|
|
204
|
+
* of `site.yml` left the Starter template's 93 lines as 10 — every comment gone — and a
|
|
205
|
+
* `pull --merge` then conflicted on the whole commented header (F1, F12).*
|
|
206
|
+
*
|
|
207
|
+
* @param {string} text - the file as it is
|
|
208
|
+
* @param {object} before - what it says, read by the build's reader
|
|
209
|
+
* @param {object} after - what it must say
|
|
210
|
+
* @param {object} [dumpOptions] - js-yaml's, for what is written
|
|
211
|
+
* @returns {string|null} the edited text; null when the file cannot be edited in place —
|
|
212
|
+
* not a map, an alias or merge key, a parse error, or a result that reads differently
|
|
213
|
+
*/
|
|
214
|
+
export function editYamlText(text, before, after, dumpOptions = YAML_DUMP_OPTS) {
|
|
215
|
+
if (typeof text !== 'string' || !isPlainObject(before) || !isPlainObject(after)) return null
|
|
216
|
+
let doc
|
|
217
|
+
try {
|
|
218
|
+
doc = parseDocument(text)
|
|
219
|
+
} catch {
|
|
220
|
+
return null
|
|
221
|
+
}
|
|
222
|
+
if (doc.errors.length || usesReferences(doc)) return null
|
|
223
|
+
const dump = (value, extra = {}) => yaml.dump(value, { ...dumpOptions, ...extra })
|
|
224
|
+
const edits = []
|
|
225
|
+
if (doc.contents === null || doc.contents === undefined) {
|
|
226
|
+
// Comments and nothing else: what the file says goes after them.
|
|
227
|
+
const lead = text && !text.endsWith('\n') ? '\n' : ''
|
|
228
|
+
edits.push({ start: text.length, end: text.length, text: lead + dump(after) })
|
|
229
|
+
} else if (!isMap(doc.contents) || doc.contents.flow || !editMap(text, doc.contents, before, after, edits, dump)) {
|
|
230
|
+
return null
|
|
231
|
+
}
|
|
232
|
+
// Last first, so an offset is never moved by an edit before it. At one offset: a removal
|
|
233
|
+
// before an insertion, and the outer map's new keys before the inner map's, so the inner
|
|
234
|
+
// map's land first in the text.
|
|
235
|
+
const order = edits.map((e, i) => ({ ...e, i })).sort((a, b) => b.start - a.start || b.end - a.end || b.i - a.i)
|
|
236
|
+
let out = text
|
|
237
|
+
for (const e of order) out = out.slice(0, e.start) + e.text + out.slice(e.end)
|
|
238
|
+
let read
|
|
239
|
+
try {
|
|
240
|
+
read = yaml.load(out, YAML_OPTIONS) ?? {}
|
|
241
|
+
} catch {
|
|
242
|
+
return null
|
|
243
|
+
}
|
|
244
|
+
return sameValue(read, after) ? out : null
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** What `text` says, as the build reads it — null when it is not YAML, or not a map. */
|
|
248
|
+
function readMap(text) {
|
|
249
|
+
try {
|
|
250
|
+
const data = yaml.load(text, YAML_OPTIONS) ?? {}
|
|
251
|
+
return isPlainObject(data) ? data : null
|
|
252
|
+
} catch {
|
|
253
|
+
return null
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Set top-level `key` to `value` in `text` — added after the last key when it is not
|
|
259
|
+
* there — editing nothing else.
|
|
260
|
+
*
|
|
261
|
+
* @param {string} text - the file's contents
|
|
262
|
+
* @param {string} key - a top-level key, e.g. `foundation`
|
|
263
|
+
* @param {*} value
|
|
264
|
+
* @returns {string|null} the new contents; null when the text cannot be edited in place
|
|
265
|
+
* (`editYamlText`) — a caller deciding before it writes refuses then
|
|
266
|
+
*/
|
|
267
|
+
export function setYamlKey(text, key, value) {
|
|
268
|
+
const before = readMap(text)
|
|
269
|
+
return before ? editYamlText(text, before, { ...before, [key]: value }) : null
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Replace entries of the top-level list `key` — each `from` entry becomes its `to` — where
|
|
274
|
+
* they stand, in the quotes they were written in. An entry that only CONTAINS `from` is
|
|
275
|
+
* left alone, and so is everything else in the file.
|
|
276
|
+
*
|
|
277
|
+
* @param {string} text - the file's contents
|
|
278
|
+
* @param {string} key - a top-level list key, e.g. `extensions`
|
|
279
|
+
* @param {Map<*, *>} replacements - old entry → new entry
|
|
280
|
+
* @returns {string|null} the new contents; null when `key` is not a list, or the text
|
|
281
|
+
* cannot be edited in place
|
|
282
|
+
*/
|
|
283
|
+
export function replaceInYamlList(text, key, replacements) {
|
|
284
|
+
const before = readMap(text)
|
|
285
|
+
if (!before || !Array.isArray(before[key])) return null
|
|
286
|
+
const list = before[key].map((v) => (replacements.has(v) ? replacements.get(v) : v))
|
|
287
|
+
return editYamlText(text, before, { ...before, [key]: list })
|
|
288
|
+
}
|
package/src/validate-data.js
CHANGED
|
@@ -31,7 +31,7 @@ import { YAML_OPTIONS } from './utils/yaml-schema.js'
|
|
|
31
31
|
import { queryNameFromUrl, declaredKeys, fillDeclaredKeys, fetchLevels, pageRouteQuery } from '@uniweb/core'
|
|
32
32
|
import { parentRouteOf } from '@uniweb/core/route-match'
|
|
33
33
|
|
|
34
|
-
import { validateItem, validateRecordFile, validateBound, contentBodyField, rootListSection, referencesOf } from '@uniweb/schemas/conform'
|
|
34
|
+
import { validateItem, validateRecordFile, validateBound, validateKeyValue, contentBodyField, rootListSection, referencesOf } from '@uniweb/schemas/conform'
|
|
35
35
|
import { isUuid } from './uwx/uuid.js'
|
|
36
36
|
import { validateAndNormalizeSchema, buildDataSchemaMap } from './resolve-data-schema.js'
|
|
37
37
|
|
|
@@ -147,6 +147,10 @@ export async function validateDataInputs({ siteRoot, foundationPath }) {
|
|
|
147
147
|
// `@uniweb/core/page-data`) — ⛔ it wrote its own list until 2026-09-19, which read only
|
|
148
148
|
// the declared parent and had no route binding for a nested page.
|
|
149
149
|
const declared = declaredKeys(bindings)
|
|
150
|
+
// Each key's schema ref as the runtime reads it — never the raw value, whose spelling varies
|
|
151
|
+
// (⛔ until 2026-10-07 a `'…/*'` value was looked up whole here, matched no schema, and its
|
|
152
|
+
// key was never checked).
|
|
153
|
+
const refOf = new Map(declared.map(([key, ref]) => [key, ref]))
|
|
150
154
|
const levels = fetchLevels({ own: section.fetch, page: page.fetch, parent, route, site: config.fetch })
|
|
151
155
|
const inputs = collectInputs(levels)
|
|
152
156
|
const held = nodesOfType(section.content, 'dataBlock').map((node) => node.attrs?.tag).filter(Boolean)
|
|
@@ -194,8 +198,7 @@ export async function validateDataInputs({ siteRoot, foundationPath }) {
|
|
|
194
198
|
}
|
|
195
199
|
if (!input.path) continue
|
|
196
200
|
|
|
197
|
-
const
|
|
198
|
-
const ref = typeof binding === 'string' ? binding : binding?.schema
|
|
201
|
+
const ref = refOf.get(key)
|
|
199
202
|
if (!ref) continue // ungoverned key — no schema declared for it
|
|
200
203
|
|
|
201
204
|
const schema = dataSchemas[ref]
|
|
@@ -480,9 +483,12 @@ function nodesOfType(doc, type) {
|
|
|
480
483
|
* becomes one (it falls back to `codeBlock`), so a malformed block cannot reach
|
|
481
484
|
* here and be misreported as a schema violation.
|
|
482
485
|
*
|
|
483
|
-
*
|
|
484
|
-
*
|
|
485
|
-
*
|
|
486
|
+
* ⭐ A block holds what its key DECLARES (ruled 2026-10-07 [Diego]): a list of records, or one
|
|
487
|
+
* with `single: true`; each its brief, or as stored with `whole: true`; a list schema's list either
|
|
488
|
+
* way — `validateKeyValue`, the one rule for a key's value. ⛔ Until 2026-10-07 the schema's root
|
|
489
|
+
* alone decided (`validateBound`), so a block under a key typed by a record schema had to hold ONE
|
|
490
|
+
* record while a query under the same key delivered a list — and a key declared `'…/*'` was looked
|
|
491
|
+
* up with its suffix, matched no schema, and was never checked.
|
|
486
492
|
*
|
|
487
493
|
* @param {Object} site - collected site content
|
|
488
494
|
* @param {Object} foundation - the built foundation schema (type → { data })
|
|
@@ -501,16 +507,17 @@ export function validateTaggedDataBlocks(site, foundation, dataSchemas) {
|
|
|
501
507
|
const bindings = type && foundation?.[type]?.data
|
|
502
508
|
if (!bindings || typeof bindings !== 'object') return
|
|
503
509
|
|
|
510
|
+
const declared = new Map(declaredKeys(bindings).map(([key, ref, whole, single]) => [key, { ref, whole, single }]))
|
|
504
511
|
for (const node of nodesOfType(section.content, 'dataBlock')) {
|
|
505
512
|
const tag = node.attrs?.tag
|
|
506
513
|
if (!tag) continue
|
|
507
514
|
|
|
508
|
-
const
|
|
509
|
-
if (
|
|
515
|
+
const declaration = declared.get(tag)
|
|
516
|
+
if (declaration === undefined) continue // this key is not governed — say nothing
|
|
510
517
|
|
|
511
|
-
// A
|
|
518
|
+
// A key is typed by a named ref, or by an inline schema. Only a ref resolves to a
|
|
512
519
|
// normalized schema here; an inline one is reported rather than guessed at.
|
|
513
|
-
const ref
|
|
520
|
+
const { ref, whole, single } = declaration
|
|
514
521
|
if (typeof ref !== 'string') {
|
|
515
522
|
deferred.push({ route: page.route, section: type, key: tag, reason: 'inline schema on the binding' })
|
|
516
523
|
continue
|
|
@@ -520,7 +527,7 @@ export function validateTaggedDataBlocks(site, foundation, dataSchemas) {
|
|
|
520
527
|
|
|
521
528
|
schemas.add(ref)
|
|
522
529
|
checked++
|
|
523
|
-
for (const finding of
|
|
530
|
+
for (const finding of validateKeyValue(schema, node.attrs?.data, { whole, single })) {
|
|
524
531
|
violations.push({
|
|
525
532
|
file: `${page.route || '/'} › ${type} › ${node.attrs?.language || 'yaml'}:${tag}`,
|
|
526
533
|
schema: ref,
|