@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
|
@@ -1,35 +1,108 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* A site's services
|
|
3
|
-
*
|
|
2
|
+
* A site's services — `site.yml::services`, ONE entry per service, saying
|
|
3
|
+
* everything the owner says about it.
|
|
4
4
|
*
|
|
5
5
|
* ```yaml
|
|
6
6
|
* services:
|
|
7
|
-
* search:
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
7
|
+
* search: # on: the host's, or the built-in index
|
|
8
|
+
* exclude: { routes: [/legal] } # an option the site's runtime reads
|
|
9
|
+
* submit: https://forms.example.com/f/abc # an address: a provider the site brings
|
|
10
|
+
* tracking: { consent: required }
|
|
11
|
+
* backend: { grade: pro } # a setting for the host's service
|
|
11
12
|
* ```
|
|
12
13
|
*
|
|
13
|
-
* ⭐ ONE
|
|
14
|
-
*
|
|
15
|
-
*
|
|
14
|
+
* ⭐ ONE PLACE [Diego, 2026-10-06]. Until then the site's own provider and options
|
|
15
|
+
* lived in top-level `search:` / `submit:` / `assistant:` / `tracking:` / `api:`, and
|
|
16
|
+
* the request to the host in `services:` — two switches for one thing. Those keys are
|
|
17
|
+
* retired and refused (`refuseRetiredServiceKeys`).
|
|
16
18
|
*
|
|
17
|
-
*
|
|
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.
|
|
19
|
+
* Each entry goes ONE place per lane, decided here and nowhere else:
|
|
21
20
|
*
|
|
22
|
-
*
|
|
23
|
-
* the
|
|
21
|
+
* - **a built site** reads it as its own config (`config.<name>` in the payload) —
|
|
22
|
+
* the switch, an address, the options. ⛔ For `backend` only its switch and
|
|
23
|
+
* address: its settings are for the host that provisions it. Never a credential.
|
|
24
|
+
* - **a push** sends it as the request: ONE row per entry in the `services` Section,
|
|
25
|
+
* `{ name, enabled?: false, config? }`, whose `config` is the whole entry — an
|
|
26
|
+
* address, the options, the host's settings — never a credential. What reaches a
|
|
27
|
+
* hosted page from a row is the host's to project.
|
|
28
|
+
*
|
|
29
|
+
* ⭐ THE ROW CARRIES THE WHOLE ENTRY [Diego, 2026-10-06: "If it's on and has
|
|
30
|
+
* configuration, it can all be in the same place"]. ⛔ Until that evening a push sent
|
|
31
|
+
* the address and the options as `settings.<name>` beside the row — the two places
|
|
32
|
+
* this module exists to replace, moved from `site.yml` onto the wire.
|
|
33
|
+
*
|
|
34
|
+
* ⭐ AN ADDRESS MEANS THE SITE BRINGS ITS OWN PROVIDER [Diego, 2026-10-06], so the
|
|
35
|
+
* host is asked to leave its own off — a host's offer outranks the site's address
|
|
36
|
+
* (`core/src/services.js`), and would otherwise win.
|
|
37
|
+
*
|
|
38
|
+
* ⭐ ONE REQUEST, WHICHEVER BACKEND A COMMAND TALKS TO. What a push sends is what the
|
|
39
|
+
* file says, plus OFF for each service this copy holds that the file no longer lists
|
|
40
|
+
* (`statedServices`); the services it holds are named, `{ name: $uuid }`, in that
|
|
41
|
+
* backend's entry in `sync.json`, which nobody edits. The backend decides per service.
|
|
42
|
+
*
|
|
43
|
+
* Pure functions, shared by the static build (`content-collector.js`), the producer
|
|
44
|
+
* (`site.js`), pull (`site-project.js`) and the CLI's push, publish and doctor.
|
|
45
|
+
*
|
|
46
|
+
* Spec: kb/framework/reference/site-services-request.md
|
|
24
47
|
*
|
|
25
48
|
* @module
|
|
26
49
|
*/
|
|
27
50
|
|
|
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
51
|
const isMap = (v) => v !== null && typeof v === 'object' && !Array.isArray(v)
|
|
32
52
|
|
|
53
|
+
/**
|
|
54
|
+
* The options each service's RUNTIME reads from the site's config — the framework's
|
|
55
|
+
* own vocabulary, which `uniweb doctor` checks an entry against. On the wire they ride
|
|
56
|
+
* in the row's `config` with everything else; this list decides nothing there.
|
|
57
|
+
*
|
|
58
|
+
* ⚠️ Read off the readers, and owed to them: `Website.getSearchConfig` and kit's search
|
|
59
|
+
* client, and the search index `@uniweb/projections` builds (search); `readEndpoint`
|
|
60
|
+
* (every `endpoint`); `wireTracker` (tracking). A key one of them starts reading
|
|
61
|
+
* belongs here, or `doctor` calls it unknown.
|
|
62
|
+
*/
|
|
63
|
+
export const RUNTIME_KEYS = Object.freeze({
|
|
64
|
+
search: Object.freeze(['enabled', 'provider', 'endpoint', 'include', 'exclude', 'fields', 'weight']),
|
|
65
|
+
submit: Object.freeze(['enabled', 'endpoint']),
|
|
66
|
+
assistant: Object.freeze(['enabled', 'endpoint']),
|
|
67
|
+
tracking: Object.freeze(['enabled', 'endpoint', 'emit', 'consent', 'scripts', 'flushIntervalMs', 'debug']),
|
|
68
|
+
backend: Object.freeze(['enabled', 'endpoint'])
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Services renamed, by their old name. The old name is refused wherever a service is
|
|
73
|
+
* named — an entry here, and a foundation's `uniweb.supports` — naming the new one.
|
|
74
|
+
*
|
|
75
|
+
* ⛔ No alias, and the reason is the runtime: a name no host offers resolves to
|
|
76
|
+
* nothing, so a site or a foundation still saying `api` would simply lose the service
|
|
77
|
+
* on every page, with nothing anywhere saying why. Here it stops where it is written.
|
|
78
|
+
*
|
|
79
|
+
* ⭐ `api` → `backend`, 2026-10-07 [Diego: "name the actual service, and not the
|
|
80
|
+
* interface of the service, which is a route named /api"]. The site's own backend is
|
|
81
|
+
* named for what it is; `/_api` stays the route it answers on, and `@uniweb/api` is
|
|
82
|
+
* named for that interface.
|
|
83
|
+
*/
|
|
84
|
+
export const RENAMED_SERVICES = Object.freeze({ api: 'backend' })
|
|
85
|
+
|
|
86
|
+
/** `site.yml` keys renamed with a service — refused by the old name, like the services. */
|
|
87
|
+
const RENAMED_SITE_KEYS = Object.freeze({ $devApi: '$devBackend' })
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Services only a host provides, which a site never answers itself: no address, no
|
|
91
|
+
* runtime part. A site does not declare its own record provider — a third-party
|
|
92
|
+
* source is a foundation transport.
|
|
93
|
+
*/
|
|
94
|
+
const HOST_ONLY = new Set(['records'])
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Credential-shaped keys, mirroring the set the delivery edge strips on the reading
|
|
98
|
+
* side — deliberately the SAME list, so the two guards are visibly twins. Never sent
|
|
99
|
+
* and never published: a key belongs in the host's secret store, set in the app.
|
|
100
|
+
*/
|
|
101
|
+
const CREDENTIAL_KEYS = Object.freeze(['apiKey', 'api_key', 'key', 'token', 'secret'])
|
|
102
|
+
|
|
103
|
+
/** The top-level keys `services:` replaced, refused by name. */
|
|
104
|
+
const RETIRED_SERVICE_KEYS = Object.freeze(['search', 'submit', 'assistant', 'tracking', 'api'])
|
|
105
|
+
|
|
33
106
|
/** Deterministic JSON: object keys sorted at every depth, arrays left in order. */
|
|
34
107
|
function stable(value) {
|
|
35
108
|
return JSON.stringify(value ?? null, (_k, v) =>
|
|
@@ -38,243 +111,443 @@ function stable(value) {
|
|
|
38
111
|
}
|
|
39
112
|
|
|
40
113
|
/**
|
|
41
|
-
*
|
|
42
|
-
*
|
|
114
|
+
* YAML 1.1's words for a switch, which js-yaml — YAML 1.2 — reads as TEXT, and `true` /
|
|
115
|
+
* `false` written in quotes. ⛔ As text, `search: yes` was an address: the site's own
|
|
116
|
+
* search provider at `yes`, with the host's search asked OFF, while the CLI said
|
|
117
|
+
* "site.yml turns on search" (measured on 0.85.0, 2026-10-07 — F14).
|
|
43
118
|
*/
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
return
|
|
119
|
+
const SWITCH_WORDS = /^(y|yes|on|true|n|no|off|false)$/i
|
|
120
|
+
const ON_WORDS = /^(y|yes|on|true)$/i
|
|
121
|
+
|
|
122
|
+
/** What a value is, in words — for a message that says what was found. */
|
|
123
|
+
function described(value) {
|
|
124
|
+
if (value === null || value === undefined) return 'empty'
|
|
125
|
+
if (Array.isArray(value)) return 'a list'
|
|
126
|
+
if (typeof value === 'string') return value.trim() ? `the text \`${value}\`` : 'empty'
|
|
127
|
+
return `\`${JSON.stringify(value)}\``
|
|
50
128
|
}
|
|
51
129
|
|
|
52
|
-
|
|
53
|
-
|
|
130
|
+
/** A switch written as a word: what it says, and what to write instead. */
|
|
131
|
+
function switchWord(path, key, value) {
|
|
132
|
+
const on = ON_WORDS.test(value.trim())
|
|
133
|
+
return (
|
|
134
|
+
`\`${path}\` is the text \`${value}\`, not a switch — YAML reads yes, no, on and off as words. ` +
|
|
135
|
+
`Write \`${key}: ${on}\` to turn it ${on ? 'on' : 'off'}.`
|
|
136
|
+
)
|
|
137
|
+
}
|
|
54
138
|
|
|
55
139
|
/**
|
|
56
|
-
*
|
|
57
|
-
*
|
|
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
|
|
140
|
+
* What is wrong with one entry, or null when it is `true`, `false`, an address or a map —
|
|
141
|
+
* the one judgement both `readServicesRequest` and `refuseUnreadableServices` make.
|
|
63
142
|
*/
|
|
64
|
-
|
|
65
|
-
if (
|
|
66
|
-
|
|
67
|
-
|
|
143
|
+
function entryProblem(name, value) {
|
|
144
|
+
if (RENAMED_SERVICES[name]) {
|
|
145
|
+
const now = RENAMED_SERVICES[name]
|
|
146
|
+
return (
|
|
147
|
+
`\`services.${name}\` is now \`services.${now}\` — the site's own backend is the \`${now}\` service. ` +
|
|
148
|
+
`Rename the entry; what it says stays. On a site you push, \`uniweb pull\` brings it renamed.`
|
|
149
|
+
)
|
|
150
|
+
}
|
|
151
|
+
if (typeof value === 'boolean') return null
|
|
152
|
+
if (typeof value === 'string') {
|
|
153
|
+
if (SWITCH_WORDS.test(value.trim())) return switchWord(`services.${name}`, name, value)
|
|
154
|
+
if (!value.trim()) return `\`services.${name}\` is empty — write true, false, an address, or a map.`
|
|
155
|
+
if (HOST_ONLY.has(name)) return `\`services.${name}\` has no address of its own — your host provides it. Write \`${name}: true\`.`
|
|
68
156
|
return null
|
|
69
157
|
}
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
}
|
|
158
|
+
if (!isMap(value)) return `\`services.${name}\` is true, false, an address, or a map — not ${described(value)}.`
|
|
159
|
+
if (value.enabled !== undefined && typeof value.enabled !== 'boolean') {
|
|
160
|
+
return typeof value.enabled === 'string' && SWITCH_WORDS.test(value.enabled.trim())
|
|
161
|
+
? switchWord(`services.${name}.enabled`, 'enabled', value.enabled)
|
|
162
|
+
: `\`services.${name}.enabled\` is true or false — not ${described(value.enabled)}.`
|
|
92
163
|
}
|
|
93
|
-
|
|
164
|
+
if (typeof value.endpoint === 'string' && SWITCH_WORDS.test(value.endpoint.trim())) {
|
|
165
|
+
return `\`services.${name}.endpoint\` is the text \`${value.endpoint}\`, not an address — an address is a URL or a path. To turn the service on, leave \`endpoint\` out.`
|
|
166
|
+
}
|
|
167
|
+
if (HOST_ONLY.has(name) && addressOf(value)) {
|
|
168
|
+
return `\`services.${name}\` has no address of its own — your host provides it. Remove \`endpoint\`.`
|
|
169
|
+
}
|
|
170
|
+
return null
|
|
94
171
|
}
|
|
95
172
|
|
|
96
173
|
/**
|
|
97
|
-
*
|
|
174
|
+
* Every entry of `site.yml::services` that neither a build nor a push can read, each said
|
|
175
|
+
* with its fix — [] when there is none.
|
|
98
176
|
*
|
|
99
|
-
*
|
|
100
|
-
*
|
|
177
|
+
* @param {*} declared - the raw `services:` value
|
|
178
|
+
* @returns {string[]}
|
|
179
|
+
*/
|
|
180
|
+
export function unreadableServices(declared) {
|
|
181
|
+
if (declared === undefined || declared === null) return []
|
|
182
|
+
if (!isMap(declared)) return ['`services:` is a map of service names — `services: { search: true }`.']
|
|
183
|
+
return Object.entries(declared)
|
|
184
|
+
.map(([name, value]) => entryProblem(name, value))
|
|
185
|
+
.filter(Boolean)
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Stop on a `services:` entry neither lane can read — beside `refuseRetiredServiceKeys`,
|
|
190
|
+
* in the static build and in the push.
|
|
101
191
|
*
|
|
102
|
-
*
|
|
103
|
-
*
|
|
192
|
+
* ⛔ NOT "warn and ignore it". A push states every service the file lists and OFF for each
|
|
193
|
+
* one this copy holds that it does not (`statedServices`), so an entry it skipped turned
|
|
194
|
+
* that service off — `search: 7`, or `records: /_query`, switched off on the site under a
|
|
195
|
+
* warning that said "Ignoring it" (until 2026-10-07). And a word for a switch was not
|
|
196
|
+
* skipped but read as an address, which asks the host to leave its own off (F14).
|
|
197
|
+
*
|
|
198
|
+
* @param {object} siteYml
|
|
199
|
+
* @param {string} [where]
|
|
200
|
+
* @throws {Error} naming each entry and what to write
|
|
104
201
|
*/
|
|
105
|
-
export function
|
|
106
|
-
if (!
|
|
107
|
-
const
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
202
|
+
export function refuseUnreadableServices(siteYml, where = 'site.yml') {
|
|
203
|
+
if (!isMap(siteYml)) return
|
|
204
|
+
const found = unreadableServices(siteYml.services)
|
|
205
|
+
if (!found.length) return
|
|
206
|
+
throw new Error(
|
|
207
|
+
found.length === 1
|
|
208
|
+
? `[uniweb] ${where}: ${found[0]}`
|
|
209
|
+
: `[uniweb] ${where}: \`services:\` has entries that cannot be read:\n${found.map((f) => ` - ${f}`).join('\n')}`
|
|
210
|
+
)
|
|
113
211
|
}
|
|
114
212
|
|
|
115
|
-
/**
|
|
116
|
-
function
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
213
|
+
/** The address an entry names, or null — a string, or a map's `endpoint`. */
|
|
214
|
+
function addressOf(value) {
|
|
215
|
+
if (typeof value === 'string') return value.trim() || null
|
|
216
|
+
if (isMap(value) && typeof value.endpoint === 'string' && value.endpoint.trim()) return value.endpoint.trim()
|
|
217
|
+
return null
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** The keys of a map the runtime reads for this service. */
|
|
221
|
+
function isRuntimeKey(name, key) {
|
|
222
|
+
const keys = RUNTIME_KEYS[name]
|
|
223
|
+
return keys ? keys.includes(key) : true
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** A map without its credential-shaped keys, and which ones it had. */
|
|
227
|
+
function withoutCredentials(value) {
|
|
228
|
+
const found = CREDENTIAL_KEYS.filter((k) => k in value)
|
|
229
|
+
if (!found.length) return { value, found }
|
|
230
|
+
const out = { ...value }
|
|
231
|
+
for (const k of found) delete out[k]
|
|
232
|
+
return { value: out, found }
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function sayCredentials(name, found, warn) {
|
|
236
|
+
if (found.length) {
|
|
237
|
+
warn(
|
|
238
|
+
`\`services.${name}\` carries ${found.map((k) => `\`${k}\``).join(', ')} — a credential is never sent ` +
|
|
239
|
+
'or published. Set it in the app, where secrets are kept.'
|
|
240
|
+
)
|
|
241
|
+
}
|
|
121
242
|
}
|
|
122
243
|
|
|
123
244
|
/**
|
|
124
|
-
*
|
|
125
|
-
* service the site holds no row for leaves the map.
|
|
245
|
+
* Refuse the top-level keys `services:` replaced.
|
|
126
246
|
*
|
|
127
|
-
* @param {
|
|
128
|
-
* @param {
|
|
129
|
-
* @
|
|
130
|
-
* @returns {object|null} the new map, or null when nothing is left in it
|
|
247
|
+
* @param {object} siteYml
|
|
248
|
+
* @param {string} [where]
|
|
249
|
+
* @throws {Error} naming each key and how to move it
|
|
131
250
|
*/
|
|
132
|
-
export function
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
251
|
+
export function refuseRetiredServiceKeys(siteYml, where = 'site.yml') {
|
|
252
|
+
if (!isMap(siteYml)) return
|
|
253
|
+
const renamed = Object.keys(RENAMED_SITE_KEYS).filter((k) => siteYml[k] !== undefined)
|
|
254
|
+
if (renamed.length) {
|
|
255
|
+
throw new Error(
|
|
256
|
+
`[uniweb] ${where}: ${renamed.map((k) => `\`${k}:\` is now \`${RENAMED_SITE_KEYS[k]}:\``).join(', ')} — ` +
|
|
257
|
+
"it names what answers the site's `backend` service in `uniweb dev`. Rename the key; its value stays."
|
|
258
|
+
)
|
|
138
259
|
}
|
|
139
|
-
|
|
260
|
+
const found = RETIRED_SERVICE_KEYS.filter((k) => siteYml[k] !== undefined)
|
|
261
|
+
if (!found.length) return
|
|
262
|
+
// The block to move them into names each service as `services:` does now.
|
|
263
|
+
const lines = found.map((k) => ` ${RENAMED_SERVICES[k] || k}: …`).join('\n')
|
|
264
|
+
const api = found.includes('api')
|
|
265
|
+
? '\n `api:` named the address a local mock answered on — in `uniweb dev`, `$devBackend` now ' +
|
|
266
|
+
"supplies it. Asking your host for the site's own backend is `backend: true` under `services:`."
|
|
267
|
+
: ''
|
|
268
|
+
throw new Error(
|
|
269
|
+
`[uniweb] ${where}: ${found.map((k) => `\`${k}:\``).join(', ')} ` +
|
|
270
|
+
`${found.length === 1 ? 'is' : 'are'} retired — a service lives under \`services:\`, ` +
|
|
271
|
+
`one entry per service:\n\nservices:\n${lines}${api}`
|
|
272
|
+
)
|
|
140
273
|
}
|
|
141
274
|
|
|
142
275
|
/**
|
|
143
|
-
*
|
|
144
|
-
*
|
|
276
|
+
* Services whose settings are never published: only the switch and the address reach
|
|
277
|
+
* the site's config. `backend`'s settings are for the host that provisions it (a grade,
|
|
278
|
+
* sign-in providers, billing) and no page reads them.
|
|
279
|
+
*/
|
|
280
|
+
const UNPUBLISHED_SETTINGS = new Set(['backend'])
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* One entry → what the site's config carries: `false`, an address, or the entry's
|
|
284
|
+
* options — or undefined when there is nothing for it (`true` is the default).
|
|
145
285
|
*
|
|
146
|
-
* @param {
|
|
147
|
-
* @param {
|
|
148
|
-
* @returns {
|
|
286
|
+
* @param {string} name
|
|
287
|
+
* @param {*} value - the entry
|
|
288
|
+
* @returns {false|string|object|undefined}
|
|
149
289
|
*/
|
|
150
|
-
export function
|
|
151
|
-
if (
|
|
152
|
-
if (
|
|
153
|
-
if (
|
|
154
|
-
|
|
155
|
-
|
|
290
|
+
export function runtimeServiceConfig(name, value) {
|
|
291
|
+
if (HOST_ONLY.has(name)) return undefined
|
|
292
|
+
if (value === false) return false
|
|
293
|
+
if (typeof value === 'string') return value.trim() || undefined
|
|
294
|
+
if (!isMap(value)) return undefined
|
|
295
|
+
const out = {}
|
|
296
|
+
for (const [key, v] of Object.entries(withoutCredentials(value).value)) {
|
|
297
|
+
if (!UNPUBLISHED_SETTINGS.has(name) || isRuntimeKey(name, key)) out[key] = v
|
|
298
|
+
}
|
|
299
|
+
if (out.enabled === true) delete out.enabled
|
|
300
|
+
return Object.keys(out).length ? out : undefined
|
|
156
301
|
}
|
|
157
302
|
|
|
158
|
-
/**
|
|
159
|
-
|
|
160
|
-
|
|
303
|
+
/**
|
|
304
|
+
* `site.yml::services` → `{ <name>: <runtime value> }` for every entry the runtime
|
|
305
|
+
* has something for — what a static payload's `config.<name>` carries.
|
|
306
|
+
*
|
|
307
|
+
* @param {*} declared
|
|
308
|
+
* @param {{ warn?: (message: string) => void }} [opts]
|
|
309
|
+
* @returns {object}
|
|
310
|
+
*/
|
|
311
|
+
export function runtimeServicesConfig(declared, { warn = () => {} } = {}) {
|
|
312
|
+
const out = {}
|
|
313
|
+
if (!isMap(declared)) return out
|
|
314
|
+
for (const [name, value] of Object.entries(declared)) {
|
|
315
|
+
if (isMap(value)) sayCredentials(name, withoutCredentials(value).found, warn)
|
|
316
|
+
const runtime = runtimeServiceConfig(name, value)
|
|
317
|
+
if (runtime !== undefined) out[name] = runtime
|
|
318
|
+
}
|
|
319
|
+
return out
|
|
161
320
|
}
|
|
162
321
|
|
|
163
322
|
/**
|
|
164
|
-
*
|
|
323
|
+
* `site.yml::services` → the asks to the host, ONE row per entry, in the `services`
|
|
324
|
+
* Section's shape: `{ name, enabled?: false, config? }`.
|
|
165
325
|
*
|
|
166
|
-
*
|
|
167
|
-
*
|
|
168
|
-
*
|
|
169
|
-
*
|
|
170
|
-
* `
|
|
326
|
+
* `true` asks on; `false` asks off; a map asks on — or off, with `enabled: false` — and
|
|
327
|
+
* its keys are the row's `config`. An address — a string, or `endpoint:` in a map — is
|
|
328
|
+
* the site's own provider: it rides as `config.endpoint`, and the host is asked to
|
|
329
|
+
* leave its own off, since a host's offer outranks the site's address
|
|
330
|
+
* (`core/src/services.js`) and would otherwise win. `enabled` is the row's switch and
|
|
331
|
+
* never in `config`; a credential never leaves at all.
|
|
171
332
|
*
|
|
172
|
-
* @param {
|
|
173
|
-
* @param {
|
|
174
|
-
*
|
|
333
|
+
* @param {*} declared - the raw `services:` value
|
|
334
|
+
* @param {{ warn?: (message: string) => void }} [opts] - told about every value it
|
|
335
|
+
* ignores or trims, with the fix
|
|
336
|
+
* @returns {object[]|null} null when the file asks nothing — no key, or no usable entry
|
|
175
337
|
*/
|
|
176
|
-
export function
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
at.set(row.name, out.length)
|
|
182
|
-
out.push(canonicalRow(row))
|
|
338
|
+
export function readServicesRequest(declared, { warn = () => {} } = {}) {
|
|
339
|
+
if (declared === undefined || declared === null) return null
|
|
340
|
+
if (!isMap(declared)) {
|
|
341
|
+
warn('`services:` is a map of service names — `services: { search: true }`. Ignoring it.')
|
|
342
|
+
return null
|
|
183
343
|
}
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
if (
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
}
|
|
194
|
-
|
|
344
|
+
const asks = []
|
|
345
|
+
for (const [name, value] of Object.entries(declared)) {
|
|
346
|
+
// ⚠️ Skipped here for a caller that only reads; the build and the push stop on it
|
|
347
|
+
// first (`refuseUnreadableServices`), since a push would state a skipped one off.
|
|
348
|
+
// First, so a renamed service is skipped whatever its value — `true` included.
|
|
349
|
+
const problem = entryProblem(name, value)
|
|
350
|
+
if (problem) {
|
|
351
|
+
warn(`${problem} Ignoring \`${name}\`.`)
|
|
352
|
+
continue
|
|
353
|
+
}
|
|
354
|
+
if (value === true) {
|
|
355
|
+
asks.push({ name })
|
|
356
|
+
continue
|
|
357
|
+
}
|
|
358
|
+
if (value === false) {
|
|
359
|
+
asks.push({ name, enabled: false })
|
|
360
|
+
continue
|
|
195
361
|
}
|
|
362
|
+
const address = addressOf(value)
|
|
363
|
+
const { enabled, ...entry } = isMap(value) ? value : { endpoint: address }
|
|
364
|
+
const { value: config, found } = withoutCredentials(entry)
|
|
365
|
+
sayCredentials(name, found, warn)
|
|
366
|
+
if (address) {
|
|
367
|
+
config.endpoint = address
|
|
368
|
+
// ⚠️ Said for `backend` because its old spelling invites it: `api: /_api` was where
|
|
369
|
+
// a local mock answered, and under `services:` an address turns the host's off.
|
|
370
|
+
if (name === 'backend') {
|
|
371
|
+
warn(
|
|
372
|
+
"`services.backend` is an address: it asks your host to leave its own `backend` off, so the site uses yours. " +
|
|
373
|
+
"For your host's, write `backend: true`; in `uniweb dev`, `$devBackend` answers it."
|
|
374
|
+
)
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
asks.push({
|
|
378
|
+
name,
|
|
379
|
+
...(address || enabled === false ? { enabled: false } : {}),
|
|
380
|
+
...(Object.keys(config).length ? { config } : {})
|
|
381
|
+
})
|
|
196
382
|
}
|
|
197
|
-
return
|
|
383
|
+
return asks.length ? asks : null
|
|
198
384
|
}
|
|
199
385
|
|
|
200
386
|
/**
|
|
201
|
-
*
|
|
387
|
+
* One row → the entry `site.yml::services` writes for it.
|
|
202
388
|
*
|
|
203
|
-
*
|
|
204
|
-
* the
|
|
389
|
+
* An address with the host's own off is the site's own provider, written as the entry
|
|
390
|
+
* was — a bare address when that is all it says. With the host's own on, the address
|
|
391
|
+
* goes: the host's offer is what answers now, and it was turned on elsewhere.
|
|
392
|
+
*/
|
|
393
|
+
function entryFromRow(row) {
|
|
394
|
+
const off = row.enabled === false
|
|
395
|
+
const config = isMap(row.config) ? { ...row.config } : {}
|
|
396
|
+
const address = addressOf(config)
|
|
397
|
+
if (address && off) return Object.keys(config).length === 1 ? address : config
|
|
398
|
+
if (address) delete config.endpoint
|
|
399
|
+
if (!Object.keys(config).length) return !off
|
|
400
|
+
return off ? { enabled: false, ...config } : config
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* The credentials an author typed into an entry, kept on the one a pull writes over it.
|
|
405
|
+
* A push never sends one — it is stripped, with a warning naming the app — so the site's
|
|
406
|
+
* row cannot hold it, and writing the row whole would delete what the author typed.
|
|
407
|
+
* ⛔ *Until 2026-10-06 they sat in top-level keys the writer merged one level deep,
|
|
408
|
+
* which kept them; `services` is written whole.*
|
|
409
|
+
*/
|
|
410
|
+
function keepCredentials(entry, local) {
|
|
411
|
+
if (!isMap(local)) return entry
|
|
412
|
+
const found = CREDENTIAL_KEYS.filter((k) => k in local)
|
|
413
|
+
if (!found.length) return entry
|
|
414
|
+
const kept = Object.fromEntries(found.map((k) => [k, local[k]]))
|
|
415
|
+
if (entry === true) return kept
|
|
416
|
+
if (entry === false) return { enabled: false, ...kept }
|
|
417
|
+
if (typeof entry === 'string') return { endpoint: entry, ...kept }
|
|
418
|
+
return isMap(entry) ? { ...entry, ...kept } : entry
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* The site's rows → the `site.yml::services` map — what a pull writes.
|
|
205
423
|
*
|
|
206
|
-
*
|
|
207
|
-
*
|
|
208
|
-
*
|
|
209
|
-
*
|
|
210
|
-
*
|
|
211
|
-
*
|
|
424
|
+
* ⭐ AN OFF SERVICE WITH NO SETTINGS IS WRITTEN ONLY WHERE `site.yml` ALREADY NAMES IT
|
|
425
|
+
* [Diego, 2026-10-07]. Every service is on or off, and off by default, so a `false` line
|
|
426
|
+
* adds nothing — and writing one for every service switched off in the app, or for each
|
|
427
|
+
* line the file dropped, would fill the file with them. So a pull writes a service that is
|
|
428
|
+
* on, or that has settings (an own provider's address); and one that is off, as `false`,
|
|
429
|
+
* where the file names it — a file listing a service the site has since switched off must
|
|
430
|
+
* say so, or its next push switches it back on. The rows it leaves out are still held
|
|
431
|
+
* (`sync.json`), and a push states them off, as the site has them (`statedServices`).
|
|
432
|
+
* ⛔ *Until then every row was written, an off one as `false`.*
|
|
212
433
|
*
|
|
213
|
-
*
|
|
214
|
-
*
|
|
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.
|
|
434
|
+
* ⚖️ What it gives up: a `false` an author wrote does not reach a fresh clone, whose
|
|
435
|
+
* `site.yml` names nothing yet. It still means off there.
|
|
218
436
|
*
|
|
219
437
|
* @param {object} p
|
|
220
|
-
* @param {object[]} p.
|
|
221
|
-
* @param {object
|
|
222
|
-
*
|
|
223
|
-
* @
|
|
224
|
-
* @returns {{ send: string[], adopt: string[], conflict: string[], unreadable: boolean }}
|
|
438
|
+
* @param {object[]} [p.rows] - the `services` Section: the site's rows
|
|
439
|
+
* @param {object} [p.local] - the `services:` the pull writes over — which services it
|
|
440
|
+
* names, and the credentials a push never sends (`keepCredentials`)
|
|
441
|
+
* @returns {object|null} null when there is nothing to write
|
|
225
442
|
*/
|
|
226
|
-
export function
|
|
227
|
-
const
|
|
228
|
-
const
|
|
229
|
-
const
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
? { send, adopt, conflict, unreadable: true }
|
|
235
|
-
: { send: asks.map((a) => a.name), adopt, conflict, unreadable: false }
|
|
443
|
+
export function servicesFromDocument({ rows, local } = {}) {
|
|
444
|
+
const before = isMap(local) ? local : {}
|
|
445
|
+
const out = {}
|
|
446
|
+
for (const row of Array.isArray(rows) ? rows : []) {
|
|
447
|
+
if (!isMap(row) || typeof row.name !== 'string' || !row.name || row.name in out) continue
|
|
448
|
+
const entry = entryFromRow(row)
|
|
449
|
+
if (entry === false && !(row.name in before)) continue
|
|
450
|
+
out[row.name] = keepCredentials(entry, before[row.name])
|
|
236
451
|
}
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
452
|
+
return Object.keys(out).length ? out : null
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/**
|
|
456
|
+
* The `services` Section a push sends: every service `site.yml` lists, as it says it, and
|
|
457
|
+
* OFF for every service this copy holds that the file does not list.
|
|
458
|
+
*
|
|
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.
|
|
461
|
+
*
|
|
462
|
+
* - A service the file lists goes WHOLE — its switch, and the entry as its `config` — so
|
|
463
|
+
* a setting removed from the file is removed.
|
|
464
|
+
* - A service this copy holds — a pull returned it, or a push sent it — and the file
|
|
465
|
+
* does not list is stated `{ name, enabled: false }`, with no settings. Deleting the
|
|
466
|
+
* line turns it off and removes its settings, which would otherwise stay live on the
|
|
467
|
+
* page, since a row's `config` reaches it whether the row is on or off, and come back
|
|
468
|
+
* with the next pull.
|
|
469
|
+
* - A service this copy never saw is not sent, and the site keeps it.
|
|
470
|
+
*
|
|
471
|
+
* A held service carries its `$uuid`, and the push sends the version of every item it
|
|
472
|
+
* holds (`withBaseVersion`), so a service changed on the site since is kept when the file
|
|
473
|
+
* left it as it was, and refused when the file changed it too.
|
|
474
|
+
*
|
|
475
|
+
* ⛔ *Until then a push sent the site's whole list, read just before it, with the file's
|
|
476
|
+
* changed asks applied — a service left out was deleted — and the CLI kept its own copy
|
|
477
|
+
* of the list in `sync.json` to tell who had changed what.*
|
|
478
|
+
*
|
|
479
|
+
* @param {object[]|null} asks - from `readServicesRequest`
|
|
480
|
+
* @param {object} [held] - `{ <name>: <$uuid> }`, the services this copy holds (`sync.json`)
|
|
481
|
+
* @returns {object[]} the rows; `[]` when there is nothing to state
|
|
482
|
+
*/
|
|
483
|
+
export function statedServices(asks, held = {}) {
|
|
484
|
+
const ids = isMap(held) ? held : {}
|
|
485
|
+
const uuidOf = (name) => (typeof ids[name] === 'string' && ids[name] ? ids[name] : null)
|
|
486
|
+
const rows = []
|
|
487
|
+
const listed = new Set()
|
|
488
|
+
for (const ask of asks || []) {
|
|
489
|
+
listed.add(ask.name)
|
|
490
|
+
const row = { name: ask.name }
|
|
491
|
+
if (ask.enabled === false) row.enabled = false
|
|
492
|
+
if (ask.config) row.config = { ...ask.config }
|
|
493
|
+
if (uuidOf(ask.name)) row.$uuid = uuidOf(ask.name)
|
|
494
|
+
rows.push(row)
|
|
254
495
|
}
|
|
255
|
-
|
|
496
|
+
for (const name of Object.keys(ids).sort()) {
|
|
497
|
+
if (listed.has(name) || !uuidOf(name)) continue
|
|
498
|
+
rows.push({ name, enabled: false, $uuid: uuidOf(name) })
|
|
499
|
+
}
|
|
500
|
+
return rows
|
|
256
501
|
}
|
|
257
502
|
|
|
258
503
|
/**
|
|
259
|
-
* The
|
|
504
|
+
* The services this copy holds after a push or a pull — `{ <name>: <$uuid> }`, read from
|
|
505
|
+
* the document the backend returned.
|
|
260
506
|
*
|
|
261
|
-
*
|
|
262
|
-
*
|
|
263
|
-
*
|
|
507
|
+
* A pull returns every service the site has, all seen (`sent` absent). A push returns the
|
|
508
|
+
* site's rows after the write, which may include services added since this copy's last
|
|
509
|
+
* pull: those were neither sent nor held before, so they are left out — held, a later
|
|
510
|
+
* push would state them off, switching off a service nobody here has seen.
|
|
264
511
|
*
|
|
265
512
|
* @param {object} p
|
|
266
|
-
* @param {object[]} [p.
|
|
267
|
-
* @param {object[]} [p.
|
|
268
|
-
* @param {
|
|
269
|
-
* @returns {object
|
|
513
|
+
* @param {object[]} [p.written] - the `services` rows the backend returned
|
|
514
|
+
* @param {object[]} [p.sent] - the rows the push sent; absent for a pull
|
|
515
|
+
* @param {object} [p.prior] - the services held before
|
|
516
|
+
* @returns {object} the services now held
|
|
270
517
|
*/
|
|
271
|
-
export function
|
|
272
|
-
|
|
273
|
-
const
|
|
274
|
-
const out =
|
|
275
|
-
for (const
|
|
276
|
-
|
|
277
|
-
if (
|
|
518
|
+
export function heldServices({ written, sent, prior } = {}) {
|
|
519
|
+
const before = isMap(prior) ? prior : {}
|
|
520
|
+
const stated = Array.isArray(sent) ? new Set(sent.filter(isMap).map((r) => r.name)) : null
|
|
521
|
+
const out = {}
|
|
522
|
+
for (const row of Array.isArray(written) ? written : []) {
|
|
523
|
+
if (!isMap(row) || typeof row.name !== 'string' || !row.name) continue
|
|
524
|
+
if (typeof row.$uuid !== 'string' || !row.$uuid) continue
|
|
525
|
+
if (stated && !stated.has(row.name) && !(row.name in before)) continue
|
|
526
|
+
out[row.name] = row.$uuid
|
|
278
527
|
}
|
|
528
|
+
// Sorted: it is written to `sync.json`, a committed file that must not reorder itself.
|
|
529
|
+
return Object.fromEntries(Object.keys(out).sort().map((k) => [k, out[k]]))
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
/** What a row SAYS — its switch and its `config` — not the fields a backend adds to it. */
|
|
533
|
+
function said(row) {
|
|
534
|
+
if (!isMap(row)) return null
|
|
535
|
+
const out = {}
|
|
536
|
+
if (row.enabled === false) out.enabled = false
|
|
537
|
+
if (isMap(row.config) && Object.keys(row.config).length) out.config = row.config
|
|
279
538
|
return out
|
|
280
539
|
}
|
|
540
|
+
|
|
541
|
+
/**
|
|
542
|
+
* Whether the site holds a service as a push sent it: the switch and the WHOLE
|
|
543
|
+
* `config`. ⛔ Not "equal on the keys we sent", which units use: an `on` row sends no
|
|
544
|
+
* `enabled` and an entry with no settings no `config`, so a row the app switched off,
|
|
545
|
+
* or gave settings, would read as ours.
|
|
546
|
+
*
|
|
547
|
+
* @param {object} sent - the row a push sent
|
|
548
|
+
* @param {object} written - the site's row after it
|
|
549
|
+
* @returns {boolean}
|
|
550
|
+
*/
|
|
551
|
+
export function sameServiceRow(sent, written) {
|
|
552
|
+
return isMap(sent) && isMap(written) && stable(said(sent)) === stable(said(written))
|
|
553
|
+
}
|