@uniweb/build 0.52.1 → 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.1",
3
+ "version": "0.52.3",
4
4
  "description": "Build tooling for the Uniweb Component Web Platform",
5
5
  "type": "module",
6
6
  "exports": {
@@ -58,14 +58,14 @@
58
58
  "sharp": "^0.35.3",
59
59
  "yaml": "^2.5.0",
60
60
  "@uniweb/content-reader": "^1.2.5",
61
- "@uniweb/content-writer": "^0.3.4",
62
- "@uniweb/projections": "^0.7.3",
61
+ "@uniweb/schemas": "^0.2.15",
63
62
  "@uniweb/semantic-parser": "^1.4.0",
64
- "@uniweb/schemas": "^0.2.13",
65
- "@uniweb/theming": "^0.1.15"
63
+ "@uniweb/projections": "^0.7.3",
64
+ "@uniweb/theming": "^0.1.15",
65
+ "@uniweb/content-writer": "^0.3.4"
66
66
  },
67
67
  "optionalDependencies": {
68
- "@uniweb/runtime": "^0.26.2"
68
+ "@uniweb/runtime": "^0.26.4"
69
69
  },
70
70
  "peerDependencies": {
71
71
  "vite": "^5.0.0 || ^6.0.0 || ^7.0.0",
@@ -74,7 +74,7 @@
74
74
  "@tailwindcss/vite": "^4.0.0",
75
75
  "@vitejs/plugin-react": "^4.0.0 || ^5.0.0",
76
76
  "vite-plugin-svgr": "^4.0.0",
77
- "@uniweb/core": "^0.29.3"
77
+ "@uniweb/core": "^0.29.4"
78
78
  },
