@uniweb/build 0.77.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 +8 -8
- package/src/content/index.js +0 -1
- package/src/dev/api-mount.js +30 -20
- package/src/foundation/derive-supports.js +3 -2
- package/src/resolve-data-schema.js +2 -1
- package/src/runtime-schema.js +30 -12
- package/src/schema.js +93 -1
- package/src/site/content-collector.js +21 -6
- package/src/site/data-fetcher.js +19 -0
- package/src/site/deploy-config-writer.js +4 -2
- package/src/site/index.js +1 -2
- package/src/site/plugin.js +19 -6
- package/src/site/queries-config.js +2 -1
- package/src/site/query-processor.js +6 -42
- package/src/uwx/emit-surface.json +1 -13
- package/src/uwx/index.js +12 -5
- package/src/uwx/project-writer.js +31 -21
- package/src/uwx/services-request.js +471 -198
- package/src/uwx/shown-records.js +52 -0
- package/src/uwx/site-diff.js +47 -0
- package/src/uwx/site-project.js +40 -55
- package/src/uwx/site.js +61 -162
- package/src/uwx/sync-package.js +30 -21
- package/src/uwx/yaml-edit.js +288 -0
- package/src/validate-data.js +18 -11
- package/src/uwx/yaml-upsert.js +0 -79
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The queries whose records a site's pages SHOW live — what the `records` service is for.
|
|
3
|
+
*
|
|
4
|
+
* ⭐ `records` is the live delivery of records to a published site, nothing else
|
|
5
|
+
* [Diego, 2026-10-07: "syncing records is unrelated to `records` being on/off. The
|
|
6
|
+
* service is the live delivery of records to published site"]. So what decides whether a
|
|
7
|
+
* site needs it is what its published pages fetch — never whether it has records to sync.
|
|
8
|
+
* A query counts when a page, a section or the site fetches it, and its records have a
|
|
9
|
+
* data schema (the declaration names one) and come from the site's own folder (no `url:`).
|
|
10
|
+
* A schema-less query's records are delivered as static files, which `records` does not
|
|
11
|
+
* touch; an external query is fetched from its own address.
|
|
12
|
+
*
|
|
13
|
+
* @module
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const isMap = (v) => v !== null && typeof v === 'object' && !Array.isArray(v)
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* @param {object} doc - a site-content document, as a push sends it
|
|
20
|
+
* @returns {string[]} the query names, sorted
|
|
21
|
+
*/
|
|
22
|
+
export function recordQueriesShown(doc) {
|
|
23
|
+
const fetched = new Set()
|
|
24
|
+
const take = (fetch) => {
|
|
25
|
+
for (const one of [fetch].flat()) if (isMap(one) && typeof one.query === 'string' && one.query) fetched.add(one.query)
|
|
26
|
+
}
|
|
27
|
+
const walkSections = (sections) => {
|
|
28
|
+
for (const section of Array.isArray(sections) ? sections : []) {
|
|
29
|
+
take(section?.params?.fetch)
|
|
30
|
+
take(section?.fetch)
|
|
31
|
+
walkSections(section?.$children)
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
const walkPages = (pages) => {
|
|
35
|
+
for (const page of Array.isArray(pages) ? pages : []) {
|
|
36
|
+
take(page?.fetch)
|
|
37
|
+
walkSections(page?.page_sections)
|
|
38
|
+
walkPages(page?.$children)
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
take(doc?.settings?.fetch)
|
|
42
|
+
walkPages(doc?.pages)
|
|
43
|
+
walkSections(doc?.layout_sections)
|
|
44
|
+
const live = new Set()
|
|
45
|
+
for (const query of Array.isArray(doc?.queries) ? doc.queries : []) {
|
|
46
|
+
if (!isMap(query) || typeof query.name !== 'string') continue
|
|
47
|
+
if (typeof query.schema !== 'string' || !query.schema) continue
|
|
48
|
+
if (isMap(query.source) && query.source.url !== undefined) continue
|
|
49
|
+
live.add(query.name)
|
|
50
|
+
}
|
|
51
|
+
return [...fetched].filter((name) => live.has(name)).sort()
|
|
52
|
+
}
|
package/src/uwx/site-diff.js
CHANGED
|
@@ -321,3 +321,50 @@ export function collectQueryUuids(doc) {
|
|
|
321
321
|
}
|
|
322
322
|
return out
|
|
323
323
|
}
|
|
324
|
+
|
|
325
|
+
// The Sections whose items are matched by a field of their own, and that field — how
|
|
326
|
+
// the backend matches them too. `extensions` has no declared key yet; its `ref` or
|
|
327
|
+
// `url` is what the file names it by.
|
|
328
|
+
const KEYED_SECTIONS = {
|
|
329
|
+
queries: (item) => item?.name,
|
|
330
|
+
services: (item) => item?.name,
|
|
331
|
+
extensions: (item) => item?.ref || item?.url
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Every item of a site-content document, under a key that names it the same way in OUR
|
|
336
|
+
* document and in the BACKEND's, so the two can be paired item by item.
|
|
337
|
+
*
|
|
338
|
+
* - units — `site.yml` (the `info` item), pages, page sections and layout sections —
|
|
339
|
+
* by the path the projector writes them to (`unit:<path>`), each its OWN content, as
|
|
340
|
+
* `collectSiteUnits` keys them;
|
|
341
|
+
* - `settings`, one item, by its Section;
|
|
342
|
+
* - `queries`, `services` and `extensions` by the field each is matched by
|
|
343
|
+
* (`queries:<name>`, …).
|
|
344
|
+
*
|
|
345
|
+
* ⛔ `secrets` is left out on purpose: a push never deletes a secret by leaving it out,
|
|
346
|
+
* and its values are set in the app, so no version of one is ever kept or sent.
|
|
347
|
+
* A Section this does not know is left out too — an item that cannot be paired is
|
|
348
|
+
* never taken as seen.
|
|
349
|
+
*
|
|
350
|
+
* @returns {Map<string, { uuid: string|null, record: object }>}
|
|
351
|
+
*/
|
|
352
|
+
export function siteItemsByKey(doc, sourceLocale = LOCALIZED_FIELD_ASSUMPTION.defaultSourceLocale) {
|
|
353
|
+
const out = new Map()
|
|
354
|
+
const uuidOf = (record) => (typeof record?.$uuid === 'string' && record.$uuid ? record.$uuid : null)
|
|
355
|
+
const units = collectSiteUnits(doc, sourceLocale)
|
|
356
|
+
walkSiteUnits(doc, (path, record) => {
|
|
357
|
+
if (!out.has(`unit:${path}`)) out.set(`unit:${path}`, { uuid: uuidOf(record), record: units.get(path) })
|
|
358
|
+
}, sourceLocale)
|
|
359
|
+
if (doc?.settings && typeof doc.settings === 'object' && !Array.isArray(doc.settings)) {
|
|
360
|
+
out.set('settings', { uuid: uuidOf(doc.settings), record: doc.settings })
|
|
361
|
+
}
|
|
362
|
+
for (const [section, keyOf] of Object.entries(KEYED_SECTIONS)) {
|
|
363
|
+
for (const item of Array.isArray(doc?.[section]) ? doc[section] : []) {
|
|
364
|
+
const key = keyOf(item)
|
|
365
|
+
if (typeof key !== 'string' || !key || out.has(`${section}:${key}`)) continue
|
|
366
|
+
out.set(`${section}:${key}`, { uuid: uuidOf(item), record: item })
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
return out
|
|
370
|
+
}
|
package/src/uwx/site-project.js
CHANGED
|
@@ -36,7 +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 {
|
|
39
|
+
import { servicesFromDocument, heldServices } from './services-request.js'
|
|
40
40
|
import { readFileSync, existsSync, unlinkSync, renameSync, rmSync, readdirSync, statSync, mkdirSync } from 'node:fs'
|
|
41
41
|
import { isMarkdownFile, isIgnoredFolder } from '../utils/content-files.js'
|
|
42
42
|
import { createHash } from 'node:crypto'
|
|
@@ -56,7 +56,6 @@ import { siteContentDirs } from './site-dirs.js'
|
|
|
56
56
|
import { layoutAreaRoute } from '../site/layout-folder.js'
|
|
57
57
|
import { orderFolders, rootOrderConfig, parseNumericPrefix, parseWildcardArray, composeLocalizedRoute, stripAtPrefix, compareFilenames } from '../site/content-collector.js'
|
|
58
58
|
import { parseFrontmatter } from '../utils/frontmatter.js'
|
|
59
|
-
import { upsertYamlScalar } from './yaml-upsert.js'
|
|
60
59
|
|
|
61
60
|
// The pull-side identity index: a per-clone, GITIGNORED `uuid → relative path`
|
|
62
61
|
// map under `.uniweb/`, the home for the backend's per-item identity so that
|
|
@@ -124,33 +123,15 @@ const INFO_TO_SITE_YML = {
|
|
|
124
123
|
// Publish intent — verbatim both ways, dangling codes included (they carry
|
|
125
124
|
// the preserved publish intent of a temporarily-undeclared language).
|
|
126
125
|
favicon: 'favicon',
|
|
127
|
-
//
|
|
128
|
-
//
|
|
129
|
-
//
|
|
130
|
-
//
|
|
131
|
-
//
|
|
132
|
-
//
|
|
133
|
-
//
|
|
134
|
-
//
|
|
135
|
-
//
|
|
136
|
-
// backend never stores one and it cannot come back. What that does to the
|
|
137
|
-
// author's file depends on which direction they are pulling into, and the two
|
|
138
|
-
// differ — ⛔ this comment asserted the wrong one until it was run:
|
|
139
|
-
//
|
|
140
|
-
// - `pull` over an EXISTING site.yml — the local `apiKey` SURVIVES.
|
|
141
|
-
// `mergeYamlConfig` spreads one level (`{...existing[key], ...value}`), so
|
|
142
|
-
// the projected `{endpoint, …}` merges over the local block and nothing
|
|
143
|
-
// removes a key the document does not carry.
|
|
144
|
-
// - `clone` into a fresh directory — no local block exists, so the file is
|
|
145
|
-
// written from the document alone and has no `apiKey`.
|
|
146
|
-
//
|
|
147
|
-
// Both are correct: a pull does not silently delete something the author
|
|
148
|
-
// typed, and the push already warned them. The security property is upheld at
|
|
149
|
-
// the push, not by the pull. Measured end-to-end against a live uniwebd —
|
|
150
|
-
// every unit test passed before and after, so only a real push touched it.
|
|
151
|
-
// Authored-only, like `submit` and `assistant`: a host's tracking endpoint is
|
|
152
|
-
// offered through `config.services.tracking` and resolved at render, so it
|
|
153
|
-
// never enters `info` and a pull cannot launder it into authored config.
|
|
126
|
+
// ⛔ `submit` / `assistant` / `tracking` are not `info` keys: they left it for the
|
|
127
|
+
// `settings` Section on 2026-09-09, and on 2026-10-06 left that for each service's
|
|
128
|
+
// row — a pull writes `site.yml::services` from the rows (`servicesFromDocument`).
|
|
129
|
+
// Two behaviours measured while they were here still hold, now in
|
|
130
|
+
// `keepCredentials`: a credential is stripped on push and a pull over an existing
|
|
131
|
+
// file keeps the author's (a `clone` into a fresh directory has none to keep) — a
|
|
132
|
+
// pull does not silently delete what the author typed, and the push already warned
|
|
133
|
+
// them; and a host's endpoint never enters the record, so a pull cannot launder it
|
|
134
|
+
// into authored config.
|
|
154
135
|
// ⛔ The site's declaration is not verbatim — see the explicit `settings.fetch`
|
|
155
136
|
// branch below: it projects to `site.yml::query` or `site.yml::fetch`.
|
|
156
137
|
template: 'template',
|
|
@@ -200,14 +181,12 @@ const SETTINGS_TO_SITE_YML = {
|
|
|
200
181
|
paths: 'paths',
|
|
201
182
|
seo: 'seo',
|
|
202
183
|
layout: 'layout',
|
|
203
|
-
//
|
|
204
|
-
//
|
|
184
|
+
// ⛔ `search` / `submit` / `assistant` / `tracking` are not here: each service rides
|
|
185
|
+
// its row in the `services` Section, whole, and a pull writes it into
|
|
186
|
+
// `site.yml::services` (`servicesFromDocument`, below). They were top-level keys
|
|
187
|
+
// until 2026-10-06 and `settings` keys until that evening. A host's own address is
|
|
205
188
|
// offered through `config.services` and resolved at render, never entering the
|
|
206
|
-
// stored record.
|
|
207
|
-
search: 'search',
|
|
208
|
-
submit: 'submit',
|
|
209
|
-
assistant: 'assistant',
|
|
210
|
-
tracking: 'tracking',
|
|
189
|
+
// stored record, so a pull cannot launder it into authored config.
|
|
211
190
|
agents: 'agents',
|
|
212
191
|
}
|
|
213
192
|
|
|
@@ -310,30 +289,36 @@ export function siteInfoToConfig({ document, siteRoot, backend = null, sourceLoc
|
|
|
310
289
|
: []
|
|
311
290
|
if (extensions.length > 0) siteChanges.extensions = extensions
|
|
312
291
|
|
|
313
|
-
// services
|
|
292
|
+
// services → site.yml::services, one entry per service; which services this copy
|
|
293
|
+
// now holds → sync.json.
|
|
314
294
|
//
|
|
315
|
-
// ⭐ `site.yml` TAKES WHAT WAS DECIDED [Diego, 2026-10-06: "pull writes it"].
|
|
316
|
-
//
|
|
317
|
-
//
|
|
318
|
-
//
|
|
295
|
+
// ⭐ `site.yml` TAKES WHAT WAS DECIDED [Diego, 2026-10-06: "pull writes it"]. Each
|
|
296
|
+
// entry is its row in the `services` Section — the whole entry in its `config`
|
|
297
|
+
// (`servicesFromDocument`) — except an off service with no settings, written only
|
|
298
|
+
// where the file already names it [Diego, 2026-10-07]. The map is written whole,
|
|
299
|
+
// keeping only the credentials an author typed, which a push never sends; a document
|
|
300
|
+
// with no Section writes nothing. The retired top-level keys go, so a pulled file
|
|
301
|
+
// builds.
|
|
319
302
|
//
|
|
320
|
-
// ⭐
|
|
321
|
-
//
|
|
322
|
-
//
|
|
323
|
-
//
|
|
324
|
-
//
|
|
325
|
-
//
|
|
326
|
-
//
|
|
327
|
-
// ⛔ The record needs a backend: with none there is nowhere coherent to file it.
|
|
303
|
+
// ⭐ `sync.json` takes, under this backend, the services this copy now HOLDS —
|
|
304
|
+
// `{ name: $uuid }`, every row the site has, written to the file or not
|
|
305
|
+
// (`heldServices`). A push states each one the file does not list as off, so the site
|
|
306
|
+
// keeps what it has. `secrets` is an inventory of names whose values are set in the
|
|
307
|
+
// app, `$id` dropped. ⛔ *Until 2026-10-07 `services` held the rows themselves, the
|
|
308
|
+
// record a push compared the file against, with `servicesNamed` beside it.*
|
|
309
|
+
// ⛔ What is held needs a backend: with none there is nowhere coherent to file it.
|
|
328
310
|
if (Array.isArray(document?.services)) {
|
|
329
|
-
siteChanges.services =
|
|
311
|
+
siteChanges.services = servicesFromDocument({
|
|
312
|
+
rows: document.services,
|
|
313
|
+
local: readAuthoredYaml(join(siteRoot, 'site.yml'))?.services
|
|
314
|
+
})
|
|
315
|
+
for (const retired of ['search', 'submit', 'assistant', 'tracking', 'api']) siteChanges[retired] = null
|
|
330
316
|
}
|
|
331
317
|
if (backend) {
|
|
332
318
|
const provisioned = {}
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
provisioned[section] = records.map(({ $id: _id, ...fields }) => fields)
|
|
319
|
+
if (Array.isArray(document?.services)) provisioned.services = heldServices({ written: document.services })
|
|
320
|
+
if (Array.isArray(document?.secrets)) {
|
|
321
|
+
provisioned.secrets = document.secrets.map(({ $id: _id, ...fields }) => fields)
|
|
337
322
|
}
|
|
338
323
|
if (Object.keys(provisioned).length) {
|
|
339
324
|
updateBackendState(siteRoot, backend, provisioned)
|
|
@@ -1169,7 +1154,7 @@ export function siteContentDocumentToProject({ document, siteRoot, backend = nul
|
|
|
1169
1154
|
const listed = Array.isArray(root.pages) ? parseWildcardArray(root.pages) : null
|
|
1170
1155
|
// A list that names the homepage first beats `index:`, so it opens with `...` instead.
|
|
1171
1156
|
if (listed && listed.before.length > 0) root.write(['...', ...root.pages.filter((e) => e !== '...')])
|
|
1172
|
-
|
|
1157
|
+
writeSiteConfig(siteRoot, { index: ctx.homepage })
|
|
1173
1158
|
}
|
|
1174
1159
|
}
|
|
1175
1160
|
projectLayout(document?.layout_sections, layoutBaseDir, report, prune, ctx)
|
package/src/uwx/site.js
CHANGED
|
@@ -80,8 +80,12 @@ 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 {
|
|
84
|
-
|
|
83
|
+
import {
|
|
84
|
+
readServicesRequest,
|
|
85
|
+
statedServices,
|
|
86
|
+
refuseRetiredServiceKeys,
|
|
87
|
+
refuseUnreadableServices
|
|
88
|
+
} from './services-request.js'
|
|
85
89
|
import { resolveQueriesConfig } from './queries-config.js'
|
|
86
90
|
import { resolveSelfScope, siteSelfScope, refuseOrgOption } from './self-scope.js'
|
|
87
91
|
import { siteContentDirs } from './site-dirs.js'
|
|
@@ -103,46 +107,6 @@ function scalarString(value) {
|
|
|
103
107
|
return undefined
|
|
104
108
|
}
|
|
105
109
|
|
|
106
|
-
// Credential-shaped keys, mirroring the set the delivery edge strips on the
|
|
107
|
-
// reading side. Deliberately the SAME list rather than a stricter one, so the
|
|
108
|
-
// two guards are visibly twins and a key added to one is obviously owed to the
|
|
109
|
-
// other.
|
|
110
|
-
const CREDENTIAL_KEYS = ['apiKey', 'api_key', 'key', 'token', 'secret']
|
|
111
|
-
|
|
112
|
-
/**
|
|
113
|
-
* Drop credential-shaped keys out of an authored service block.
|
|
114
|
-
*
|
|
115
|
-
* An authored block crosses into backend storage here and is served from there
|
|
116
|
-
* in a published payload — which is world-readable. So a credential in
|
|
117
|
-
* `site.yml` is not merely untidy, it is disclosed. The host's secret store is
|
|
118
|
-
* the only right home, resolved at request time.
|
|
119
|
-
*
|
|
120
|
-
* Warns rather than throwing: the fix belongs to the author, a failed push
|
|
121
|
-
* helps nobody, and the block is still useful without the key. Returns the
|
|
122
|
-
* value untouched when there is nothing to strip, so `setIf`'s absent-vs-empty
|
|
123
|
-
* behaviour is unchanged — a block that carried ONLY a credential arrives as
|
|
124
|
-
* `{}` rather than vanishing, keeping the mistake visible where the author
|
|
125
|
-
* looks for it.
|
|
126
|
-
*
|
|
127
|
-
* @param {*} block - the authored value, any shape
|
|
128
|
-
* @param {string} label - the site.yml key, for the warning
|
|
129
|
-
* @returns {*} the block, minus anything credential-shaped
|
|
130
|
-
*/
|
|
131
|
-
function stripCredentials(block, label) {
|
|
132
|
-
if (!block || typeof block !== 'object' || Array.isArray(block)) return block
|
|
133
|
-
const found = CREDENTIAL_KEYS.filter(key => key in block)
|
|
134
|
-
if (found.length === 0) return block
|
|
135
|
-
|
|
136
|
-
console.warn(
|
|
137
|
-
`uwx/site: dropped ${found.join(', ')} from \`${label}:\` — authored config is published ` +
|
|
138
|
-
'world-readable, so a credential belongs in the host secret store, never in site.yml.'
|
|
139
|
-
)
|
|
140
|
-
|
|
141
|
-
const cleaned = { ...block }
|
|
142
|
-
for (const key of found) delete cleaned[key]
|
|
143
|
-
return cleaned
|
|
144
|
-
}
|
|
145
|
-
|
|
146
110
|
// page_sections and layout_sections share this content shape.
|
|
147
111
|
// processMarkdownFile takes only type/hidden/props/fetch/query/data/id out of the
|
|
148
112
|
// frontmatter (and drops preset/input), so `background:` and `theme:` stay
|
|
@@ -1038,8 +1002,8 @@ const DECL_NOT_ON_WIRE = new Set([
|
|
|
1038
1002
|
// passed unknown keys through blindly and would have sent `route` from every site
|
|
1039
1003
|
// that declared one.
|
|
1040
1004
|
'route',
|
|
1041
|
-
//
|
|
1042
|
-
//
|
|
1005
|
+
// ⛔ RETIRED 2026-10-06 — the build refuses it (`refuseQueryDeclaration`), so it is never sent.
|
|
1006
|
+
// ⚠️ This said it was "translated to the canonical `where` upstream"; nothing translated it.
|
|
1043
1007
|
'filter',
|
|
1044
1008
|
// ⛔ RETIRED 2026-09-27 — the build refuses it (`refuseQueryDeclaration`), so it is never sent.
|
|
1045
1009
|
'deferred',
|
|
@@ -1089,8 +1053,6 @@ function queriesNested(declarations, uuids = null, scope = null, keyTyped = null
|
|
|
1089
1053
|
// → `siteContent.queryFields`), since one that does not refuses a push carrying it.
|
|
1090
1054
|
if (typed && Array.isArray(queryFields) && queryFields.includes('typed_by_data_key')) data.typed_by_data_key = true
|
|
1091
1055
|
setIf(data, 'sort', d.sort)
|
|
1092
|
-
// Legacy `filter:` is not synced — it is translated to `where` upstream
|
|
1093
|
-
// (the canonical predicate). No legacy fields on the wire.
|
|
1094
1056
|
setIf(data, 'where', d.where)
|
|
1095
1057
|
setIf(data, 'limit', d.limit)
|
|
1096
1058
|
setIf(data, 'excerpt', d.excerpt)
|
|
@@ -1134,14 +1096,16 @@ function queriesNested(declarations, uuids = null, scope = null, keyTyped = null
|
|
|
1134
1096
|
//
|
|
1135
1097
|
// ⭐ THE `services` SECTION IS THE OWNER'S REQUEST, AUTHORED IN `site.yml::services` — a
|
|
1136
1098
|
// map from service name to `true`, `false` or the service's settings (spec:
|
|
1137
|
-
// kb/framework/reference/site-services-request.md).
|
|
1138
|
-
//
|
|
1139
|
-
//
|
|
1140
|
-
//
|
|
1141
|
-
//
|
|
1142
|
-
//
|
|
1143
|
-
// ⛔ *
|
|
1144
|
-
//
|
|
1099
|
+
// kb/framework/reference/site-services-request.md). A push STATES: every service the
|
|
1100
|
+
// file lists, as it says it, and OFF for every service this copy holds that the file no
|
|
1101
|
+
// longer lists (`statedServices`). The services it holds are named in that backend's
|
|
1102
|
+
// entry in `sync.json`, `{ name: $uuid }`, written by pull and by a push that succeeded;
|
|
1103
|
+
// one it never saw is not sent, and the site keeps it. The backend decides per service
|
|
1104
|
+
// from the versions the push sends [Diego, 2026-10-07 — kb/framework/plans/services-exchange.md].
|
|
1105
|
+
// ⛔ *Until then the Section went as a FULL LIST — the site's rows, read just before the
|
|
1106
|
+
// push, with the file's changed asks applied — because the backend replaced the Section
|
|
1107
|
+
// with what it was sent; and from 2026-09-20 to 2026-10-06 the rows lived only in
|
|
1108
|
+
// `sync.json`, which left a CLI user nowhere to ask.*
|
|
1145
1109
|
//
|
|
1146
1110
|
// `secrets` stays in `sync.json`: an inventory of names a pull writes, whose values are
|
|
1147
1111
|
// set in the app — `#ref` marks one as set.
|
|
@@ -1153,12 +1117,8 @@ function queriesNested(declarations, uuids = null, scope = null, keyTyped = null
|
|
|
1153
1117
|
// A row here is read by the service's owner and reaches neither tier, so an address
|
|
1154
1118
|
// filed here would reach no page.
|
|
1155
1119
|
//
|
|
1156
|
-
//
|
|
1157
|
-
//
|
|
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.
|
|
1120
|
+
// The Section is emitted only when there is something to state: a file that lists no
|
|
1121
|
+
// service, on a copy that holds none, sends no `services`.
|
|
1162
1122
|
|
|
1163
1123
|
/**
|
|
1164
1124
|
* A backend's provisioned `services` / `secrets` (its `sync.json` entry) → Section
|
|
@@ -1220,25 +1180,22 @@ function servicesNested(rows) {
|
|
|
1220
1180
|
}
|
|
1221
1181
|
|
|
1222
1182
|
/**
|
|
1223
|
-
* The `services` Section for this push
|
|
1224
|
-
*
|
|
1225
|
-
* decided to send applied), else the file's asks over the record of the site's rows.
|
|
1183
|
+
* The `services` Section for this push — the file's services, and the held ones it no
|
|
1184
|
+
* longer lists stated off (`statedServices`) — or undefined when there is nothing to state.
|
|
1226
1185
|
*
|
|
1227
1186
|
* @param {object} siteYml
|
|
1228
|
-
* @param {object|null} provisioned - the backend's entry in `sync.json`
|
|
1229
|
-
*
|
|
1187
|
+
* @param {object|null} provisioned - the backend's entry in `sync.json`; its `services`
|
|
1188
|
+
* is `{ <name>: <$uuid> }`, the services this copy holds
|
|
1230
1189
|
* @returns {object[]|undefined}
|
|
1231
1190
|
*/
|
|
1232
|
-
function requestedServices(siteYml, provisioned
|
|
1233
|
-
|
|
1234
|
-
const
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
: []
|
|
1241
|
-
return base ? servicesNested(mergeServiceRows(base, asks)) : undefined
|
|
1191
|
+
function requestedServices(siteYml, provisioned) {
|
|
1192
|
+
const held = provisioned?.services
|
|
1193
|
+
const rows = statedServices(
|
|
1194
|
+
readServicesRequest(siteYml.services),
|
|
1195
|
+
held && typeof held === 'object' && !Array.isArray(held) ? held : {}
|
|
1196
|
+
)
|
|
1197
|
+
// `$uuid` rides beside the payload-local `$id`, as on a query's row.
|
|
1198
|
+
return rows.length ? servicesNested(rows) : undefined
|
|
1242
1199
|
}
|
|
1243
1200
|
|
|
1244
1201
|
/**
|
|
@@ -1352,19 +1309,11 @@ function settingsNested(siteYml, { headHtml, themeYml, sourceLocale, translation
|
|
|
1352
1309
|
// carries the translation collector with it.
|
|
1353
1310
|
setIf(settings, 'keywords', localizeScalarList(siteYml.keywords, sourceLocale, translations))
|
|
1354
1311
|
|
|
1355
|
-
//
|
|
1356
|
-
// Section
|
|
1357
|
-
//
|
|
1358
|
-
//
|
|
1359
|
-
//
|
|
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.)*
|
|
1363
|
-
setIf(settings, 'search', siteYml.search)
|
|
1364
|
-
setIf(settings, 'submit', siteYml.submit)
|
|
1365
|
-
// ⛔ Credentials are stripped, not trusted — this block is published world-readable.
|
|
1366
|
-
setIf(settings, 'assistant', stripCredentials(siteYml.assistant, 'assistant'))
|
|
1367
|
-
setIf(settings, 'tracking', stripCredentials(siteYml.tracking, 'tracking'))
|
|
1312
|
+
// ⛔ NO SERVICE IS HERE. A service's address and options ride its row in the
|
|
1313
|
+
// `services` Section, whole (`requestedServices`) [Diego, 2026-10-06: "If it's on and
|
|
1314
|
+
// has configuration, it can all be in the same place"]. *Until that evening
|
|
1315
|
+
// `settings.search` / `.submit` / `.assistant` / `.tracking` carried them beside the
|
|
1316
|
+
// row, and until that morning they were top-level `site.yml` keys.*
|
|
1368
1317
|
|
|
1369
1318
|
// Projections opt-out + route exclusions. Carried because the app is a second
|
|
1370
1319
|
// PUBLISHER of projections and derives them from stored content: without this it
|
|
@@ -1415,10 +1364,6 @@ function settingsNested(siteYml, { headHtml, themeYml, sourceLocale, translation
|
|
|
1415
1364
|
* known, `@/x` ships as written.
|
|
1416
1365
|
* @param {string[]} [opts.schemaless] - the queries whose records resolved no data schema
|
|
1417
1366
|
* (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`.
|
|
1422
1367
|
* @returns {Promise<object>} the section-keyed `$`-document:
|
|
1423
1368
|
* `{ $uuid?, $id, $schema, info, settings?, pages, layout_sections, extensions,
|
|
1424
1369
|
* queries, services?, secrets? }`
|
|
@@ -1426,6 +1371,8 @@ function settingsNested(siteYml, { headHtml, themeYml, sourceLocale, translation
|
|
|
1426
1371
|
export async function siteProjectToDocument(siteRoot, opts = {}) {
|
|
1427
1372
|
refuseOrgOption(opts, 'uwx/site')
|
|
1428
1373
|
const siteYml = await readYamlFile(join(siteRoot, 'site.yml'))
|
|
1374
|
+
refuseRetiredServiceKeys(siteYml)
|
|
1375
|
+
refuseUnreadableServices(siteYml)
|
|
1429
1376
|
const sourceLocale = opts.sourceLocale || resolveDefaultLocale(siteYml)
|
|
1430
1377
|
if (!siteYml.name) {
|
|
1431
1378
|
throw new Error('uwx/site: site.yml::name is required')
|
|
@@ -1514,68 +1461,30 @@ export async function siteProjectToDocument(siteRoot, opts = {}) {
|
|
|
1514
1461
|
// and default keywords exist for any share/SSR/crawler. `seo` rides verbatim
|
|
1515
1462
|
// as authored config (round-trips like favicon); `keywords` is a localized
|
|
1516
1463
|
// list (like page keywords).
|
|
1517
|
-
// `submit` — where this site's forms send submissions. Same family as
|
|
1518
|
-
// `fetcher`/`search`: the site declares it, the runtime reads it, and it
|
|
1519
|
-
// round-trips verbatim. It has to be listed HERE because this lane is an
|
|
1520
|
-
// explicit allowlist while the bundle lane spreads all of site.yml — without
|
|
1521
|
-
// the line a `submit:` block works on a static host and vanishes silently on
|
|
1522
|
-
// the synced lane, which is the worst shape a config bug can take.
|
|
1523
1464
|
// `agents` — the projections opt-out + route exclusions. Carried because the
|
|
1524
1465
|
// app is a second PUBLISHER of projections and derives them from stored
|
|
1525
1466
|
// content: without this block it cannot see `agents: false` or
|
|
1526
1467
|
// `agents.exclude`, so an author's opt-out is silently reversed and an
|
|
1527
1468
|
// excluded branch becomes both discoverable AND summarized by the index.
|
|
1528
1469
|
// (The CLI lane reads site.yml directly and honors it either way.)
|
|
1529
|
-
// `assistant` — the site's own declaration for an AI assistant: where it
|
|
1530
|
-
// lives (`endpoint`, read by kit's `resolveService`) plus authored settings a
|
|
1531
|
-
// host reads (`system` persona, model hints). Same family as
|
|
1532
|
-
// `search`/`submit`, and here for the reason spelled out above them — the
|
|
1533
|
-
// bundle lane spreads all of site.yml while this one is an allowlist, so
|
|
1534
|
-
// without this line the block works on a static host and vanishes silently
|
|
1535
|
-
// on the synced lane.
|
|
1536
|
-
//
|
|
1537
|
-
// ⚠️ That is not hypothetical: this replaces `intelligence.yml`, a SEPARATE
|
|
1538
|
-
// file, which needed a bespoke line in each lane and got one in only the
|
|
1539
|
-
// bundle lane — so an authored persona never reached a hosted site at all.
|
|
1540
|
-
// A key inside site.yml cannot repeat that, because only this lane needs a
|
|
1541
|
-
// line.
|
|
1542
1470
|
//
|
|
1543
|
-
//
|
|
1544
|
-
//
|
|
1545
|
-
//
|
|
1546
|
-
//
|
|
1547
|
-
//
|
|
1548
|
-
//
|
|
1549
|
-
//
|
|
1471
|
+
// The services — `site.yml::services` — ride the `services` Section: one row per
|
|
1472
|
+
// entry, the whole entry in its `config` (`requestedServices`). ⚠️ An entry must
|
|
1473
|
+
// reach this lane whole because the bundle lane spreads it into the payload, and a
|
|
1474
|
+
// block that works on a static host and vanishes on the synced lane is the worst
|
|
1475
|
+
// shape a config bug can take — `intelligence.yml`, a separate file the assistant's
|
|
1476
|
+
// persona once lived in, reached only the bundle lane, so a persona never reached a
|
|
1477
|
+
// hosted site. ⛔ A credential is dropped (`readServicesRequest`) — but only as a
|
|
1478
|
+
// KEYED FIELD: one embedded in an endpoint URL (`https://collector/e?key=…`) is
|
|
1479
|
+
// invisible there and is disclosed. The host's secret store is the only right home.
|
|
1550
1480
|
//
|
|
1551
|
-
// ⛔
|
|
1552
|
-
//
|
|
1553
|
-
//
|
|
1554
|
-
//
|
|
1555
|
-
//
|
|
1556
|
-
|
|
1557
|
-
//
|
|
1558
|
-
// above it argues the opposite — three services are on this allowlist precisely so
|
|
1559
|
-
// an authored block cannot work on a static host and vanish on the synced one.
|
|
1560
|
-
// Without this paragraph the next reader adds the missing fourth line and calls it
|
|
1561
|
-
// a bug fix.
|
|
1562
|
-
//
|
|
1563
|
-
// ⭐ `api` is the one service a site does not AUTHOR. It is a real backend that is
|
|
1564
|
-
// provisioned, so its address is the host's to supply — it arrives as
|
|
1565
|
-
// `config.services.api` and `@uniweb/api` reads it there (`resolveBase`). An
|
|
1566
|
-
// authored `api:` is the SITE tier: it yields to a host that offers the service,
|
|
1567
|
-
// and is the answer wherever one does not.
|
|
1568
|
-
//
|
|
1569
|
-
// ⇒ Carrying it would turn a local-dev address into a production one the moment
|
|
1570
|
-
// someone pushed: the host would store `settings.api` and serve it back as
|
|
1571
|
-
// `config.api`, and a site with no `api` service of its own would draw sign-in
|
|
1572
|
-
// against an address nobody answers — where the absence would have drawn
|
|
1573
|
-
// nothing. The vanish on this lane is the correct behaviour, not the bug the
|
|
1574
|
-
// comments above describe — there, a dropped block leaves a site with NO
|
|
1575
|
-
// endpoint; here it leaves the site with the RIGHT answer.
|
|
1576
|
-
//
|
|
1577
|
-
// Asking for the `api` service is `site.yml::services` → the `services` Section (see
|
|
1578
|
-
// requestedServices); its address still comes from the host.
|
|
1481
|
+
// ⛔ `backend`'S ADDRESS IS THE HOST'S TO SUPPLY — `config.services.backend`, read by
|
|
1482
|
+
// `@uniweb/api` (`resolveBase`). Asking for it is `services: { backend: true }`, or a
|
|
1483
|
+
// map of the host's settings; an address of the site's own asks the host to leave its
|
|
1484
|
+
// own off, and `readServicesRequest` says so, because `/_api` — where a local mock
|
|
1485
|
+
// used to answer — reads that way. In `uniweb dev`, `$devBackend` supplies the mock's
|
|
1486
|
+
// address (`dev/api-mount.js`). *(The service was `api` until 2026-10-07; that name is
|
|
1487
|
+
// refused — `RENAMED_SERVICES`.)*
|
|
1579
1488
|
// ⛔ THE CONFIGURATION KEYS ARE NOT HERE — they ride the `settings` Section
|
|
1580
1489
|
// (`settingsNested` above). `info` is the BRIEF: what a card or a select dropdown
|
|
1581
1490
|
// renders, plus what a listing can filter on. Eighteen keys moved off it on
|
|
@@ -1739,24 +1648,14 @@ export async function siteProjectToDocument(siteRoot, opts = {}) {
|
|
|
1739
1648
|
opts.queryFields,
|
|
1740
1649
|
Array.isArray(opts.schemaless) ? new Set(opts.schemaless) : null
|
|
1741
1650
|
)
|
|
1742
|
-
// Emitted
|
|
1743
|
-
// `serviceRecords
|
|
1744
|
-
//
|
|
1745
|
-
//
|
|
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.
|
|
1751
|
-
//
|
|
1752
|
-
// `opts.declareServices === false` withholds both Sections for THIS push.
|
|
1753
|
-
const declare = opts.declareServices !== false
|
|
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`.
|
|
1651
|
+
// Emitted only when there is something to state — see the header above
|
|
1652
|
+
// `serviceRecords`. ⭐ What this copy holds is per backend — a service is bought on
|
|
1653
|
+
// the backend that sold it — and the request is one, in `site.yml`. Push, publish,
|
|
1654
|
+
// `status` and a pull's re-bank all build it alike, from the file and `sync.json`.
|
|
1756
1655
|
const provisioned = opts.backend ? readBackendState(siteRoot, opts.backend) : null
|
|
1757
|
-
const services =
|
|
1656
|
+
const services = requestedServices(siteYml, provisioned)
|
|
1758
1657
|
if (services) doc.services = services
|
|
1759
|
-
const secrets =
|
|
1658
|
+
const secrets = secretsNested(provisioned)
|
|
1760
1659
|
if (secrets) doc.secrets = secrets
|
|
1761
1660
|
return doc
|
|
1762
1661
|
}
|