@uniweb/build 0.52.2 → 0.52.3

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.52.2",
3
+ "version": "0.52.3",
4
4
  "description": "Build tooling for the Uniweb Component Web Platform",
5
5
  "type": "module",
6
6
  "exports": {
@@ -57,11 +57,11 @@
57
57
  "js-yaml": "^4.1.0",
58
58
  "sharp": "^0.35.3",
59
59
  "yaml": "^2.5.0",
60
- "@uniweb/semantic-parser": "^1.4.0",
61
- "@uniweb/schemas": "^0.2.14",
62
- "@uniweb/theming": "^0.1.15",
63
60
  "@uniweb/content-reader": "^1.2.5",
61
+ "@uniweb/schemas": "^0.2.15",
62
+ "@uniweb/semantic-parser": "^1.4.0",
64
63
  "@uniweb/projections": "^0.7.3",
64
+ "@uniweb/theming": "^0.1.15",
65
65
  "@uniweb/content-writer": "^0.3.4"
66
66
  },
67
67
  "optionalDependencies": {
@@ -44,7 +44,7 @@
44
44
  * `submit`, because the framework has no list of permitted names
45
45
  * (`core/src/services.js`).
46
46
  *
47
- * ## ⛔ THE `tracking` ENTRY IS SLATED FOR REMOVAL — DO NOT EXTEND IT (decided 2026-09-15)
47
+ * ## ⛔ `tracking` IS NEVER EMITTED — AND THAT IS NOT A DETECTION GAP
48
48
  *
49
49
  * ⭐ **Tracking renders nothing.** This field exists because a service with no
50
50
  * surface to draw is invisible from outside — a search endpoint with no search
@@ -54,23 +54,25 @@
54
54
  * emitting it conditionally **asserts something false about every foundation that
55
55
  * does not call these** — not a missing niche capability.
56
56
  *
57
- * ⛔ **So the gap is not a gap, and the obvious fixes are wrong:** `block.track()`
58
- * (a `Website`-adjacent method in externalized core, invisible here) and
59
- * `kit/src/hooks/useReadingDepth.js` (a second hook the module rule below never
60
- * named) both emit custom events and derive nothing. **A matcher for either was
61
- * considered and rejected.** The rule below and the name `tracking` both go; until
62
- * they do, this paragraph is why nobody should widen them.
57
+ * ⛔ **So do not "fix" the obvious misses.** `block.track()` (a method in
58
+ * externalized core, invisible here) and `kit/src/hooks/useReadingDepth.js` both
59
+ * emit custom events and derive nothing. **A matcher for either was considered
60
+ * and rejected**, along with a separate `own-events` name. There is nothing here
61
+ * to detect. `NEVER_EMITTED` below is what enforces it, because the generic
62
+ * `isServiceEnabled` matcher would otherwise pick the name back up from any
63
+ * `isTrackingEnabled()` call.
63
64
  *
64
- * Two gates need help, and only two:
65
+ * One gate needs help, and only one:
65
66
  *
66
- * - **`useTracker`** resolves nothing itself — it calls through to
67
- * `getUniweb()?.tracking`, and the `'tracking'` literal lives in
68
- * `runtime/src/wire-foundation.js`, which is never bundled into a
69
- * foundation. Its module presence is the signal.
70
67
  * - **`isSearchEnabled()`** is a `Website` method, and its literal is in
71
68
  * `core/src/website.js` — external, so never a survivor. The call is the
72
69
  * signal.
73
70
  *
71
+ * ⚠️ **`records` is not derived here at all** — its resolver is in externalized
72
+ * core too, so nothing naming it survives the shake. It comes from the component
73
+ * schema instead (`declaredKeys`, applied in `build/src/schema.js`), which is a
74
+ * second derivation over a different artifact.
75
+ *
74
76
  * ⚠️ `@uniweb/api` deliberately needs no entry: it calls
75
77
  * `resolveService(website, SERVICE_NAME)` against a module-level `const`, which
76
78
  * `readModuleConsts` resolves. Without that resolution `api` would be both
@@ -89,8 +91,38 @@
89
91
  * @module @uniweb/build/foundation/derive-supports
90
92
  */
91
93
 
92
- /** Kit's `useTracker`, in a workspace clone or a published install alike. */
93
- const TRACKER_MODULE = /(^|[/\\])kit[/\\]src[/\\]hooks[/\\]useTracker\.js$/
94
+ import { declaredKeys } from '@uniweb/core/data-keys'
95
+
96
+ /**
97
+ * Service names this never emits, however a foundation reaches for them.
98
+ *
99
+ * ⛔ **Excluding a name is not the same as failing to detect one**, which is why
100
+ * this is a list and not an omission. `tracking` arrives through the generic
101
+ * `isServiceEnabled` matcher from any `isTrackingEnabled()` call, so deleting
102
+ * the old module rule was not enough on its own — a foundation that guards a
103
+ * consent banner would have put the name back.
104
+ *
105
+ * See the header: a service with no rendered surface cannot be claimed or
106
+ * withheld by a foundation, and naming it conditionally asserts a falsehood
107
+ * about every foundation that does not call these.
108
+ */
109
+ const NEVER_EMITTED = new Set(['tracking'])
110
+
111
+ /** The names in `names` that this field may carry. */
112
+ function nameable(names) {
113
+ return names.filter((s) => !NEVER_EMITTED.has(s))
114
+ }
115
+
116
+ /**
117
+ * The names in `names` that it may not — for the caller's warning.
118
+ *
119
+ * Exported because an AUTHORED name is dropped too (`composeSupports`), and a
120
+ * developer who typed one deserves to be told rather than to find it missing
121
+ * from the registry.
122
+ */
123
+ export function unnameableIn(names) {
124
+ return (names || []).filter((s) => NEVER_EMITTED.has(s))
125
+ }
94
126
 
95
127
  /**
96
128
  * Walk an ESTree tree, visiting every node.
@@ -208,7 +240,8 @@ function collectSurvivors(bundle) {
208
240
  * Three call shapes are read, and two of them carry the name as an argument so
209
241
  * they cover the open registry without a rule per service:
210
242
  * `resolveService(website, name)`, `website.isServiceEnabled(name)` (what every
211
- * `@uniweb/kit` predicate compiles to), and the two hand-written gates below.
243
+ * `@uniweb/kit` predicate compiles to), and the one hand-written gate,
244
+ * `isSearchEnabled()`. Names in `NEVER_EMITTED` are dropped from the result.
212
245
  *
213
246
  * @param {object} bundle - Rollup's bundle, as handed to `writeBundle`
214
247
  * @param {object} ctx - the Rollup plugin context (`this` in the hook)
@@ -224,8 +257,6 @@ export function deriveSupports(bundle, ctx) {
224
257
  const blindAt = new Set()
225
258
 
226
259
  for (const id of collectSurvivors(bundle)) {
227
- if (TRACKER_MODULE.test(id)) services.add('tracking')
228
-
229
260
  const info = ctx?.getModuleInfo?.(id)
230
261
  const ast = info?.ast
231
262
  if (!ast) continue
@@ -257,12 +288,47 @@ export function deriveSupports(bundle, ctx) {
257
288
  }
258
289
 
259
290
  return {
260
- services: [...services].sort(),
291
+ services: nameable([...services]).sort(),
261
292
  blind: blindAt.size > 0,
262
293
  blindAt: [...blindAt].sort(),
263
294
  }
264
295
  }
265
296
 
297
+ /**
298
+ * Does this foundation support `records`?
299
+ *
300
+ * ⭐ **The second derivation, over a different artifact.** `records` has no
301
+ * module-graph signature — `resolveRecordsService` is in externalized core, so
302
+ * nothing naming it survives a foundation's tree-shake. The evidence is the
303
+ * component schema instead.
304
+ *
305
+ * Since the 2026-09-14 ruling a section receives only the `data:` keys it
306
+ * declares (`assembleData`, `runtime/src/prepare-props.js`), so a foundation
307
+ * declaring none at either tier can never deliver a record to anything.
308
+ *
309
+ * ⛔ **Key presence, not ref presence.** A `data:` entry may name a schema, but
310
+ * that is a shape hint; the Model ref deciding whether records are live or
311
+ * static is on the site's QUERY, defaulted to the query's own name, and is
312
+ * chosen after this foundation is registered. Counting only ref-bearing entries
313
+ * measured backwards across the templates: it named the forms template and
314
+ * missed the one built to demonstrate query-driven pages.
315
+ *
316
+ * What it claims: *given query results, this foundation will render them* — not
317
+ * that any site supplies them. A declared key may equally be filled by a data
318
+ * block authored inline in the markdown (`block.heldData`).
319
+ *
320
+ * @param {Object} components - componentName → meta, each with its `data:`
321
+ * @param {Object|false|null|undefined} foundationData - `main.js` `data:`
322
+ * @returns {boolean}
323
+ */
324
+ export function deriveRecordsSupport(components, foundationData) {
325
+ // The foundation tier reaches every section, so one key there is enough — and
326
+ // it is checked separately because a foundation declaring one still supports
327
+ // records when it has no components of its own.
328
+ if (declaredKeys(undefined, foundationData).length > 0) return true
329
+ return Object.values(components || {}).some((meta) => declaredKeys(meta?.data).length > 0)
330
+ }
331
+
266
332
  /**
267
333
  * Compose what the foundation publishes from the authored and derived halves.
268
334
  *
@@ -293,16 +359,23 @@ export function deriveSupports(bundle, ctx) {
293
359
  * @returns {{supports?: string[]}} spread into `_self`; `{}` keeps the key absent
294
360
  */
295
361
  export function composeSupports(authored, derived) {
362
+ // An authored name this field may not carry is dropped HERE rather than at
363
+ // its source: `package.json` is the developer's file, and rewriting it is
364
+ // `doctor --fix`'s job. `reportSupports` says what was dropped and why.
365
+ // ⛔ Absence must survive the filter — `undefined` is UNKNOWN, `[]` is a
366
+ // declaration, and mapping one to the other loses a state.
367
+ const declared = authored === undefined ? undefined : nameable(authored)
368
+
296
369
  // No derivation ran (dev rebuild): the authored value is the whole answer,
297
370
  // and an absent one stays absent.
298
- if (!derived) return authored === undefined ? {} : { supports: authored }
371
+ if (!derived) return declared === undefined ? {} : { supports: declared }
299
372
 
300
373
  const { services, blind } = derived
301
374
 
302
- if (authored === undefined && services.length === 0) {
375
+ if (declared === undefined && services.length === 0) {
303
376
  return blind ? {} : { supports: [] }
304
377
  }
305
378
 
306
- const union = [...new Set([...(authored || []), ...services])].sort()
379
+ const union = [...new Set([...(declared || []), ...services])].sort()
307
380
  return { supports: union }
308
381
  }
@@ -322,6 +322,18 @@ export async function generateEntryPoint(srcDir, outputPath = null, options = {}
322
322
  // Resolve the data schemas referenced by section bindings, then extract
323
323
  // per-component runtime metadata (which lean-extracts field defaults from
324
324
  // the resolved schemas into meta.schemas[<key>]).
325
+ // ⛔ Components only, unlike `schema.js`, which also passes the foundation's
326
+ // `main.js` `data:`. Not an oversight: this map feeds `extractAllRuntimeSchemas`,
327
+ // and `extractRuntimeSchema` reads `fullMeta.data` — per component. A
328
+ // foundation-tier key reaches every section (`declaredKeys`) but has nowhere to
329
+ // put its lean schema in a PER-COMPONENT runtime meta, so resolving its ref here
330
+ // would produce an entry nothing reads.
331
+ //
332
+ // ⚠️ The consequence is real and still open: a foundation-tier `data:` entry
333
+ // naming a schema gets no field defaults from `applySchemas` at runtime. Latent
334
+ // today — every foundation-tier declaration in the templates is ref-less — and
335
+ // fixing it means deciding where a foundation-wide schema lives in the runtime
336
+ // meta, which is a design question, not a missing argument.
325
337
  const dataSchemaMap = await buildDataSchemaMap(collectSchemaRefs(components), { srcDir })
326
338
  const meta = extractAllRuntimeSchemas(components, dataSchemaMap)
327
339
 
@@ -80,19 +80,30 @@ const packageForScope = (scope) => SCOPE_PACKAGE[scope] ?? `@${scope}/schemas`
80
80
  * Reads `data: { key: '<ref>' }` (short) and `data: { key: { schema: '<ref>' } }`
81
81
  * (full). Non-string / schemaless entries are ignored.
82
82
  *
83
+ * ⛔ **The foundation's own `data:` counts too, and did not until 2026-09-16.**
84
+ * `main.js` may declare `data:` and those keys reach EVERY section
85
+ * (`declaredKeys`, `@uniweb/core/data-keys`) — but only components were scanned
86
+ * here, so a ref at that tier was neither resolved into `dataSchemas` nor
87
+ * rejected when dangling, while the key was still delivered at runtime. The
88
+ * symptom would have been no schema for the editor and no field defaults from
89
+ * `applySchemas`, with no build error. It bit nobody only because every
90
+ * foundation-tier declaration in the templates is ref-less.
91
+ *
83
92
  * @param {Object} components - Map of componentName → full meta (with `data`)
93
+ * @param {Object|false|null} [foundationData] - the foundation's `main.js` `data:`
84
94
  * @returns {Set<string>}
85
95
  */
86
- export function collectSchemaRefs(components) {
96
+ export function collectSchemaRefs(components, foundationData = null) {
87
97
  const refs = new Set()
88
- for (const meta of Object.values(components || {})) {
89
- const data = meta?.data
90
- if (!data || typeof data !== 'object' || data === false) continue
98
+ const readData = (data) => {
99
+ if (!data || typeof data !== 'object' || data === false) return
91
100
  for (const binding of Object.values(data)) {
92
101
  const ref = typeof binding === 'string' ? binding : binding?.schema
93
102
  if (typeof ref === 'string') refs.add(ref)
94
103
  }
95
104
  }
105
+ for (const meta of Object.values(components || {})) readData(meta?.data)
106
+ readData(foundationData)
96
107
  return refs
97
108
  }
98
109
 
package/src/schema.js CHANGED
@@ -18,7 +18,11 @@ import { join, dirname, extname, basename } from 'node:path'
18
18
  import { pathToFileURL } from 'node:url'
19
19
  import { inferTitle } from './utils/infer-title.js'
20
20
  import { collectSchemaRefs, buildDataSchemaMap } from './resolve-data-schema.js'
21
- import { composeSupports } from './foundation/derive-supports.js'
21
+ import {
22
+ composeSupports,
23
+ deriveRecordsSupport,
24
+ unnameableIn,
25
+ } from './foundation/derive-supports.js'
22
26
 
23
27
  // Component meta file name
24
28
  const META_FILE_NAME = 'meta.js'
@@ -790,6 +794,19 @@ export async function discoverComponents(srcDir, sectionPaths = DEFAULT_SECTION_
790
794
  * the artifact is already correct without it.
791
795
  */
792
796
  function reportSupports(srcDir, authored, derived, emitted) {
797
+ // ⛔ BEFORE the dev-rebuild bail: a name this field may not carry is dropped
798
+ // from what ships, and a developer who typed one has to hear it. Left below
799
+ // the bail it would be silent in dev, which is where they are working.
800
+ const dropped = unnameableIn(authored)
801
+ if (dropped.length > 0) {
802
+ console.warn(
803
+ `Warning: ${srcDir}/package.json declares \`uniweb.supports: ${dropped.join(', ')}\`, ` +
804
+ `which this field does not carry. A foundation renders no surface for it — the runtime ` +
805
+ `handles it for every foundation alike — so naming it would tell a consumer that any ` +
806
+ `foundation NOT naming it lacks the capability, which is false. Dropped.`,
807
+ )
808
+ }
809
+
793
810
  if (!derived) return
794
811
 
795
812
  const added = (emitted || []).filter((s) => !(authored || []).includes(s))
@@ -865,7 +882,10 @@ export async function buildSchema(srcDir, sectionPaths, derivedSupports = null)
865
882
 
866
883
  // Resolve the data schemas referenced by section bindings (carried in
867
884
  // schema.json for the editor/platform — see named-data-schemas.md).
868
- const dataSchemas = await buildDataSchemaMap(collectSchemaRefs(components), { srcDir })
885
+ const dataSchemas = await buildDataSchemaMap(
886
+ collectSchemaRefs(components, foundationConfig.data),
887
+ { srcDir },
888
+ )
869
889
 
870
890
  // Discover layouts from src/layouts/
871
891
  const layouts = await discoverLayoutsInPath(srcDir)
@@ -886,13 +906,31 @@ export async function buildSchema(srcDir, sectionPaths, derivedSupports = null)
886
906
  // Build _self, stripping the raw extension boolean in favor of normalized role
887
907
  const { extension: _ext, ...configWithoutExtension } = foundationConfig
888
908
 
889
- // `supports` is the one identity field the graph knows better than the file.
909
+ // `supports` is the one identity field the build knows better than the file.
890
910
  // `identity.supports` is already normalized (absent stays absent, `[]` stays
891
911
  // `[]`), and composeSupports keeps that three-state distinction while adding
892
- // what the bundle proves — see its table. Spread AFTER `...identity` so it
912
+ // what the build proves — see its table. Spread AFTER `...identity` so it
893
913
  // replaces the authored-only value rather than being overwritten by it.
894
- const supports = composeSupports(identity.supports, derivedSupports)
895
- reportSupports(srcDir, identity.supports, derivedSupports, supports.supports)
914
+ //
915
+ // ⭐ TWO DERIVATIONS, TWO ARTIFACTS. `derivedSupports` is read off the bundle's
916
+ // post-tree-shake module graph; `records` cannot be, because its resolver is in
917
+ // externalized core. It comes from the component schema, which is right here.
918
+ //
919
+ // ⛔ GATED WITH THE GRAPH DERIVATION, DELIBERATELY. This one is free to compute
920
+ // and would work on a dev rebuild, where `derivedSupports` is null. It is
921
+ // skipped there anyway: dev never registers, and a dev-built schema.json whose
922
+ // `supports` differs from the one a real build produces is a trap rather than a
923
+ // convenience — the artifact would disagree with itself depending on which
924
+ // command wrote it. One rule: the derivation runs on a real build.
925
+ const derived = derivedSupports && {
926
+ ...derivedSupports,
927
+ services: deriveRecordsSupport(components, foundationConfig.data)
928
+ ? [...new Set([...derivedSupports.services, 'records'])].sort()
929
+ : derivedSupports.services,
930
+ }
931
+
932
+ const supports = composeSupports(identity.supports, derived)
933
+ reportSupports(srcDir, identity.supports, derived, supports.supports)
896
934
 
897
935
  return {
898
936
  _self: {