@uniweb/build 0.42.1 → 0.43.1
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/foundation/derive-supports.js +259 -0
- package/src/schema.js +77 -1
- package/src/site/content-collector.js +36 -4
- package/src/uwx/site-project.js +25 -0
- package/src/uwx/site.js +77 -2
- package/src/uwx/sync-package.js +6 -1
- package/src/vite-foundation-plugin.js +40 -15
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uniweb/build",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.43.1",
|
|
4
4
|
"description": "Build tooling for the Uniweb Component Web Platform",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -57,14 +57,14 @@
|
|
|
57
57
|
"sharp": "^0.35.3",
|
|
58
58
|
"yaml": "^2.5.0",
|
|
59
59
|
"@uniweb/content-reader": "^1.2.4",
|
|
60
|
-
"@uniweb/content-writer": "^0.3.4",
|
|
61
|
-
"@uniweb/projections": "^0.5.13",
|
|
62
|
-
"@uniweb/semantic-parser": "^1.4.0",
|
|
63
60
|
"@uniweb/schemas": "^0.2.13",
|
|
64
|
-
"@uniweb/theming": "^0.1.15"
|
|
61
|
+
"@uniweb/theming": "^0.1.15",
|
|
62
|
+
"@uniweb/projections": "^0.5.13",
|
|
63
|
+
"@uniweb/content-writer": "^0.3.4",
|
|
64
|
+
"@uniweb/semantic-parser": "^1.4.0"
|
|
65
65
|
},
|
|
66
66
|
"optionalDependencies": {
|
|
67
|
-
"@uniweb/runtime": "^0.
|
|
67
|
+
"@uniweb/runtime": "^0.19.3"
|
|
68
68
|
},
|
|
69
69
|
"peerDependencies": {
|
|
70
70
|
"vite": "^5.0.0 || ^6.0.0 || ^7.0.0",
|
|
@@ -73,7 +73,7 @@
|
|
|
73
73
|
"@tailwindcss/vite": "^4.0.0",
|
|
74
74
|
"@vitejs/plugin-react": "^4.0.0 || ^5.0.0",
|
|
75
75
|
"vite-plugin-svgr": "^4.0.0",
|
|
76
|
-
"@uniweb/core": "^0.24.
|
|
76
|
+
"@uniweb/core": "^0.24.2"
|
|
77
77
|
},
|
|
78
78
|
"peerDependenciesMeta": {
|
|
79
79
|
"vite": {
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Derive the host services a foundation is built against, from its own bundle.
|
|
3
|
+
*
|
|
4
|
+
* ## The one idea
|
|
5
|
+
*
|
|
6
|
+
* A foundation declares `uniweb.supports` in `package.json` so a consumer can
|
|
7
|
+
* tell "this foundation draws a search box" from "this foundation has never
|
|
8
|
+
* heard of search". The declaration is authored, and an author who never learns
|
|
9
|
+
* the key exists never writes one — which is the failure the field was added to
|
|
10
|
+
* prevent, reappearing one level up.
|
|
11
|
+
*
|
|
12
|
+
* ⭐ **But the bundler already knows.** `@uniweb/kit` is not externalized
|
|
13
|
+
* (`DEFAULT_EXTERNALS` is the react entries plus the bare `@uniweb/core`), so
|
|
14
|
+
* kit's service code is compiled into the foundation and tree-shaken with it.
|
|
15
|
+
* A module that survives that shake is a module something reachable uses.
|
|
16
|
+
*
|
|
17
|
+
* So this reads the answer off the module graph instead of asking for it, and
|
|
18
|
+
* `package.json` becomes the *supplement* for what the graph cannot see rather
|
|
19
|
+
* than the declaration itself.
|
|
20
|
+
*
|
|
21
|
+
* ## ⛔ POST-TREE-SHAKE ONLY — the pre-shake graph OVER-REPORTS
|
|
22
|
+
*
|
|
23
|
+
* Rollup parses modules it later discards, so `getModuleIds()` includes code
|
|
24
|
+
* that was shaken out. Measured 2026-09-06 on `templates/services`, which uses
|
|
25
|
+
* submit and not search:
|
|
26
|
+
*
|
|
27
|
+
* | scanned | literals found |
|
|
28
|
+
* |---|---|
|
|
29
|
+
* | the whole graph (`getModuleIds()`) | ⛔ `search, submit` |
|
|
30
|
+
* | survivors only (`renderedLength > 0`) | ✅ `submit` |
|
|
31
|
+
*
|
|
32
|
+
* 475 modules parsed, 28 survived. ⇒ **Over-reporting is the dangerous
|
|
33
|
+
* direction** — it tells a host to offer a service the foundation never draws,
|
|
34
|
+
* which is exactly the invisible failure this whole mechanism exists to stop.
|
|
35
|
+
* Never widen the scan set to "everything Rollup saw".
|
|
36
|
+
*
|
|
37
|
+
* ## ⭐ The service name travels with the code, so there is almost no map
|
|
38
|
+
*
|
|
39
|
+
* `kit/src/utils/submitTarget.js` contains `resolveService(website, 'submit')`
|
|
40
|
+
* in its own source and survives beside the hook that pulled it in. So scanning
|
|
41
|
+
* survivors for that call recovers the name without anyone maintaining a
|
|
42
|
+
* module→service table — and it covers the **open registry** for free: a
|
|
43
|
+
* foundation that invents `booking` is read the same way as one that uses
|
|
44
|
+
* `submit`, because the framework has no list of permitted names
|
|
45
|
+
* (`core/src/services.js`).
|
|
46
|
+
*
|
|
47
|
+
* Two gates need help, and only two:
|
|
48
|
+
*
|
|
49
|
+
* - **`useTracker`** resolves nothing itself — it calls through to
|
|
50
|
+
* `getUniweb()?.tracking`, and the `'tracking'` literal lives in
|
|
51
|
+
* `runtime/src/wire-foundation.js`, which is never bundled into a
|
|
52
|
+
* foundation. Its module presence is the signal.
|
|
53
|
+
* - **`isSearchEnabled()`** is a `Website` method, and its literal is in
|
|
54
|
+
* `core/src/website.js` — external, so never a survivor. The call is the
|
|
55
|
+
* signal.
|
|
56
|
+
*
|
|
57
|
+
* ⚠️ `@uniweb/api` deliberately needs no entry: it calls
|
|
58
|
+
* `resolveService(website, SERVICE_NAME)` against a module-level `const`, which
|
|
59
|
+
* `readModuleConsts` resolves. Without that resolution `api` would be both
|
|
60
|
+
* missed *and* counted as blindness, marking every foundation that uses it
|
|
61
|
+
* unknowable.
|
|
62
|
+
*
|
|
63
|
+
* ## Why the AST rather than a regex
|
|
64
|
+
*
|
|
65
|
+
* The probe that produced the table above also found its only "computed call
|
|
66
|
+
* site" inside a **JSDoc comment** in `core/src/services.js` describing the
|
|
67
|
+
* signature, and `export function resolveService(website, name)` is a
|
|
68
|
+
* declaration a regex reads as a call with a variable argument. Both vanish on
|
|
69
|
+
* an AST, and Rollup has already parsed every module — `info.ast` was present
|
|
70
|
+
* for all 475 — so this costs a walk, not a parse.
|
|
71
|
+
*
|
|
72
|
+
* @module @uniweb/build/foundation/derive-supports
|
|
73
|
+
*/
|
|
74
|
+
|
|
75
|
+
/** Kit's `useTracker`, in a workspace clone or a published install alike. */
|
|
76
|
+
const TRACKER_MODULE = /(^|[/\\])kit[/\\]src[/\\]hooks[/\\]useTracker\.js$/
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Walk an ESTree tree, visiting every node.
|
|
80
|
+
*
|
|
81
|
+
* Hand-rolled rather than pulled from a dependency: `@uniweb/build` is on the
|
|
82
|
+
* install path of every foundation, and this is a dozen lines.
|
|
83
|
+
*/
|
|
84
|
+
function walk(node, visit) {
|
|
85
|
+
if (!node || typeof node !== 'object') return
|
|
86
|
+
if (Array.isArray(node)) {
|
|
87
|
+
for (const child of node) walk(child, visit)
|
|
88
|
+
return
|
|
89
|
+
}
|
|
90
|
+
if (typeof node.type === 'string') visit(node)
|
|
91
|
+
for (const key in node) {
|
|
92
|
+
if (key === 'type' || key === 'loc' || key === 'range' || key === 'start' || key === 'end') {
|
|
93
|
+
continue
|
|
94
|
+
}
|
|
95
|
+
const value = node[key]
|
|
96
|
+
if (value && typeof value === 'object') walk(value, visit)
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Module-scope `const NAME = 'string'` bindings.
|
|
102
|
+
*
|
|
103
|
+
* ⛔ Top level only, deliberately. A nested binding of the same name would
|
|
104
|
+
* shadow, and resolving one could name a service the code never asks for —
|
|
105
|
+
* over-reporting, the direction that must not happen. An unresolved identifier
|
|
106
|
+
* is treated as blindness instead, which is the safe answer.
|
|
107
|
+
*/
|
|
108
|
+
function readModuleConsts(ast) {
|
|
109
|
+
const consts = new Map()
|
|
110
|
+
const record = (decl) => {
|
|
111
|
+
if (!decl || decl.type !== 'VariableDeclaration') return
|
|
112
|
+
for (const d of decl.declarations || []) {
|
|
113
|
+
if (d.id?.type === 'Identifier' && typeof d.init?.value === 'string') {
|
|
114
|
+
consts.set(d.id.name, d.init.value)
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
for (const node of ast.body || []) {
|
|
119
|
+
record(node)
|
|
120
|
+
if (node.type === 'ExportNamedDeclaration') record(node.declaration)
|
|
121
|
+
}
|
|
122
|
+
return consts
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** `resolveService(...)`, called bare or through a namespace. */
|
|
126
|
+
function isResolveServiceCall(node) {
|
|
127
|
+
if (node.type !== 'CallExpression') return false
|
|
128
|
+
const callee = node.callee
|
|
129
|
+
if (callee?.type === 'Identifier') return callee.name === 'resolveService'
|
|
130
|
+
if (callee?.type === 'MemberExpression' && !callee.computed) {
|
|
131
|
+
return callee.property?.name === 'resolveService'
|
|
132
|
+
}
|
|
133
|
+
return false
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** A `.isSearchEnabled()` call on anything. */
|
|
137
|
+
function isSearchEnabledCall(node) {
|
|
138
|
+
return (
|
|
139
|
+
node.type === 'CallExpression' &&
|
|
140
|
+
node.callee?.type === 'MemberExpression' &&
|
|
141
|
+
!node.callee.computed &&
|
|
142
|
+
node.callee.property?.name === 'isSearchEnabled'
|
|
143
|
+
)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* The set of modules that contributed code to the written bundle.
|
|
148
|
+
*
|
|
149
|
+
* `renderedLength > 0` is the discriminator: a module whose every binding was
|
|
150
|
+
* shaken out contributes nothing, and counting it would reintroduce exactly the
|
|
151
|
+
* over-reporting this module exists to avoid.
|
|
152
|
+
*/
|
|
153
|
+
function collectSurvivors(bundle) {
|
|
154
|
+
const survivors = new Set()
|
|
155
|
+
for (const chunk of Object.values(bundle || {})) {
|
|
156
|
+
if (chunk?.type !== 'chunk' || !chunk.modules) continue
|
|
157
|
+
for (const [id, mod] of Object.entries(chunk.modules)) {
|
|
158
|
+
if (mod?.renderedLength > 0) survivors.add(id)
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return survivors
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Derive the services this foundation reaches for.
|
|
166
|
+
*
|
|
167
|
+
* @param {object} bundle - Rollup's bundle, as handed to `writeBundle`
|
|
168
|
+
* @param {object} ctx - the Rollup plugin context (`this` in the hook)
|
|
169
|
+
* @returns {{services: string[], blind: boolean, blindAt: string[]}}
|
|
170
|
+
* `services` is sorted and de-duplicated, and is a **lower bound**.
|
|
171
|
+
* `blind` is true when a `resolveService` call names its service with
|
|
172
|
+
* something this cannot read — the one case where the lower bound is known to
|
|
173
|
+
* be incomplete, and the caller must not report an empty result as a proven
|
|
174
|
+
* "none". `blindAt` names the modules, for the warning.
|
|
175
|
+
*/
|
|
176
|
+
export function deriveSupports(bundle, ctx) {
|
|
177
|
+
const services = new Set()
|
|
178
|
+
const blindAt = new Set()
|
|
179
|
+
|
|
180
|
+
for (const id of collectSurvivors(bundle)) {
|
|
181
|
+
if (TRACKER_MODULE.test(id)) services.add('tracking')
|
|
182
|
+
|
|
183
|
+
const info = ctx?.getModuleInfo?.(id)
|
|
184
|
+
const ast = info?.ast
|
|
185
|
+
if (!ast) continue
|
|
186
|
+
|
|
187
|
+
const consts = readModuleConsts(ast)
|
|
188
|
+
|
|
189
|
+
walk(ast, (node) => {
|
|
190
|
+
if (isSearchEnabledCall(node)) {
|
|
191
|
+
services.add('search')
|
|
192
|
+
return
|
|
193
|
+
}
|
|
194
|
+
if (!isResolveServiceCall(node)) return
|
|
195
|
+
|
|
196
|
+
const arg = node.arguments?.[1]
|
|
197
|
+
if (typeof arg?.value === 'string') {
|
|
198
|
+
services.add(arg.value)
|
|
199
|
+
} else if (arg?.type === 'Identifier' && consts.has(arg.name)) {
|
|
200
|
+
services.add(consts.get(arg.name))
|
|
201
|
+
} else {
|
|
202
|
+
// A name this cannot read. Not an error — a foundation may legitimately
|
|
203
|
+
// compute one — but it means the derived set is short by an unknown
|
|
204
|
+
// amount, and the caller has to say so rather than claim completeness.
|
|
205
|
+
blindAt.add(id)
|
|
206
|
+
}
|
|
207
|
+
})
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
return {
|
|
211
|
+
services: [...services].sort(),
|
|
212
|
+
blind: blindAt.size > 0,
|
|
213
|
+
blindAt: [...blindAt].sort(),
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Compose what the foundation publishes from the authored and derived halves.
|
|
219
|
+
*
|
|
220
|
+
* ## ⛔ THE THREE STATES SURVIVE, AND TWO OF THEM ARE NOT THE SAME
|
|
221
|
+
*
|
|
222
|
+
* `info.supports` carries three values and a consumer distinguishes all of them:
|
|
223
|
+
* **absent** is UNKNOWN — nobody said — **`[]`** is an explicit none, and a list
|
|
224
|
+
* is *these and only these*. Returning `{}` versus `{ supports: [] }` is how
|
|
225
|
+
* that distinction reaches the wire, so neither branch below may be collapsed
|
|
226
|
+
* into the other. It is the same three-state rule `info.runtime` states in the
|
|
227
|
+
* same brief: an omission is UNKNOWN rather than unconstrained, because a floor
|
|
228
|
+
* nobody stated cannot be shown to be satisfied.
|
|
229
|
+
*
|
|
230
|
+
* | authored | derived | emitted |
|
|
231
|
+
* |---|---|---|
|
|
232
|
+
* | absent | some | the derived set |
|
|
233
|
+
* | absent | none, nothing blind | ⭐ `[]` — a **proven** none, not an assumed one |
|
|
234
|
+
* | absent | none, something blind | ⛔ nothing — UNKNOWN, honestly |
|
|
235
|
+
* | a list | any | the union |
|
|
236
|
+
* | `[]` | some | the union, with a warning: the evidence contradicts the claim |
|
|
237
|
+
*
|
|
238
|
+
* ⭐ **The union only ever grows the set**, so the failure the authored-only
|
|
239
|
+
* design guarded against — publishing a set shorter than the truth — stays
|
|
240
|
+
* unreachable: whatever a developer wrote is still there.
|
|
241
|
+
*
|
|
242
|
+
* @param {string[]|undefined} authored - normalized `uniweb.supports`; `undefined` when absent
|
|
243
|
+
* @param {{services: string[], blind: boolean}|null} derived - null when not derived (a dev rebuild)
|
|
244
|
+
* @returns {{supports?: string[]}} spread into `_self`; `{}` keeps the key absent
|
|
245
|
+
*/
|
|
246
|
+
export function composeSupports(authored, derived) {
|
|
247
|
+
// No derivation ran (dev rebuild): the authored value is the whole answer,
|
|
248
|
+
// and an absent one stays absent.
|
|
249
|
+
if (!derived) return authored === undefined ? {} : { supports: authored }
|
|
250
|
+
|
|
251
|
+
const { services, blind } = derived
|
|
252
|
+
|
|
253
|
+
if (authored === undefined && services.length === 0) {
|
|
254
|
+
return blind ? {} : { supports: [] }
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const union = [...new Set([...(authored || []), ...services])].sort()
|
|
258
|
+
return { supports: union }
|
|
259
|
+
}
|
package/src/schema.js
CHANGED
|
@@ -17,6 +17,7 @@ import { join, dirname, extname, basename } from 'node:path'
|
|
|
17
17
|
import { pathToFileURL } from 'node:url'
|
|
18
18
|
import { inferTitle } from './utils/infer-title.js'
|
|
19
19
|
import { collectSchemaRefs, buildDataSchemaMap } from './resolve-data-schema.js'
|
|
20
|
+
import { composeSupports } from './foundation/derive-supports.js'
|
|
20
21
|
|
|
21
22
|
// Component meta file name
|
|
22
23
|
const META_FILE_NAME = 'meta.js'
|
|
@@ -719,6 +720,69 @@ export async function discoverComponents(srcDir, sectionPaths = DEFAULT_SECTION_
|
|
|
719
720
|
return sections
|
|
720
721
|
}
|
|
721
722
|
|
|
723
|
+
/**
|
|
724
|
+
* Say what the derivation added, at the one moment the developer is looking.
|
|
725
|
+
*
|
|
726
|
+
* ⭐ This is the whole answer to *"a developer may not realize they have to
|
|
727
|
+
* declare the service"*. `uniweb doctor` cannot reach that developer — it is
|
|
728
|
+
* opt-in, and someone who does not know the key exists has no reason to run it.
|
|
729
|
+
* A build is on the path they already walk.
|
|
730
|
+
*
|
|
731
|
+
* ⛔ It reports; it never edits. Writing the names into `package.json` is
|
|
732
|
+
* `uniweb doctor --fix`, where it is a thing the developer asked for and can
|
|
733
|
+
* read in a diff — a build that rewrites source on every run is a surprise, and
|
|
734
|
+
* the artifact is already correct without it.
|
|
735
|
+
*/
|
|
736
|
+
function reportSupports(srcDir, authored, derived, emitted) {
|
|
737
|
+
if (!derived) return
|
|
738
|
+
|
|
739
|
+
const added = (emitted || []).filter((s) => !(authored || []).includes(s))
|
|
740
|
+
|
|
741
|
+
if (added.length > 0) {
|
|
742
|
+
const list = added.join(', ')
|
|
743
|
+
if (authored === undefined) {
|
|
744
|
+
console.log(`Derived uniweb.supports from the bundle: ${list}`)
|
|
745
|
+
console.log(` Nothing was declared, so this is what the foundation publishes.`)
|
|
746
|
+
console.log(` \`uniweb doctor --fix\` writes it into package.json if you want it in the file.`)
|
|
747
|
+
} else if (authored.length === 0) {
|
|
748
|
+
// An explicit `[]` says "this foundation honours no host service", and the
|
|
749
|
+
// bundle contradicts it. The union wins — evidence beats a stale claim —
|
|
750
|
+
// but silently overriding what someone typed is how a declaration stops
|
|
751
|
+
// meaning anything, so say it.
|
|
752
|
+
console.warn(
|
|
753
|
+
`Warning: ${srcDir}/package.json declares \`uniweb.supports: []\` (no services), ` +
|
|
754
|
+
`but the bundle reaches for ${list}. Publishing ${list}.`,
|
|
755
|
+
)
|
|
756
|
+
} else {
|
|
757
|
+
console.log(`Derived uniweb.supports additions: ${list} (declared: ${authored.join(', ')})`)
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
// ⛔ WARN WHENEVER THE DERIVATION WAS BLIND — not only when it came back empty.
|
|
762
|
+
//
|
|
763
|
+
// This used to require an empty result, which left the more likely case
|
|
764
|
+
// silent: a foundation that reaches `submit` through a literal and `booking`
|
|
765
|
+
// through a computed name publishes `["submit"]` — short, with no warning, and
|
|
766
|
+
// the one person who could fix it never hears about it.
|
|
767
|
+
//
|
|
768
|
+
// ⭐ That is what makes the lower bound tolerable. How often a foundation
|
|
769
|
+
// computes a service name is unknowable from here — foundations are
|
|
770
|
+
// third-party — but it does not need to be known, because the build detects
|
|
771
|
+
// its own blindness and can say so to the developer at the moment they build.
|
|
772
|
+
if (derived.blind) {
|
|
773
|
+
const empty = emitted === undefined || emitted.length === 0
|
|
774
|
+
console.warn(
|
|
775
|
+
empty
|
|
776
|
+
? `Warning: a service is resolved by a computed name, so \`uniweb.supports\` cannot be ` +
|
|
777
|
+
`derived and is left undeclared. List it in package.json to publish it.`
|
|
778
|
+
: `Warning: a service is resolved by a computed name, so the derived ` +
|
|
779
|
+
`\`uniweb.supports\` may be incomplete. Add any service missing from ` +
|
|
780
|
+
`[${(emitted || []).join(', ')}] to package.json.`,
|
|
781
|
+
)
|
|
782
|
+
for (const at of derived.blindAt || []) console.warn(` at ${at}`)
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
|
|
722
786
|
/**
|
|
723
787
|
* Build complete schema for a foundation
|
|
724
788
|
* Returns { _self: { identity + config }, ComponentName: componentMeta, ... }
|
|
@@ -729,8 +793,11 @@ export async function discoverComponents(srcDir, sectionPaths = DEFAULT_SECTION_
|
|
|
729
793
|
*
|
|
730
794
|
* @param {string} srcDir - Source directory
|
|
731
795
|
* @param {string[]} [sectionPaths] - Paths to scan for section types
|
|
796
|
+
* @param {{services: string[], blind: boolean}} [derivedSupports] - what the
|
|
797
|
+
* module graph says this foundation reaches for (`foundation/derive-supports.js`).
|
|
798
|
+
* Omitted on a dev rebuild, where nothing reads the result.
|
|
732
799
|
*/
|
|
733
|
-
export async function buildSchema(srcDir, sectionPaths) {
|
|
800
|
+
export async function buildSchema(srcDir, sectionPaths, derivedSupports = null) {
|
|
734
801
|
// Load identity from package.json
|
|
735
802
|
const identity = await loadPackageJson(srcDir)
|
|
736
803
|
|
|
@@ -763,10 +830,19 @@ export async function buildSchema(srcDir, sectionPaths) {
|
|
|
763
830
|
// Build _self, stripping the raw extension boolean in favor of normalized role
|
|
764
831
|
const { extension: _ext, ...configWithoutExtension } = foundationConfig
|
|
765
832
|
|
|
833
|
+
// `supports` is the one identity field the graph knows better than the file.
|
|
834
|
+
// `identity.supports` is already normalized (absent stays absent, `[]` stays
|
|
835
|
+
// `[]`), and composeSupports keeps that three-state distinction while adding
|
|
836
|
+
// what the bundle proves — see its table. Spread AFTER `...identity` so it
|
|
837
|
+
// replaces the authored-only value rather than being overwritten by it.
|
|
838
|
+
const supports = composeSupports(identity.supports, derivedSupports)
|
|
839
|
+
reportSupports(srcDir, identity.supports, derivedSupports, supports.supports)
|
|
840
|
+
|
|
766
841
|
return {
|
|
767
842
|
_self: {
|
|
768
843
|
...configWithoutExtension,
|
|
769
844
|
...identity,
|
|
845
|
+
...supports,
|
|
770
846
|
// foundation.js overrides package.json for editor-facing identity
|
|
771
847
|
...(foundationConfig.name && { name: foundationConfig.name }),
|
|
772
848
|
...(foundationConfig.description && { description: foundationConfig.description }),
|
|
@@ -2095,11 +2095,19 @@ async function collectPagesRecursive(dirPath, parentRoute, siteRoot, orderConfig
|
|
|
2095
2095
|
/**
|
|
2096
2096
|
* Load foundation schema data needed by the content collector.
|
|
2097
2097
|
*
|
|
2098
|
+
* `hasContentHandler` reports whether the foundation declares `handlers.content`
|
|
2099
|
+
* — the hook that resolves `{…}` in page content. It is NOT used to decide
|
|
2100
|
+
* anything about the build; its only consumer is the `placeholders:` warning at
|
|
2101
|
+
* the call site, which needs to know whether a declared value has any reader.
|
|
2102
|
+
* ⚠️ It cannot tell WHICH engine the handler uses (a handler is a function, and
|
|
2103
|
+
* the build never calls it), so it answers "something could resolve this",
|
|
2104
|
+
* never "Loom will".
|
|
2105
|
+
*
|
|
2098
2106
|
* @param {string} foundationPath - Path to foundation directory
|
|
2099
|
-
* @returns {Promise<{ vars: Object, layoutNames: Set<string
|
|
2107
|
+
* @returns {Promise<{ vars: Object, layoutNames: Set<string>, hasContentHandler: boolean }>}
|
|
2100
2108
|
*/
|
|
2101
2109
|
export async function loadFoundationInfo(foundationPath) {
|
|
2102
|
-
if (!foundationPath) return { vars: {}, layoutNames: new Set() }
|
|
2110
|
+
if (!foundationPath) return { vars: {}, layoutNames: new Set(), hasContentHandler: false }
|
|
2103
2111
|
|
|
2104
2112
|
// ⛔ **NOT `dist/meta/schema.json`.** That file is the EDITOR's artifact — the
|
|
2105
2113
|
// rich per-section declaration a visual editor needs to render parameter forms
|
|
@@ -2133,9 +2141,13 @@ export async function loadFoundationInfo(foundationPath) {
|
|
|
2133
2141
|
// Two independent reads, so a failure in one does not cost the other. The
|
|
2134
2142
|
// previous single try/catch lost the layouts when only the config was broken.
|
|
2135
2143
|
let vars = {}
|
|
2144
|
+
let hasContentHandler = false
|
|
2136
2145
|
try {
|
|
2137
2146
|
const config = await loadFoundationConfig(srcDir)
|
|
2138
2147
|
vars = config?.vars || {}
|
|
2148
|
+
// `loadFoundationConfig` spreads the module's default export, so `handlers`
|
|
2149
|
+
// arrives intact even though it holds functions and never reaches schema.json.
|
|
2150
|
+
hasContentHandler = typeof config?.handlers?.content === 'function'
|
|
2139
2151
|
} catch (err) {
|
|
2140
2152
|
console.warn(
|
|
2141
2153
|
`[content-collector] Could not read the foundation's declared theme vars from ${srcDir}: ${err.message}\n` +
|
|
@@ -2154,7 +2166,7 @@ export async function loadFoundationInfo(foundationPath) {
|
|
|
2154
2166
|
)
|
|
2155
2167
|
}
|
|
2156
2168
|
|
|
2157
|
-
return { vars, layoutNames }
|
|
2169
|
+
return { vars, layoutNames, hasContentHandler }
|
|
2158
2170
|
}
|
|
2159
2171
|
|
|
2160
2172
|
/**
|
|
@@ -2372,7 +2384,27 @@ export async function collectSiteContent(sitePath, options = {}) {
|
|
|
2372
2384
|
const rawThemeConfig = await readYamlFile(join(sitePath, 'theme.yml'))
|
|
2373
2385
|
|
|
2374
2386
|
// Load foundation info (vars + layout names) and process theme
|
|
2375
|
-
const { vars: foundationVars, layoutNames: layoutNames } =
|
|
2387
|
+
const { vars: foundationVars, layoutNames: layoutNames, hasContentHandler } =
|
|
2388
|
+
await loadFoundationInfo(foundationPath)
|
|
2389
|
+
|
|
2390
|
+
// ⭐ `placeholders:` IS DECLARED FOR A READER THAT MAY NOT EXIST, and that is
|
|
2391
|
+
// the one way this feature fails. Resolving `{…}` in page content is a
|
|
2392
|
+
// FOUNDATION capability (`handlers.content`, normally @uniweb/loom), not
|
|
2393
|
+
// something the framework does for every site — so on a foundation that
|
|
2394
|
+
// declares no content handler the block is inert and the page renders the
|
|
2395
|
+
// literal `{vendor.email}`. That reads as an authoring typo, which is why it
|
|
2396
|
+
// is worth a build-time line rather than leaving the author to find it.
|
|
2397
|
+
//
|
|
2398
|
+
// ⚖️ A WARNING, never an error: the site may be mid-migration, or the author
|
|
2399
|
+
// may be about to switch foundations, and a declared-but-unread value harms
|
|
2400
|
+
// nothing. Same rule as the retired-`fetcher:` keys above — warn once, carry on.
|
|
2401
|
+
if (siteConfig.placeholders && !hasContentHandler) {
|
|
2402
|
+
console.warn(
|
|
2403
|
+
`[uniweb] site.yml declares \`placeholders:\` but the foundation has no \`handlers.content\`, ` +
|
|
2404
|
+
`so nothing will resolve them — pages will render the literal \`{name}\` text.\n` +
|
|
2405
|
+
`[uniweb] A foundation opts in with \`handlers: createLoomHandlers({ vars })\` from @uniweb/loom.`
|
|
2406
|
+
)
|
|
2407
|
+
}
|
|
2376
2408
|
// `base` reaches the theme because self-hosted font faces are authored
|
|
2377
2409
|
// root-relative (`/fonts/x.woff2`) and the emitted @font-face lives in an
|
|
2378
2410
|
// inline <style> — under a subdirectory deployment it must carry the base.
|
package/src/uwx/site-project.js
CHANGED
|
@@ -155,6 +155,23 @@ const INFO_TO_SITE_YML = {
|
|
|
155
155
|
seo: 'seo',
|
|
156
156
|
}
|
|
157
157
|
|
|
158
|
+
// ── `config` Section → site.yml ───────────────────────────────────────────────
|
|
159
|
+
//
|
|
160
|
+
// The `config` Section (see `site.js::configNested`) carries authored
|
|
161
|
+
// configuration that does not belong on `info`. Each key maps to a
|
|
162
|
+
// top-level `site.yml` key of the same name, verbatim, so the author's file
|
|
163
|
+
// round-trips unchanged.
|
|
164
|
+
//
|
|
165
|
+
// ⛔ THIS MAP IS THE HALF THAT GETS LEFT OUT. The push side tests green entirely
|
|
166
|
+
// on its own, so a missing entry here is invisible until someone pulls and finds
|
|
167
|
+
// their block gone from site.yml. Every key `configNested` emits needs a line.
|
|
168
|
+
//
|
|
169
|
+
// 📌 `theme` will belong here after the stage-2 move off `info` — it is projected
|
|
170
|
+
// to `theme.yml` (not site.yml) and so will need its own handling, not a row.
|
|
171
|
+
const CONFIG_TO_SITE_YML = {
|
|
172
|
+
placeholders: 'placeholders',
|
|
173
|
+
}
|
|
174
|
+
|
|
158
175
|
/**
|
|
159
176
|
* Project a site-content document's `info` (+ `extensions`) onto the site's
|
|
160
177
|
* config files: `site.yml`, `theme.yml`, and `head.html`. Idempotent; only the
|
|
@@ -193,6 +210,14 @@ export function siteInfoToConfig({ document, siteRoot, sourceLocale = LOCALIZED_
|
|
|
193
210
|
if (info[infoKey] !== undefined) siteChanges[ymlKey] = info[infoKey]
|
|
194
211
|
}
|
|
195
212
|
|
|
213
|
+
// The `config` Section — authored configuration that is not identity, so it is
|
|
214
|
+
// not on `info`. Same verbatim treatment as the `info` block above; a Section
|
|
215
|
+
// the document does not carry writes nothing, like every other absent key here.
|
|
216
|
+
const configSection = document?.config || {}
|
|
217
|
+
for (const [configKey, ymlKey] of Object.entries(CONFIG_TO_SITE_YML)) {
|
|
218
|
+
if (configSection[configKey] !== undefined) siteChanges[ymlKey] = configSection[configKey]
|
|
219
|
+
}
|
|
220
|
+
|
|
196
221
|
// extensions[] → site.yml::extensions. Each entry carries EITHER `ref` (a
|
|
197
222
|
// catalog ref or a local name — an extension is a foundation and is declared
|
|
198
223
|
// like one) OR `url`. Project back whichever is present so a ref survives a
|
package/src/uwx/site.js
CHANGED
|
@@ -955,6 +955,54 @@ function secretsNested(siteYml) {
|
|
|
955
955
|
)
|
|
956
956
|
}
|
|
957
957
|
|
|
958
|
+
// ── `config` — the site's authored configuration ──────────────────────────────
|
|
959
|
+
//
|
|
960
|
+
// ⭐ THE LINE IS IDENTITY vs CONFIGURATION. `info` answers *"which site is this?"*
|
|
961
|
+
// — the name/label record, and it is read far more often than it is read in full.
|
|
962
|
+
// `config` answers *"what does this site render with?"*, and it exists because that
|
|
963
|
+
// second question had no home and its answers were accumulating on `info`.
|
|
964
|
+
//
|
|
965
|
+
// A `single` Section: one record, holding each block verbatim under its own key.
|
|
966
|
+
// Verbatim is the point — the authored shape is a nested map and it comes back as
|
|
967
|
+
// one, so nothing has to be flattened on push or rebuilt on pull.
|
|
968
|
+
//
|
|
969
|
+
// ⚠️ AUTHORS NEVER SEE THIS NAME. It is a wire and Model name; `site-project.js`
|
|
970
|
+
// writes `config.placeholders` back out to `site.yml::placeholders`. So it does
|
|
971
|
+
// not have to read well in a YAML file, and it is named flatly for what it holds,
|
|
972
|
+
// like `pages` / `queries` / `records`.
|
|
973
|
+
//
|
|
974
|
+
// ⚠️ AND IT IS A SUBSET OF THE RUNTIME'S `website.config`, not the same thing —
|
|
975
|
+
// that object is all of site.yml spread whole. One word, two scopes: everything
|
|
976
|
+
// in this Section lands in `website.config`, never the reverse. (uwx-format.md →
|
|
977
|
+
// the `config` Section.)
|
|
978
|
+
//
|
|
979
|
+
// 📌 Stage 2, not done here: `info.theme` belongs in this Section by the same
|
|
980
|
+
// argument and is NOT moved, because moving it is a DROP from `info` and a drop
|
|
981
|
+
// refuses (there is no rename detection — uwx-format.md § *A rename refuses*).
|
|
982
|
+
// That is a destructive migration on live data and is priced separately with the
|
|
983
|
+
// lane that pays it. Adding this Section is additive and auto-applies; do not
|
|
984
|
+
// quietly fold `theme` in on the strength of the comment above.
|
|
985
|
+
//
|
|
986
|
+
// ⛔ NEVER EMIT `{}` — and NOT for the reason this comment first gave. It said `{}`
|
|
987
|
+
// reads as "clear the stored record", by analogy with `services` above. Backend
|
|
988
|
+
// corrected it (2026-09-08): on a `single` Section the value must be an object, so
|
|
989
|
+
// `{}` parses as ONE RECORD WITH NO FIELDS, not zero records. There is no `[]`
|
|
990
|
+
// analogue — `multi` can say "zero records", `single` cannot.
|
|
991
|
+
//
|
|
992
|
+
// ⚠️ TODAY THE TWO COINCIDE BY ACCIDENT, because `placeholders` is this Section's
|
|
993
|
+
// only field, so "a record with no fields" and "placeholders cleared" are the same
|
|
994
|
+
// state. They diverge the moment `config` gains a second field, and then `{}` means
|
|
995
|
+
// *clear every field on config* — a far wider statement than the one intended.
|
|
996
|
+
//
|
|
997
|
+
// ⇒ The behaviour below is right either way: emit only when the file declares
|
|
998
|
+
// something. If an explicit clear is ever wanted, ask backend for a real form rather
|
|
999
|
+
// than inferring one from an empty object.
|
|
1000
|
+
function configNested(siteYml) {
|
|
1001
|
+
const config = {}
|
|
1002
|
+
setIf(config, 'placeholders', siteYml.placeholders)
|
|
1003
|
+
return Object.keys(config).length > 0 ? config : undefined
|
|
1004
|
+
}
|
|
1005
|
+
|
|
958
1006
|
/**
|
|
959
1007
|
* Map a file site project to the nested `@uniweb/site-content` `$`-document
|
|
960
1008
|
* (see the lane header above). PURE — reads the project, never mints, never writes.
|
|
@@ -1124,6 +1172,13 @@ export async function siteProjectToDocument(siteRoot, opts = {}) {
|
|
|
1124
1172
|
// The provisioned record rides the `$services` section instead (see servicesNested).
|
|
1125
1173
|
setIf(info, 'paths', siteYml.paths)
|
|
1126
1174
|
setIf(info, 'data', siteYml.data ?? siteYml.fetch)
|
|
1175
|
+
// ⛔ `placeholders` IS NOT HERE, DELIBERATELY — it rides the `config` Section
|
|
1176
|
+
// (`configNested` below). `info` carries the site's IDENTITY, and every key on
|
|
1177
|
+
// this allowlist is one WE name and the author merely fills. `placeholders` is
|
|
1178
|
+
// the first where the author invents the key set, and it is unbounded — which
|
|
1179
|
+
// puts it on the Section side of the same line `queries` / `records` / `folders`
|
|
1180
|
+
// already sit on. See `configNested` for the split.
|
|
1181
|
+
//
|
|
1127
1182
|
// ⛔ `app` IS RETIRED — do not reintroduce it, in either direction. It carried an
|
|
1128
1183
|
// opaque uuid naming a separate entity a host bound to the site; that entity is
|
|
1129
1184
|
// gone, a site's services belong to the site itself, and NOTHING replaces the key.
|
|
@@ -1177,6 +1232,9 @@ export async function siteProjectToDocument(siteRoot, opts = {}) {
|
|
|
1177
1232
|
doc.$id = SITE_ENTITY_KEY // one site-content entity per project (stable handle)
|
|
1178
1233
|
doc.$model = SITE_MODEL_NAME
|
|
1179
1234
|
doc.info = info
|
|
1235
|
+
// Emitted only when the file declares something — see `configNested`.
|
|
1236
|
+
const config = configNested(siteYml)
|
|
1237
|
+
if (config) doc.config = config
|
|
1180
1238
|
doc.pages = pages
|
|
1181
1239
|
doc.layout_sections = layoutSections
|
|
1182
1240
|
doc.extensions = extensionsNested(siteYml)
|
|
@@ -1191,9 +1249,26 @@ export async function siteProjectToDocument(siteRoot, opts = {}) {
|
|
|
1191
1249
|
// Emitted ONLY when the file declares the key — see the header above
|
|
1192
1250
|
// `serviceRecords`: on a replaced Section, absent and empty are different
|
|
1193
1251
|
// requests and one of them is destructive.
|
|
1194
|
-
|
|
1252
|
+
//
|
|
1253
|
+
// ⭐ AND ONLY WHEN THE CALLER SAYS THE DECLARATION IS A REQUEST.
|
|
1254
|
+
// `opts.declareServices === false` withholds both Sections for THIS push, which
|
|
1255
|
+
// is not the same as the file having no key — the file still declares one; the
|
|
1256
|
+
// caller has determined the owner is not asking for anything new by it.
|
|
1257
|
+
//
|
|
1258
|
+
// ⛔ Why this decision cannot live here: the Sections are REPLACED wholesale by
|
|
1259
|
+
// what we send (`SectionScope::DeclaredOnly`), so re-sending an unchanged block
|
|
1260
|
+
// OVERWRITES whatever the stored request has become since — including a decision
|
|
1261
|
+
// the owner made in the app, where the consent workflow's publish happens. But
|
|
1262
|
+
// "has it changed since we last agreed?" needs the last agreed state, which is
|
|
1263
|
+
// project memory (`deploy.yml`) the CLI owns and this pure mapper must not read.
|
|
1264
|
+
// ⇒ The CLI decides; this honours the decision.
|
|
1265
|
+
//
|
|
1266
|
+
// ⚖️ Default is to declare, so every existing caller is unchanged and the
|
|
1267
|
+
// withholding is opt-in.
|
|
1268
|
+
const declare = opts.declareServices !== false
|
|
1269
|
+
const services = declare ? servicesNested(siteYml) : undefined
|
|
1195
1270
|
if (services) doc.services = services
|
|
1196
|
-
const secrets = secretsNested(siteYml)
|
|
1271
|
+
const secrets = declare ? secretsNested(siteYml) : undefined
|
|
1197
1272
|
if (secrets) doc.secrets = secrets
|
|
1198
1273
|
return doc
|
|
1199
1274
|
}
|
package/src/uwx/sync-package.js
CHANGED
|
@@ -305,7 +305,12 @@ export async function emitSyncPackages(siteRoot, opts = {}) {
|
|
|
305
305
|
const siteDoc = includeSite
|
|
306
306
|
? await siteProjectToDocument(siteRoot, {
|
|
307
307
|
sourceLocale,
|
|
308
|
-
...(opts.queryUuids ? { queryUuids: opts.queryUuids } : {})
|
|
308
|
+
...(opts.queryUuids ? { queryUuids: opts.queryUuids } : {}),
|
|
309
|
+
// Withhold the `$services`/`$secrets` Sections when the caller has
|
|
310
|
+
// determined the file is not asking for anything new by them. Passed
|
|
311
|
+
// through rather than decided here: the last-agreed state is project
|
|
312
|
+
// memory the CLI owns. See site.js at `declareServices`.
|
|
313
|
+
...(opts.declareServices === false ? { declareServices: false } : {})
|
|
309
314
|
})
|
|
310
315
|
: null
|
|
311
316
|
// Deploy-derived `info` fields (e.g. `data_bundle`, the static-data ball URL) are
|
|
@@ -15,12 +15,13 @@ import { generateEntryPoint, shouldRegenerateForFile } from './generate-entry.js
|
|
|
15
15
|
import { processAllPreviews } from './images.js'
|
|
16
16
|
import { generateFoundationVars } from './theme/index.js'
|
|
17
17
|
import { DEFAULT_EXTERNALS } from './import-map-plugin.js'
|
|
18
|
+
import { deriveSupports } from './foundation/derive-supports.js'
|
|
18
19
|
|
|
19
20
|
/**
|
|
20
21
|
* Build schema.json with preview image references
|
|
21
22
|
*/
|
|
22
|
-
async function buildSchemaWithPreviews(srcDir, outDir, isProduction, sectionPaths) {
|
|
23
|
-
const schema = await buildSchema(srcDir, sectionPaths)
|
|
23
|
+
async function buildSchemaWithPreviews(srcDir, outDir, isProduction, sectionPaths, derivedSupports) {
|
|
24
|
+
const schema = await buildSchema(srcDir, sectionPaths, derivedSupports)
|
|
24
25
|
|
|
25
26
|
// Process preview images
|
|
26
27
|
const { schema: schemaWithImages, totalImages } = await processAllPreviews(
|
|
@@ -419,20 +420,24 @@ async function emitFoundationVarsCss(outDir, schema) {
|
|
|
419
420
|
* foundation rebuild. "Same set" was a comment three copies made a promise
|
|
420
421
|
* rather than a fact; deriving it makes drift unrepresentable.
|
|
421
422
|
*
|
|
422
|
-
* PLUS the client-only
|
|
423
|
+
* PLUS the client-only library kit code-splits via dynamic import:
|
|
423
424
|
* - shiki / shiki/bundle/full — syntax highlighting (kit Code renderer)
|
|
424
|
-
*
|
|
425
|
-
*
|
|
426
|
-
*
|
|
427
|
-
*
|
|
428
|
-
*
|
|
425
|
+
* It hydrates in the browser and never runs during renderToString (CLAUDE.md
|
|
426
|
+
* gotcha #12). Keeping it external drops the ~10 MB Shiki language graph from
|
|
427
|
+
* the SSR bundle and leaves it a DORMANT dynamic import the isolate never
|
|
428
|
+
* awaits — so no extra modules-map entry is needed for it edge-side.
|
|
429
|
+
*
|
|
430
|
+
* ⛔ `fuse.js` was listed here too, until the local search ranker became
|
|
431
|
+
* `@uniweb/projections/search` (2026-09-06). Nothing imports fuse now, so the
|
|
432
|
+
* entry matched no id and was removed rather than left as a claim that it is
|
|
433
|
+
* still in play. The projections engine needs no entry: it is a leaf of a
|
|
434
|
+
* package already in the graph, not a third-party dependency.
|
|
429
435
|
*/
|
|
430
436
|
const SSR_DEFAULT_EXTERNALS = DEFAULT_EXTERNALS
|
|
431
437
|
|
|
432
438
|
function isSSRExternal(id) {
|
|
433
439
|
if (SSR_DEFAULT_EXTERNALS.includes(id)) return true
|
|
434
440
|
if (id === 'shiki' || id.startsWith('shiki/')) return true
|
|
435
|
-
if (id === 'fuse.js' || id.startsWith('fuse.js/')) return true
|
|
436
441
|
return false
|
|
437
442
|
}
|
|
438
443
|
|
|
@@ -442,11 +447,11 @@ function isSSRExternal(id) {
|
|
|
442
447
|
*
|
|
443
448
|
* The modern browser `entry.js` is a facade that re-exports from
|
|
444
449
|
* `_entry.generated-*.js` and lazily code-splits kit's client-only features
|
|
445
|
-
* (Shiki
|
|
450
|
+
* (Shiki) into hundreds of chunks — a graph the Cloudflare Dynamic Worker
|
|
446
451
|
* isolate can't resolve (it loads a single `foundation` module). This builds the
|
|
447
452
|
* SAME source entry into ONE file, inlining the foundation's own graph and
|
|
448
453
|
* externalizing the runtime/React set (→ the isolate's shared worker-runtime)
|
|
449
|
-
* and the client-only Shiki
|
|
454
|
+
* and the client-only Shiki lib. Result: a ~foundation-sized ESM module
|
|
450
455
|
* (no React, no Shiki) the edge loads as `foundation` for request-time SSR.
|
|
451
456
|
*
|
|
452
457
|
* Built from source (not by re-bundling the built `entry.js`, whose Shiki
|
|
@@ -583,10 +588,23 @@ export function foundationBuildPlugin(options = {}) {
|
|
|
583
588
|
isDevRebuild = (config.plugins || []).some((p) => p?.name === DEV_REBUILD_MARKER)
|
|
584
589
|
},
|
|
585
590
|
|
|
586
|
-
async writeBundle() {
|
|
591
|
+
async writeBundle(_options, bundle) {
|
|
587
592
|
// Skip if this is a recursive call from buildSSRBundle
|
|
588
593
|
if (_buildingSSRBundle) return
|
|
589
594
|
|
|
595
|
+
// What host services this foundation actually reaches for, read off the
|
|
596
|
+
// post-tree-shake module graph rather than asked for in package.json.
|
|
597
|
+
//
|
|
598
|
+
// ⛔ GATED ON `isDevRebuild`, NOT ON `isProduction` — the dev server runs
|
|
599
|
+
// a real Vite build() of the foundation on every watched change, so
|
|
600
|
+
// `command`, `mode` and `isProduction` all say "build" and cannot tell a
|
|
601
|
+
// save from a shipping build (see DEV_REBUILD_MARKER). Same gate, and the
|
|
602
|
+
// same reason, as the entry-ssr.js sub-build below: nothing in the dev
|
|
603
|
+
// loop reads this, because `supports` is register-time metadata and dev
|
|
604
|
+
// never registers. `register`'s build-if-stale check treats a dist left
|
|
605
|
+
// by a dev session as stale, so this can never ship underived.
|
|
606
|
+
const derivedSupports = isDevRebuild ? null : deriveSupports(bundle, this)
|
|
607
|
+
|
|
590
608
|
// After bundle is written, generate schema.json in meta folder
|
|
591
609
|
const outDir = resolve(resolvedOutDir)
|
|
592
610
|
const metaDir = join(outDir, 'meta')
|
|
@@ -598,7 +616,8 @@ export function foundationBuildPlugin(options = {}) {
|
|
|
598
616
|
resolvedSrcDir,
|
|
599
617
|
outDir,
|
|
600
618
|
isProduction,
|
|
601
|
-
sectionPaths
|
|
619
|
+
sectionPaths,
|
|
620
|
+
derivedSupports
|
|
602
621
|
)
|
|
603
622
|
|
|
604
623
|
const schemaPath = join(metaDir, 'schema.json')
|
|
@@ -627,7 +646,7 @@ export function foundationBuildPlugin(options = {}) {
|
|
|
627
646
|
// browser dist/entry.js — for the Cloudflare edge isolate. React + the
|
|
628
647
|
// runtime stay externalized (resolved to the isolate's SHARED
|
|
629
648
|
// worker-runtime, so runtime patches propagate without a rebuild — the
|
|
630
|
-
// Strategy S win); the client-only Shiki
|
|
649
|
+
// Strategy S win); the client-only Shiki lib are externalized so the
|
|
631
650
|
// ~10 MB Shiki graph stays out. The edge loads this as its single
|
|
632
651
|
// `foundation` module for request-time SSR, gated on its presence.
|
|
633
652
|
//
|
|
@@ -721,8 +740,14 @@ export function foundationPlugin(options = {}) {
|
|
|
721
740
|
devPlugin.configResolved?.(config)
|
|
722
741
|
},
|
|
723
742
|
|
|
743
|
+
// ⛔ `.call(this, …)`, not `buildPlugin.writeBundle(…)`. The inner hook reads
|
|
744
|
+
// the Rollup plugin context (`this.getModuleInfo`) to derive
|
|
745
|
+
// `uniweb.supports` from the module graph, and a plain method call would
|
|
746
|
+
// bind `this` to `buildPlugin` instead. Nothing would throw: the context
|
|
747
|
+
// probe is optional-chained, so the derivation would silently return an
|
|
748
|
+
// empty set and every foundation would publish nothing.
|
|
724
749
|
async writeBundle(...args) {
|
|
725
|
-
await buildPlugin.writeBundle?.(...args)
|
|
750
|
+
await buildPlugin.writeBundle?.call(this, ...args)
|
|
726
751
|
},
|
|
727
752
|
|
|
728
753
|
handleHotUpdate(...args) {
|