@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniweb/build",
3
- "version": "0.42.1",
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.18.1"
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.0"
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 } = await loadFoundationInfo(foundationPath)
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.
@@ -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
- const services = servicesNested(siteYml)
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
  }
@@ -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 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) {