@uniweb/build 0.78.0 → 0.80.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 +7 -7
- package/src/dev/api-mount.js +11 -9
- package/src/foundation/default-layout.js +52 -0
- package/src/foundation/derive-supports.js +3 -2
- package/src/generate-entry.js +11 -0
- package/src/resolve-data-schema.js +2 -1
- package/src/runtime-schema.js +30 -12
- package/src/schema.js +101 -4
- package/src/site/content-collector.js +14 -9
- package/src/site/deploy-config-writer.js +4 -2
- package/src/site/plugin.js +5 -5
- package/src/site/queries-config.js +55 -2
- package/src/site/query-processor.js +42 -34
- package/src/uwx/data-key-types.js +4 -2
- package/src/uwx/index.js +7 -1
- package/src/uwx/project-writer.js +31 -21
- package/src/uwx/services-request.js +148 -27
- package/src/uwx/site-diff.js +6 -2
- package/src/uwx/site-project.js +2 -3
- 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
|
@@ -41,6 +41,7 @@ import { refuseQueryRoute, refuseLimit, refuseFilter } from './data-fetcher.js'
|
|
|
41
41
|
import { readFile } from 'node:fs/promises'
|
|
42
42
|
import yaml from 'js-yaml'
|
|
43
43
|
import { YAML_OPTIONS } from '../utils/yaml-schema.js'
|
|
44
|
+
import { dataKeyTypes } from '../uwx/data-key-types.js'
|
|
44
45
|
|
|
45
46
|
// Read its own YAML rather than importing the site build's helper. That import
|
|
46
47
|
// pointed the wrong way — a config resolver reaching into the collector that
|
|
@@ -320,21 +321,73 @@ function refuseMethod(method, where) {
|
|
|
320
321
|
* held a key a declaration may want for something else [Diego, 2026-09-27]. Until then every
|
|
321
322
|
* query on the payload read `{ name: 'articles', … }` under `articles`.
|
|
322
323
|
*
|
|
324
|
+
* ⭐ A query that declares no `schema:` and whose records a data key types carries the TYPE —
|
|
325
|
+
* `articles` → `@/post` (`dataKeyTyping`) — which is what a push names it on the wire
|
|
326
|
+
* (`uwx/site.js::queriesNested`). The schema a page's query carries is what a section's key is
|
|
327
|
+
* filled by at render (automatic `as`), so the two lanes fill the same keys. ⛔ Until 2026-10-08 the
|
|
328
|
+
* static payload carried the name (`@/articles`), and a key typed `@/post` filled on a hosted site
|
|
329
|
+
* and stayed empty on a static one.
|
|
330
|
+
*
|
|
323
331
|
* Returns undefined for a site with no queries, so `config.queries`
|
|
324
332
|
* stays absent rather than becoming an empty object — an empty object reads as
|
|
325
333
|
* "declared, and empty" to anything checking for presence.
|
|
334
|
+
*
|
|
335
|
+
* @param {object} declarations - `resolveQueriesConfig(…).declarations`
|
|
336
|
+
* @param {Map<string, string>|null} [typing] - `dataKeyTyping`: name-defaulted ref → its type
|
|
326
337
|
*/
|
|
327
|
-
export function toConfigQueries(declarations) {
|
|
338
|
+
export function toConfigQueries(declarations, typing = null) {
|
|
328
339
|
const names = Object.keys(declarations || {})
|
|
329
340
|
if (names.length === 0) return undefined
|
|
330
341
|
const out = {}
|
|
331
342
|
for (const key of names) {
|
|
332
343
|
const { schemaExplicit, name, ...rest } = declarations[key]
|
|
333
|
-
|
|
344
|
+
const typed = schemaExplicit === false ? typing?.get(rest.schema) : null
|
|
345
|
+
out[key] = typed ? { ...rest, schema: typed } : rest
|
|
334
346
|
}
|
|
335
347
|
return out
|
|
336
348
|
}
|
|
337
349
|
|
|
350
|
+
/**
|
|
351
|
+
* ⭐ THE TYPE A DATA KEY GIVES A NAME — `@/articles` → `@/post` (ruled 2026-09-25 [Diego];
|
|
352
|
+
* `uwx/data-key-types.js`), decided for the static build as a push decides it
|
|
353
|
+
* (`uwx/records.js::typedFor`), so both lanes give a query one schema.
|
|
354
|
+
*
|
|
355
|
+
* A name-defaulted ref, `@/<name>`, stands for the type of the data key of that name when:
|
|
356
|
+
*
|
|
357
|
+
* - no data schema has the name — `@/<name>` resolves to nothing;
|
|
358
|
+
* - the foundation's section types declare the key `<name>` with one type, and it resolves;
|
|
359
|
+
* - no query asks for `@/<name>` explicitly — that one is the author's, and fails loudly.
|
|
360
|
+
*
|
|
361
|
+
* Then `records/<name>/` holds records of the type, and a query named `<name>` that declares no
|
|
362
|
+
* schema is a query of it. ⚠️ Where the records live on disk and how their translations are keyed
|
|
363
|
+
* stay keyed by the name.
|
|
364
|
+
*
|
|
365
|
+
* @param {string} siteRoot
|
|
366
|
+
* @param {object} declarations - `resolveQueriesConfig(…).declarations`, for which refs are explicit
|
|
367
|
+
* @param {object} [opts]
|
|
368
|
+
* @param {object} [opts.siteYml] - an already-read site.yml
|
|
369
|
+
* @returns {Promise<Map<string, string>>} name-defaulted ref → the type; empty when none
|
|
370
|
+
*/
|
|
371
|
+
export async function dataKeyTyping(siteRoot, declarations, opts = {}) {
|
|
372
|
+
const typing = new Map()
|
|
373
|
+
const siteYml = opts.siteYml || (await readYamlFile(join(siteRoot, 'site.yml')))
|
|
374
|
+
const keyTypes = dataKeyTypes(await foundationSections(siteRoot, siteYml))
|
|
375
|
+
if (keyTypes.size === 0) return typing
|
|
376
|
+
const explicit = new Set(
|
|
377
|
+
Object.values(declarations || {})
|
|
378
|
+
.filter((d) => d && d.schemaExplicit === true)
|
|
379
|
+
.map((d) => d.schema)
|
|
380
|
+
)
|
|
381
|
+
const named = [...keyTypes.keys()].map(defaultSchema)
|
|
382
|
+
const { schemas } = await resolveRecordSchemas(siteRoot, [...named, ...keyTypes.values()], { siteYml })
|
|
383
|
+
for (const [key, type] of keyTypes) {
|
|
384
|
+
const ref = defaultSchema(key)
|
|
385
|
+
if (schemas[ref] || explicit.has(ref) || !schemas[type]) continue
|
|
386
|
+
typing.set(ref, type)
|
|
387
|
+
}
|
|
388
|
+
return typing
|
|
389
|
+
}
|
|
390
|
+
|
|
338
391
|
|
|
339
392
|
/**
|
|
340
393
|
* The data schemas a site's records are shaped by, keyed by ref — each resolved from
|
|
@@ -65,8 +65,8 @@ import { readEntityPool, groupPoolBySchema, poolDirsForSchema } from './entity-p
|
|
|
65
65
|
import { readRecordsConfig, resolveFolder, folderTreeOrder } from './records-config.js'
|
|
66
66
|
import { isDraftRecord } from './record-draft.js'
|
|
67
67
|
import { refuseNotOneRecord } from './record-file.js'
|
|
68
|
-
import { resolveRecordSchemas, resolveQueriesConfig,
|
|
69
|
-
import {
|
|
68
|
+
import { resolveRecordSchemas, resolveQueriesConfig, dataKeyTyping } from './queries-config.js'
|
|
69
|
+
import { keyOfDefaultRef } from '../uwx/data-key-types.js'
|
|
70
70
|
import { parseFrontmatter } from '../utils/frontmatter.js'
|
|
71
71
|
import { toDeliveredRecord, toStoredRecord, storedPath, briefFieldMap, contentBodyField, misplacedFields, mapReferences, recordLayout } from '@uniweb/schemas/conform'
|
|
72
72
|
import { collectNestedRefs } from '@uniweb/schemas/format'
|
|
@@ -1197,10 +1197,24 @@ export async function processQueries(siteDir, queriesConfig, recordsDir, basePat
|
|
|
1197
1197
|
const poolBySchema = groupPoolBySchema(pool.entities)
|
|
1198
1198
|
const recordsRoot = resolve(siteDir, pool.dir)
|
|
1199
1199
|
|
|
1200
|
+
// ⭐ A FOLDER NAMED FOR A DATA KEY HOLDS RECORDS OF THE KEY'S TYPE (ruled 2026-09-25 [Diego];
|
|
1201
|
+
// `dataKeyTyping`): `records/articles/`, when no data schema has the name and the foundation types
|
|
1202
|
+
// `articles` as `@/post`, holds `@/post` records — so they join that type's records, as a push
|
|
1203
|
+
// sends them, and any query of `@/post` reads them, as a host answers one. ⛔ Until 2026-10-08 they
|
|
1204
|
+
// stayed under `@/articles`: only a query named `articles` read them, and it carried `@/articles`
|
|
1205
|
+
// on the payload where a push named `@/post`.
|
|
1206
|
+
const typing = await recordTyping(siteDir, queriesConfig, poolBySchema)
|
|
1207
|
+
for (const [ref, type] of typing) {
|
|
1208
|
+
const held = poolBySchema.get(ref)
|
|
1209
|
+
if (!held) continue
|
|
1210
|
+
poolBySchema.delete(ref)
|
|
1211
|
+
poolBySchema.set(type, [...(poolBySchema.get(type) || []), ...held])
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1200
1214
|
// ⭐ THE DATA SCHEMA EACH QUERY'S RECORDS ARE DELIVERED IN (`deliverRecord`) — resolved
|
|
1201
1215
|
// from the foundation's source once for every query. A query with none compiles its
|
|
1202
1216
|
// records as their files hold them.
|
|
1203
|
-
const dataSchemas = options.dataSchemas ?? (await querySchemas(siteDir, queriesConfig))
|
|
1217
|
+
const dataSchemas = options.dataSchemas ?? (await querySchemas(siteDir, queriesConfig, typing))
|
|
1204
1218
|
const references = referenceHydrator({ poolBySchema, dataSchemas, siteDir, recordsRoot, basePath, includeDrafts })
|
|
1205
1219
|
|
|
1206
1220
|
const results = {}
|
|
@@ -1211,6 +1225,9 @@ export async function processQueries(siteDir, queriesConfig, recordsDir, basePat
|
|
|
1211
1225
|
// be an empty `/data/<name>.json` standing in for a live source.
|
|
1212
1226
|
if (config && typeof config === 'object' && config.url !== undefined) continue
|
|
1213
1227
|
const parsed = parseQueryConfig(name, config)
|
|
1228
|
+
// A query that still names its folder (`@/articles`) is a query of the folder's type.
|
|
1229
|
+
const folderRef = parsed.schema
|
|
1230
|
+
if (parsed.schema && typing.has(parsed.schema)) parsed.schema = typing.get(parsed.schema)
|
|
1214
1231
|
parsed.poolEntities = parsed.schema ? poolBySchema.get(parsed.schema) || [] : []
|
|
1215
1232
|
parsed.placements = folder.placements
|
|
1216
1233
|
parsed.folderRank = folderRank
|
|
@@ -1218,7 +1235,7 @@ export async function processQueries(siteDir, queriesConfig, recordsDir, basePat
|
|
|
1218
1235
|
parsed.dataSchema = (parsed.schema && dataSchemas[parsed.schema]) || null
|
|
1219
1236
|
parsed.references = references
|
|
1220
1237
|
if (parsed.poolEntities.length === 0) {
|
|
1221
|
-
const dirs =
|
|
1238
|
+
const dirs = folderRef ? poolDirsForSchema(folderRef) : null
|
|
1222
1239
|
console.warn(
|
|
1223
1240
|
`[query-processor] Query "${name}" matches no records — ` +
|
|
1224
1241
|
(dirs
|
|
@@ -1240,12 +1257,12 @@ export async function processQueries(siteDir, queriesConfig, recordsDir, basePat
|
|
|
1240
1257
|
* its records are compiled as their files hold them, which is not the shape a
|
|
1241
1258
|
* component receives from a host.
|
|
1242
1259
|
*/
|
|
1243
|
-
async function querySchemas(siteDir, queriesConfig) {
|
|
1244
|
-
const refs = []
|
|
1260
|
+
async function querySchemas(siteDir, queriesConfig, typing = new Map()) {
|
|
1261
|
+
const refs = [...typing.values()]
|
|
1245
1262
|
for (const config of Object.values(queriesConfig)) {
|
|
1246
1263
|
if (config && typeof config === 'object' && config.url !== undefined) continue
|
|
1247
1264
|
const ref = typeof config === 'string' ? config : config?.schema
|
|
1248
|
-
if (typeof ref === 'string') refs.push(ref)
|
|
1265
|
+
if (typeof ref === 'string' && !typing.has(ref)) refs.push(ref)
|
|
1249
1266
|
}
|
|
1250
1267
|
const { schemas, failures } = await resolveRecordSchemas(siteDir, refs)
|
|
1251
1268
|
for (const { ref, message } of failures) {
|
|
@@ -1254,28 +1271,25 @@ async function querySchemas(siteDir, queriesConfig) {
|
|
|
1254
1271
|
`their files hold them rather than as a component receives them: ${message}`
|
|
1255
1272
|
)
|
|
1256
1273
|
}
|
|
1257
|
-
await typeByDataKey(siteDir, queriesConfig, schemas)
|
|
1258
1274
|
return schemas
|
|
1259
1275
|
}
|
|
1260
1276
|
|
|
1261
1277
|
/**
|
|
1262
|
-
* ⭐
|
|
1263
|
-
* `
|
|
1264
|
-
*
|
|
1265
|
-
*
|
|
1266
|
-
*
|
|
1267
|
-
* query asked for explicitly: that one is the author's, and does not resolve. ⛔ Until 2026-09-27
|
|
1268
|
-
* this lane compiled them as if they had no data schema, while a push typed them.
|
|
1278
|
+
* ⭐ WHICH FOLDERS HOLD RECORDS OF A DATA KEY'S TYPE (ruled 2026-09-25 [Diego]): `@/articles` →
|
|
1279
|
+
* `@/post` — `dataKeyTyping`, the rule a push applies, asked only when the site has a folder or a
|
|
1280
|
+
* query it could apply to: a name-defaulted ref, `@/<name>`. ⛔ Until 2026-10-08 this lane typed a
|
|
1281
|
+
* query's RECORDS by aliasing its `@/<name>` to the type's schema, while its query and its folder
|
|
1282
|
+
* kept the name.
|
|
1269
1283
|
*
|
|
1270
1284
|
* @param {string} siteDir
|
|
1271
|
-
* @param {Object} queriesConfig
|
|
1272
|
-
* @param {
|
|
1285
|
+
* @param {Object} queriesConfig - what the build compiles (`config.queries`)
|
|
1286
|
+
* @param {Map<string, Array>} poolBySchema - the records, by the ref their folder names
|
|
1287
|
+
* @returns {Promise<Map<string, string>>} name-defaulted ref → the type
|
|
1273
1288
|
*/
|
|
1274
|
-
async function
|
|
1275
|
-
const
|
|
1276
|
-
|
|
1277
|
-
)
|
|
1278
|
-
if (untyped.length === 0) return
|
|
1289
|
+
async function recordTyping(siteDir, queriesConfig, poolBySchema) {
|
|
1290
|
+
const candidate = (ref) => typeof ref === 'string' && keyOfDefaultRef(ref) !== null
|
|
1291
|
+
const queried = Object.values(queriesConfig).map((c) => (typeof c === 'string' ? c : c?.schema))
|
|
1292
|
+
if (![...poolBySchema.keys(), ...queried].some(candidate)) return new Map()
|
|
1279
1293
|
// `config.queries` carries no trace of whether its author wrote the schema; the declarations do.
|
|
1280
1294
|
let declarations = {}
|
|
1281
1295
|
try {
|
|
@@ -1283,17 +1297,11 @@ async function typeByDataKey(siteDir, queriesConfig, schemas) {
|
|
|
1283
1297
|
} catch {
|
|
1284
1298
|
// a declaration the build refuses is reported where the build reads it
|
|
1285
1299
|
}
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
for (const [name, c] of pending) {
|
|
1291
|
-
const type = keyTypes.get(name)
|
|
1292
|
-
if (type) wanted.set(c.schema, type)
|
|
1300
|
+
try {
|
|
1301
|
+
return await dataKeyTyping(siteDir, declarations)
|
|
1302
|
+
} catch {
|
|
1303
|
+
return new Map() // a foundation that does not read is reported by the build that reads it
|
|
1293
1304
|
}
|
|
1294
|
-
if (wanted.size === 0) return
|
|
1295
|
-
const { schemas: types } = await resolveRecordSchemas(siteDir, wanted.values())
|
|
1296
|
-
for (const [ref, type] of wanted) if (types[type]) schemas[ref] = types[type]
|
|
1297
1305
|
}
|
|
1298
1306
|
|
|
1299
1307
|
/**
|
|
@@ -1376,8 +1384,8 @@ export async function writeQueryFiles(siteDir, byQuery, queriesConfig = null) {
|
|
|
1376
1384
|
|
|
1377
1385
|
// ⭐ TWO PAYLOADS FOR EVERY QUERY (ruled 2026-09-27 [Diego]):
|
|
1378
1386
|
// 1. Each record WHOLE, as stored, at /data/<name>/<$name>.json — a key per section, the
|
|
1379
|
-
// brief's included — what a whole question answers: a
|
|
1380
|
-
//
|
|
1387
|
+
// brief's included — what a whole question answers: a record for a component that
|
|
1388
|
+
// declares its key `whole: true`, and kit's `useWholeRecord`.
|
|
1381
1389
|
// 2. The list at /data/<name>.json — each record's BRIEF, the brief's fields at the top —
|
|
1382
1390
|
// what `query: <name>` delivers everywhere else. ⛔ `deferred:`, which left fields of the
|
|
1383
1391
|
// brief out of it, was retired the same day [Diego].
|
|
@@ -7,8 +7,10 @@
|
|
|
7
7
|
// records folder (`records/team/`). When no data schema has that name, but the foundation's
|
|
8
8
|
// section types declare the data key of that name with a type — `data: { team: '@/member' }` in a
|
|
9
9
|
// `meta.js` — the records ARE of that type: a push sends them as its entities, and the query names
|
|
10
|
-
// it on the wire
|
|
11
|
-
//
|
|
10
|
+
// it on the wire; the static build names it on the payload and reads them as the type's records
|
|
11
|
+
// (`site/queries-config.js::dataKeyTyping`, since 2026-10-08 — until then the payload named the query
|
|
12
|
+
// `@/team`). ⛔ Until 2026-09-25 they were read as having no data schema at all and went out as
|
|
13
|
+
// static files, although the one section that reads them declared what they are.
|
|
12
14
|
//
|
|
13
15
|
// ⚠️ THIS DECIDES ONLY WHICH DATA SCHEMA THE RECORDS BELONG TO. Where they live on disk, how the
|
|
14
16
|
// site builds them and how their translations are keyed stay keyed by the name (`team`), as the
|
package/src/uwx/index.js
CHANGED
|
@@ -54,7 +54,8 @@ export {
|
|
|
54
54
|
queriesYmlPath,
|
|
55
55
|
QUERIES_YML_RELPATH,
|
|
56
56
|
} from './queries-config.js'
|
|
57
|
-
|
|
57
|
+
// Edits to a YAML file an author wrote — the one editor; files are written through project-writer.js.
|
|
58
|
+
export { editYamlText, setYamlKey, replaceInYamlList } from './yaml-edit.js'
|
|
58
59
|
export { buildFolderEntity,
|
|
59
60
|
collectFolderItemUuids,
|
|
60
61
|
stampFolderItemUuids
|
|
@@ -87,6 +88,9 @@ export {
|
|
|
87
88
|
sectionRecordToFile,
|
|
88
89
|
pageSectionsToFiles,
|
|
89
90
|
siteContentDocumentToProject,
|
|
91
|
+
// The file the author keeps a section in (`1-hero.md` holds `hero`) — what a section
|
|
92
|
+
// unit's path names on disk, for a push recording what it delivered.
|
|
93
|
+
existingSectionFile,
|
|
90
94
|
} from './site-project.js'
|
|
91
95
|
export {
|
|
92
96
|
createTranslationCollector,
|
|
@@ -120,6 +124,8 @@ export {
|
|
|
120
124
|
export {
|
|
121
125
|
RUNTIME_KEYS,
|
|
122
126
|
readServicesRequest,
|
|
127
|
+
unreadableServices,
|
|
128
|
+
refuseUnreadableServices,
|
|
123
129
|
statedServices,
|
|
124
130
|
heldServices,
|
|
125
131
|
servicesFromDocument,
|
|
@@ -22,6 +22,7 @@ import { canonicalJson, sameMarkdownDocument } from './same-content.js'
|
|
|
22
22
|
import { renderEntityDocument } from './backfill.js'
|
|
23
23
|
import { queriesYmlPath } from './queries-config.js'
|
|
24
24
|
import { folderYmlPath } from '../site/records-config.js'
|
|
25
|
+
import { editYamlText, YAML_DUMP_OPTS } from './yaml-edit.js'
|
|
25
26
|
import { DECLARATION_KEYS } from '../site/fetch-shapes.js'
|
|
26
27
|
|
|
27
28
|
// Frontmatter keys that belong to the CCA framework / the developer's local
|
|
@@ -45,9 +46,6 @@ export const DEFAULT_RESERVED_FRONTMATTER = new Set([
|
|
|
45
46
|
// editor toggles it, so a pull must carry the store's value into the file (2026-09-28).
|
|
46
47
|
])
|
|
47
48
|
|
|
48
|
-
// js-yaml dump options shared by every config write, so output is byte-stable.
|
|
49
|
-
export const YAML_DUMP_OPTS = { lineWidth: -1, quotingType: "'", forceQuotes: false, noRefs: true }
|
|
50
|
-
|
|
51
49
|
/**
|
|
52
50
|
* Write `text` to `filePath` only when it differs from what's on disk, using a
|
|
53
51
|
* tmp-file + rename so a reader never sees a half-written file. Creates the
|
|
@@ -216,10 +214,8 @@ export function writeSectionFile({ filePath, content, params, reserved = DEFAULT
|
|
|
216
214
|
// Shallow-merge `changes` into a YAML config file and write idempotently. A key
|
|
217
215
|
// whose value is null/undefined is deleted; an object value is shallow-merged one
|
|
218
216
|
// level deep (so partial `theme` / `build` updates don't drop sibling keys) unless
|
|
219
|
-
// the key is in `replace`; any other value replaces.
|
|
220
|
-
//
|
|
221
|
-
// config, but comment-preserving merges for hand-authored config files are a
|
|
222
|
-
// quality bar to revisit.
|
|
217
|
+
// the key is in `replace`; any other value replaces. The file is edited, not
|
|
218
|
+
// re-dumped: its comments and layout stay (`writeYamlIfChanged`).
|
|
223
219
|
function mergeYamlConfig(filePath, changes, { replace = [] } = {}) {
|
|
224
220
|
const prior = loadYamlFile(filePath)
|
|
225
221
|
const existing = isPlainObject(prior.value) ? { ...prior.value } : {}
|
|
@@ -240,23 +236,37 @@ function mergeYamlConfig(filePath, changes, { replace = [] } = {}) {
|
|
|
240
236
|
return writeYamlIfChanged(filePath, existing, prior)
|
|
241
237
|
}
|
|
242
238
|
|
|
243
|
-
// A YAML file, parsed — and whether there was one: a missing
|
|
239
|
+
// A YAML file, parsed, with its text — and whether there was one: a missing file is never "unchanged".
|
|
240
|
+
// ⛔ One that EXISTS and does not parse is refused, never read as missing: read as missing, the writer
|
|
241
|
+
// replaced the author's whole file with only the keys it was writing (until 2026-10-07).
|
|
244
242
|
function loadYamlFile(filePath) {
|
|
243
|
+
let text
|
|
245
244
|
try {
|
|
246
|
-
|
|
245
|
+
text = readFileSync(filePath, 'utf8')
|
|
247
246
|
} catch {
|
|
248
|
-
return { value: {}, read: false }
|
|
247
|
+
return { value: {}, read: false, text: null }
|
|
248
|
+
}
|
|
249
|
+
try {
|
|
250
|
+
return { value: yaml.load(text, YAML_OPTIONS) ?? {}, read: true, text }
|
|
251
|
+
} catch (err) {
|
|
252
|
+
throw new Error(`${filePath} is not YAML that can be read, so it was not written: ${err.message.split('\n')[0]}`)
|
|
249
253
|
}
|
|
250
254
|
}
|
|
251
255
|
|
|
252
|
-
// ⭐ A FILE WHOSE MEANING WOULD NOT CHANGE IS LEFT AS ITS AUTHOR WROTE IT.
|
|
253
|
-
//
|
|
254
|
-
//
|
|
255
|
-
//
|
|
256
|
-
//
|
|
256
|
+
// ⭐ A FILE WHOSE MEANING WOULD NOT CHANGE IS LEFT AS ITS AUTHOR WROTE IT. ⛔ Until 2026-09-26 a pull into
|
|
257
|
+
// the copy that pushed it re-dumped `site.yml`, `theme.yml` and every `page.yml` (measured on the
|
|
258
|
+
// `international` template: every comment in its `site.yml` gone, and nothing had changed).
|
|
259
|
+
//
|
|
260
|
+
// ⭐ AND ONE THAT CHANGES IS EDITED, NOT RE-DUMPED (2026-10-07): only the entries that differ are
|
|
261
|
+
// rewritten, and every comment, blank line and list the author wrote stays (`editYamlText`). ⛔ Until then
|
|
262
|
+
// every writer here dumped the whole file — a pull that changed one key of `site.yml` left the Starter
|
|
263
|
+
// template's 93 lines as 10, and a `pull --merge` conflicted on the whole commented header. The whole dump
|
|
264
|
+
// remains for a file with nothing to keep (none yet, unreadable) and one the editor declines (an alias or a
|
|
265
|
+
// `<<` merge key, or a result that would read differently).
|
|
257
266
|
function writeYamlIfChanged(filePath, value, prior) {
|
|
258
267
|
if (prior.read && canonicalJson(value) === canonicalJson(prior.value)) return 'unchanged'
|
|
259
|
-
|
|
268
|
+
const edited = prior.read ? editYamlText(prior.text, prior.value, value, YAML_DUMP_OPTS) : null
|
|
269
|
+
return writeIfChanged(filePath, edited ?? yaml.dump(value, YAML_DUMP_OPTS))
|
|
260
270
|
}
|
|
261
271
|
|
|
262
272
|
const isPlainObject = (v) => Boolean(v) && typeof v === 'object' && !Array.isArray(v)
|
|
@@ -280,10 +290,10 @@ export function writeSiteConfig(siteRoot, config) {
|
|
|
280
290
|
const SITE_CONFIG_WHOLE = Object.freeze([...DECLARATION_KEYS, 'services'])
|
|
281
291
|
|
|
282
292
|
/**
|
|
283
|
-
* Write a YAML object to a file (
|
|
293
|
+
* Write a YAML object to a file (idempotent) — for machine-owned
|
|
284
294
|
* config the projector authors wholesale, e.g. a projected `page.yml`/`folder.yml`.
|
|
285
|
-
*
|
|
286
|
-
* `
|
|
295
|
+
* An existing file is edited, not re-dumped — its comments stay (`writeYamlIfChanged`);
|
|
296
|
+
* `site.yml` and `queries.yml` use the merging writers.
|
|
287
297
|
* @returns {'updated'|'unchanged'}
|
|
288
298
|
*/
|
|
289
299
|
export function writeYamlFile(filePath, obj) {
|
|
@@ -296,7 +306,7 @@ export function writeYamlFile(filePath, obj) {
|
|
|
296
306
|
* absent from `projected` is removed (the projector owns the managed set
|
|
297
307
|
* wholesale); unknown keys keep their value and relative order. Idempotent.
|
|
298
308
|
*
|
|
299
|
-
* Same key-
|
|
309
|
+
* Same key- and comment-preserving bar as `writeSiteConfig` — for the
|
|
300
310
|
* hand-authored `page.yml`/`folder.yml`, whose author-added keys must survive a
|
|
301
311
|
* pull rather than being clobbered by a full re-dump.
|
|
302
312
|
*
|
|
@@ -323,7 +333,7 @@ export function writeMergedYaml(filePath, projected, managedKeys) {
|
|
|
323
333
|
/**
|
|
324
334
|
* Merge `queries` into `queries.yml` (shallow). Preserves queries not in the
|
|
325
335
|
* incoming set; each incoming one is replaced wholesale. Same key-preserving
|
|
326
|
-
* (comment-
|
|
336
|
+
* (and comment-preserving) bar as `writeSiteConfig`.
|
|
327
337
|
*
|
|
328
338
|
* ⛔ `queries.yml` IS THE MAP — there is no root key, so `queries` is passed bare.
|
|
329
339
|
* The predecessor wrote `{ collections: {...} }` into `collections.yml`; handing
|
|
@@ -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-diff.js
CHANGED
|
@@ -96,8 +96,12 @@ export function collectSiteUnits(doc, sourceLocale = LOCALIZED_FIELD_ASSUMPTION.
|
|
|
96
96
|
export function walkSiteUnits(doc, cb, sourceLocale = LOCALIZED_FIELD_ASSUMPTION.defaultSourceLocale) {
|
|
97
97
|
const walkSections = (sections, dir) => {
|
|
98
98
|
for (const record of sections || []) {
|
|
99
|
-
// The projector's own file rule
|
|
100
|
-
//
|
|
99
|
+
// The projector's own file rule — including an app-created section named from its
|
|
100
|
+
// `$uuid`. ⚠️ The path NAMES the unit by its id; it is not always the author's file:
|
|
101
|
+
// a pull writes into an existing `1-about.md` or `@about.md` in place
|
|
102
|
+
// (`existingSectionFile`), so a reader mapping a unit to disk looks it up there.
|
|
103
|
+
// (This said "a unit's path here is the file it is written to" until 2026-10-07,
|
|
104
|
+
// and a push recorded no file for a numbered section.)
|
|
101
105
|
const base = sectionFileBase(record)
|
|
102
106
|
if (base) cb(`${dir}/${base}.md`, record, 'section')
|
|
103
107
|
walkSections(record.$children, dir)
|