@uniweb/build 1.5.1 → 1.6.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": "1.5.1",
3
+ "version": "1.6.0",
4
4
  "description": "Build tooling for the Uniweb Component Web Platform",
5
5
  "type": "module",
6
6
  "exports": {
@@ -56,15 +56,15 @@
56
56
  "js-yaml": "^4.1.0",
57
57
  "sharp": "^0.35.3",
58
58
  "yaml": "^2.5.0",
59
- "@uniweb/content-reader": "~1.5.2",
60
- "@uniweb/projections": "~1.1.4",
61
- "@uniweb/semantic-parser": "~1.12.0",
59
+ "@uniweb/semantic-parser": "~1.12.2",
60
+ "@uniweb/content-reader": "~1.5.4",
61
+ "@uniweb/theming": "~1.2.0",
62
62
  "@uniweb/content-writer": "~1.3.0",
63
- "@uniweb/schemas": "~1.5.0",
64
- "@uniweb/theming": "~1.2.0"
63
+ "@uniweb/projections": "~1.1.4",
64
+ "@uniweb/schemas": "~1.5.0"
65
65
  },
66
66
  "optionalDependencies": {
67
- "@uniweb/runtime": "~1.1.8"
67
+ "@uniweb/runtime": "~1.1.11"
68
68
  },
69
69
  "peerDependencies": {
70
70
  "vite": "^5.0.0 || ^6.0.0 || ^7.0.0",
@@ -73,8 +73,8 @@
73
73
  "@tailwindcss/vite": "^4.0.0",
74
74
  "@vitejs/plugin-react": "^4.0.0 || ^5.0.0",
75
75
  "vite-plugin-svgr": "^4.0.0",
76
- "@uniweb/runtime": "~1.1.8",
77
- "@uniweb/core": "~1.3.0"
76
+ "@uniweb/core": "~1.3.1",
77
+ "@uniweb/runtime": "~1.1.11"
78
78
  },
