@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.
@@ -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
- out[key] = rest
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, foundationSections } from './queries-config.js'
69
- import { dataKeyTypes } from '../uwx/data-key-types.js'
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 = parsed.schema ? poolDirsForSchema(parsed.schema) : null
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
- * ⭐ A QUERY NAMED FOR A DATA KEY HOLDS RECORDS OF THE KEY'S TYPE (ruled 2026-09-25 [Diego],
1263
- * `uwx/data-key-types.js`). `articles:` with no `schema:` names `@/articles`; when no data schema
1264
- * has that name while the foundation's section types declare `data: { articles: '@/post' }`, its
1265
- * records are `@/post` records — a push sends them as such, and they are delivered as such here,
1266
- * where they still live and are keyed by the name (`records/articles/`). Never for a schema the
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 {Object} schemas - resolved data schemas by ref; a typed query's ref is added to it
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 typeByDataKey(siteDir, queriesConfig, schemas) {
1275
- const untyped = Object.entries(queriesConfig).filter(
1276
- ([name, c]) => c && typeof c === 'object' && c.url === undefined && c.schema === `@/${name}` && !schemas[c.schema]
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
- const pending = untyped.filter(([name]) => declarations[name]?.schemaExplicit !== true)
1287
- if (pending.length === 0) return
1288
- const keyTypes = dataKeyTypes(await foundationSections(siteDir))
1289
- const wanted = new Map()
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 parametric page's record for a
1380
- // component that declares `'@x/y/*'`, and kit's `useWholeRecord`.
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. ⛔ Until 2026-09-25 they were read as having no data schema at all and went out
11
- // as static files, although the one section that reads them declared what they are.
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
- export { upsertYamlScalar, removeYamlScalar } from './yaml-upsert.js'
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. NOTE: this re-dumps the file,
220
- // so author comments/order are not preserved — acceptable for machine-owned
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 or unreadable file is never "unchanged".
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
- return { value: yaml.load(readFileSync(filePath, 'utf8'), YAML_OPTIONS) ?? {}, read: true }
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. Every writer here re-dumps the
253
- // whole file — its comments dropped, its strings re-quoted, its lists reflowed — so a pull that restated
254
- // a file rewrote it all the same. ⛔ Until 2026-09-26 a pull into the copy that pushed it re-dumped
255
- // `site.yml`, `theme.yml` and every `page.yml` (measured on the `international` template: every comment
256
- // in its `site.yml` gone, and nothing had changed).
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
- return writeIfChanged(filePath, yaml.dump(value, YAML_DUMP_OPTS))
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 (full dump, idempotent) — for machine-owned
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
- * (Comment/unknown-key preservation is a later refinement; `site.yml`/
286
- * `collections.yml` use the merging writers instead.)
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-preserving (comment-dropping) bar as `writeSiteConfig` — for the
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-dropping) bar as `writeSiteConfig`.
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
- * api: { grade: pro } # a setting for the host's service
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 `api` only its switch and address:
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
- api: Object.freeze(['enabled', 'endpoint'])
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
- const lines = found.map((k) => ` ${k}: …`).join('\n')
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`, `$devApi` now ' +
140
- 'supplies it. Asking your host for an app backend is `api: true` under `services:`.'
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. `api`'s settings are for the host that provisions it (a grade,
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(['api'])
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 `api` because its old spelling invites it: `api: /_api` was where a
248
- // local mock answered, and under `services:` an address turns the host's off.
249
- if (name === 'api') {
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.api` is an address: it asks your host to leave its own `api` off, so the site uses yours. " +
252
- "For your host's, write `api: true`; in `uniweb dev`, `$devApi` answers it."
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
  }
@@ -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, so a unit's path here is the file it is
100
- // written to — including an app-created section named from its `$uuid`.
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)