@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniweb/build",
3
- "version": "0.78.0",
3
+ "version": "0.79.0",
4
4
  "description": "Build tooling for the Uniweb Component Web Platform",
5
5
  "type": "module",
6
6
  "exports": {
@@ -57,15 +57,15 @@
57
57
  "js-yaml": "^4.1.0",
58
58
  "sharp": "^0.35.3",
59
59
  "yaml": "^2.5.0",
60
- "@uniweb/projections": "^0.7.14",
60
+ "@uniweb/projections": "^0.7.15",
61
61
  "@uniweb/content-writer": "^0.3.4",
62
62
  "@uniweb/semantic-parser": "^1.5.1",
63
+ "@uniweb/schemas": "^0.13.0",
63
64
  "@uniweb/content-reader": "^1.2.8",
64
- "@uniweb/schemas": "^0.12.0",
65
65
  "@uniweb/theming": "^0.2.0"
66
66
  },
67
67
  "optionalDependencies": {
68
- "@uniweb/runtime": "^0.29.2"
68
+ "@uniweb/runtime": "^0.29.3"
69
69
  },
70
70
  "peerDependencies": {
71
71
  "vite": "^5.0.0 || ^6.0.0 || ^7.0.0",
@@ -74,8 +74,8 @@
74
74
  "@tailwindcss/vite": "^4.0.0",
75
75
  "@vitejs/plugin-react": "^4.0.0 || ^5.0.0",
76
76
  "vite-plugin-svgr": "^4.0.0",
77
- "@uniweb/core": "^0.37.2",
78
- "@uniweb/runtime": "^0.29.2"
77
+ "@uniweb/runtime": "^0.29.3",
78
+ "@uniweb/core": "^0.38.0"
79
79
  },