79
79
  "peerDependenciesMeta": {
80
80
  "vite": {
@@ -44,16 +44,35 @@
44
44
  * `submit`, because the framework has no list of permitted names
45
45
  * (`core/src/services.js`).
46
46
  *
47
- * Two gates need help, and only two:
47
+ * ## ⛔ `tracking` IS NEVER EMITTED — AND THAT IS NOT A DETECTION GAP
48
+ *
49
+ * ⭐ **Tracking renders nothing.** This field exists because a service with no
50
+ * surface to draw is invisible from outside — a search endpoint with no search
51
+ * box. But the runtime emits `page_view` / `outbound_click` / `section_view` for
52
+ * every foundation alike (`wireTracker`), so a foundation neither supplies nor
53
+ * withholds tracking, and under the three-state rule (*these and only these*)
54
+ * emitting it conditionally **asserts something false about every foundation that
55
+ * does not call these** — not a missing niche capability.
56
+ *
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.
64
+ *
65
+ * One gate needs help, and only one:
48
66
  *
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
67
  * - **`isSearchEnabled()`** is a `Website` method, and its literal is in
54
68
  * `core/src/website.js` — external, so never a survivor. The call is the
55
69
  * signal.
56
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
+ *
57
76
  * ⚠️ `@uniweb/api` deliberately needs no entry: it calls
58
77
  * `resolveService(website, SERVICE_NAME)` against a module-level `const`, which
59
78
  * `readModuleConsts` resolves. Without that resolution `api` would be both
@@ -72,8 +91,38 @@
72
91
  * @module @uniweb/build/foundation/derive-supports
73
92
  */
74
93
 
75
- /** Kit's `useTracker`, in a workspace clone or a published install alike. */
76
- 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
+ }
77
126
 
78
127
  /**
79
128
  * Walk an ESTree tree, visiting every node.
@@ -191,7 +240,8 @@ function collectSurvivors(bundle) {
191
240
  * Three call shapes are read, and two of them carry the name as an argument so
192
241
  * they cover the open registry without a rule per service:
193
242
  * `resolveService(website, name)`, `website.isServiceEnabled(name)` (what every
194
- * `@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.
195
245
  *
196
246
  * @param {object} bundle - Rollup's bundle, as handed to `writeBundle`
197
247
  * @param {object} ctx - the Rollup plugin context (`this` in the hook)
@@ -207,8 +257,6 @@ export function deriveSupports(bundle, ctx) {
207
257
  const blindAt = new Set()
208
258
 
209
259
  for (const id of collectSurvivors(bundle)) {
210
- if (TRACKER_MODULE.test(id)) services.add('tracking')
211
-
212
260
  const info = ctx?.getModuleInfo?.(id)
213
261
  const ast = info?.ast
214
262
  if (!ast) continue
@@ -240,12 +288,47 @@ export function deriveSupports(bundle, ctx) {
240
288
  }
241
289
 
242
290
  return {
243
- services: [...services].sort(),
291
+ services: nameable([...services]).sort(),
244
292
  blind: blindAt.size > 0,
245
293
  blindAt: [...blindAt].sort(),
246
294
  }
247
295
  }
248
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
+
249
332
  /**
250
333
  * Compose what the foundation publishes from the authored and derived halves.
251
334
  *
@@ -276,16 +359,23 @@ export function deriveSupports(bundle, ctx) {
276
359
  * @returns {{supports?: string[]}} spread into `_self`; `{}` keeps the key absent
277
360
  */
278
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
+
279
369
  // No derivation ran (dev rebuild): the authored value is the whole answer,
280
370
  // and an absent one stays absent.
281
- if (!derived) return authored === undefined ? {} : { supports: authored }
371
+ if (!derived) return declared === undefined ? {} : { supports: declared }
282
372
 
283
373
  const { services, blind } = derived
284
374
 
285
- if (authored === undefined && services.length === 0) {
375
+ if (declared === undefined && services.length === 0) {
286
376
  return blind ? {} : { supports: [] }
287
377
  }
288
378
 
289
- const union = [...new Set([...(authored || []), ...services])].sort()
379
+ const union = [...new Set([...(declared || []), ...services])].sort()
290
380
  return { supports: union }
291
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'
@@ -464,11 +468,27 @@ function hasEntryFile(dirPath, dirName) {
464
468
  * Create an implicit empty meta for a section type discovered without meta.js
465
469
  */
466
470
  function createImplicitMeta(name) {
467
- return { title: inferTitle(name) }
471
+ return { title: inferTitle(name), titleInferred: true }
468
472
  }
469
473
 
470
474
  /**
471
- * Build a component entry with title inference applied
475
+ * Build a component entry with title inference applied.
476
+ *
477
+ * ⭐ `titleInferred` SAYS THE TITLE IS OURS, NOT THE AUTHOR'S, and without it a
478
+ * consumer cannot tell. We fill `title` from the component name whenever
479
+ * `meta.js` declares none, so `title` is ALWAYS present — which means an editor
480
+ * showing section names has no way to know that `"Hero"` is a string the build
481
+ * invented rather than one a developer chose.
482
+ *
483
+ * ⛔ THAT DIFFERENCE DECIDES WHETHER THE NAME CAN BE TRANSLATED. An authored
484
+ * title is the foundation's own words and must be shown verbatim in every UI
485
+ * language. An inferred one is a placeholder, and a consumer that knows the
486
+ * section's `family` has a better string available — one it can localize. A
487
+ * foundation that never named its Hero would otherwise ship the English
488
+ * `"Hero"` to every author in the world.
489
+ *
490
+ * ⚖️ The build itself reads neither field. This is a statement ABOUT the title,
491
+ * for whoever renders it.
472
492
  */
473
493
  function buildComponentEntry(name, relativePath, meta) {
474
494
  const entry = {
@@ -479,6 +499,7 @@ function buildComponentEntry(name, relativePath, meta) {
479
499
  // Apply title inference if meta has no explicit title
480
500
  if (!entry.title) {
481
501
  entry.title = inferTitle(name)
502
+ entry.titleInferred = true
482
503
  }
483
504
  return entry
484
505
  }
@@ -773,6 +794,19 @@ export async function discoverComponents(srcDir, sectionPaths = DEFAULT_SECTION_
773
794
  * the artifact is already correct without it.
774
795
  */
775
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
+
776
810
  if (!derived) return
777
811
 
778
812
  const added = (emitted || []).filter((s) => !(authored || []).includes(s))
@@ -848,7 +882,10 @@ export async function buildSchema(srcDir, sectionPaths, derivedSupports = null)
848
882
 
849
883
  // Resolve the data schemas referenced by section bindings (carried in
850
884
  // schema.json for the editor/platform — see named-data-schemas.md).
851
- const dataSchemas = await buildDataSchemaMap(collectSchemaRefs(components), { srcDir })
885
+ const dataSchemas = await buildDataSchemaMap(
886
+ collectSchemaRefs(components, foundationConfig.data),
887
+ { srcDir },
888
+ )
852
889
 
853
890
  // Discover layouts from src/layouts/
854
891
  const layouts = await discoverLayoutsInPath(srcDir)
@@ -869,13 +906,31 @@ export async function buildSchema(srcDir, sectionPaths, derivedSupports = null)
869
906
  // Build _self, stripping the raw extension boolean in favor of normalized role
870
907
  const { extension: _ext, ...configWithoutExtension } = foundationConfig
871
908
 
872
- // `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.
873
910
  // `identity.supports` is already normalized (absent stays absent, `[]` stays
874
911
  // `[]`), and composeSupports keeps that three-state distinction while adding
875
- // 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
876
913
  // replaces the authored-only value rather than being overwritten by it.
877
- const supports = composeSupports(identity.supports, derivedSupports)
878
- 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)
879
934
 
880
935
  return {
881
936
  _self: {