@uniweb/build 0.42.0 → 0.43.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniweb/build",
3
- "version": "0.42.0",
3
+ "version": "0.43.0",
4
4
  "description": "Build tooling for the Uniweb Component Web Platform",
5
5
  "type": "module",
6
6
  "exports": {
@@ -64,7 +64,7 @@
64
64
  "@uniweb/theming": "^0.1.15"
65
65
  },
66
66
  "optionalDependencies": {
67
- "@uniweb/runtime": "^0.18.0"
67
+ "@uniweb/runtime": "^0.19.0"
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.0"
76
+ "@uniweb/core": "^0.24.1"
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
+ }
@@ -11,7 +11,7 @@
11
11
  * Used by:
12
12
  * - Site builds (runtime mode + extensions) — packages/build/src/site/config.js
13
13
  * - Runtime shell build — packages/runtime/vite.config.app.js
14
- * - Dynamic-runtime (editor preview) packages/uniweb-editor/dynamic-runtime/
14
+ * - An authoring tool's live preview its own runtime build
15
15
  *
16
16
  * @module @uniweb/build/import-map-plugin
17
17
  */
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,56 @@ 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
+ if (derived.blind && (emitted === undefined || emitted.length === 0)) {
762
+ // The one case where an empty result is NOT a proven "none": something
763
+ // named a service in a way the AST could not read, so the set is short by
764
+ // an unknown amount and absent/UNKNOWN is the honest wire value.
765
+ console.warn(
766
+ `Warning: a service is resolved by a computed name, so \`uniweb.supports\` cannot be ` +
767
+ `derived and is left undeclared. List it in package.json to publish it.`,
768
+ )
769
+ for (const at of derived.blindAt || []) console.warn(` at ${at}`)
770
+ }
771
+ }
772
+
722
773
  /**
723
774
  * Build complete schema for a foundation
724
775
  * Returns { _self: { identity + config }, ComponentName: componentMeta, ... }
@@ -729,8 +780,11 @@ export async function discoverComponents(srcDir, sectionPaths = DEFAULT_SECTION_
729
780
  *
730
781
  * @param {string} srcDir - Source directory
731
782
  * @param {string[]} [sectionPaths] - Paths to scan for section types
783
+ * @param {{services: string[], blind: boolean}} [derivedSupports] - what the
784
+ * module graph says this foundation reaches for (`foundation/derive-supports.js`).
785
+ * Omitted on a dev rebuild, where nothing reads the result.
732
786
  */
733
- export async function buildSchema(srcDir, sectionPaths) {
787
+ export async function buildSchema(srcDir, sectionPaths, derivedSupports = null) {
734
788
  // Load identity from package.json
735
789
  const identity = await loadPackageJson(srcDir)
736
790
 
@@ -763,10 +817,19 @@ export async function buildSchema(srcDir, sectionPaths) {
763
817
  // Build _self, stripping the raw extension boolean in favor of normalized role
764
818
  const { extension: _ext, ...configWithoutExtension } = foundationConfig
765
819
 
820
+ // `supports` is the one identity field the graph knows better than the file.
821
+ // `identity.supports` is already normalized (absent stays absent, `[]` stays
822
+ // `[]`), and composeSupports keeps that three-state distinction while adding
823
+ // what the bundle proves — see its table. Spread AFTER `...identity` so it
824
+ // replaces the authored-only value rather than being overwritten by it.
825
+ const supports = composeSupports(identity.supports, derivedSupports)
826
+ reportSupports(srcDir, identity.supports, derivedSupports, supports.supports)
827
+
766
828
  return {
767
829
  _self: {
768
830
  ...configWithoutExtension,
769
831
  ...identity,
832
+ ...supports,
770
833
  // foundation.js overrides package.json for editor-facing identity
771
834
  ...(foundationConfig.name && { name: foundationConfig.name }),
772
835
  ...(foundationConfig.description && { description: foundationConfig.description }),
@@ -2,7 +2,8 @@
2
2
  * Split Content Helper
3
3
  *
4
4
  * Shared utility to determine whether a site should use split page content.
5
- * Used by the site plugin, prerender, and unicloud.
5
+ * Used by the site plugin, prerender, and any host that assembles the shell
6
+ * server-side.
6
7
  *
7
8
  * @module @uniweb/build/site
8
9
  */
@@ -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 libraries kit code-splits via dynamic import:
423
+ * PLUS the client-only library kit code-splits via dynamic import:
423
424
  * - shiki / shiki/bundle/full — syntax highlighting (kit Code renderer)
424
- * - fuse.js — client search index
425
- * Both hydrate in the browser and never run during renderToString (CLAUDE.md
426
- * gotcha #12). Keeping them external drops the ~10 MB Shiki language graph from
427
- * the SSR bundle and leaves them as DORMANT dynamic imports the isolate never
428
- * awaits — so no extra modules-map entry is needed for them edge-side.
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, Fuse) into hundreds of chunks — a graph the Cloudflare Dynamic Worker
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/Fuse libs. Result: a ~foundation-sized ESM module
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/Fuse libs are externalized so the
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) {