@uniweb/build 0.30.0 → 0.30.2
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 +7 -7
- package/src/dev/api-mount.js +142 -0
- package/src/site/content-collector.js +10 -0
- package/src/site/plugin.js +13 -0
- package/src/uwx/data-schema.js +35 -4
- package/src/uwx/folder.js +9 -2
- package/src/uwx/site-project.js +26 -1
- package/src/uwx/site.js +152 -5
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uniweb/build",
|
|
3
|
-
"version": "0.30.
|
|
3
|
+
"version": "0.30.2",
|
|
4
4
|
"description": "Build tooling for the Uniweb Component Web Platform",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -59,15 +59,15 @@
|
|
|
59
59
|
"js-yaml": "^4.1.0",
|
|
60
60
|
"sharp": "^0.35.3",
|
|
61
61
|
"yaml": "^2.5.0",
|
|
62
|
-
"@uniweb/theming": "^0.1.15",
|
|
63
|
-
"@uniweb/semantic-parser": "^1.3.1",
|
|
64
62
|
"@uniweb/content-reader": "^1.2.4",
|
|
65
|
-
"@uniweb/content-writer": "^0.3.4",
|
|
66
63
|
"@uniweb/projections": "^0.5.2",
|
|
67
|
-
"@uniweb/schemas": "^0.2.
|
|
64
|
+
"@uniweb/schemas": "^0.2.12",
|
|
65
|
+
"@uniweb/semantic-parser": "^1.3.1",
|
|
66
|
+
"@uniweb/theming": "^0.1.15",
|
|
67
|
+
"@uniweb/content-writer": "^0.3.4"
|
|
68
68
|
},
|
|
69
69
|
"optionalDependencies": {
|
|
70
|
-
"@uniweb/runtime": "^0.13.
|
|
70
|
+
"@uniweb/runtime": "^0.13.3"
|
|
71
71
|
},
|
|
72
72
|
"peerDependencies": {
|
|
73
73
|
"vite": "^5.0.0 || ^6.0.0 || ^7.0.0",
|
|
@@ -76,7 +76,7 @@
|
|
|
76
76
|
"@tailwindcss/vite": "^4.0.0",
|
|
77
77
|
"@vitejs/plugin-react": "^4.0.0 || ^5.0.0",
|
|
78
78
|
"vite-plugin-svgr": "^4.0.0",
|
|
79
|
-
"@uniweb/core": "^0.14.
|
|
79
|
+
"@uniweb/core": "^0.14.1"
|
|
80
80
|
},
|
|
81
81
|
"peerDependenciesMeta": {
|
|
82
82
|
"vite": {
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs'
|
|
2
|
+
import { resolve, join } from 'node:path'
|
|
3
|
+
import { pathToFileURL } from 'node:url'
|
|
4
|
+
import yaml from 'js-yaml'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Mount a site's own request handler in the dev server.
|
|
8
|
+
*
|
|
9
|
+
* A site that talks to a backend needs one running to be developed against, and
|
|
10
|
+
* making that a live deployment is slow, costs money, and puts a shared database
|
|
11
|
+
* behind a developer's experiments. So a site may name a **local handler** and the
|
|
12
|
+
* dev server mounts it at the site's own service address:
|
|
13
|
+
*
|
|
14
|
+
* ```yaml
|
|
15
|
+
* # site.yml
|
|
16
|
+
* api: /_api # where the site's app backend answers
|
|
17
|
+
* devApi: ./mock/api.js # what answers it, in development only
|
|
18
|
+
* ```
|
|
19
|
+
*
|
|
20
|
+
* ```js
|
|
21
|
+
* // mock/api.js — default-export a fetch handler
|
|
22
|
+
* export default (request) => new Response('{}', { headers: { 'content-type': 'application/json' } })
|
|
23
|
+
* ```
|
|
24
|
+
*
|
|
25
|
+
* ## ⭐ The framework mounts; the site supplies
|
|
26
|
+
*
|
|
27
|
+
* This knows nothing about what it is mounting — not the routes, not the shapes,
|
|
28
|
+
* not which backend is being imitated. It takes a `Request` handler and puts it on
|
|
29
|
+
* a path. ⛔ **That is deliberate and load-bearing:** the moment the framework
|
|
30
|
+
* knows what a "mock backend" is, it has a favourite one, and a site talking to
|
|
31
|
+
* something else is a second-class citizen in its own dev server. A handler is the
|
|
32
|
+
* whole contract, and anything that can produce one — a hand-written stub, a
|
|
33
|
+
* recorded fixture, someone's real service in a function — mounts the same way.
|
|
34
|
+
*
|
|
35
|
+
* ## ⛔ Development only, and it cannot leak
|
|
36
|
+
*
|
|
37
|
+
* `devApi` is read by the dev plugin and by nothing else: no build reads it, no
|
|
38
|
+
* `info` key carries it, and nothing writes it into a payload. A site's *address*
|
|
39
|
+
* (`api:`) is authored config and travels; what answers that address locally is a
|
|
40
|
+
* fact about one machine.
|
|
41
|
+
*
|
|
42
|
+
* ⚠️ **Same-origin on purpose.** Mounting inside the dev server means cookies and
|
|
43
|
+
* `credentials: 'same-origin'` behave as they do in production, where a site's app
|
|
44
|
+
* backend answers on the site's own origin. A handler on another port would work
|
|
45
|
+
* too, and would exercise CORS and third-party-cookie rules that production does
|
|
46
|
+
* not have — so a problem found that way might not be a real one.
|
|
47
|
+
*
|
|
48
|
+
* ## ⛔ Registered SYNCHRONOUSLY, and that is not a style choice
|
|
49
|
+
*
|
|
50
|
+
* Vite adds middleware registered during `configureServer` BEFORE its own — but
|
|
51
|
+
* only what is registered before that hook returns. An `await` first, and the
|
|
52
|
+
* middleware lands after the SPA fallback, which answers every path with
|
|
53
|
+
* `index.html`: the API returns a 200 of HTML, the client fails to parse it, and
|
|
54
|
+
* nothing in the log says why. So the config is read with `readFileSync` and the
|
|
55
|
+
* middleware goes on the stack immediately; only the module load is deferred, and
|
|
56
|
+
* the middleware awaits it on the first request.
|
|
57
|
+
*
|
|
58
|
+
* @param {import('vite').ViteDevServer} server
|
|
59
|
+
* @param {object} options
|
|
60
|
+
* @param {string} options.root - the site directory
|
|
61
|
+
* @returns {boolean} whether a handler was mounted
|
|
62
|
+
*/
|
|
63
|
+
export function mountDevApi(server, { root }) {
|
|
64
|
+
// ⛔ Read from the RAW site.yml, never from the collected `config`. `$`-prefixed
|
|
65
|
+
// keys are stripped from the payload precisely because they are local to a
|
|
66
|
+
// checkout — so the one place that needs this one goes to the file. That is the
|
|
67
|
+
// rule working: if it were readable from `config`, it would also be published.
|
|
68
|
+
let site
|
|
69
|
+
try {
|
|
70
|
+
site = yaml.load(readFileSync(join(root, 'site.yml'), 'utf8')) || {}
|
|
71
|
+
} catch {
|
|
72
|
+
return false
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const spec = site.$devApi
|
|
76
|
+
if (!spec) return false
|
|
77
|
+
|
|
78
|
+
const declared = site.api
|
|
79
|
+
const mount = typeof declared === 'string' ? declared : declared?.endpoint
|
|
80
|
+
if (!mount) {
|
|
81
|
+
console.error("[dev-api] `$devApi` needs an `api:` address to answer on — add `api: /_api` to site.yml.")
|
|
82
|
+
return false
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Loaded once, lazily, and awaited by the middleware. ⚠️ Loud and specific on
|
|
86
|
+
// failure: a dev API that silently fails to load looks exactly like a backend
|
|
87
|
+
// refusing every request, and a developer debugs their own client for an hour
|
|
88
|
+
// before finding a typo in a path.
|
|
89
|
+
let loading = null
|
|
90
|
+
const getHandler = () => {
|
|
91
|
+
if (!loading) {
|
|
92
|
+
loading = server
|
|
93
|
+
.ssrLoadModule(pathToFileURL(resolve(root, spec)).href)
|
|
94
|
+
.then((loaded) => {
|
|
95
|
+
const handler = loaded?.default ?? loaded?.fetch
|
|
96
|
+
if (typeof handler !== 'function') {
|
|
97
|
+
throw new Error(`'${spec}' must default-export a function (request) => Response`)
|
|
98
|
+
}
|
|
99
|
+
return handler
|
|
100
|
+
})
|
|
101
|
+
.catch((err) => {
|
|
102
|
+
console.error(`[dev-api] could not load '${spec}': ${err.message}`)
|
|
103
|
+
throw err
|
|
104
|
+
})
|
|
105
|
+
}
|
|
106
|
+
return loading
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const prefix = mount.endsWith('/') ? mount.slice(0, -1) : mount
|
|
110
|
+
|
|
111
|
+
server.middlewares.use(async (req, res, next) => {
|
|
112
|
+
if (!req.url || (req.url !== prefix && !req.url.startsWith(`${prefix}/`))) return next()
|
|
113
|
+
|
|
114
|
+
// The handler sees the path WITHOUT the mount point: where a site chooses to
|
|
115
|
+
// expose its backend is the site's business, and a handler written against one
|
|
116
|
+
// deployment's prefix would not survive another's.
|
|
117
|
+
const inner = req.url.slice(prefix.length) || '/'
|
|
118
|
+
const origin = `http://${req.headers.host || 'localhost'}`
|
|
119
|
+
const init = { method: req.method, headers: req.headers }
|
|
120
|
+
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
|
121
|
+
const chunks = []
|
|
122
|
+
for await (const chunk of req) chunks.push(chunk)
|
|
123
|
+
if (chunks.length) init.body = Buffer.concat(chunks)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
try {
|
|
127
|
+
const handler = await getHandler()
|
|
128
|
+
const response = await handler(new Request(new URL(inner, origin), init))
|
|
129
|
+
res.statusCode = response.status
|
|
130
|
+
response.headers.forEach((value, key) => res.setHeader(key, value))
|
|
131
|
+
const text = await response.text()
|
|
132
|
+
res.end(text || undefined)
|
|
133
|
+
} catch (err) {
|
|
134
|
+
res.statusCode = 500
|
|
135
|
+
res.setHeader('content-type', 'application/json')
|
|
136
|
+
res.end(JSON.stringify({ status: 500, title: 'DevApiFailure', detail: err?.message }))
|
|
137
|
+
}
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
console.log(`[dev-api] '${spec}' answering ${prefix}/*`)
|
|
141
|
+
return true
|
|
142
|
+
}
|
|
@@ -2396,6 +2396,16 @@ export async function collectSiteContent(sitePath, options = {}) {
|
|
|
2396
2396
|
// consumer and never ships in a payload (the visitor runtime is
|
|
2397
2397
|
// list-unaware; the sync lane reads site.yml directly, not this output).
|
|
2398
2398
|
const { publishLanguages: _publishLanguages, ...runtimeSiteConfig } = siteConfig
|
|
2399
|
+
// ⛔ `$`-prefixed keys are the project's BACKEND-SCOPED state — `$uuid`, `$org`,
|
|
2400
|
+
// `$backend`, `$services`, `$secrets` — and this payload is a PUBLISHED artifact
|
|
2401
|
+
// that a visitor can fetch. They have no runtime reader (nothing in core, runtime
|
|
2402
|
+
// or kit reads a `config.$*` key), so this removes noise for four of them and a
|
|
2403
|
+
// real disclosure for the fifth: `$secrets` carries no values, but its entries
|
|
2404
|
+
// name every secret the site has, and an inventory of credential names is not
|
|
2405
|
+
// something an `export` should publish.
|
|
2406
|
+
for (const key of Object.keys(runtimeSiteConfig)) {
|
|
2407
|
+
if (key.startsWith('$')) delete runtimeSiteConfig[key]
|
|
2408
|
+
}
|
|
2399
2409
|
|
|
2400
2410
|
return {
|
|
2401
2411
|
config: {
|
package/src/site/plugin.js
CHANGED
|
@@ -200,6 +200,7 @@ async function processDevSectionFetches(sections, fetchOptions) {
|
|
|
200
200
|
}
|
|
201
201
|
import { generateSearchIndex, isSearchEnabled, getSearchIndexFilename } from '../search/index.js'
|
|
202
202
|
import { mergeTranslations } from '../i18n/merge.js'
|
|
203
|
+
import { mountDevApi } from '../dev/api-mount.js'
|
|
203
204
|
|
|
204
205
|
/*
|
|
205
206
|
* `applyRouteTranslation` now comes from `@uniweb/projections` (imported
|
|
@@ -879,6 +880,18 @@ export function siteContentPlugin(options = {}) {
|
|
|
879
880
|
configureServer(devServer) {
|
|
880
881
|
server = devServer
|
|
881
882
|
|
|
883
|
+
// A site's own backend, answered locally in development. `site.yml::$devApi`
|
|
884
|
+
// names a module that default-exports a fetch handler, mounted at the site's
|
|
885
|
+
// own `api:` address so the address is identical in dev and in production.
|
|
886
|
+
// ⚠️ Synchronous on purpose — see mountDevApi. An await here and the
|
|
887
|
+
// middleware lands after Vite's SPA fallback, which answers the API with
|
|
888
|
+
// index.html and says nothing about why.
|
|
889
|
+
try {
|
|
890
|
+
mountDevApi(devServer, { root: resolvedSitePath })
|
|
891
|
+
} catch (err) {
|
|
892
|
+
console.error(`[dev-api] ${err.message}`)
|
|
893
|
+
}
|
|
894
|
+
|
|
882
895
|
// Watch for content changes in dev mode
|
|
883
896
|
if (shouldWatch) {
|
|
884
897
|
const siteYmlPath = resolve(resolvedSitePath, 'site.yml')
|
package/src/uwx/data-schema.js
CHANGED
|
@@ -78,7 +78,8 @@ export function isContentBodyField(field) {
|
|
|
78
78
|
* @param {(ref: string) => string} [opts.resolveOptions] - maps an `options`
|
|
79
79
|
* (item_ref) ref to its full `@org/model/<section>` path. Falls back to
|
|
80
80
|
* `resolveName` (model only) when not supplied.
|
|
81
|
-
* @returns {Object} the declaration (`{ name, description?,
|
|
81
|
+
* @returns {Object} the declaration (`{ name, label?, description?, source_locale?,
|
|
82
|
+
* creatable_by?, linkable?, sections }`).
|
|
82
83
|
*/
|
|
83
84
|
export function toDataSchemaDeclaration(normalized, { name, resolveName, resolveOptions } = {}) {
|
|
84
85
|
if (!name) throw new Error('toDataSchemaDeclaration: a registry name is required')
|
|
@@ -100,10 +101,40 @@ export function toDataSchemaDeclaration(normalized, { name, resolveName, resolve
|
|
|
100
101
|
}
|
|
101
102
|
|
|
102
103
|
const decl = { name }
|
|
104
|
+
if (normalized.label) decl.label = normalized.label
|
|
103
105
|
if (normalized.description) decl.description = normalized.description
|
|
104
|
-
|
|
105
|
-
//
|
|
106
|
-
|
|
106
|
+
if (normalized.sourceLocale) decl.source_locale = normalized.sourceLocale
|
|
107
|
+
// `creatable_by` — who may instantiate this Model. Omitted when the author
|
|
108
|
+
// declares nothing, because the registry's own default (open) is the absent
|
|
109
|
+
// meaning; sending it explicitly would state a policy the author did not.
|
|
110
|
+
if (normalized.creatableBy) decl.creatable_by = normalized.creatableBy
|
|
111
|
+
|
|
112
|
+
// `linkable` — DERIVED AND AUTHORED, and the two compose in one direction only.
|
|
113
|
+
//
|
|
114
|
+
// A brief-less model has no card to hydrate as an `entity_ref` target, so it
|
|
115
|
+
// cannot be linkable whatever it says; a model with a brief is linkable by
|
|
116
|
+
// default (omit ⇒ true). ⇒ **The derivation is a ceiling and the authored value
|
|
117
|
+
// may only lower it.** An author may say `linkable: false` on a model with a
|
|
118
|
+
// brief — a real choice, "do not let other models point at this" — and that is
|
|
119
|
+
// honoured.
|
|
120
|
+
//
|
|
121
|
+
// ⛔ The contradiction is refused rather than silently resolved: `linkable: true`
|
|
122
|
+
// on a brief-less model asks for something that cannot exist, and quietly
|
|
123
|
+
// ignoring it is how an author comes to believe a ref target works. Naming it
|
|
124
|
+
// costs one line and the message says which half to change.
|
|
125
|
+
if (!brief) {
|
|
126
|
+
if (normalized.linkable === true) {
|
|
127
|
+
throw new Error(
|
|
128
|
+
`Data schema '${name}': 'linkable: true' needs a brief section — a model with no brief ` +
|
|
129
|
+
`has no card to hydrate when another model references it. Mark a section 'brief: true', ` +
|
|
130
|
+
`or drop 'linkable'.`
|
|
131
|
+
)
|
|
132
|
+
}
|
|
133
|
+
decl.linkable = false
|
|
134
|
+
} else if (normalized.linkable === false) {
|
|
135
|
+
decl.linkable = false
|
|
136
|
+
}
|
|
137
|
+
|
|
107
138
|
decl.sections = sections
|
|
108
139
|
return decl
|
|
109
140
|
}
|
package/src/uwx/folder.js
CHANGED
|
@@ -187,10 +187,17 @@ export function buildFolderEntity({ recordEntities, folderNodes = [], declared,
|
|
|
187
187
|
|
|
188
188
|
const missing = []
|
|
189
189
|
const contents = contentsFromNodes(folderNodes, byEntityId, missing)
|
|
190
|
+
// ⚠️ `id` IS THE ENTITY'S POOL PATH, NOT A FOLDER PATH — say so, because the two
|
|
191
|
+
// read identically and a reader who takes it for a placement concludes the
|
|
192
|
+
// emitter is dropping a branch it never had. *(Measured 2026-08-31: the backend
|
|
193
|
+
// lane read `folder: "articles/outdoor-hygge"` as a placement under an
|
|
194
|
+
// `articles` branch and opened a channel about a missing branch node; the string
|
|
195
|
+
// was naming `entities/articles/outdoor-hygge.md`.)*
|
|
190
196
|
const warnings = missing.map(
|
|
191
197
|
(id) =>
|
|
192
|
-
`
|
|
193
|
-
`
|
|
198
|
+
`records.yml: "${id}" — a path under entities/ — is listed, but no record ` +
|
|
199
|
+
`entity was produced for it (check that its schema resolves). The placement ` +
|
|
200
|
+
`was dropped rather than sent pointing at nothing.`
|
|
194
201
|
)
|
|
195
202
|
|
|
196
203
|
const document = {
|
package/src/uwx/site-project.js
CHANGED
|
@@ -153,7 +153,6 @@ const INFO_TO_SITE_YML = {
|
|
|
153
153
|
data: 'data',
|
|
154
154
|
template: 'template',
|
|
155
155
|
seo: 'seo',
|
|
156
|
-
app: 'app', // deployment's @uniweb/app-spec ref (bare uuid; deployment-local)
|
|
157
156
|
}
|
|
158
157
|
|
|
159
158
|
/**
|
|
@@ -208,6 +207,32 @@ export function siteInfoToConfig({ document, siteRoot, sourceLocale = LOCALIZED_
|
|
|
208
207
|
: []
|
|
209
208
|
if (extensions.length > 0) siteChanges.extensions = extensions
|
|
210
209
|
|
|
210
|
+
// services[] → site.yml::$services · secrets[] → site.yml::$secrets.
|
|
211
|
+
//
|
|
212
|
+
// ⭐ The `$` prefix, and not the bare name, for the reason spelled out in
|
|
213
|
+
// `uwx/site.js`: `site.yml::services` already means "pretend a host offers these"
|
|
214
|
+
// on the bundle lane, and one key cannot mean two things.
|
|
215
|
+
//
|
|
216
|
+
// ⭐ `$id` is DROPPED — it is derived (a service's `name`; a secret's
|
|
217
|
+
// `service:name` pair), so writing it back would put a redundant handle in the
|
|
218
|
+
// author's file and invite them to edit the one field that must not drift from
|
|
219
|
+
// the fields it is derived from. Same call as `extensions` above.
|
|
220
|
+
//
|
|
221
|
+
// ⛔ Everything else rides VERBATIM, `config` included: it is opaque, per-service
|
|
222
|
+
// and will grow, so projecting a known subset would quietly drop whatever the
|
|
223
|
+
// service gained since this line was written — and the next push would then send
|
|
224
|
+
// the truncated version back as authoritative.
|
|
225
|
+
//
|
|
226
|
+
// ⚠️ An EMPTY section is written as an empty list, not skipped. `[]` is a real
|
|
227
|
+
// state — "this site has no service rows" — and it is the state a `pull` must be
|
|
228
|
+
// able to deliver after the last one was removed. Skipping would leave a stale
|
|
229
|
+
// `$services` on disk that the next push would resurrect.
|
|
230
|
+
for (const [section, ymlKey] of [['services', '$services'], ['secrets', '$secrets']]) {
|
|
231
|
+
const records = document?.[section]
|
|
232
|
+
if (!Array.isArray(records)) continue
|
|
233
|
+
siteChanges[ymlKey] = records.map(({ $id: _id, ...fields }) => fields)
|
|
234
|
+
}
|
|
235
|
+
|
|
211
236
|
const result = { siteConfig: writeSiteConfig(siteRoot, siteChanges) }
|
|
212
237
|
|
|
213
238
|
// theme (whole object) → theme.yml.
|
package/src/uwx/site.js
CHANGED
|
@@ -809,6 +809,121 @@ function queriesNested(declarations, uuids = null) {
|
|
|
809
809
|
return out
|
|
810
810
|
}
|
|
811
811
|
|
|
812
|
+
// ── `services` + `secrets` — a site's own service records ─────────────────────
|
|
813
|
+
//
|
|
814
|
+
// ⭐ THE FILE KEYS ARE `$services` / `$secrets`, NOT `services` / `secrets`, and the
|
|
815
|
+
// `$` is load-bearing rather than decorative. `site.yml::services` is ALREADY TAKEN,
|
|
816
|
+
// on the other lane: the bundle lane spreads site.yml whole into the payload, so a
|
|
817
|
+
// `services:` block there lands at `config.services` — the HOST tier — which is the
|
|
818
|
+
// documented way to simulate a host locally (`kit/src/utils/submitTarget.js`).
|
|
819
|
+
// Reusing the name would give one key two meanings that differ per lane, which is
|
|
820
|
+
// the shape of bug nobody finds.
|
|
821
|
+
//
|
|
822
|
+
// `$` already means "backend-scoped, round-tripped, not hand-authored" in this file
|
|
823
|
+
// (`$uuid`, `$org`, `$backend`), and that is exactly what these are: a service's
|
|
824
|
+
// config is bound where the service is provisioned, and arrives here by `pull`.
|
|
825
|
+
//
|
|
826
|
+
// ⚖️ WHAT THESE ARE NOT. A site's OWN service declarations — `search:`, `submit:`,
|
|
827
|
+
// `assistant:`, `tracking:` — stay top-level `info.*` keys and are untouched. Those
|
|
828
|
+
// are authored, they resolve at the SITE tier (`config.<name>`, first choice in
|
|
829
|
+
// `@uniweb/core`'s `resolveService`), and moving them here would flip them to the
|
|
830
|
+
// host tier, where a block's mere presence declines every service it does not name.
|
|
831
|
+
// These Sections carry the services a site is PROVISIONED with — `api` above all,
|
|
832
|
+
// which has no file-authored form because it is bought, not declared.
|
|
833
|
+
//
|
|
834
|
+
// ⛔ ABSENT IS NOT EMPTY, and the difference is destructive. The Section is
|
|
835
|
+
// REPLACED by what we send, so `[]` means "drop every stored config row" while a
|
|
836
|
+
// missing key means "I am not telling you about this". A project that has never
|
|
837
|
+
// pulled has no `$services`, and its ordinary push must not read as a request to
|
|
838
|
+
// wipe a service the operator configured in the app. So: emit the Section only when
|
|
839
|
+
// the file declares the key. Clearing is available and explicit — `$services: []`.
|
|
840
|
+
//
|
|
841
|
+
// ⚠️ The push gate is NOT what makes this safe, though it usually catches it: its
|
|
842
|
+
// tokens live in a gitignored per-clone cache, so a fresh clone pushes
|
|
843
|
+
// unconditionally. Correctness has to sit here.
|
|
844
|
+
|
|
845
|
+
/**
|
|
846
|
+
* `$services` / `$secrets` → Section records, or undefined when the key is absent.
|
|
847
|
+
*
|
|
848
|
+
* ⭐ PASSTHROUGH, NOT AN ALLOWLIST — the same rule and the same reason as
|
|
849
|
+
* `queriesNested` above. The field set belongs to the backend's Model, a service's
|
|
850
|
+
* `config` is opaque and per-service, and reconcile replaces `data` wholesale — so
|
|
851
|
+
* enumerating keys here would not merely fail to send a field we do not know, it
|
|
852
|
+
* would DESTROY whatever is stored under it on every push. Framework can enumerate
|
|
853
|
+
* its own vocabulary and cannot enumerate theirs; withhold ours, forward the rest.
|
|
854
|
+
*
|
|
855
|
+
* @param {*} declared - the raw `$services` / `$secrets` value from site.yml
|
|
856
|
+
* @param {(entry: object) => string|null} identify - the record's stable `$id`
|
|
857
|
+
* @param {string} label - the key name, for the one warning below
|
|
858
|
+
* @returns {object[]|undefined}
|
|
859
|
+
*/
|
|
860
|
+
function serviceRecords(declared, identify, label) {
|
|
861
|
+
if (declared === undefined || declared === null) return undefined
|
|
862
|
+
if (!Array.isArray(declared)) {
|
|
863
|
+
console.warn(
|
|
864
|
+
`uwx/site: \`${label}:\` must be a list of entries — ignoring a ${typeof declared}.`
|
|
865
|
+
)
|
|
866
|
+
return undefined
|
|
867
|
+
}
|
|
868
|
+
const out = []
|
|
869
|
+
for (const entry of declared) {
|
|
870
|
+
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) continue
|
|
871
|
+
const id = identify(entry)
|
|
872
|
+
// ⚠️ `name` is OUR OWN declared-required field, so a warning here is honest —
|
|
873
|
+
// unlike an unrecognized key, which only the server can judge. And the loss is
|
|
874
|
+
// otherwise invisible: on a replaced Section a dropped entry reads as a
|
|
875
|
+
// deliberate removal of its config row.
|
|
876
|
+
if (!id) {
|
|
877
|
+
console.warn(
|
|
878
|
+
`uwx/site: \`${label}:\` has an entry with no \`name\` — skipping it. On a replaced ` +
|
|
879
|
+
'section a dropped entry reads as a deliberate removal of its config.'
|
|
880
|
+
)
|
|
881
|
+
continue
|
|
882
|
+
}
|
|
883
|
+
const data = {}
|
|
884
|
+
for (const [key, value] of Object.entries(entry)) {
|
|
885
|
+
if (value === undefined) continue
|
|
886
|
+
data[key] = value
|
|
887
|
+
}
|
|
888
|
+
out.push(withIdentity(id, data))
|
|
889
|
+
}
|
|
890
|
+
return out
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
/** One service per `name` — the same keyspace `config.services` uses at runtime. */
|
|
894
|
+
function servicesNested(siteYml) {
|
|
895
|
+
return serviceRecords(
|
|
896
|
+
siteYml.$services,
|
|
897
|
+
(e) => (typeof e.name === 'string' && e.name ? e.name : null),
|
|
898
|
+
'$services'
|
|
899
|
+
)
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
/**
|
|
903
|
+
* One secret per `(service, name)` — the pair the backend merges on. A site-level
|
|
904
|
+
* secret belongs to no service, so `service` is optional and the handle degrades to
|
|
905
|
+
* the bare name.
|
|
906
|
+
*
|
|
907
|
+
* ⛔ `value` IS FORWARDED VERBATIM, INCLUDING A LITERAL. A pulled secret carries the
|
|
908
|
+
* marker `#ref` meaning "a secret is set", never the value, and pushing the marker
|
|
909
|
+
* back means "leave it alone" — so the ordinary round trip sends nothing sensitive.
|
|
910
|
+
* A literal typed into the file is refused by the server, which is where that
|
|
911
|
+
* judgement belongs; framework does not strip it, because silently dropping a value
|
|
912
|
+
* an author typed would leave them believing a secret was set.
|
|
913
|
+
*/
|
|
914
|
+
function secretsNested(siteYml) {
|
|
915
|
+
return serviceRecords(
|
|
916
|
+
siteYml.$secrets,
|
|
917
|
+
(e) => {
|
|
918
|
+
if (typeof e.name !== 'string' || !e.name) return null
|
|
919
|
+
return typeof e.service === 'string' && e.service
|
|
920
|
+
? `${e.service}:${e.name}`
|
|
921
|
+
: e.name
|
|
922
|
+
},
|
|
923
|
+
'$secrets'
|
|
924
|
+
)
|
|
925
|
+
}
|
|
926
|
+
|
|
812
927
|
/**
|
|
813
928
|
* Map a file site project to the nested `@uniweb/site-content` `$`-document
|
|
814
929
|
* (see the lane header above). PURE — reads the project, never mints, never writes.
|
|
@@ -957,13 +1072,38 @@ export async function siteProjectToDocument(siteRoot, opts = {}) {
|
|
|
957
1072
|
// secret store is the only right home either way.
|
|
958
1073
|
|
|
959
1074
|
setIf(info, 'tracking', stripCredentials(siteYml.tracking, 'tracking'))
|
|
1075
|
+
// ⛔ `api` IS DELIBERATELY NOT HERE, and this note exists because every comment
|
|
1076
|
+
// above it argues the opposite — three services are on this allowlist precisely so
|
|
1077
|
+
// an authored block cannot work on a static host and vanish on the synced one.
|
|
1078
|
+
// Without this paragraph the next reader adds the missing fourth line and calls it
|
|
1079
|
+
// a bug fix.
|
|
1080
|
+
//
|
|
1081
|
+
// ⭐ `api` is the one service a site does not AUTHOR. It is a real backend that is
|
|
1082
|
+
// provisioned and paid for, so its address is the host's to supply — it arrives as
|
|
1083
|
+
// `config.services.api` and `@uniweb/api` reads it there (`resolveBase`). An
|
|
1084
|
+
// authored `api:` is the SITE tier, which outranks the host permanently.
|
|
1085
|
+
//
|
|
1086
|
+
// ⇒ Carrying it would turn a local-dev override into a production one the moment
|
|
1087
|
+
// someone pushed: the host would store `info.api` and serve it back as `config.api`,
|
|
1088
|
+
// which wins over the address of the backend the site actually has. The vanish on
|
|
1089
|
+
// this lane is the correct behaviour, not the bug the comments above describe —
|
|
1090
|
+
// there, a dropped block leaves a site with NO endpoint; here it leaves the site
|
|
1091
|
+
// with the RIGHT one.
|
|
1092
|
+
//
|
|
1093
|
+
// The provisioned record rides the `$services` section instead (see servicesNested).
|
|
960
1094
|
setIf(info, 'paths', siteYml.paths)
|
|
961
1095
|
setIf(info, 'data', siteYml.data ?? siteYml.fetch)
|
|
962
|
-
// `app` —
|
|
963
|
-
//
|
|
964
|
-
//
|
|
965
|
-
//
|
|
966
|
-
|
|
1096
|
+
// ⛔ `app` IS RETIRED — do not reintroduce it, in either direction. It carried an
|
|
1097
|
+
// opaque uuid naming a separate entity a host bound to the site; that entity is
|
|
1098
|
+
// gone, a site's services belong to the site itself, and NOTHING replaces the key.
|
|
1099
|
+
//
|
|
1100
|
+
// ⚠️ Removing the emit is the FIRST of two steps and the order is forced: a host
|
|
1101
|
+
// refuses a key it does not declare, so the producer stops sending before the
|
|
1102
|
+
// declaration is dropped. The reverse order fails every push in between.
|
|
1103
|
+
//
|
|
1104
|
+
// ✅ Unobservable, because nothing ever originated the key — no template writes
|
|
1105
|
+
// `site.yml::app`. The line round-tripped a value that was never set.
|
|
1106
|
+
// (uwx-format.md → info.app.)
|
|
967
1107
|
// `template: true` designates this site as a clonable SITE-TEMPLATE: on push the
|
|
968
1108
|
// backend applies a clonability designation to this site-content entity (it is
|
|
969
1109
|
// NOT a registry artifact). Verbatim; absent → a normal (non-template) site.
|
|
@@ -1017,6 +1157,13 @@ export async function siteProjectToDocument(siteRoot, opts = {}) {
|
|
|
1017
1157
|
// ⚠️ `queriesNested` keeps its name. §2's rule: rename what an author or a
|
|
1018
1158
|
// consumer sees, leave the identifier alone.
|
|
1019
1159
|
doc.queries = queriesNested(colConfig.declarations, opts.queryUuids)
|
|
1160
|
+
// Emitted ONLY when the file declares the key — see the header above
|
|
1161
|
+
// `serviceRecords`: on a replaced Section, absent and empty are different
|
|
1162
|
+
// requests and one of them is destructive.
|
|
1163
|
+
const services = servicesNested(siteYml)
|
|
1164
|
+
if (services) doc.services = services
|
|
1165
|
+
const secrets = secretsNested(siteYml)
|
|
1166
|
+
if (secrets) doc.secrets = secrets
|
|
1020
1167
|
return doc
|
|
1021
1168
|
}
|
|
1022
1169
|
|