@uniweb/build 0.76.0 → 0.77.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.76.0",
3
+ "version": "0.77.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/semantic-parser": "^1.5.1",
61
+ "@uniweb/theming": "^0.2.0",
62
+ "@uniweb/schemas": "^0.12.0",
60
63
  "@uniweb/content-reader": "^1.2.8",
61
- "@uniweb/content-writer": "^0.3.4",
62
64
  "@uniweb/projections": "^0.7.14",
63
- "@uniweb/semantic-parser": "^1.5.1",
64
- "@uniweb/schemas": "^0.11.0",
65
- "@uniweb/theming": "^0.2.0"
65
+ "@uniweb/content-writer": "^0.3.4"
66
66
  },
67
67
  "optionalDependencies": {
68
- "@uniweb/runtime": "^0.29.0"
68
+ "@uniweb/runtime": "^0.29.2"
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/runtime": "^0.29.0",
78
- "@uniweb/core": "^0.37.0"
77
+ "@uniweb/core": "^0.37.2",
78
+ "@uniweb/runtime": "^0.29.2"
79
79
  },
80
80
  "peerDependenciesMeta": {
81
81
  "vite": {
package/src/prerender.js CHANGED
@@ -23,7 +23,7 @@ import {
23
23
  deriveCacheKey,
24
24
  evaluateQuery,
25
25
  } from '@uniweb/core'
26
- import { recordTitle, routePatternToRegex, routeFieldOf } from '@uniweb/core/route-match'
26
+ import { recordTitle, recordImage, routePatternToRegex, routeFieldOf } from '@uniweb/core/route-match'
27
27
  import DataStore from '@uniweb/core/datastore'
28
28
  import { executeFetch, mergeDataIntoContent, toFetchList } from './site/data-fetcher.js'
29
29
  import { createFileTransport } from './site/file-transport.js'
@@ -473,6 +473,9 @@ export function expandDynamicPages(pages, fetched, onProgress = () => {}, stats
473
473
  const title = recordTitle(item)
474
474
  if (title) concretePage.title = title
475
475
  if (item.description || item.excerpt) concretePage.description = item.description || item.excerpt
476
+ // ⭐ And its picture is what a link to the page shows (`recordImage`) — the SPA's rule.
477
+ const image = recordImage(item)
478
+ if (image) concretePage.seo = { ...(concretePage.seo || {}), image }
476
479
 
477
480
  expandedPages.push(concretePage)
478
481
  }
@@ -2437,6 +2437,48 @@ async function collectLayouts(layoutDir, siteRoot) {
2437
2437
  * @param {boolean} [options.strict=false] - Fail on content problems that would ship broken output rather than warning about them. A production build passes true; dev leaves it false, because a folder an author just created is empty for a moment and that is not an error.
2438
2438
  * @returns {Promise<Object>} Site content object with assets manifest
2439
2439
  */
2440
+ /**
2441
+ * `site.yml` as a payload carries it — without what is authoring or publish intent,
2442
+ * and without the backend-scoped `$` keys. One rule for every path that ships a
2443
+ * site's config.
2444
+ *
2445
+ * `publishLanguages` is authoring/publish intent — it has no runtime consumer and
2446
+ * never ships in a payload (the visitor runtime is list-unaware; the sync lane reads
2447
+ * site.yml directly, not this output). The `query:` shorthand ships as
2448
+ * `config.fetch`, desugared by the caller; carried raw it would sit beside
2449
+ * `config.queries`, the declarations, and read as one.
2450
+ *
2451
+ * ⛔ `services` is the owner's REQUEST to their host (`uwx/services-request.js`), and
2452
+ * here it would land at `config.services` — the HOST tier, where a present block
2453
+ * declines every service it does not name. A request is never an answer, so it never
2454
+ * ships. *(Until 2026-10-06 it did, and doubled as an undocumented way to simulate a
2455
+ * host's offer locally.)*
2456
+ *
2457
+ * ⛔ `$`-prefixed keys were the project's BACKEND-SCOPED state — `$uuid`, `$org`,
2458
+ * `$backend`, `$services`, `$secrets` — until they moved to `sync.json` (2026-09-20),
2459
+ * and a `site.yml` written before then can still carry them. This payload is a
2460
+ * PUBLISHED artifact that a visitor can fetch. They have no runtime reader (nothing in
2461
+ * core, runtime or kit reads a `config.$*` key), so this removes noise for four of
2462
+ * them and a real disclosure for the fifth: `$secrets` carries no values, but its
2463
+ * entries name every secret the site has, and an inventory of credential names is not
2464
+ * something an `export` should publish.
2465
+ *
2466
+ * @param {object} siteConfig
2467
+ * @returns {object}
2468
+ */
2469
+ function payloadSiteConfig(siteConfig) {
2470
+ const {
2471
+ publishLanguages: _publishLanguages,
2472
+ query: _query,
2473
+ services: _services,
2474
+ ...runtimeSiteConfig
2475
+ } = siteConfig
2476
+ for (const key of Object.keys(runtimeSiteConfig)) {
2477
+ if (key.startsWith('$')) delete runtimeSiteConfig[key]
2478
+ }
2479
+ return runtimeSiteConfig
2480
+ }
2481
+
2440
2482
  export async function collectSiteContent(sitePath, options = {}) {
2441
2483
  const { foundationPath, configFile = 'site.yml', profile: profileName, dropUnpublished = false, base = '/', strict = false } = options
2442
2484
 
@@ -2547,7 +2589,7 @@ export async function collectSiteContent(sitePath, options = {}) {
2547
2589
  // Check if pages directory exists
2548
2590
  if (!existsSync(pagesPath)) {
2549
2591
  return {
2550
- config: siteConfig,
2592
+ config: payloadSiteConfig(siteConfig),
2551
2593
  theme: {
2552
2594
  ...processedTheme,
2553
2595
  css: themeCSS,
@@ -2725,23 +2767,7 @@ export async function collectSiteContent(sitePath, options = {}) {
2725
2767
  if (page.slug) delete page.slug
2726
2768
  }
2727
2769
 
2728
- // `publishLanguages` is authoring/publish intent — it has no runtime
2729
- // consumer and never ships in a payload (the visitor runtime is
2730
- // list-unaware; the sync lane reads site.yml directly, not this output).
2731
- // The `query:` shorthand ships as `config.fetch`, desugared below; carried raw
2732
- // it would sit beside `config.queries`, the declarations, and read as one.
2733
- const { publishLanguages: _publishLanguages, query: _query, ...runtimeSiteConfig } = siteConfig
2734
- // ⛔ `$`-prefixed keys were the project's BACKEND-SCOPED state — `$uuid`, `$org`,
2735
- // `$backend`, `$services`, `$secrets` — until they moved to `sync.json`
2736
- // (2026-09-20), and a `site.yml` written before then can still carry them. This
2737
- // payload is a PUBLISHED artifact that a visitor can fetch. They have no runtime reader (nothing in core, runtime
2738
- // or kit reads a `config.$*` key), so this removes noise for four of them and a
2739
- // real disclosure for the fifth: `$secrets` carries no values, but its entries
2740
- // name every secret the site has, and an inventory of credential names is not
2741
- // something an `export` should publish.
2742
- for (const key of Object.keys(runtimeSiteConfig)) {
2743
- if (key.startsWith('$')) delete runtimeSiteConfig[key]
2744
- }
2770
+ const runtimeSiteConfig = payloadSiteConfig(siteConfig)
2745
2771
 
2746
2772
  // ⛔ **A BUILD does not ship a site whose config failed to parse.**
2747
2773
  // `strict` is already this codebase's word for it — `build-site-data.js` sets
@@ -112,7 +112,7 @@ export function isContentBodyField(field) {
112
112
  * @param {(ref: string) => string} [opts.resolveOptions] - maps an `options`
113
113
  * (item_ref) ref to its full `@org/model/<section>` path. Falls back to
114
114
  * `resolveName` (model only) when not supplied.
115
- * @returns {Object} the declaration (`{ name, label?, plural?, description?,
115
+ * @returns {Object} the declaration (`{ name, label?, plural?, icon?, description?,
116
116
  * source_locale?, linkable?, sections }`).
117
117
  */
118
118
  export function toDataSchemaDeclaration(normalized, { name, resolveName, resolveOptions } = {}) {
@@ -137,6 +137,9 @@ export function toDataSchemaDeclaration(normalized, { name, resolveName, resolve
137
137
  const decl = { name }
138
138
  if (normalized.label) decl.label = normalized.label
139
139
  if (normalized.plural) decl.plural = normalized.plural
140
+ // The type's icon — a framework icon name (`lu-user`), sent as written; the registry
141
+ // stores it verbatim and a consumer resolves it. Since 2026-10-05 [Diego].
142
+ if (normalized.icon) decl.icon = normalized.icon
140
143
  if (normalized.description) decl.description = normalized.description
141
144
  if (normalized.sourceLocale) decl.source_locale = normalized.sourceLocale
142
145
  // ⛔ No `creatable_by`: retired 2026-09-23 — Models are open, and `@uniweb/schemas`
@@ -106,11 +106,11 @@
106
106
  },
107
107
  "services": {
108
108
  "kind": "passthrough",
109
- "authoredAs": "site.yml::$services"
109
+ "authoredAs": "site.yml::services"
110
110
  },
111
111
  "secrets": {
112
112
  "kind": "passthrough",
113
- "authoredAs": "site.yml::$secrets"
113
+ "authoredAs": "sync.json::backends.<origin>.secrets"
114
114
  },
115
115
  "pages": {
116
116
  "kind": "records"
package/src/uwx/index.js CHANGED
@@ -113,6 +113,15 @@ export {
113
113
  refForAssetId,
114
114
  normalizeOrigin as normalizeBackendOrigin,
115
115
  } from './sync-store.js'
116
+ // A site's services request — `site.yml::services`, applied over the site's own rows.
117
+ // Spec: kb/framework/reference/site-services-request.md
118
+ export {
119
+ readServicesRequest,
120
+ takeServices,
121
+ mergeServiceRows,
122
+ reconcileServices,
123
+ recordAfter,
124
+ } from './services-request.js'
116
125
  export {
117
126
  diffSiteUnits,
118
127
  describeSiteDiff,
@@ -268,13 +268,17 @@ const isPlainObject = (v) => Boolean(v) && typeof v === 'object' && !Array.isArr
268
268
  * ⛔ The declaration keys (`query` / `fetch` / `data`) are written WHOLE, never
269
269
  * merged: a declaration is one value, and merging an incoming `fetch:` into the
270
270
  * local one kept whatever key the remote no longer has — a stale `limit` survived
271
- * every pull.
271
+ * every pull. So is `services`: it is the site's settled request, and a service
272
+ * merged in from the old map would be an ask nobody made.
272
273
  * @returns {'updated'|'unchanged'}
273
274
  */
274
275
  export function writeSiteConfig(siteRoot, config) {
275
- return mergeYamlConfig(join(siteRoot, 'site.yml'), config, { replace: DECLARATION_KEYS })
276
+ return mergeYamlConfig(join(siteRoot, 'site.yml'), config, { replace: SITE_CONFIG_WHOLE })
276
277
  }
277
278
 
279
+ /** `site.yml` keys a writer replaces whole rather than merging into. */
280
+ const SITE_CONFIG_WHOLE = Object.freeze([...DECLARATION_KEYS, 'services'])
281
+
278
282
  /**
279
283
  * Write a YAML object to a file (full dump, idempotent) — for machine-owned
280
284
  * config the projector authors wholesale, e.g. a projected `page.yml`/`folder.yml`.
@@ -0,0 +1,280 @@
1
+ /**
2
+ * A site's services request — `site.yml::services`, a map from service name to
3
+ * what the owner asks their host for.
4
+ *
5
+ * ```yaml
6
+ * services:
7
+ * search: true # turn it on
8
+ * submit: false # turn it off
9
+ * api: # turn it on, with the service's own settings
10
+ * grade: pro
11
+ * ```
12
+ *
13
+ * ⭐ ONE REQUEST, WHICHEVER BACKEND A COMMAND TALKS TO. The rows a backend holds
14
+ * for the site are kept apart from it, in that backend's entry in `sync.json` — the
15
+ * CLI's record of what it last agreed with that site, which nobody edits.
16
+ *
17
+ * ⛔ NOT THE TOP-LEVEL `search:` / `submit:` / `assistant:` / `tracking:` KEYS.
18
+ * Those say how the site uses a provider it brings itself; this asks the host. A
19
+ * site's own `submit: https://forms.example.com/…` read as a request would turn on
20
+ * the host's form handling, whose address then outranks the site's.
21
+ *
22
+ * Pure functions, shared by the producer (`site.js`), pull (`site-project.js`) and
23
+ * the CLI's push and publish.
24
+ *
25
+ * @module
26
+ */
27
+
28
+ /** Top-level `site.yml` keys that hold an address — named when one is misplaced here. */
29
+ const ADDRESS_KEYS = new Set(['search', 'submit', 'assistant', 'tracking', 'api'])
30
+
31
+ const isMap = (v) => v !== null && typeof v === 'object' && !Array.isArray(v)
32
+
33
+ /** Deterministic JSON: object keys sorted at every depth, arrays left in order. */
34
+ function stable(value) {
35
+ return JSON.stringify(value ?? null, (_k, v) =>
36
+ isMap(v) ? Object.fromEntries(Object.keys(v).sort().map((k) => [k, v[k]])) : v
37
+ )
38
+ }
39
+
40
+ /**
41
+ * A row as it is stored and compared: no payload-local `$id`, `enabled` only when
42
+ * it is `false` (absent means on), no empty `config`.
43
+ */
44
+ function canonicalRow(row) {
45
+ const { $id: _id, enabled, ...rest } = row
46
+ const out = { ...rest }
47
+ if (enabled === false) out.enabled = false
48
+ if (isMap(out.config) && Object.keys(out.config).length === 0) delete out.config
49
+ return out
50
+ }
51
+
52
+ const rowNamed = (rows, name) =>
53
+ Array.isArray(rows) ? rows.find((r) => isMap(r) && r.name === name) : undefined
54
+
55
+ /**
56
+ * `site.yml::services` → the asks, as rows in the `services` Section's shape:
57
+ * `{ name, enabled?: false, config? }`.
58
+ *
59
+ * @param {*} declared - the raw `services:` value
60
+ * @param {{ warn?: (message: string) => void }} [opts] - told about every value it
61
+ * ignores, with the fix
62
+ * @returns {object[]|null} null when the file asks nothing — no key, or no usable entry
63
+ */
64
+ export function readServicesRequest(declared, { warn = () => {} } = {}) {
65
+ if (declared === undefined || declared === null) return null
66
+ if (!isMap(declared)) {
67
+ warn('`services:` is a map of service names — `services: { search: true }`. Ignoring it.')
68
+ return null
69
+ }
70
+ const asks = []
71
+ for (const [name, value] of Object.entries(declared)) {
72
+ if (value === true) asks.push({ name })
73
+ else if (value === false) asks.push({ name, enabled: false })
74
+ else if (isMap(value)) {
75
+ const { enabled, ...settings } = value
76
+ if (enabled !== undefined && typeof enabled !== 'boolean') {
77
+ warn(`\`services.${name}.enabled\` is true or false. Ignoring \`${name}\`.`)
78
+ continue
79
+ }
80
+ asks.push({
81
+ name,
82
+ ...(enabled === false ? { enabled: false } : {}),
83
+ ...(Object.keys(settings).length ? { config: settings } : {})
84
+ })
85
+ } else {
86
+ const address =
87
+ typeof value === 'string' && ADDRESS_KEYS.has(name)
88
+ ? ` An address for the site's own ${name} goes in the top-level \`${name}:\` key.`
89
+ : ''
90
+ warn(`\`services.${name}\` is true, false, or a map of its settings. Ignoring it.${address}`)
91
+ }
92
+ }
93
+ return asks.length ? asks : null
94
+ }
95
+
96
+ /**
97
+ * The site's stored rows → the `site.yml::services` map — what pull writes.
98
+ *
99
+ * A row becomes `true` or `false`, or a map of its settings with `enabled: false`
100
+ * when it is off.
101
+ *
102
+ * @param {object[]} rows
103
+ * @returns {object|null} null when there is nothing to write
104
+ */
105
+ export function servicesRequestFromRows(rows) {
106
+ if (!Array.isArray(rows)) return null
107
+ const out = {}
108
+ for (const row of rows) {
109
+ if (!isMap(row) || typeof row.name !== 'string' || !row.name) continue
110
+ out[row.name] = requestValue(row)
111
+ }
112
+ return Object.keys(out).length ? out : null
113
+ }
114
+
115
+ /** One stored row → its value in the `site.yml::services` map. */
116
+ function requestValue(row) {
117
+ const off = row.enabled === false
118
+ const settings = isMap(row.config) && Object.keys(row.config).length ? row.config : null
119
+ if (!settings) return !off
120
+ return off ? { enabled: false, ...settings } : { ...settings }
121
+ }
122
+
123
+ /**
124
+ * The `site.yml::services` map with these services taken from the site's rows — a
125
+ * service the site holds no row for leaves the map.
126
+ *
127
+ * @param {*} declared - the current `services:` value
128
+ * @param {object[]} stored - the site's rows now
129
+ * @param {string[]} names
130
+ * @returns {object|null} the new map, or null when nothing is left in it
131
+ */
132
+ export function takeServices(declared, stored, names) {
133
+ const out = isMap(declared) ? { ...declared } : {}
134
+ for (const name of names) {
135
+ const row = rowNamed(stored, name)
136
+ if (row) out[name] = requestValue(row)
137
+ else delete out[name]
138
+ }
139
+ return Object.keys(out).length ? out : null
140
+ }
141
+
142
+ /**
143
+ * Does this stored row already give what the ask asks — the same switch, and every
144
+ * setting the ask names equal?
145
+ *
146
+ * @param {object|undefined} row
147
+ * @param {object} ask
148
+ * @returns {boolean}
149
+ */
150
+ export function satisfies(row, ask) {
151
+ if (!isMap(row)) return false
152
+ if ((row.enabled === false) !== (ask.enabled === false)) return false
153
+ if (!ask.config) return true
154
+ const stored = isMap(row.config) ? row.config : {}
155
+ return Object.entries(ask.config).every(([key, value]) => stable(stored[key]) === stable(value))
156
+ }
157
+
158
+ /** Two rows that are the same as stored — both absent included. */
159
+ function sameRow(a, b) {
160
+ return stable(isMap(a) ? canonicalRow(a) : null) === stable(isMap(b) ? canonicalRow(b) : null)
161
+ }
162
+
163
+ /**
164
+ * The full list to send: the stored rows, with these asks applied by name.
165
+ *
166
+ * ⭐ A FULL LIST, because the backend replaces the `services` Section with what it is
167
+ * sent: a row left out would lose its stored settings. A row no ask names is sent as
168
+ * stored. A row an ask names takes the ask's switch, and its settings over the
169
+ * stored ones, key by key. Every field is kept — a row is opaque past `name`,
170
+ * `enabled` and `config`.
171
+ *
172
+ * @param {object[]|null|undefined} base - the site's rows (or the record of them)
173
+ * @param {object[]} asks - from `readServicesRequest`
174
+ * @returns {object[]}
175
+ */
176
+ export function mergeServiceRows(base, asks) {
177
+ const out = []
178
+ const at = new Map()
179
+ for (const row of Array.isArray(base) ? base : []) {
180
+ if (!isMap(row) || typeof row.name !== 'string' || !row.name || at.has(row.name)) continue
181
+ at.set(row.name, out.length)
182
+ out.push(canonicalRow(row))
183
+ }
184
+ for (const ask of asks || []) {
185
+ const index = at.get(ask.name)
186
+ const next = index === undefined ? { name: ask.name } : { ...out[index] }
187
+ if (ask.enabled === false) next.enabled = false
188
+ else delete next.enabled
189
+ if (ask.config) next.config = { ...(isMap(next.config) ? next.config : {}), ...ask.config }
190
+ if (index === undefined) {
191
+ at.set(ask.name, out.length)
192
+ out.push(next)
193
+ } else {
194
+ out[index] = next
195
+ }
196
+ }
197
+ return out
198
+ }
199
+
200
+ /**
201
+ * Per service the file names: who moved since the last agreement, and so what to do.
202
+ *
203
+ * Three states — the file's asks, the site's rows now (`stored`), and the record of
204
+ * the last agreement (`record`, from `sync.json`):
205
+ *
206
+ * | the file vs the record | the site vs the record | outcome |
207
+ * |---|---|---|
208
+ * | unchanged | unchanged | nothing |
209
+ * | changed | unchanged | `send` |
210
+ * | unchanged | changed | `adopt` — the site's is kept, and may be taken into the file |
211
+ * | changed | changed, differently | `conflict` — only the owner can rank the two |
212
+ *
213
+ * A service the site already gives as asked is nothing, whoever moved. With no record
214
+ * the two cannot be told apart: a service the site holds nothing for is sent, one it
215
+ * holds differently is a conflict. With the site unreadable, what changed against the
216
+ * record is sent over the record; with neither, nothing can be merged onto for a site
217
+ * that exists — `unreadable` — while a site not yet created has nothing stored.
218
+ *
219
+ * @param {object} p
220
+ * @param {object[]} p.asks - from `readServicesRequest`
221
+ * @param {object[]} [p.record] - the rows last agreed, when the project has them
222
+ * @param {object[]} [p.stored] - the site's rows now, when they could be read
223
+ * @param {boolean} [p.siteKnown=true] - whether the site exists on this backend
224
+ * @returns {{ send: string[], adopt: string[], conflict: string[], unreadable: boolean }}
225
+ */
226
+ export function reconcileServices({ asks, record, stored, siteKnown = true }) {
227
+ const send = []
228
+ const adopt = []
229
+ const conflict = []
230
+ const haveRecord = Array.isArray(record)
231
+ const haveStored = Array.isArray(stored)
232
+ if (!haveRecord && !haveStored) {
233
+ return siteKnown
234
+ ? { send, adopt, conflict, unreadable: true }
235
+ : { send: asks.map((a) => a.name), adopt, conflict, unreadable: false }
236
+ }
237
+ for (const ask of asks) {
238
+ const now = rowNamed(stored, ask.name)
239
+ const then = rowNamed(record, ask.name)
240
+ if (!haveStored) {
241
+ if (!satisfies(then, ask)) send.push(ask.name)
242
+ continue
243
+ }
244
+ if (satisfies(now, ask)) continue
245
+ if (!haveRecord) {
246
+ ;(now === undefined ? send : conflict).push(ask.name)
247
+ continue
248
+ }
249
+ const fileMoved = !satisfies(then, ask)
250
+ const siteMoved = !sameRow(now, then)
251
+ if (fileMoved && !siteMoved) send.push(ask.name)
252
+ else if (!fileMoved && siteMoved) adopt.push(ask.name)
253
+ else conflict.push(ask.name)
254
+ }
255
+ return { send, adopt, conflict, unreadable: false }
256
+ }
257
+
258
+ /**
259
+ * The record after a push: what this project now agrees with the site on.
260
+ *
261
+ * The rows sent, or — when nothing was — the site's rows now. ⛔ For a service left
262
+ * OPEN (an adopt not taken, a conflict not resolved) the earlier agreement stands,
263
+ * or the next run would read the site's change as the file's and send the stale ask.
264
+ *
265
+ * @param {object} p
266
+ * @param {object[]} [p.record] - the rows last agreed
267
+ * @param {object[]} [p.agreed] - the rows sent, or the site's rows now
268
+ * @param {string[]} [p.open] - services whose decision is still open
269
+ * @returns {object[]|undefined} undefined when there is nothing to record
270
+ */
271
+ export function recordAfter({ record, agreed, open = [] }) {
272
+ if (!Array.isArray(agreed)) return undefined
273
+ const keep = new Set(open)
274
+ const out = agreed.filter((r) => isMap(r) && !keep.has(r.name)).map(canonicalRow)
275
+ for (const name of open) {
276
+ const then = rowNamed(record, name)
277
+ if (then) out.push(canonicalRow(then))
278
+ }
279
+ return out
280
+ }
@@ -36,6 +36,7 @@ import { foldOpenGraph } from './open-graph.js'
36
36
  import { join, relative, extname, basename, dirname } from 'node:path'
37
37
  import { restoreAssetRefs } from './asset-map.js'
38
38
  import { readBackendState, updateBackendState } from './sync-store.js'
39
+ import { servicesRequestFromRows } from './services-request.js'
39
40
  import { readFileSync, existsSync, unlinkSync, renameSync, rmSync, readdirSync, statSync, mkdirSync } from 'node:fs'
40
41
  import { isMarkdownFile, isIgnoredFolder } from '../utils/content-files.js'
41
42
  import { createHash } from 'node:crypto'
@@ -309,30 +310,24 @@ export function siteInfoToConfig({ document, siteRoot, backend = null, sourceLoc
309
310
  : []
310
311
  if (extensions.length > 0) siteChanges.extensions = extensions
311
312
 
312
- // services[] / secrets[] → sync.json::backends.<origin>.{services,secrets}.
313
+ // services[] → site.yml::services, and the same rows → sync.json as the record.
313
314
  //
314
- // ⭐ The `$` prefix, and not the bare name, for the reason spelled out in
315
- // `uwx/site.js`: `site.yml::services` already means "pretend a host offers these"
316
- // on the bundle lane, and one key cannot mean two things.
315
+ // ⭐ `site.yml` TAKES WHAT WAS DECIDED [Diego, 2026-10-06: "pull writes it"]. The
316
+ // stored list is the site's settled request, so the author's map becomes it, whole: a
317
+ // row is `true`, `false`, or its settings (`servicesRequestFromRows`). An empty list
318
+ // removes the key; a document with no `services` Section writes nothing.
317
319
  //
318
- // ⭐ `$id` is DROPPED — it is derived (a service's `name`; a secret's
319
- // `service:name` pair), so writing it back would put a redundant handle in the
320
- // author's file and invite them to edit the one field that must not drift from
321
- // the fields it is derived from. Same call as `extensions` above.
322
- //
323
- // ⛔ Everything else rides VERBATIM, `config` included: it is opaque, per-service
324
- // and will grow, so projecting a known subset would quietly drop whatever the
325
- // service gained since this line was written — and the next push would then send
326
- // the truncated version back as authoritative.
327
- //
328
- // ⚠️ An EMPTY section is written as an empty list, not skipped. `[]` is a real
329
- // state — "this site has no service rows" — and it is the state a `pull` must be
330
- // able to deliver after the last one was removed. Skipping would leave a stale
331
- // `$services` on disk that the next push would resurrect.
332
- // ⭐ To `sync.json`, under the backend that provisioned them — not `site.yml`.
333
- // A service is bought on one backend, so its rows describe that backend alone.
334
- // ⛔ Needs a backend: with none there is nowhere coherent to file them, and
335
- // writing them to a shared place is exactly the single-copy bug this removed.
320
+ // ⭐ The record goes to `sync.json`, under this backend: the rows as the site holds
321
+ // them — the last state both sides agreed on, which push and publish compare the file
322
+ // against (`reconcileServices`). `$id` is dropped, being derived; everything else rides
323
+ // VERBATIM, `config` included — it is opaque, per-service and will grow, and the next
324
+ // push sends the stored rows as its base. `secrets` likewise: an inventory of names
325
+ // whose values are set in the app. `[]` is written as `[]`: "the site holds no rows" is
326
+ // a state a pull must be able to deliver.
327
+ // ⛔ The record needs a backend: with none there is nowhere coherent to file it.
328
+ if (Array.isArray(document?.services)) {
329
+ siteChanges.services = servicesRequestFromRows(document.services)
330
+ }
336
331
  if (backend) {
337
332
  const provisioned = {}
338
333
  for (const section of ['services', 'secrets']) {
package/src/uwx/site.js CHANGED
@@ -80,6 +80,7 @@ import { resolveLocaleList } from '../i18n/locales.js'
80
80
  import { translationContext, SITE_META_CONTEXT } from '../i18n/extract.js'
81
81
  import { splitOpenGraph, openGraphValue, seoBesideOpenGraph } from './open-graph.js'
82
82
  import { updateBackendState, readBackendState } from './sync-store.js'
83
+ import { readServicesRequest, mergeServiceRows } from './services-request.js'
83
84
  import { upsertYamlScalar } from './yaml-upsert.js'
84
85
  import { resolveQueriesConfig } from './queries-config.js'
85
86
  import { resolveSelfScope, siteSelfScope, refuseOrgOption } from './self-scope.js'
@@ -1129,48 +1130,35 @@ function queriesNested(declarations, uuids = null, scope = null, keyTyped = null
1129
1130
  return out
1130
1131
  }
1131
1132
 
1132
- // ── `services` + `secrets` — a site's own service records ─────────────────────
1133
+ // ── `services` + `secrets` — what a site asks of its host ─────────────────────────
1133
1134
  //
1134
- // ⚠️ THESE WERE `site.yml::$services` / `$secrets` UNTIL 2026-09-20. They now live in
1135
- // `sync.json::backends.<origin>.{services,secrets}`, because what a site is PROVISIONED
1136
- // with is a fact about one backend — a service is bought on the backend that sold it.
1137
- // The `$` prefix existed to keep them off `site.yml::services`, which means something
1138
- // else on the bundle lane (the HOST tier); out of `site.yml` entirely, that collision
1139
- // is gone too.
1135
+ // ⭐ THE `services` SECTION IS THE OWNER'S REQUEST, AUTHORED IN `site.yml::services` — a
1136
+ // map from service name to `true`, `false` or the service's settings (spec:
1137
+ // kb/framework/reference/site-services-request.md). It is sent as a FULL LIST: the
1138
+ // site's rows, with the owner's asks applied by name (`mergeServiceRows`), because the
1139
+ // backend REPLACES the Section with what it is sent and a row left out would lose its
1140
+ // stored settings. The site's rows come from the caller when it has just read them
1141
+ // (`opts.serviceRows`, already merged), else from the record of the last agreement —
1142
+ // that backend's entry in `sync.json`, written by pull and by a push that succeeded.
1143
+ // ⛔ *The rows lived only in `sync.json` from 2026-09-20 to 2026-10-06, which left a
1144
+ // CLI user nowhere to ask; `site.yml::$services` / `$secrets` before that.*
1140
1145
  //
1141
- // The original note, kept because it explains why the names could not simply be reused: `site.yml::services` is ALREADY TAKEN,
1142
- // on the other lane: the bundle lane spreads site.yml whole into the payload, so a
1143
- // `services:` block there lands at `config.services` — the HOST tier — which is the
1144
- // documented way to simulate a host locally (`kit/src/utils/submitTarget.js`).
1145
- // Reusing the name would give one key two meanings that differ per lane, which is
1146
- // the shape of bug nobody finds.
1147
- //
1148
- // `$` already means "backend-scoped, round-tripped, not hand-authored" in this file
1149
- // (`$uuid`, `$org`, `$backend`), and that is exactly what these are: a service's
1150
- // config is bound where the service is provisioned, and arrives here by `pull`.
1146
+ // `secrets` stays in `sync.json`: an inventory of names a pull writes, whose values are
1147
+ // set in the app — `#ref` marks one as set.
1151
1148
  //
1152
1149
  // ⚖️ WHAT THESE ARE NOT. A site's OWN service declarations — `search:`, `submit:`,
1153
1150
  // `assistant:`, `tracking:` — ride the `settings` Section and are untouched here.
1154
- // (They were top-level `info.*` keys until 2026-09-09; `settingsNested` below is
1155
- // where they are now written.) Those
1156
- // are authored, they resolve at the SITE tier (`config.<name>`, first choice in
1157
- // `@uniweb/core`'s `resolveService`), and moving them here would flip them to the
1158
- // host tier, where a block's mere presence declines every service it does not name.
1159
- // These Sections carry the services a site is PROVISIONED with — `api` above all,
1160
- // which has no file-authored form because it is bought, not declared.
1161
- //
1162
- // ⛔ ABSENT IS NOT EMPTY, and the difference is destructive. The Section is
1163
- // REPLACED by what we send, so `[]` means "drop every stored config row" while a
1164
- // missing key means "I am not telling you about this". A project that has never
1165
- // pulled has no `services` for that backend, and its ordinary push must not read as
1166
- // a request to wipe a service the operator configured in the app. So: emit the
1167
- // Section only when that backend's entry in `sync.json` carries the key. Clearing is
1168
- // available and explicit — `services: []` there. *(Both said `site.yml::$services`
1169
- // until the move on 2026-09-20.)*
1151
+ // Those are authored addresses and land at the SITE tier, `config.<name>`, which
1152
+ // `@uniweb/core`'s `resolveService` reads for any service the host does not offer.
1153
+ // A row here is read by the service's owner and reaches neither tier, so an address
1154
+ // filed here would reach no page.
1170
1155
  //
1171
- // ⚠️ The push gate is NOT what makes this safe, though it usually catches it: its
1172
- // tokens live in a gitignored per-clone cache, so a fresh clone pushes
1173
- // unconditionally. Correctness has to sit here.
1156
+ // ⛔ ABSENT IS NOT EMPTY, and the difference is destructive. The Section is REPLACED
1157
+ // by what we send, so `[]` means "drop every stored row" while a missing key means "I
1158
+ // am not telling you about this". So `services` is emitted only when `site.yml` asks
1159
+ // for something and there is a list to apply it to — never for a file that says
1160
+ // nothing, and never as a partial list for a site whose rows this project does not
1161
+ // know. A site with no entry in `sync.json` has nothing stored yet.
1174
1162
 
1175
1163
  /**
1176
1164
  * A backend's provisioned `services` / `secrets` (its `sync.json` entry) → Section
@@ -1223,14 +1211,36 @@ function serviceRecords(declared, identify, label) {
1223
1211
  }
1224
1212
 
1225
1213
  /** One service per `name` — the same keyspace `config.services` uses at runtime. */
1226
- function servicesNested(provisioned) {
1214
+ function servicesNested(rows) {
1227
1215
  return serviceRecords(
1228
- provisioned?.services,
1216
+ rows,
1229
1217
  (e) => (typeof e.name === 'string' && e.name ? e.name : null),
1230
- '$services'
1218
+ 'services'
1231
1219
  )
1232
1220
  }
1233
1221
 
1222
+ /**
1223
+ * The `services` Section for this push, or undefined when there is nothing to send:
1224
+ * the caller's rows when it passes them (the site's, read just now, with the asks it
1225
+ * decided to send applied), else the file's asks over the record of the site's rows.
1226
+ *
1227
+ * @param {object} siteYml
1228
+ * @param {object|null} provisioned - the backend's entry in `sync.json`
1229
+ * @param {object[]} [serviceRows]
1230
+ * @returns {object[]|undefined}
1231
+ */
1232
+ function requestedServices(siteYml, provisioned, serviceRows) {
1233
+ if (Array.isArray(serviceRows)) return servicesNested(serviceRows)
1234
+ const asks = readServicesRequest(siteYml.services)
1235
+ if (!asks) return undefined
1236
+ const base = Array.isArray(provisioned?.services)
1237
+ ? provisioned.services
1238
+ : provisioned?.site?.uuid
1239
+ ? null
1240
+ : []
1241
+ return base ? servicesNested(mergeServiceRows(base, asks)) : undefined
1242
+ }
1243
+
1234
1244
  /**
1235
1245
  * One secret per `(service, name)` — the pair the backend merges on. A site-level
1236
1246
  * secret belongs to no service, so `service` is optional and the handle degrades to
@@ -1342,12 +1352,14 @@ function settingsNested(siteYml, { headHtml, themeYml, sourceLocale, translation
1342
1352
  // carries the translation collector with it.
1343
1353
  setIf(settings, 'keywords', localizeScalarList(siteYml.keywords, sourceLocale, translations))
1344
1354
 
1345
- // Authored service declarations. ⛔ These must NOT be filed with the `$services`
1346
- // Section: authored ones resolve at the SITE tier (`config.<name>`, first choice
1347
- // in `@uniweb/core`'s `resolveService`) while `$services` is the HOST tier, where
1348
- // a block's mere PRESENCE declines every service it does not name
1349
- // (`core/src/services.js`). Moving them there would invert their precedence and
1350
- // turn a site's own search off with no error and no message.
1355
+ // Authored service declarations. ⛔ These must NOT be filed with the `services`
1356
+ // Section. Here they are the SITE tier, `config.<name>` — the address
1357
+ // `@uniweb/core`'s `resolveService` uses wherever the host offers none. That
1358
+ // Section is a provisioning request whose rows reach neither tier, so filed there
1359
+ // a site's own search endpoint would reach no page, and on a host whose services
1360
+ // block does not name search the site would lose search with no error and no
1361
+ // message (`core/src/services.js`). *(Until 2026-10-06 this called that Section
1362
+ // "the HOST tier"; it is a request about what the host provides.)*
1351
1363
  setIf(settings, 'search', siteYml.search)
1352
1364
  setIf(settings, 'submit', siteYml.submit)
1353
1365
  // ⛔ Credentials are stripped, not trusted — this block is published world-readable.
@@ -1403,6 +1415,10 @@ function settingsNested(siteYml, { headHtml, themeYml, sourceLocale, translation
1403
1415
  * known, `@/x` ships as written.
1404
1416
  * @param {string[]} [opts.schemaless] - the queries whose records resolved no data schema
1405
1417
  * (the records lane's `schemaless`): their declarations name no `schema`.
1418
+ * @param {object[]} [opts.serviceRows] - the `services` Section to send, as the caller
1419
+ * decided it (the site's rows with the owner's asks applied). Default: the
1420
+ * file's asks over the record in `sync.json`.
1421
+ * @param {boolean} [opts.declareServices] - `false` withholds `services` and `secrets`.
1406
1422
  * @returns {Promise<object>} the section-keyed `$`-document:
1407
1423
  * `{ $uuid?, $id, $schema, info, settings?, pages, layout_sections, extensions,
1408
1424
  * queries, services?, secrets? }`
@@ -1558,7 +1574,8 @@ export async function siteProjectToDocument(siteRoot, opts = {}) {
1558
1574
  // comments above describe — there, a dropped block leaves a site with NO
1559
1575
  // endpoint; here it leaves the site with the RIGHT answer.
1560
1576
  //
1561
- // The provisioned record rides the `$services` section instead (see servicesNested).
1577
+ // Asking for the `api` service is `site.yml::services` → the `services` Section (see
1578
+ // requestedServices); its address still comes from the host.
1562
1579
  // ⛔ THE CONFIGURATION KEYS ARE NOT HERE — they ride the `settings` Section
1563
1580
  // (`settingsNested` above). `info` is the BRIEF: what a card or a select dropdown
1564
1581
  // renders, plus what a listing can filter on. Eighteen keys moved off it on
@@ -1722,31 +1739,22 @@ export async function siteProjectToDocument(siteRoot, opts = {}) {
1722
1739
  opts.queryFields,
1723
1740
  Array.isArray(opts.schemaless) ? new Set(opts.schemaless) : null
1724
1741
  )
1725
- // Emitted ONLY when the file declares the key — see the header above
1742
+ // Emitted ONLY when there is something to say — see the header above
1726
1743
  // `serviceRecords`: on a replaced Section, absent and empty are different
1727
1744
  // requests and one of them is destructive.
1728
1745
  //
1729
- // ⭐ AND ONLY WHEN THE CALLER SAYS THE DECLARATION IS A REQUEST.
1730
- // `opts.declareServices === false` withholds both Sections for THIS push, which
1731
- // is not the same as the file having no key — the file still declares one; the
1732
- // caller has determined the owner is not asking for anything new by it.
1733
- //
1734
- // ⛔ Why this decision cannot live here: the Sections are REPLACED wholesale by
1735
- // what we send (`SectionScope::DeclaredOnly`), so re-sending an unchanged block
1736
- // OVERWRITES whatever the stored request has become since — including a decision
1737
- // the owner made in the app, where the consent workflow's publish happens. But
1738
- // "has it changed since we last agreed?" needs the last agreed state, which is
1739
- // project memory (`deploy.yml`) the CLI owns and this pure mapper must not read.
1740
- // ⇒ The CLI decides; this honours the decision.
1746
+ // ⭐ WHICH ASKS GO IS THE CALLER'S. Push and publish read the site's rows and the
1747
+ // record of the last agreement, apply only what the owner changed, and pass the
1748
+ // result as `opts.serviceRows` — so an ask the site has since decided otherwise is
1749
+ // never re-sent over that decision (`reconcileServices`). Without them, the file's
1750
+ // asks apply over the record, which is what `status` and a pull's re-bank compare.
1741
1751
  //
1742
- // ⚖️ Default is to declare, so every existing caller is unchanged and the
1743
- // withholding is opt-in.
1752
+ // `opts.declareServices === false` withholds both Sections for THIS push.
1744
1753
  const declare = opts.declareServices !== false
1745
- // ⭐ Provisioned per backend — a service is bought on the backend that sold it,
1746
- // so which rows exist is a question about ONE backend. They were `site.yml::$services`
1747
- // / `$secrets`, a single copy that could only ever describe one.
1754
+ // ⭐ The rows are per backend — a service is bought on the backend that sold it —
1755
+ // and the request is one, in `site.yml`.
1748
1756
  const provisioned = opts.backend ? readBackendState(siteRoot, opts.backend) : null
1749
- const services = declare ? servicesNested(provisioned) : undefined
1757
+ const services = declare ? requestedServices(siteYml, provisioned, opts.serviceRows) : undefined
1750
1758
  if (services) doc.services = services
1751
1759
  const secrets = declare ? secretsNested(provisioned) : undefined
1752
1760
  if (secrets) doc.secrets = secrets
@@ -215,6 +215,8 @@ function rewriteEntityAssets(node, map, ids, noStamp = null) {
215
215
  * @param {string} [opts.sourceLocale] - localized-field wrap locale
216
216
  * @param {Object<string,string>} [opts.priorHashes] - sync-cache (send-only-changed)
217
217
  * @param {boolean} [opts.sendAll] - bypass the prior-hash filter
218
+ * @param {object[]} [opts.serviceRows] - the `services` Section to send, as the
219
+ * caller decided it (site.js `requestedServices`)
218
220
  * @param {Object<string,string>} [opts.itemUuids] - unit path → backend `$uuid`,
219
221
  * stamped onto the site-content document so the backend matches our items
220
222
  * instead of re-minting them (which deletes and recreates every page and
@@ -375,10 +377,11 @@ export async function emitSyncPackages(siteRoot, opts = {}) {
375
377
  ...(Array.isArray(opts.queryFields) ? { queryFields: opts.queryFields } : {}),
376
378
  ...(Array.isArray(opts.pageFields) ? { pageFields: opts.pageFields } : {}),
377
379
  ...(Array.isArray(opts.settingsFields) ? { settingsFields: opts.settingsFields } : {}),
378
- // Withhold the `$services`/`$secrets` Sections when the caller has
379
- // determined the file is not asking for anything new by them. Passed
380
- // through rather than decided here: the last-agreed state is project
381
- // memory the CLI owns. See site.js at `declareServices`.
380
+ // The `services` Section as the caller decided it — the site's rows with the
381
+ // owner's changed asks applied — or the Sections withheld. Passed through
382
+ // rather than decided here: the site's rows and the last agreement are what
383
+ // the CLI reads. See site.js at `requestedServices`.
384
+ ...(Array.isArray(opts.serviceRows) ? { serviceRows: opts.serviceRows } : {}),
382
385
  ...(opts.declareServices === false ? { declareServices: false } : {})
383
386
  })
384
387
  : null