79
79
  "peerDependenciesMeta": {
80
80
  "vite": {
@@ -1,9 +1,9 @@
1
1
  /**
2
2
  * Prose without its stylesheet — the one foundation mistake that renders fine and looks wrong.
3
3
  *
4
- * kit's `<Prose>` (and `<Article>`, which is `<Prose>` in an `<article>`) draws content inside
5
- * Tailwind Typography's `prose` classes. Those classes style nothing unless the foundation's
6
- * stylesheet brings the plugin, which is one line:
4
+ * kit's `<Prose>` (and `<Article>`, which is `<Prose>` in an `<article>`) draws content inside a
5
+ * `prose` container. The class styles nothing unless the foundation's stylesheet imports kit's
6
+ * prose layer, which is one line:
7
7
  *
8
8
  * @import "@uniweb/kit/prose-tokens.css";
9
9
  *
@@ -20,7 +20,7 @@
20
20
  /** kit's Prose module, wherever kit resolves from — the workspace or node_modules. */
21
21
  const PROSE_MODULE = /[\\/]kit[\\/]src[\\/]styled[\\/]Prose[\\/]index\.jsx$/
22
22
 
23
- /** A rule of the typography plugin's, which every one of its selectors starts with. */
23
+ /** A rule of kit's prose layer, which every one of its selectors starts with. ⛔ Tailwind Typography's until 2026-10-19. */
24
24
  const PROSE_RULE = /\.prose\b/
25
25
 
26
26
  /**
@@ -35,7 +35,7 @@
35
35
  * the branch history stays intact and revertible.
36
36
  *
37
37
  * UNIWEB_BASE is decided at workflow scaffold time based on the deploy
38
- * target (per gotcha #15 for the base-path flow):
38
+ * target, and becomes the site's base path:
39
39
  *
40
40
  * - With `domain`: bakes `UNIWEB_BASE: /` and emits a CNAME file under
41
41
  * `<sitePath>/public/`. GitHub Pages serves the custom domain at
@@ -2,19 +2,24 @@
2
2
  * Site data builder — runs the data pipeline without vite.
3
3
  *
4
4
  * `siteContentPlugin` (`./plugin.js`) emits site data inside vite's
5
- * `generateBundle` hook because today's `uniweb build` always goes through
6
- * vite (to produce a static-host JS bundle). For `uniweb build --link`,
7
- * the JS bundle is wasted CPU — the deployed Uniweb-hosted site is rendered
8
- * by the worker using its own runtime + the foundation served from the
9
- * registry; nothing from the site's vite output reaches the browser.
5
+ * `generateBundle` hook, on the bundle lane, where vite builds a JS bundle for
6
+ * a static host. `uniweb build --link`, which `uniweb publish` runs, needs no
7
+ * bundle: what `publish` sends a backend carries no JS. The content goes over
8
+ * the sync wire, read from the site's source files (`../uwx/`), and the site
9
+ * names its foundation by registered version.
10
+ *
11
+ * ⛔ *Until 2026-10-19 this said a Uniweb-hosted site "is rendered by the worker
12
+ * using its own runtime" — a statement about a host, which framework cannot
13
+ * make (the note under `site-content.json` below).*
10
14
  *
11
15
  * This function is the shared data-emission core used by the link-mode
12
16
  * pipeline. The vite plugin keeps its own emission for the bundle-mode
13
17
  * path (where the static-host extras — sitemap, robots, search-index,
14
18
  * `_pages/*` for split content — are actually consumed). Both paths use
15
19
  * the same underlying building blocks (`collectSiteContent`,
16
- * `processQueries`, `processAssets`, etc.), so behavior stays
17
- * consistent without forcing one path through the other's lifecycle.
20
+ * `processQueries`, etc.), so behavior stays consistent without forcing
21
+ * one path through the other's lifecycle. Media processing — WebP copies,
22
+ * video posters, PDF thumbnails — is the bundle path's alone (below).
18
23
  */
19
24
 
20
25
  import { writeFile, readFile, mkdir, cp } from 'node:fs/promises'
@@ -25,8 +30,6 @@ import { resolveDefaultLocale, DATA_DIR } from '@uniweb/core'
25
30
  import { collectSiteContent } from './content-collector.js'
26
31
  import { processQueries, writeQueryFiles } from './query-processor.js'
27
32
  import { resolveRecordsDir } from './entity-pool.js'
28
- import { processAssets, rewriteSiteContentPaths } from './asset-processor.js'
29
- import { processAdvancedAssets } from './advanced-processors.js'
30
33
  import {
31
34
  renderSiteIndex,
32
35
  renderPageMarkdown,
@@ -39,11 +42,17 @@ import {
39
42
  } from '@uniweb/projections'
40
43
 
41
44
  /**
42
- * Build the site data outputs needed by `uniweb deploy` (link-mode).
45
+ * Build the site data for `uniweb build --link`, which `uniweb publish` runs.
46
+ * `publish` reads two things back from `distDir`: the files of the queries that
47
+ * have no data schema, which it uploads, and the languages to make live.
43
48
  *
44
49
  * Emits to `distDir`:
45
50
  * - `site-content.json` — full content tree, sections always inlined.
46
- * The deploy CLI reads this and ships it as `payload.locales[default]`.
51
+ * `publish` reads the languages to make live from it and from the
52
+ * `<locale>/site-content.json` files beside it (`languagesFromContent`);
53
+ * the content itself goes over the sync wire, built from source.
54
+ * ⛔ *Until 2026-10-19 this said the deploy CLI shipped this file as
55
+ * `payload.locales[default]`.*
47
56
  *
48
57
  * ⛔ Ship FULL sections; never strip them per the split-content rule.
49
58
  * A stripped manifest is what prerender emits for static-host bundles,
@@ -54,27 +63,30 @@ import {
54
63
  * does with the payload. Removed 2026-08-18: the description was
55
64
  * accurate about one deployment and false about another, and both were
56
65
  * live at once — so naming "the worker" was wrong even though the
57
- * sentence was true somewhere. Framework has no host client and no host
58
- * knowledge (root CLAUDE.md, THE LANE CHAIN); we cannot tell consumers
59
- * apart and must not write as though we can.
66
+ * sentence was true somewhere. The framework has no host client and no
67
+ * knowledge of hosts; we cannot tell consumers apart and must not write
68
+ * as though we can.
60
69
  *
61
70
  * ⇒ The rule stands on its own: we do not know which consumers
62
71
  * re-derive, so we always ship enough for the ones that do.
63
- * - `data/<collection>.json` (+ each record's own file,
64
- * `data/<collection>/<$name>.json`) — same shape `processQueries` produces today.
72
+ * - `data/<query>.json` (+ each record's own file, `data/<query>/<$name>.json`) —
73
+ * same shape `processQueries` produces today. `publish` uploads the files of
74
+ * the queries that have no data schema (`collectSchemalessData`).
65
75
  * - `records/<path under the records directory>` — the records' co-located assets, where
66
76
  * the compiled records point.
67
- * - `assets/<media>` — processed images / video posters / PDF
68
- * thumbnails. Filtered by the deploy CLI to MEDIA only at upload time.
69
77
  *
70
78
  * Does NOT emit:
79
+ * - `assets/<media>` — no media is processed here, and a media ref in
80
+ * `site-content.json` stays the site's own path. `publish` uploads a
81
+ * site's media from the site's own files (the CLI's `uploadSiteMedia`), so
82
+ * processed copies would be built for nothing. ⛔ *Until 2026-10-19 this
83
+ * lane made WebP copies, video posters and PDF thumbnails into
84
+ * `dist/assets/` on every publish, and rewrote the refs to them.*
71
85
  * - HTML, JS, CSS, source maps, `_importmap/*` — static-host bundle
72
86
  * artifacts; a host on this lane renders pages itself.
73
87
  * - `_pages/<route>.json` — only meaningful for static-host bundles,
74
88
  * where they are served as files.
75
- * - `sitemap.xml`, `robots.txt` — static-host bundle territory. Note these
76
- * are simply absent from Uniweb-hosted sites today: nothing in platform
77
- * generates them, at publish time or at request time.
89
+ * - `sitemap.xml`, `robots.txt` — static-host bundle territory.
78
90
  * - ANY search index — neither the single-file `search-index.json` nor the
79
91
  * split `_search/{locale}/*.json`. This lane emitted both until
80
92
  * 2026-08-01 and emits neither now; see step 5 for why. A host that
@@ -91,14 +103,7 @@ import {
91
103
  * @param {string} params.distDir - Absolute path to the dist output directory.
92
104
  * @param {string} [params.foundationPath] - Absolute path to the foundation
93
105
  * directory, when one exists locally. Used by `collectSiteContent` to
94
- * resolve theme variable defaults from `foundation.js::theme.vars`.
95
- * @param {Object} [params.assets] - Asset processing options.
96
- * @param {boolean} [params.assets.process=true]
97
- * @param {boolean} [params.assets.convertToWebp=true]
98
- * @param {number} [params.assets.quality=80]
99
- * @param {string} [params.assets.outputDir='assets']
100
- * @param {boolean} [params.assets.videoPosters=true]
101
- * @param {boolean} [params.assets.pdfThumbnails=true]
106
+ * resolve the defaults of the theme variables it declares (its `main.js` `vars`).
102
107
  * @param {string} [params.basePath='/'] - Base path prefix for collection URLs.
103
108
  * @returns {Promise<{ siteContent: Object, distDir: string }>}
104
109
  */
@@ -106,7 +111,6 @@ export async function buildSiteData({
106
111
  siteRoot,
107
112
  distDir,
108
113
  foundationPath,
109
- assets = {},
110
114
  basePath = '/',
111
115
  }) {
112
116
  if (!siteRoot) throw new Error('buildSiteData: siteRoot is required')
@@ -114,21 +118,13 @@ export async function buildSiteData({
114
118
 
115
119
  const resolvedSiteRoot = resolve(siteRoot)
116
120
  const resolvedDistDir = resolve(distDir)
117
- const assetsOpts = {
118
- process: assets.process !== false,
119
- convertToWebp: assets.convertToWebp !== false,
120
- quality: assets.quality || 80,
121
- outputDir: assets.outputDir || 'assets',
122
- videoPosters: assets.videoPosters !== false,
123
- pdfThumbnails: assets.pdfThumbnails !== false,
124
- }
125
121
 
126
122
  await mkdir(resolvedDistDir, { recursive: true })
127
123
 
128
124
  // 1. Collect content (pages, sections, theme, config, assets manifest).
129
125
  // No vite needed — collectSiteContent is a plain async function.
130
- // dropUnpublished: link mode is always a published deploy — prune hidden
131
- // pages + their subtree so drafts never reach the served site.
126
+ // dropUnpublished: link mode is the build `publish` runs, so it reads
127
+ // the site as published — hidden pages and their subtree pruned.
132
128
  let siteContent = await collectSiteContent(resolvedSiteRoot, { foundationPath, dropUnpublished: true, base: basePath, strict: true })
133
129
 
134
130
  // 2. Compile content collections (file-based markdown/yaml/json).
@@ -166,69 +162,27 @@ export async function buildSiteData({
166
162
  }
167
163
  }
168
164
 
169
- // 3. Process media assets — images, optional video posters and PDF
170
- // thumbnails — and rewrite the in-memory content tree so its image/
171
- // video/document references point at the processed paths.
172
- let finalContent = siteContent
173
- if (assetsOpts.process && siteContent?.assets) {
174
- const assetCount = Object.keys(siteContent.assets).length
175
- if (assetCount > 0) {
176
- const { pathMapping, files } = await processAssets(siteContent.assets, {
177
- outputDir: resolvedDistDir,
178
- assetsSubdir: assetsOpts.outputDir,
179
- convertToWebp: assetsOpts.convertToWebp,
180
- quality: assetsOpts.quality,
181
- basePath,
182
- })
183
-
184
- const advancedEnabled = assetsOpts.videoPosters || assetsOpts.pdfThumbnails
185
- if (advancedEnabled) {
186
- const { posterMapping, thumbnailMapping } = await processAdvancedAssets(
187
- siteContent.assets,
188
- {
189
- outputDir: resolvedDistDir,
190
- assetsSubdir: assetsOpts.outputDir,
191
- videoPosters: assetsOpts.videoPosters,
192
- pdfThumbnails: assetsOpts.pdfThumbnails,
193
- quality: assetsOpts.quality,
194
- basePath,
195
- hasExplicitPoster: siteContent.hasExplicitPoster || new Set(),
196
- hasExplicitPreview: siteContent.hasExplicitPreview || new Set(),
197
- }
198
- )
199
-
200
- finalContent = rewriteSiteContentPaths(siteContent, pathMapping, files)
201
- if (Object.keys(posterMapping).length > 0 || Object.keys(thumbnailMapping).length > 0) {
202
- finalContent._assetMeta = {
203
- posters: posterMapping,
204
- thumbnails: thumbnailMapping,
205
- }
206
- }
207
- } else {
208
- finalContent = rewriteSiteContentPaths(siteContent, pathMapping, files)
209
- }
210
- }
211
- } else {
212
- // Drop the assets manifest from the output when processing is off
213
- // (matches the vite plugin's behavior — manifest is internal).
214
- finalContent = { ...siteContent }
215
- delete finalContent.assets
216
- }
165
+ // 3. No media processing (header, "Does NOT emit"). The collector's asset
166
+ // manifest is internal, so it is dropped — as the vite plugin drops it
167
+ // when processing is off.
168
+ let finalContent = { ...siteContent }
169
+ delete finalContent.assets
217
170
 
218
- // Strip internal-only properties that don't serialize and have no
219
- // place in the published payload (Sets don't JSON-roundtrip; the
220
- // build's split-content emit consumed them already).
171
+ // Strip internal-only properties that don't serialize (Sets don't
172
+ // JSON-roundtrip): the collector records them for media processing.
221
173
  delete finalContent.hasExplicitPoster
222
174
  delete finalContent.hasExplicitPreview
223
175
 
224
176
  // ⛔ `config.icons` is a HOST-owned slot on this lane — drop it.
225
177
  //
226
- // This payload only ever goes to a backend (link mode is what `uniweb deploy`
227
- // runs), and `config` is site.yml spread whole, so an author's
228
- // `icons.cdnUrl` would otherwise ride along and land in a slot the host is
229
- // supposed to fill. The icon base is a property of the DEPLOYMENT, identical
230
- // for every site a given backend serves — not something one site gets to
231
- // choose [Diego, 2026-08-17].
178
+ // This is the build for a backend (link mode is what `uniweb publish` runs),
179
+ // and `config` is site.yml spread whole, so an author's `icons.cdnUrl` would
180
+ // otherwise sit in a slot the host is supposed to fill. The icon base is a
181
+ // property of the DEPLOYMENT, identical for every site a given backend
182
+ // serves — not something one site gets to choose [Diego, 2026-08-17].
183
+ // ⛔ *Until 2026-10-19 this said the payload "only ever goes to a backend":
184
+ // `publish` reads it for its languages, and sends the content over the sync
185
+ // wire, which never had this exposure (below).*
232
186
  //
233
187
  // The reason it cannot be a site's call is that the base and the NAMESPACE
234
188
  // are coupled: icon filenames are minted by whoever built the corpus, so
@@ -5,7 +5,7 @@
5
5
  * by name. Read-only — never writes. See deploy-config-writer.js for
6
6
  * the write path.
7
7
  *
8
- * On-disk shape (spec: kb/framework/reference/deploy-yml.md):
8
+ * On-disk shape:
9
9
  * default: production
10
10
  * targets:
11
11
  * production: { host, backend?, ...host-specific config }
@@ -5,8 +5,7 @@
5
5
  * (`@uniweb/runtime/ssr::loadPageData`) asks for what a page's render will read, by the rule the
6
6
  * render itself uses, and dispatches every request through one function the caller supplies. A host
7
7
  * gives it the network; this lane gives it `dist/` and `public/`. So the build asks the same
8
- * question as every other lane and only the answering differs — which is the whole point of
9
- * `kb/framework/plans/what-a-page-needs.md`.
8
+ * question as every other lane, and only the answering differs.
10
9
  *
11
10
  * ⛔ **A remote `url:` is still the browser's lane by default.** Nothing here decides that: the
12
11
  * author's `prerender:` does, and the step is run with `prerender: 'author'` so a deferred request
@@ -70,9 +70,11 @@ export function parseCatalogRef(value) {
70
70
  * - `'local'` — workspace-local source (sibling directory, file: dep, or
71
71
  * `../../foundations/<name>/`). The build inlines or runtime-links it
72
72
  * depending on the operating mode.
73
- * - `'url'` — loaded by URL at runtime. Two URL shapes:
74
- * - `@org/name@ver` → catalog ref (resolves against the registry CDN)
75
- * - `https://...` → arbitrary URL
73
+ * - `'url'` — loaded by URL at runtime: an `https://…` URL, or the object
74
+ * form's `url`. A catalog ref (`@org/name@ver`) is refused, since where one
75
+ * is served is the host's to say — except under `uniweb dev`, which is
76
+ * handed the answer (`served`). ⛔ *Until 2026-10-19 this listed a catalog
77
+ * ref as a URL shape, "resolved against the registry CDN".*
76
78
  *
77
79
  * Versionless registry refs (`@org/name`) are rejected with a specific error —
78
80
  * they were a silent fall-through before. Versionless names that don't match
@@ -181,9 +183,9 @@ export function detectFoundationType(foundation, siteRoot, { served = null } = {
181
183
  // resolve through the local checks below (file: dep,
182
184
  // workspace sibling) when the developer is iterating locally on a
183
185
  // foundation that will eventually be published as `@org/name@ver`.
184
- // Tianyu's uniweb.io site uses this shape:
185
- // site.yml: foundation: '@uniweb/io'
186
- // package.json: "@uniweb/io": "file:../../foundations/io"
186
+ // For example:
187
+ // site.yml: foundation: '@acme/marketing'
188
+ // package.json: "@acme/marketing": "file:../../foundations/marketing"
187
189
  // The file: dep check below picks it up. If no local resolution exists
188
190
  // either, the function throws below with a "missing version" hint.
189
191
 
@@ -78,7 +78,7 @@ export function openMapFromRows(rows) {
78
78
  /**
79
79
  * A `text` field marked as rich content (`format: markdown` or `html`): the
80
80
  * file-based body target. Round-trips as the raw source string — what the retired
81
- * `richtext` kind used to be. See framework/CLAUDE.md gotcha #21 and uwx-format.md.
81
+ * `richtext` kind used to be.
82
82
  */
83
83
  export function isMarkupTextField(field) {
84
84
  return field?.type === 'text' && CONTENT_TEXT_FORMATS.has(field?.format)
@@ -89,7 +89,7 @@ export function isMarkupTextField(field) {
89
89
  * authored as markdown on the file side, carried as a ProseMirror document on the
90
90
  * sync wire (the common language with the visual app). The single predicate the
91
91
  * sync producer/projector use to decide md⇄ProseMirror conversion + structural-map
92
- * localization. See framework/CLAUDE.md gotcha #21 and uwx-format.md.
92
+ * localization.
93
93
  */
94
94
  export function isProseMirrorField(field) {
95
95
  return field?.type === 'json' && field?.format === 'richtext'
@@ -36,7 +36,6 @@ import {
36
36
  * then, and ⛔ nothing reads that spelling — no production backend existed to keep a
37
37
  * window for [Diego, 2026-09-24]. The value comes back as it is, so a caller can still
38
38
  * tell an id from a name and say which it met.
39
- * Record: `kb/framework/build/uwx-format.md` § *The data schema an entity names*.
40
39
  *
41
40
  * @param {object} doc - an entity `$`-document
42
41
  * @returns {string|null}
package/src/uwx/folder.js CHANGED
@@ -75,7 +75,7 @@ export const FOLDER_ENTITY_KEY = '@folder'
75
75
  // registry of the backend we talk to. ⛔ Neither is an identity across backends: each
76
76
  // has its own registry [Diego, 2026-09-24]. (A TODO here asked
77
77
  // for the id until then; the agreement settled it the other way.) The key was `model`
78
- // until the same date. Record: `kb/framework/build/uwx-format.md` § 8.
78
+ // until the same date.
79
79
  function refLeaf(entity) {
80
80
  const leaf = { kind: 'ref', name: entity.slug }
81
81
  if (entity.uuid) leaf.entry = { schema: entity.model, entity: entity.uuid }
package/src/uwx/index.js CHANGED
@@ -106,7 +106,6 @@ export { emitSyncPackages } from './sync-package.js'
106
106
  export { restoreAssetRefs, servedFingerprint } from './asset-map.js'
107
107
  // `sync.json` — what each backend minted, keyed by origin. Absorbs `assets.json`
108
108
  // above, `site.yml`'s identity keys and three maps out of the sync cache.
109
- // Spec: kb/framework/reference/sync-json.md
110
109
  export {
111
110
  SYNC_STORE_FILE,
112
111
  readSyncStore,
@@ -122,7 +121,6 @@ export {
122
121
  normalizeOrigin as normalizeBackendOrigin,
123
122
  } from './sync-store.js'
124
123
  // A site's services request — `site.yml::services`, applied over the site's own rows.
125
- // Spec: kb/framework/reference/site-services-request.md
126
124
  export {
127
125
  RUNTIME_KEYS,
128
126
  readServicesRequest,
@@ -43,8 +43,6 @@
43
43
  * Pure functions, shared by the static build (`content-collector.js`), the producer
44
44
  * (`site.js`), pull (`site-project.js`) and the CLI's push, publish and doctor.
45
45
  *
46
- * Spec: kb/framework/reference/site-services-request.md
47
- *
48
46
  * @module
49
47
  */
50
48
 
@@ -456,8 +454,8 @@ export function servicesFromDocument({ rows, local } = {}) {
456
454
  * The `services` Section a push sends: every service `site.yml` lists, as it says it, and
457
455
  * OFF for every service this copy holds that the file does not list.
458
456
  *
459
- * ⭐ A PUSH STATES, AND THE BACKEND DECIDES PER SERVICE [Diego, 2026-10-07 —
460
- * kb/framework/plans/services-exchange.md]. Every service is on or off, off by default.
457
+ * ⭐ A PUSH STATES, AND THE BACKEND DECIDES PER SERVICE [Diego, 2026-10-07]. Every
458
+ * service is on or off, off by default.
461
459
  *
462
460
  * - A service the file lists goes WHOLE — its switch, and the entry as its `config` — so
463
461
  * a setting removed from the file is removed.
package/src/uwx/site.js CHANGED
@@ -1104,13 +1104,13 @@ function queriesNested(declarations, uuids = null, scope = null, keyTyped = null
1104
1104
  // ── `services` + `secrets` — what a site asks of its host ─────────────────────────
1105
1105
  //
1106
1106
  // ⭐ THE `services` SECTION IS THE OWNER'S REQUEST, AUTHORED IN `site.yml::services` — a
1107
- // map from service name to `true`, `false` or the service's settings (spec:
1108
- // kb/framework/reference/site-services-request.md). A push STATES: every service the
1107
+ // map from service name to `true`, `false` or the service's settings. A push STATES:
1108
+ // every service the
1109
1109
  // file lists, as it says it, and OFF for every service this copy holds that the file no
1110
1110
  // longer lists (`statedServices`). The services it holds are named in that backend's
1111
1111
  // entry in `sync.json`, `{ name: $uuid }`, written by pull and by a push that succeeded;
1112
1112
  // one it never saw is not sent, and the site keeps it. The backend decides per service
1113
- // from the versions the push sends [Diego, 2026-10-07 — kb/framework/plans/services-exchange.md].
1113
+ // from the versions the push sends [Diego, 2026-10-07].
1114
1114
  // ⛔ *Until then the Section went as a FULL LIST — the site's rows, read just before the
1115
1115
  // push, with the file's changed asks applied — because the backend replaced the Section
1116
1116
  // with what it was sent; and from 2026-09-20 to 2026-10-06 the rows lived only in
@@ -1734,5 +1734,5 @@ export function writeSiteEntityUuid(siteRoot, backend, uuid) {
1734
1734
  // ⛔ `writeSiteBackend` and `site.yml::$backend` are GONE (2026-09-20). The scope is
1735
1735
  // no longer a value recorded beside the uuid — it is the KEY the identity is stored
1736
1736
  // under (`sync.json::backends.<origin>`), so there is nothing to record and nothing
1737
- // that can disagree with it. `kb/framework/reference/sync-json.md`.
1737
+ // that can disagree with it.
1738
1738
 
@@ -53,7 +53,7 @@ const withBaseVersion = (baseVersions, itemBaseVersions, { every = false } = {})
53
53
  // present AND for items the files dropped. A push deletes only the items it holds a
54
54
  // version for, and only while the site left them unchanged — so a dropped item's
55
55
  // version is what makes leaving it out a deletion, and without it the item is kept
56
- // [Diego, 2026-10-07; backend's gate, kb/framework/plans/services-exchange.md]. The
56
+ // [Diego, 2026-10-07; backend's gate]. The
57
57
  // caller holds a version only for an item it has seen (`heldTokens`, cli), so none is
58
58
  // ever sent for an item the backend holds and this copy does not.
59
59
  // ⛔ Until then this narrowed to the units the document carried: a dropped page was
@@ -11,9 +11,6 @@
11
11
  * ⭐ **If it changed, something got synced.** Nothing else writes it — which is the
12
12
  * whole reason it is separate from `deploy.yml`, whose diff means "a target changed".
13
13
  *
14
- * Spec: `kb/framework/reference/sync-json.md`. Why it exists at all:
15
- * `kb/framework/plans/backend-scoped-project-state.md`.
16
- *
17
14
  * ## Why it is keyed by ORIGIN, all the way down
18
15
  *
19
16
  * A project may sync with any number of backends. Nothing can be shared between