80
80
  "peerDependenciesMeta": {
81
81
  "vite": {
@@ -5,7 +5,7 @@ import yaml from 'js-yaml'
5
5
  import { YAML_OPTIONS } from '../utils/yaml-schema.js'
6
6
 
7
7
  /**
8
- * Where `uniweb dev` answers the site's `api` service when `$devApi` names a handler.
8
+ * Where `uniweb dev` answers the site's `backend` service when `$devBackend` names a handler.
9
9
  * The dev server is the host here, so the address is its own.
10
10
  */
11
11
  export const DEV_API_ADDRESS = '/_api'
@@ -16,22 +16,24 @@ export const DEV_API_ADDRESS = '/_api'
16
16
  * A site that talks to a backend needs one running to be developed against, and
17
17
  * making that a live deployment is slow, costs money, and puts a shared database
18
18
  * behind a developer's experiments. So a site may name a **local handler**, and in
19
- * `uniweb dev` the dev server answers the site's `api` service with it:
19
+ * `uniweb dev` the dev server answers the site's `backend` service with it:
20
20
  *
21
21
  * ```yaml
22
22
  * # site.yml
23
23
  * services:
24
- * api: true # ask your host for an app backend (in production)
25
- * $devApi: ./mock/api.js # what answers it in `uniweb dev`, at /_api
24
+ * backend: true # ask your host for it (in production)
25
+ * $devBackend: ./mock/api.js # what answers it in `uniweb dev`, at /_api
26
26
  * ```
27
27
  *
28
28
  * ⭐ THE DEV SERVER SUPPLIES THE ADDRESS [Diego, 2026-10-06]. In `uniweb dev` it is
29
29
  * the host, so where it answers is its own to choose — `DEV_API_ADDRESS` — and the
30
- * plugin puts that address in the dev payload's `config.api`, the site tier
30
+ * plugin puts that address in the dev payload's `config.backend`, the site tier
31
31
  * `resolveService` reads when no host speaks. ⛔ *Until then the site wrote it, as a
32
32
  * top-level `api: /_api` "the same in development and in production" — an address
33
33
  * that, under `services:`, would read as "the site brings its own backend" and ask the
34
- * host to turn its own off.*
34
+ * host to turn its own off.* ⛔ *And until 2026-10-07 the service was `api` and this
35
+ * key `$devApi`; both are refused by those names now — `services.api` by
36
+ * `refuseUnreadableServices`, `$devApi` by `refuseRetiredServiceKeys`.*
35
37
  *
36
38
  * ```js
37
39
  * // mock/api.js — default-export a fetch handler
@@ -50,9 +52,9 @@ export const DEV_API_ADDRESS = '/_api'
50
52
  *
51
53
  * ## ⛔ Development only, and it cannot leak
52
54
  *
53
- * `$devApi` is read by the dev plugin and by nothing else: no build reads it, no
55
+ * `$devBackend` is read by the dev plugin and by nothing else: no build reads it, no
54
56
  * `info` key carries it, and only the dev server's payload names its address. What
55
- * answers the site's `api` service locally is a fact about one machine.
57
+ * answers the site's `backend` service locally is a fact about one machine.
56
58
  *
57
59
  * ⚠️ **Same-origin on purpose.** Mounting inside the dev server means cookies and
58
60
  * `credentials: 'same-origin'` behave as they do in production, where a site's app
@@ -87,7 +89,7 @@ export function mountDevApi(server, { root }) {
87
89
  return null
88
90
  }
89
91
 
90
- const spec = site.$devApi
92
+ const spec = site.$devBackend
91
93
  if (!spec) return null
92
94
  const mount = DEV_API_ADDRESS
93
95
 
@@ -75,9 +75,10 @@
75
75
  *
76
76
  * ⚠️ `@uniweb/api` deliberately needs no entry: it calls
77
77
  * `resolveService(website, SERVICE_NAME)` against a module-level `const`, which
78
- * `readModuleConsts` resolves. Without that resolution `api` would be both
78
+ * `readModuleConsts` resolves. Without that resolution `backend` would be both
79
79
  * missed *and* counted as blindness, marking every foundation that uses it
80
- * unknowable.
80
+ * unknowable. ⛔ *The constant was `api` until 2026-10-07, so a foundation built
81
+ * against an earlier `@uniweb/api` derives `api` — a service no host offers now.*
81
82
  *
82
83
  * ## Why the AST rather than a regex
83
84
  *
@@ -161,7 +161,8 @@ export function collectSchemaRefs(components, foundationData = null) {
161
161
  const readData = (data) => {
162
162
  if (!data || typeof data !== 'object' || data === false) return
163
163
  for (const binding of Object.values(data)) {
164
- // `'@std/article/*'` asks for whole records of `@std/article` — the ref is what precedes it.
164
+ // A ref, short or long (`{ schema, single, whole }`) — the flags change nothing about which
165
+ // schema the key is typed by.
165
166
  const { ref } = dataRefOf(binding)
166
167
  if (ref) refs.add(ref)
167
168
  }
@@ -5,8 +5,9 @@
5
5
  * reads to render, and nothing else:
6
6
  *
7
7
  * - background: 'self' when component handles its own background
8
- * - data: { <key>: <schema ref> | null } — every `content.data` key the component
9
- * declares, in order, with its schema ref (null for an inline shape)
8
+ * - data: { <key>: <schema ref> | { schema, single?, whole? } | null } — every `content.data`
9
+ * key the component declares, in order, with its schema ref and the flags set on it (null for
10
+ * an inline shape)
10
11
  * - vars: { <name>: { default? } } — the CSS variables the component declares
11
12
  * - defaults: param default values
12
13
  * - context: static capabilities for cross-block coordination
@@ -34,17 +35,34 @@
34
35
  */
35
36
 
36
37
  import { lowerData } from '@uniweb/schemas/content'
38
+ import { schemaDeclarationOf } from '@uniweb/schemas/foundation'
37
39
 
38
40
  /**
39
- * A `data:` entry's schema ref — a ref string, or `{ schema }` — or null for an inline
40
- * shape, which names no schema and is filled only under its own key.
41
+ * A `data:` entry as the runtime reads it: its schema ref, with the flags set on it — or null for an
42
+ * inline shape, which names no schema and is filled only under its own key.
41
43
  *
42
- * @param {string|Object} value
43
- * @returns {string|null}
44
+ * ⭐ Two flags, independent (ruled 2026-10-07 [Diego]): `single` — one record, not a list — and
45
+ * `whole` — each record as stored, not its brief.
46
+ * Lean on purpose: the bare ref when neither is set, which is most keys, and only a flag that is set.
47
+ * ⛔ Until 2026-10-07 this wrote the ref alone, so `{ schema, whole: true }` lost its flag here and
48
+ * only the `'…/*'` spelling, now retired, reached the runtime.
49
+ *
50
+ * @param {string} key - the key, for a refusal
51
+ * @param {*} value - its value, as authored
52
+ * @returns {string|{ schema: string, single?: true, whole?: true }|null}
53
+ * @throws {Error} for a retired or malformed declaration (`schemaDeclarationOf`)
44
54
  */
45
- function dataRef(value) {
46
- if (typeof value === 'string') return value || null
47
- return value && typeof value === 'object' && typeof value.schema === 'string' && value.schema ? value.schema : null
55
+ function leanDataValue(key, value) {
56
+ let declaration
57
+ try {
58
+ declaration = schemaDeclarationOf(value)
59
+ } catch (err) {
60
+ throw new Error(`[uniweb] Invalid 'data.${key}': ${err.message}`)
61
+ }
62
+ if (!declaration || !declaration.schema) return null
63
+ const { schema, single, whole } = declaration
64
+ if (!single && !whole) return schema
65
+ return { schema, ...(single ? { single: true } : {}), ...(whole ? { whole: true } : {}) }
48
66
  }
49
67
 
50
68
  /**
@@ -148,7 +166,7 @@ export function extractRuntimeSchema(fullMeta) {
148
166
  )
149
167
  }
150
168
  runtime.data = runtime.data || {}
151
- runtime.data[key] = dataRef(value)
169
+ runtime.data[key] = leanDataValue(key, value)
152
170
  }
153
171
  } else if (data !== undefined) {
154
172
  throw new Error(
@@ -337,12 +355,12 @@ function leanVars(vars) {
337
355
  return Object.keys(out).length > 0 ? out : null
338
356
  }
339
357
 
340
- /** A foundation's `data:` as a section type's lean `data` is: each key → its schema ref, or null. */
358
+ /** A foundation's `data:` as a section type's lean `data` is: each key → its ref and flags, or null. */
341
359
  function leanDataKeys(data) {
342
360
  const lowered = lowerData(data)
343
361
  if (!lowered || typeof lowered !== 'object' || Array.isArray(lowered)) return null
344
362
  const out = {}
345
- for (const [key, value] of Object.entries(lowered)) out[key] = dataRef(value)
363
+ for (const [key, value] of Object.entries(lowered)) out[key] = leanDataValue(key, value)
346
364
  return Object.keys(out).length > 0 ? out : null
347
365
  }
348
366
 
package/src/schema.js CHANGED
@@ -18,7 +18,8 @@ import { join, dirname, extname, basename } from 'node:path'
18
18
  import { pathToFileURL } from 'node:url'
19
19
  import { describeChildren, describeVisuals, lowerChildren } from '@uniweb/schemas/component'
20
20
  import { describeContent, lowerData } from '@uniweb/schemas/content'
21
- import { normalizeData } from '@uniweb/schemas/foundation'
21
+ import { normalizeData, schemaDeclarationOf } from '@uniweb/schemas/foundation'
22
+ import { rootListSection } from '@uniweb/schemas/conform'
22
23
  import { SECTION_PARAMS, SECTION_KEYS } from '@uniweb/schemas/section'
23
24
  import { collectSchemaRefs, buildDataSchemaMap, ownSchemaRefs } from './resolve-data-schema.js'
24
25
  import {
@@ -26,6 +27,7 @@ import {
26
27
  deriveRecordsSupport,
27
28
  unnameableIn,
28
29
  } from './foundation/derive-supports.js'
30
+ import { RENAMED_SERVICES } from './uwx/services-request.js'
29
31
  import { foundationNameOf, splitFoundationName } from './foundation-name.js'
30
32
  import { resolveFoundationSrcDir } from './utils/foundation-source-root.js'
31
33
 
@@ -635,12 +637,67 @@ function buildComponentEntry(name, relativePath, meta) {
635
637
  else delete entry.children
636
638
 
637
639
  if (meta.data !== undefined) entry.data = lowerData(meta.data)
640
+ refuseDataDeclarations(`${name} (meta.js)`, entry.data)
638
641
 
639
642
  const problems = [...content.problems, ...describeChildren(meta).problems]
640
643
  if (problems.length > 0) loweringProblems.set(entry, problems)
641
644
  return entry
642
645
  }
643
646
 
647
+ /**
648
+ * Refuse a `data:` key typed by a schema that is not written as one: the retired `/*` suffix, a
649
+ * property of the long form other than `schema`, `single` and `whole`, or a flag that is not a
650
+ * boolean (`schemaDeclarationOf`; ruled 2026-10-07 [Diego]). A refusal, not a warning: the runtime would
651
+ * read a mistyped flag as absent, and deliver a list where the author asked for one record.
652
+ *
653
+ * @param {string} owner - who declares it, for the message
654
+ * @param {*} data - a `data:` map, lowered
655
+ */
656
+ function refuseDataDeclarations(owner, data) {
657
+ if (!data || typeof data !== 'object' || Array.isArray(data)) return
658
+ for (const [key, value] of Object.entries(data)) {
659
+ try {
660
+ schemaDeclarationOf(value)
661
+ } catch (err) {
662
+ throw new Error(`[uniweb] ${owner}: data.${key}: ${err.message}`)
663
+ }
664
+ }
665
+ }
666
+
667
+ /**
668
+ * Refuse `single` or `whole` on a key whose schema is a LIST — `@std/nav`, `@std/form`: a schema
669
+ * whose root is one list section describes the key's whole value, so the key holds that list, and
670
+ * neither one record of it nor a record as stored means anything. Checked once the schemas resolve.
671
+ *
672
+ * @param {Array<[string, *]>} owners - `[who, data:]` pairs
673
+ * @param {Object} dataSchemas - normalized schemas by ref
674
+ * @param {string} srcDir - to resolve a ref no binding brought in (a layout's)
675
+ */
676
+ async function refuseFlagsOnListSchemas(owners, dataSchemas, srcDir) {
677
+ for (const [owner, data] of owners) {
678
+ if (!data || typeof data !== 'object' || Array.isArray(data)) continue
679
+ for (const [key, value] of Object.entries(data)) {
680
+ const declaration = schemaDeclarationOf(value)
681
+ if (!declaration || (!declaration.single && !declaration.whole)) continue
682
+ let schema = dataSchemas[declaration.schema]
683
+ if (!schema) {
684
+ try {
685
+ schema = (await buildDataSchemaMap([declaration.schema], { srcDir }))[declaration.schema]
686
+ } catch {
687
+ schema = null // an unresolvable ref is reported where refs resolve
688
+ }
689
+ }
690
+ if (schema && rootListSection(schema)) {
691
+ const flags = ['single', 'whole'].filter((flag) => declaration[flag]).map((flag) => `\`${flag}\``).join(' and ')
692
+ throw new Error(
693
+ `[uniweb] ${owner}: data.${key}: ${declaration.schema} is a list — its key holds that list, so ${flags} ` +
694
+ `does not apply. Write data.${key}: '${declaration.schema}'.`
695
+ )
696
+ }
697
+ }
698
+ }
699
+ }
700
+
644
701
  /** `content: {}` or `content: []` — the component's way of saying it takes no content. */
645
702
  function declaresNoContent(content) {
646
703
  if (Array.isArray(content)) return content.length === 0
@@ -954,6 +1011,29 @@ export async function discoverComponents(srcDir, sectionPaths = DEFAULT_SECTION_
954
1011
  return sections
955
1012
  }
956
1013
 
1014
+ /**
1015
+ * Stop on a service `uniweb.supports` names by its old name (`RENAMED_SERVICES`), as
1016
+ * `site.yml::services` does. A foundation claiming `api` claims a service no host offers
1017
+ * now — and the build derives `backend` from the code itself, through kit's and
1018
+ * `@uniweb/api`'s literals, so a renamed claim could only ever disagree with it.
1019
+ *
1020
+ * ⛔ Before the dev-rebuild bail, like `reportSupports`' warning: dev is where the
1021
+ * developer is working.
1022
+ *
1023
+ * @param {string} srcDir
1024
+ * @param {string[]|undefined} authored - normalized `uniweb.supports`
1025
+ * @throws {Error} naming each old name and the new one
1026
+ */
1027
+ function refuseRenamedSupports(srcDir, authored) {
1028
+ const renamed = (authored || []).filter((s) => RENAMED_SERVICES[s])
1029
+ if (!renamed.length) return
1030
+ throw new Error(
1031
+ `${srcDir}/package.json: \`uniweb.supports\` names ${renamed.map((s) => `\`${s}\``).join(', ')}, which is now ` +
1032
+ `${renamed.map((s) => `\`${RENAMED_SERVICES[s]}\``).join(', ')} — the site's own backend is the \`backend\` ` +
1033
+ 'service. Rename it in package.json.',
1034
+ )
1035
+ }
1036
+
957
1037
  /**
958
1038
  * Say what the derivation added, at the one moment the developer is looking.
959
1039
  *
@@ -1138,6 +1218,8 @@ export async function buildSchema(srcDir, sectionPaths, derivedSupports = null)
1138
1218
  // Load configuration from foundation.js
1139
1219
  const foundationConfig = await loadFoundationConfig(srcDir)
1140
1220
 
1221
+ refuseDataDeclarations('the foundation (main.js)', lowerData(foundationConfig.data))
1222
+
1141
1223
  // Discover section types
1142
1224
  const components = await discoverComponents(srcDir, sectionPaths)
1143
1225
  reportPlacementDeclarations(components)
@@ -1154,6 +1236,15 @@ export async function buildSchema(srcDir, sectionPaths, derivedSupports = null)
1154
1236
 
1155
1237
  // Discover layouts from src/layouts/
1156
1238
  const layouts = await discoverLayoutsInPath(srcDir)
1239
+ await refuseFlagsOnListSchemas(
1240
+ [
1241
+ ...Object.entries(components).map(([name, entry]) => [`${name} (meta.js)`, entry?.data]),
1242
+ ...Object.entries(layouts).map(([name, entry]) => [`layout ${name} (meta.js)`, entry?.data]),
1243
+ ['the foundation (main.js)', lowerData(foundationConfig.data)],
1244
+ ],
1245
+ dataSchemas,
1246
+ srcDir,
1247
+ )
1157
1248
  reportUnregistrableData(components, layouts, foundationConfig)
1158
1249
 
1159
1250
  // Determine extension role
@@ -1188,6 +1279,7 @@ export async function buildSchema(srcDir, sectionPaths, derivedSupports = null)
1188
1279
  // `supports` differs from the one a real build produces is a trap rather than a
1189
1280
  // convenience — the artifact would disagree with itself depending on which
1190
1281
  // command wrote it. One rule: the derivation runs on a real build.
1282
+ refuseRenamedSupports(srcDir, identity.supports)
1191
1283
  const derived = derivedSupports && {
1192
1284
  ...derivedSupports,
1193
1285
  services: deriveRecordsSupport(components, foundationConfig.data)
@@ -41,7 +41,7 @@ import { resolveDefaultLocale, resolvePublishableLocales, validateLanguageConfig
41
41
  import { authoredRedirectTarget } from '@uniweb/core/resolve-route'
42
42
  import { parseFrontmatter } from '../utils/frontmatter.js'
43
43
  import { parseGrid } from '@uniweb/schemas/grid'
44
- import { runtimeServicesConfig, refuseRetiredServiceKeys } from '../uwx/services-request.js'
44
+ import { runtimeServicesConfig, refuseRetiredServiceKeys, refuseUnreadableServices } from '../uwx/services-request.js'
45
45
 
46
46
  // Try to import content-reader, fall back to simplified parser
47
47
  let markdownToProseMirror
@@ -2503,6 +2503,7 @@ export async function collectSiteContent(sitePath, options = {}) {
2503
2503
  // Read site config and raw theme config
2504
2504
  const siteConfig = await readYamlFile(join(sitePath, configFile))
2505
2505
  refuseRetiredServiceKeys(siteConfig, configFile)
2506
+ refuseUnreadableServices(siteConfig, configFile)
2506
2507
 
2507
2508
  // Queries are declared in TWO files — `site.yml::queries` and `queries.yml`,
2508
2509
  // the latter winning per key — and resolving them is one question with one
@@ -7,8 +7,10 @@
7
7
  * must not destroy a developer's comments on the targets:/saveDeploys:
8
8
  * regions of the file.
9
9
  *
10
- * This is the only place in @uniweb/build that depends on `yaml`. The
11
- * loader (deploy-config.js) stays on js-yaml for read-only ingestion.
10
+ * The loader (deploy-config.js) stays on js-yaml for read-only ingestion.
11
+ * ⚠️ This read "the only place in @uniweb/build that depends on `yaml`" until
12
+ * 2026-10-07; the pull's config writers locate entries with it too
13
+ * (`uwx/yaml-edit.js::editYamlText`), and print with js-yaml.
12
14
  *
13
15
  * `saveDeploys: false` (or `--no-save`) makes this a no-op; otherwise it
14
16
  * touches ONLY deploys.<targetName> — plus targets.<targetName> when the file has no
@@ -655,16 +655,16 @@ export function siteContentPlugin(options = {}) {
655
655
  }
656
656
 
657
657
  let siteContent = null
658
- // Where `uniweb dev` answers the site's `api` service with `$devApi`'s handler — set
658
+ // Where `uniweb dev` answers the site's `backend` service with `$devBackend`'s handler — set
659
659
  // in `configureServer`, so never in a build.
660
660
  let devApiAddress = null
661
661
  /**
662
- * The dev payload names `$devApi`'s address as the site's `api` service: the site
662
+ * The dev payload names `$devBackend`'s address as the site's `backend` service: the site
663
663
  * tier, which `resolveService` reads when no host speaks. A build never gets here
664
664
  * with one.
665
665
  */
666
666
  const withDevApi = (content) => {
667
- if (devApiAddress && content?.config) content.config.api = devApiAddress
667
+ if (devApiAddress && content?.config) content.config.backend = devApiAddress
668
668
  return content
669
669
  }
670
670
  // What each section type declares its data blocks are — read once per collection of the site.
@@ -1009,10 +1009,10 @@ export function siteContentPlugin(options = {}) {
1009
1009
  configureServer(devServer) {
1010
1010
  server = devServer
1011
1011
 
1012
- // A site's own backend, answered locally in development. `site.yml::$devApi`
1012
+ // A site's own backend, answered locally in development. `site.yml::$devBackend`
1013
1013
  // names a module that default-exports a fetch handler; the dev server mounts it
1014
1014
  // at an address of its own and names that address in the payload it serves
1015
- // (`withDevApi`), so the site's `api` service resolves to it.
1015
+ // (`withDevApi`), so the site's `backend` service resolves to it.
1016
1016
  // ⚠️ Synchronous on purpose — see mountDevApi. An await here and the
1017
1017
  // middleware lands after Vite's SPA fallback, which answers the API with
1018
1018
  // index.html and says nothing about why.
@@ -1376,8 +1376,8 @@ export async function writeQueryFiles(siteDir, byQuery, queriesConfig = null) {
1376
1376
 
1377
1377
  // ⭐ TWO PAYLOADS FOR EVERY QUERY (ruled 2026-09-27 [Diego]):
1378
1378
  // 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`.
1379
+ // brief's included — what a whole question answers: a record for a component that
1380
+ // declares its key `whole: true`, and kit's `useWholeRecord`.
1381
1381
  // 2. The list at /data/<name>.json — each record's BRIEF, the brief's fields at the top —
1382
1382
  // what `query: <name>` delivers everywhere else. ⛔ `deferred:`, which left fields of the
1383
1383
  // brief out of it, was retired the same day [Diego].
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
@@ -120,6 +121,8 @@ export {
120
121
  export {
121
122
  RUNTIME_KEYS,
122
123
  readServicesRequest,
124
+ unreadableServices,
125
+ refuseUnreadableServices,
123
126
  statedServices,
124
127
  heldServices,
125
128
  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