@uniweb/build 0.74.4 → 0.75.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.74.4",
3
+ "version": "0.75.0",
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.8",
61
- "@uniweb/projections": "^0.7.11",
62
- "@uniweb/theming": "^0.2.0",
63
61
  "@uniweb/content-writer": "^0.3.4",
64
- "@uniweb/schemas": "^0.10.1",
62
+ "@uniweb/projections": "^0.7.13",
63
+ "@uniweb/schemas": "^0.10.2",
64
+ "@uniweb/theming": "^0.2.0",
65
65
  "@uniweb/semantic-parser": "^1.5.1"
66
66
  },
67
67
  "optionalDependencies": {
68
- "@uniweb/runtime": "^0.28.7"
68
+ "@uniweb/runtime": "^0.28.10"
69
69
  },
70
70
  "peerDependencies": {
71
71
  "vite": "^5.0.0 || ^6.0.0 || ^7.0.0",
@@ -74,8 +74,8 @@
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.35.2",
78
- "@uniweb/runtime": "^0.28.7"
77
+ "@uniweb/core": "^0.36.0",
78
+ "@uniweb/runtime": "^0.28.10"
79
79
  },
80
80
  "peerDependenciesMeta": {
81
81
  "vite": {
@@ -5,7 +5,8 @@
5
5
  *
6
6
  * Exports:
7
7
  * - `components` - Object map of component name -> React component
8
- * - `capabilities` - Custom Layout and props from src/main.js (if present)
8
+ * - `capabilities` - what the runtime reads of src/main.js (`extractFoundationRuntime`): the
9
+ * code it calls, referenced from the module, and its data, lean — plus the discovered layouts
9
10
  * - `meta` - Per-component runtime metadata extracted from meta.js files
10
11
  *
11
12
  * The `meta` export contains only properties needed at runtime:
@@ -26,10 +27,11 @@ import { join, dirname } from 'node:path'
26
27
  import {
27
28
  discoverComponents,
28
29
  discoverLayoutsInPath,
30
+ loadFoundationConfig,
29
31
  DEFAULT_SECTION_PATHS,
30
32
  LAYOUTS_PATH
31
33
  } from './schema.js'
32
- import { extractAllRuntimeSchemas, extractAllLayoutRuntimeSchemas } from './runtime-schema.js'
34
+ import { extractAllRuntimeSchemas, extractAllLayoutRuntimeSchemas, extractFoundationRuntime } from './runtime-schema.js'
33
35
  import { collectSchemaRefs, buildDataSchemaMap, SCHEMA_EXTENSIONS } from './resolve-data-schema.js'
34
36
 
35
37
  /**
@@ -88,31 +90,12 @@ async function detectHostShareableImports(srcDir) {
88
90
  }
89
91
 
90
92
  /**
91
- * Detect foundation config file (for props, vars, etc.)
92
- *
93
- * Looks for (in priority order): main.js, main.jsx
94
- *
95
- * The foundation's main authored module.
96
- *
97
- * The file should export:
98
- * - props (optional) - Foundation-wide props
99
- * - vars (optional) - CSS custom properties (also read by schema builder)
100
- * - defaultLayout (optional) - Default layout name
101
- *
102
- * Note: Layout components are now discovered from layouts/ at the source root.
93
+ * The foundation's `main.js` — its declarations, read by `loadFoundationConfig` as the schema
94
+ * reads them. `main.js` is the only name: ⛔ until 2026-10-01 this also took a `main.jsx`, which
95
+ * nothing else read, so the editor saw none of its declarations while the runtime saw all of them.
103
96
  */
104
97
  function detectFoundationExports(srcDir) {
105
- const candidates = [
106
- { path: 'main.js', ext: 'js' },
107
- { path: 'main.jsx', ext: 'jsx' },
108
- ]
109
-
110
- for (const { path, ext } of candidates) {
111
- if (existsSync(join(srcDir, path))) {
112
- return { path: `./${path}`, ext }
113
- }
114
- }
115
- return null
98
+ return existsSync(join(srcDir, 'main.js')) ? { path: './main.js', ext: 'js' } : null
116
99
  }
117
100
 
118
101
  /**
@@ -140,6 +123,7 @@ function generateEntrySource(components, options = {}) {
140
123
  const {
141
124
  cssPath = null,
142
125
  foundationExports = null,
126
+ foundationRuntime = { code: [], data: {} },
143
127
  meta = {},
144
128
  layouts = {},
145
129
  layoutMeta = {},
@@ -160,10 +144,15 @@ function generateEntrySource(components, options = {}) {
160
144
  lines.push(`import '${cssPath}'`)
161
145
  }
162
146
 
163
- // Foundation capabilities import (for props, vars, etc.)
164
- // Note: Layout/layouts no longer merged from main.js — layouts come from src/layouts/ discovery
147
+ // The foundation's `main.js` — imported for the code the runtime calls, and only referenced for
148
+ // it, so the bundler drops the rest of its default export (measured 2026-10-01). With no such
149
+ // code, imported for its side effects alone. Its data is written below, lean.
165
150
  if (foundationExports) {
166
- lines.push(`import * as _foundationModule from '${foundationExports.path}'`)
151
+ lines.push(
152
+ foundationRuntime.code.length > 0
153
+ ? `import * as _foundationModule from '${foundationExports.path}'`
154
+ : `import '${foundationExports.path}'`
155
+ )
167
156
  }
168
157
 
169
158
  // Component imports
@@ -185,21 +174,19 @@ function generateEntrySource(components, options = {}) {
185
174
  lines.push(`export { ${componentNames.join(', ')} }`)
186
175
  }
187
176
 
188
- // Foundation capabilities (props, vars, etc. + discovered layouts)
177
+ // Foundation capabilities — what the runtime reads of `main.js`, and nothing else of it
178
+ // (`extractFoundationRuntime`, ruled 2026-10-01 [Diego]): the code it calls, referenced from the
179
+ // module, and its data as a lean literal — `vars` (each var's `default`, `type`, `applyTo`, as
180
+ // `loadFoundationConfig` reads them, named export first), `data` (each key's schema ref), and the
181
+ // defaults that pick a layout, a section type, transitions and scroll. Plus the discovered layouts.
182
+ // ⛔ Until 2026-10-01 the whole default export was spread in, so a foundation's name, description
183
+ // and its vars' descriptions shipped in every published bundle.
189
184
  lines.push('')
190
185
  if (foundationExports || layoutNames.length > 0) {
191
186
  const capParts = []
192
187
  if (foundationExports) {
193
- capParts.push('..._foundationModule.default')
194
- // `vars` is a NAMED export of main.js, so the spread above misses it —
195
- // schema.js reads `module.vars || module.default?.vars` and this must
196
- // agree with it (named wins), or the runtime and the editor would see
197
- // different foundation vars. Without this line the declared theme vars
198
- // exist ONLY in dist/meta/schema.json, which is a build-time artifact:
199
- // any lane that generates theme CSS outside the build (the runtime's
200
- // ensureThemeCss, a cloud shell assembler) would silently produce a
201
- // theme missing every foundation-declared var.
202
- capParts.push('vars: _foundationModule.vars || _foundationModule.default?.vars')
188
+ for (const key of foundationRuntime.code) capParts.push(`${key}: _foundationModule.default?.${key}`)
189
+ for (const [key, value] of Object.entries(foundationRuntime.data)) capParts.push(`${key}: ${JSON.stringify(value)}`)
203
190
  }
204
191
  if (layoutNames.length > 0) {
205
192
  capParts.push(`layouts: { ${layoutNames.join(', ')} }`)
@@ -316,8 +303,13 @@ export async function generateEntryPoint(srcDir, outputPath = null, options = {}
316
303
  // Check for CSS file
317
304
  const cssPath = detectCssFile(srcDir)
318
305
 
319
- // Check for foundation exports (props, vars, etc.)
306
+ // The foundation's `main.js`, and what the runtime reads of it — read as the schema reads it
307
+ // (`loadFoundationConfig`: the named `vars` first, JSX imports transpiled), so the editor and the
308
+ // runtime see one declaration.
309
+ // Asked even with no main.js: it refuses a `foundation.js` or `main.jsx` left in its place.
320
310
  const foundationExports = detectFoundationExports(srcDir)
311
+ const foundationConfig = await loadFoundationConfig(srcDir)
312
+ const foundationRuntime = foundationExports ? extractFoundationRuntime(foundationConfig) : { code: [], data: {} }
321
313
 
322
314
  // Resolve the data schemas referenced by section bindings, then extract
323
315
  // per-component runtime metadata (which lean-extracts field defaults from
@@ -347,6 +339,7 @@ export async function generateEntryPoint(srcDir, outputPath = null, options = {}
347
339
  const source = generateEntrySource(components, {
348
340
  cssPath,
349
341
  foundationExports,
342
+ foundationRuntime,
350
343
  meta,
351
344
  layouts,
352
345
  layoutMeta,
@@ -386,6 +379,7 @@ export async function generateEntryPoint(srcDir, outputPath = null, options = {}
386
379
  componentNames,
387
380
  layoutNames,
388
381
  foundationExports,
382
+ foundationRuntime,
389
383
  meta,
390
384
  layoutMeta,
391
385
  hostShareableImports,
package/src/i18n/audit.js CHANGED
@@ -10,6 +10,7 @@
10
10
  import { readFile, writeFile } from 'fs/promises'
11
11
  import { existsSync } from 'node:fs'
12
12
  import { join } from 'node:path'
13
+ import { translationKeepsMarkup } from './merge.js'
13
14
 
14
15
  // Inline markdown → ProseMirror, so the markup check asks the merge's own question
15
16
  // ("would this value produce marks?") instead of pattern-matching markdown syntax.
@@ -67,16 +68,18 @@ export async function auditLocale(localesPath, locale) {
67
68
  const translation = getTranslationText(translations[hash])
68
69
  valid.push({ hash, source, translation })
69
70
 
70
- // ⭐ The source element carries inline markdown; the translation does not.
71
- // `merge` builds the translated element from the VALUE alone — it never
72
- // re-applies marks from the source — so this entry will render as flat prose
73
- // and its links will be gone. It still counts as translated, which is why
74
- // coverage cannot see it: the whole class scores 100%.
71
+ // ⭐ The source element carries inline markdown, and what the merge makes of
72
+ // the translation does not — so this entry will render as flat prose and its
73
+ // links will be gone. It still counts as translated, which is why coverage
74
+ // cannot see it: the whole class scores 100%. The merge's own answer
75
+ // (`translationKeepsMarkup`): a plain label for a button keeps its link there,
76
+ // so it is no loss. ⛔ Until 2026-10-01 any plain value counted, when the merge
77
+ // rebuilt every element from the value alone.
75
78
  // ⛔ REPLACES a `<N>`-tag check that could no longer fire. Those tags were the
76
79
  // pre-2026-06-24 keying scheme (`d12d594`); extraction has not emitted one
77
80
  // since, so the check was dead while looking like a live guard — and it was
78
81
  // the only thing in the pipeline that even gestured at mark fidelity.
79
- if (unit.markup && translation.length > 0 && !carriesInlineMarkup(translation)) {
82
+ if (unit.markup && translation.length > 0 && dropsSourceMarkup(unit.markup, translation)) {
80
83
  losesMarkup.push({ hash, source, markup: unit.markup, translation })
81
84
  }
82
85
  } else {
@@ -111,24 +114,19 @@ export async function auditLocale(localesPath, locale) {
111
114
  }
112
115
 
113
116
  /**
114
- * Does this translation value produce any inline marks when the merge parses it?
117
+ * Does the merge drop the inline markup this translation's source element carries?
115
118
  *
116
- * Asks the parser rather than a regex: the merge resolves a value through
117
- * `markdownToProseMirror`, so the only honest test of "will this keep its link"
118
- * is to run the same conversion. With no converter available the check declines
119
- * to fire — a missing dependency must not invent findings.
119
+ * Asks the merge rather than a regex (`translationKeepsMarkup`), since what the
120
+ * merge makes of a value is the only honest answer to "will this keep its link".
121
+ * With no converter available the check declines to fire — a missing dependency
122
+ * must not invent findings.
120
123
  */
121
- function carriesInlineMarkup(value) {
122
- if (!markdownToProseMirror) return true
124
+ function dropsSourceMarkup(markup, value) {
125
+ if (!markdownToProseMirror) return false
123
126
  try {
124
- const doc = markdownToProseMirror(value)
125
- const walk = (nodes) =>
126
- (nodes || []).some(
127
- (n) => (n?.marks && n.marks.length > 0) || (n?.type && n.type !== 'text' && n.type !== 'paragraph') || walk(n?.content)
128
- )
129
- return walk(doc?.content)
127
+ return !translationKeepsMarkup(markup, value)
130
128
  } catch {
131
- return true
129
+ return false
132
130
  }
133
131
  }
134
132
 
package/src/i18n/merge.js CHANGED
@@ -345,16 +345,16 @@ function translateProseMirrorDoc(doc, context, translations, fallbackToSource) {
345
345
  return changed
346
346
  }
347
347
 
348
- // Replace one block element's inline content with the parsed translation
349
- // fragment. `lookupTranslation` with the already-trimmed key adds no surrounding
350
- // whitespace and returns the source on a miss, so `value === key` means
351
- // "no translation" → leave the element as-is. Returns true if it replaced.
348
+ // Replace one block element's inline content with what its translation gives it
349
+ // (`translatedInline`). `lookupTranslation` with the already-trimmed key adds no
350
+ // surrounding whitespace and returns the source on a miss, so `value === key`
351
+ // means "no translation" → leave the element as-is. Returns true if it replaced.
352
352
  function applyElementTranslation(node, context, translations, fallbackToSource) {
353
353
  const key = elementText(node)
354
354
  if (!key) return false
355
355
  const value = lookupTranslation(key, context, translations, fallbackToSource)
356
356
  if (value === key) return false
357
- const fragment = inlineMarkdownToFragment(value)
357
+ const fragment = translatedInline(node.content, value)
358
358
  if (fragment && fragment.length) {
359
359
  node.content = fragment
360
360
  return true
@@ -362,6 +362,129 @@ function applyElementTranslation(node, context, translations, fallbackToSource)
362
362
  return false
363
363
  }
364
364
 
365
+ /**
366
+ * ⭐ WHAT A TRANSLATION GIVES AN ELEMENT: its value, parsed as inline markdown — with one
367
+ * exception. A value that names no link, given to an element made only of links, is their
368
+ * LABELS: a button's translation is its label (`Créer un groupe`), and buttons on consecutive
369
+ * lines take one label per line. Each keeps its source link — its target and its attributes
370
+ * (`{role=primary icon=…}`), which are structure, not words.
371
+ *
372
+ * ⛔ Until 2026-10-01 the value replaced the element whole, so a label translated on its own lost
373
+ * its link: the button became a plain paragraph, or vanished from a section that renders only
374
+ * links. Locale files keyed before whole-element keying hold exactly such labels.
375
+ *
376
+ * A value that writes a link is taken as written, re-targeted or not. So is a plain value for an
377
+ * element that mixes words and links, since which words were linked cannot be known;
378
+ * `uniweb i18n status` reports that loss (`translationKeepsMarkup`).
379
+ *
380
+ * @param {Array} source - the element's source inline content
381
+ * @param {string} value - the translation, inline markdown
382
+ * @returns {Array|null} the translated inline content; null for an empty value
383
+ */
384
+ export function translatedInline(source, value) {
385
+ const fragment = inlineMarkdownToFragment(value)
386
+ if (!fragment) return null
387
+ return asLinkLabels(source, fragment) || fragment
388
+ }
389
+
390
+ /**
391
+ * Does a translation keep the inline markup of its source element? The merge's own answer —
392
+ * what `translatedInline` makes of the value — so the audit can neither report a loss the merge
393
+ * does not make nor miss one it does.
394
+ *
395
+ * @param {string} markup - the source element's inline markdown (a manifest unit's `markup`)
396
+ * @param {string} value - the translation
397
+ * @returns {boolean}
398
+ */
399
+ export function translationKeepsMarkup(markup, value) {
400
+ const result = translatedInline(inlineMarkdownToFragment(markup) || [], value) || []
401
+ return result.some((node) => node.type !== 'text' || (node.marks && node.marks.length > 0))
402
+ }
403
+
404
+ // The source's links with the fragment as their labels — the whole fragment for one link, a line
405
+ // each for several — or null: the fragment names a link of its own, the source is not only links,
406
+ // or the lines do not match the links one to one.
407
+ function asLinkLabels(source, fragment) {
408
+ if (fragment.some((node) => linkMarkOf(node))) return null
409
+ const links = linkRuns(source)
410
+ if (!links) return null
411
+ const labels = links.length === 1 ? [trimInline(fragment)] : inlineLines(fragment)
412
+ if (labels.length !== links.length || labels.some((label) => !label.length)) return null
413
+ const out = []
414
+ links.forEach((link, i) => {
415
+ if (i > 0) out.push(...link.before)
416
+ for (const node of labels[i]) out.push(withMarks(node, link.marks))
417
+ })
418
+ return out
419
+ }
420
+
421
+ // An element made only of links, as its runs: each link's marks (those all its text shares) and
422
+ // the line breaks or spaces before it. Null when anything else is in it — words outside a link,
423
+ // an icon, an image.
424
+ function linkRuns(source) {
425
+ const runs = []
426
+ let gap = []
427
+ for (const node of source || []) {
428
+ if (!node) continue
429
+ const link = linkMarkOf(node)
430
+ if (link) {
431
+ const last = runs[runs.length - 1]
432
+ if (last && !gap.length && sameMark(last.link, link)) {
433
+ last.marks = last.marks.filter((mark) => node.marks.some((own) => sameMark(own, mark)))
434
+ } else {
435
+ runs.push({ link, marks: [...node.marks], before: gap })
436
+ }
437
+ gap = []
438
+ } else if (node.type === 'hardBreak' || (node.type === 'text' && !node.marks?.length && !node.text?.trim())) {
439
+ gap.push(node)
440
+ } else {
441
+ return null
442
+ }
443
+ }
444
+ return runs.length ? runs : null
445
+ }
446
+
447
+ function linkMarkOf(node) {
448
+ return node?.type === 'text' ? node.marks?.find((mark) => mark.type === 'link') || null : null
449
+ }
450
+
451
+ function sameMark(a, b) {
452
+ return Boolean(a && b) && a.type === b.type && JSON.stringify(a.attrs || {}) === JSON.stringify(b.attrs || {})
453
+ }
454
+
455
+ // A fragment's lines, split at line breaks, each trimmed; blank lines dropped.
456
+ function inlineLines(fragment) {
457
+ const lines = [[]]
458
+ for (const node of fragment) {
459
+ if (node.type === 'hardBreak') {
460
+ lines.push([])
461
+ } else if (node.type === 'text' && node.text.includes('\n')) {
462
+ node.text.split('\n').forEach((part, i) => {
463
+ if (i > 0) lines.push([])
464
+ if (part) lines[lines.length - 1].push({ ...node, text: part })
465
+ })
466
+ } else {
467
+ lines[lines.length - 1].push(node)
468
+ }
469
+ }
470
+ return lines.map(trimInline).filter((line) => line.length)
471
+ }
472
+
473
+ // Leading and trailing whitespace off a run of inline nodes.
474
+ function trimInline(nodes) {
475
+ const out = nodes.map((node) => ({ ...node }))
476
+ if (out[0]?.type === 'text') out[0].text = out[0].text.replace(/^\s+/, '')
477
+ const last = out[out.length - 1]
478
+ if (last?.type === 'text') last.text = last.text.replace(/\s+$/, '')
479
+ return out.filter((node) => node.type !== 'text' || node.text)
480
+ }
481
+
482
+ // A label node with its link's marks; a mark of its own of another type stays.
483
+ function withMarks(node, marks) {
484
+ const own = (node.marks || []).filter((mark) => !marks.some((linkMark) => linkMark.type === mark.type))
485
+ return { ...node, marks: [...marks, ...own] }
486
+ }
487
+
365
488
  /**
366
489
  * Translate the human-readable strings inside a section's TAGGED DATA BLOCKS,
367
490
  * in place. The other half of the fix in `extract.js` — a manifest entry nobody
@@ -434,3 +434,87 @@ export function extractAllLayoutRuntimeSchemas(layoutsMeta) {
434
434
 
435
435
  return schemas
436
436
  }
437
+
438
+ /**
439
+ * ⭐ WHAT THE RUNTIME READS OF A FOUNDATION'S `main.js` — and nothing else of it. Ruled
440
+ * 2026-10-01 [Diego]: *"Only what runtime really reads for rendering should reach it."*
441
+ *
442
+ * Read off every runtime reader of the entry's `capabilities` (2026-10-01): core's `Uniweb` and
443
+ * `Website`, the runtime's wiring, navigation and scroll, `@uniweb/theming`, kit's xref, the
444
+ * fetch dispatcher, and press. Two kinds:
445
+ *
446
+ * code `handlers`, `defaultInsets`, `xref`, `transports`, `outputs` — functions and
447
+ * components, so the entry REFERENCES each from the module and the bundler drops the rest
448
+ * of `main.js`'s default export.
449
+ * data `defaultLayout`, `defaultSection`, `viewTransitions`, `scroll`; `vars`, each var's
450
+ * `default`, `type` and `applyTo` — all `@uniweb/theming` reads; `data`, each key's schema
451
+ * ref — all `declaredKeys` reads. Written as a literal, like a section type's lean schema.
452
+ *
453
+ * ⛔ Never: `name`, `description`, `extension`, a var's label or description, a data key's shape,
454
+ * or any key the framework does not read. ⛔ Until 2026-10-01 the entry spread the whole default
455
+ * export into `capabilities`, and a foundation's name and its vars' descriptions shipped in every
456
+ * published bundle.
457
+ *
458
+ * A data key whose value is not plain data (a function, say) is referenced like code, so nothing
459
+ * is lost to serialization.
460
+ *
461
+ * @param {Object} config - the foundation's `main.js`, as `loadFoundationConfig` reads it
462
+ * @returns {{ code: string[], data: Object }} the code keys to reference, and the data to inline
463
+ */
464
+ export function extractFoundationRuntime(config = {}) {
465
+ const code = RUNTIME_CODE_CAPABILITIES.filter((key) => config?.[key] !== undefined)
466
+ const data = {}
467
+ for (const key of RUNTIME_DATA_CAPABILITIES) {
468
+ const value = config?.[key]
469
+ if (value === undefined) continue
470
+ if (isPlainData(value)) data[key] = value
471
+ else code.push(key)
472
+ }
473
+ const vars = leanVars(config?.vars)
474
+ if (vars) data.vars = vars
475
+ const keys = leanDataKeys(config?.data)
476
+ if (keys) data.data = keys
477
+ return { code, data }
478
+ }
479
+
480
+ /** The `main.js` capabilities the runtime calls or renders — referenced, never copied. */
481
+ export const RUNTIME_CODE_CAPABILITIES = ['handlers', 'defaultInsets', 'xref', 'transports', 'outputs']
482
+
483
+ /** The `main.js` values the runtime reads as data — written into the entry. */
484
+ const RUNTIME_DATA_CAPABILITIES = ['defaultLayout', 'defaultSection', 'viewTransitions', 'scroll']
485
+
486
+ /** What `@uniweb/theming` reads of a foundation var: its value, its kind, and a font role's selectors. */
487
+ const RUNTIME_VAR_FIELDS = ['default', 'type', 'applyTo']
488
+
489
+ function leanVars(vars) {
490
+ if (!vars || typeof vars !== 'object' || Array.isArray(vars)) return null
491
+ const out = {}
492
+ for (const [name, config] of Object.entries(vars)) {
493
+ if (config !== null && typeof config === 'object' && !Array.isArray(config)) {
494
+ const lean = {}
495
+ for (const field of RUNTIME_VAR_FIELDS) if (config[field] !== undefined) lean[field] = config[field]
496
+ out[name] = lean
497
+ } else {
498
+ out[name] = config
499
+ }
500
+ }
501
+ return Object.keys(out).length > 0 ? out : null
502
+ }
503
+
504
+ /** A foundation's `data:` as a section type's lean `data` is: each key → its schema ref, or null. */
505
+ function leanDataKeys(data) {
506
+ const lowered = lowerData(data)
507
+ if (!lowered || typeof lowered !== 'object' || Array.isArray(lowered)) return null
508
+ const out = {}
509
+ for (const [key, value] of Object.entries(lowered)) out[key] = dataRef(value)
510
+ return Object.keys(out).length > 0 ? out : null
511
+ }
512
+
513
+ function isPlainData(value) {
514
+ if (value === null || ['string', 'number', 'boolean'].includes(typeof value)) return true
515
+ if (Array.isArray(value)) return value.every(isPlainData)
516
+ if (typeof value === 'object' && Object.getPrototypeOf(value) === Object.prototype) {
517
+ return Object.values(value).every(isPlainData)
518
+ }
519
+ return false
520
+ }
package/src/schema.js CHANGED
@@ -18,6 +18,7 @@ import { join, dirname, extname, basename } from 'node:path'
18
18
  import { pathToFileURL } from 'node:url'
19
19
  import { describeChildren, describeVisuals, lowerChildren } from '@uniweb/schemas/component'
20
20
  import { describeContent, lowerData } from '@uniweb/schemas/content'
21
+ import { normalizeData } from '@uniweb/schemas/foundation'
21
22
  import { SECTION_PARAMS, SECTION_KEYS } from '@uniweb/schemas/section'
22
23
  import { collectSchemaRefs, buildDataSchemaMap, ownSchemaRefs } from './resolve-data-schema.js'
23
24
  import {
@@ -298,7 +299,6 @@ function retiredScopeMessage(pkgPath, scope, name) {
298
299
  * Contains foundation-wide configuration:
299
300
  * - vars: CSS custom properties sites can override
300
301
  * - defaultLayout: Default layout name
301
- * - props: Foundation-wide props
302
302
  * - Future: providers, middleware, etc.
303
303
  */
304
304
  /**
@@ -315,7 +315,6 @@ const DEFAULT_ONLY_CAPABILITIES = [
315
315
  'defaultLayout',
316
316
  'defaultSection',
317
317
  'viewTransitions',
318
- 'props',
319
318
  'defaultInsets',
320
319
  'xref',
321
320
  'outputs',
@@ -328,8 +327,9 @@ const DEFAULT_ONLY_CAPABILITIES = [
328
327
  /**
329
328
  * Warn when a capability was written as a named export.
330
329
  *
331
- * `generate-entry.js` builds `capabilities` as `{ ..._foundationModule.default,
332
- * vars: … }` — it spreads only the DEFAULT export. A named `export const xref =
330
+ * `generate-entry.js` builds `capabilities` from the DEFAULT export only — the code the
331
+ * runtime calls, referenced, and its data, lean (`extractFoundationRuntime`; until
332
+ * 2026-10-01 it spread the whole default export). A named `export const xref =
333
333
  * …` is therefore dropped with no error, no warning, and a build that succeeds.
334
334
  * Whatever the capability did simply does not happen, which reads as "the
335
335
  * feature is not implemented" rather than "it is declared in the wrong place".
@@ -474,10 +474,20 @@ export async function loadFoundationConfig(srcDir) {
474
474
  if (existsSync(legacy)) {
475
475
  throw new Error(
476
476
  `${legacy} is no longer read — rename it to main.js.\n` +
477
- ` A foundation's declarations (vars, defaultLayout, defaultSection, props, name) live in\n` +
477
+ ` A foundation's declarations (vars, defaultLayout, defaultSection, name) live in\n` +
478
478
  ` main.js. Left as foundation.js, the build would continue with none of them.`,
479
479
  )
480
480
  }
481
+ // ⛔ The same for `main.jsx`, which the entry used to import while this read nothing — the
482
+ // editor saw none of its declarations, the runtime all of them (until 2026-10-01). A main.js
483
+ // may hold JSX: `importFoundationConfig` transpiles it.
484
+ const jsx = join(srcDir, 'main.jsx')
485
+ if (existsSync(jsx)) {
486
+ throw new Error(
487
+ `${jsx} is not read — rename it to main.js, which may hold JSX.\n` +
488
+ ` A foundation's declarations live in main.js; left as main.jsx, the build would continue with none of them.`,
489
+ )
490
+ }
481
491
  return {}
482
492
  }
483
493
 
@@ -612,8 +622,12 @@ function buildComponentEntry(name, relativePath, meta) {
612
622
  path: relativePath,
613
623
  }
614
624
 
625
+ // ⭐ `[]` says the component TAKES NO CONTENT — `content: {}` or `content: []` in its `meta.js`
626
+ // [Diego, 2026-10-01] — a component that only reads `content.data`, say. Absent says it
627
+ // declared nothing, which is unknown. ⛔ Until 2026-10-01 both came out absent.
615
628
  const content = describeContent(meta)
616
629
  if (content.elements.length > 0) entry.content = content.elements
630
+ else if (declaresNoContent(meta.content)) entry.content = []
617
631
  else delete entry.content
618
632
 
619
633
  const children = lowerChildren(meta)
@@ -627,6 +641,12 @@ function buildComponentEntry(name, relativePath, meta) {
627
641
  return entry
628
642
  }
629
643
 
644
+ /** `content: {}` or `content: []` — the component's way of saying it takes no content. */
645
+ function declaresNoContent(content) {
646
+ if (Array.isArray(content)) return content.length === 0
647
+ return content !== null && typeof content === 'object' && Object.keys(content).length === 0
648
+ }
649
+
630
650
  /**
631
651
  * Refuse a declaration that was retired, naming what replaces it. A refusal, not a
632
652
  * warning: a retired key would otherwise register as if it still meant something.
@@ -1010,6 +1030,31 @@ function reportSupports(srcDir, authored, derived, emitted) {
1010
1030
  }
1011
1031
  }
1012
1032
 
1033
+ /**
1034
+ * Warn of a `data:` value `register` will refuse — one the normalized form it sends cannot
1035
+ * write (`@uniweb/schemas/foundation`): an inline field map that is not a valid data schema.
1036
+ * The build goes on; the site renders the map as before. `register` is where it stops, since
1037
+ * a registered schema is written in one spelling per meaning or not at all.
1038
+ *
1039
+ * @param {Object} components - discovered section types, by name
1040
+ * @param {Object} [layouts] - discovered layouts, by name
1041
+ * @param {Object} [foundationConfig] - the foundation's `main.js`, whose `data:` reaches every section
1042
+ */
1043
+ export function reportUnregistrableData(components, layouts = {}, foundationConfig = {}) {
1044
+ const owners = [
1045
+ ...Object.entries(components).map(([name, entry]) => [`${name} (meta.js)`, entry?.data]),
1046
+ ...Object.entries(layouts || {}).map(([name, entry]) => [`layout ${name} (meta.js)`, entry?.data]),
1047
+ ['the foundation (main.js)', foundationConfig?.data],
1048
+ ]
1049
+ for (const [owner, data] of owners) {
1050
+ try {
1051
+ normalizeData(data, { owner })
1052
+ } catch (err) {
1053
+ console.warn(`Warning: ${err.message} \`uniweb register\` refuses it until it is one.`)
1054
+ }
1055
+ }
1056
+ }
1057
+
1013
1058
  /**
1014
1059
  * Warn about `content` and `children` declarations an editor cannot use — what lowering
1015
1060
  * them could not read (`@uniweb/schemas`, the grammar the editor reads them with). The
@@ -1109,6 +1154,7 @@ export async function buildSchema(srcDir, sectionPaths, derivedSupports = null)
1109
1154
 
1110
1155
  // Discover layouts from src/layouts/
1111
1156
  const layouts = await discoverLayoutsInPath(srcDir)
1157
+ reportUnregistrableData(components, layouts, foundationConfig)
1112
1158
 
1113
1159
  // Determine extension role
1114
1160
  const isExtension = !!foundationConfig.extension
@@ -36,6 +36,7 @@ import { checkFoundationResolution } from '../utils/foundation-resolution-check.
36
36
  import { detectFoundationType } from './foundation-ref.js'
37
37
  import { readPreview, PREVIEW_ENV } from './preview.js'
38
38
  import { previewMediaPlugin } from './preview-media.js'
39
+ import { previewDirectoryIndexPlugin } from './preview-directory-index.js'
39
40
 
40
41
  /**
41
42
  * Normalize a base path for Vite compatibility
@@ -396,6 +397,9 @@ export async function defineSiteConfig(options = {}) {
396
397
 
397
398
  if (noopFoundationPlugin) plugins.push(noopFoundationPlugin)
398
399
 
400
+ // `vite preview` answers `/about` with `about/index.html`, as the hosts a build deploys to do.
401
+ plugins.push(previewDirectoryIndexPlugin())
402
+
399
403
  // Import map plugin for runtime mode production builds.
400
404
  // Emits re-export modules for each externalized package (react, @uniweb/core, etc.)
401
405
  // so the browser can resolve bare specifiers in the dynamically-imported foundation.
@@ -0,0 +1,67 @@
1
+ /**
2
+ * ⭐ `vite preview` SERVES A PAGE'S FOLDER FOR ITS URL WITHOUT THE SLASH — as the hosts a site
3
+ * deploys to do. A build writes each page as `<route>/index.html`, and GitHub Pages, Cloudflare
4
+ * Pages, Netlify and Vercel answer `/fr/a-propos` with `fr/a-propos/index.html` (the S3 + CloudFront
5
+ * adapter ships a function for it, `../hosts/s3-cloudfront.js`).
6
+ *
7
+ * `vite preview` answers it with the site's ROOT document instead: its SPA fallback serves
8
+ * `index.html` for any path with no file of its own. That document carries the default locale's
9
+ * content, so a translated page asked for without the slash rendered the not-found page once the
10
+ * runtime took over — the French URL resolved against English content — and every other page got
11
+ * the home page's prerender under its own URL. ⛔ So until 2026-10-01 the preview of a build showed
12
+ * what no host it deploys to does.
13
+ *
14
+ * Only an existing folder with an `index.html` is answered, and a `<route>.html` beside it wins, as
15
+ * on those hosts. Everything else goes to Vite unchanged.
16
+ */
17
+ import { existsSync, statSync } from 'node:fs'
18
+ import { join, resolve, sep } from 'node:path'
19
+
20
+ /**
21
+ * The URL `vite preview` should serve for a request: its folder's `index.html`, or null to leave the
22
+ * request alone.
23
+ *
24
+ * @param {{ method?: string, url?: string }} req
25
+ * @param {string} outDir - the build's output directory, absolute
26
+ * @param {string} [base] - the site's deployment base (`/`, `/docs/`)
27
+ * @returns {string|null}
28
+ */
29
+ export function directoryIndexUrl(req, outDir, base = '/') {
30
+ if (req.method !== 'GET' && req.method !== 'HEAD') return null
31
+ const url = req.url || ''
32
+ const queryAt = url.indexOf('?')
33
+ const pathname = queryAt === -1 ? url : url.slice(0, queryAt)
34
+ const query = queryAt === -1 ? '' : url.slice(queryAt)
35
+
36
+ const mount = base.endsWith('/') ? base : `${base}/`
37
+ if (pathname.endsWith('/') || !pathname.startsWith(mount)) return null
38
+
39
+ let route
40
+ try {
41
+ route = decodeURIComponent(pathname.slice(mount.length - 1))
42
+ } catch {
43
+ return null
44
+ }
45
+
46
+ const dir = join(outDir, route)
47
+ if (dir !== outDir && !dir.startsWith(outDir + sep)) return null
48
+ if (existsSync(`${dir}.html`)) return null
49
+ if (!existsSync(join(dir, 'index.html')) || !statSync(dir).isDirectory()) return null
50
+ return `${pathname}/index.html${query}`
51
+ }
52
+
53
+ /** The plugin: a middleware ahead of Vite's own, in `vite preview` only. */
54
+ export function previewDirectoryIndexPlugin() {
55
+ return {
56
+ name: 'uniweb:preview-directory-index',
57
+ configurePreviewServer(server) {
58
+ const { root, base, build } = server.config
59
+ const outDir = resolve(root, build.outDir)
60
+ server.middlewares.use((req, _res, next) => {
61
+ const target = directoryIndexUrl(req, outDir, base)
62
+ if (target) req.url = target
63
+ next()
64
+ })
65
+ },
66
+ }
67
+ }
@@ -26,6 +26,7 @@
26
26
  import { existsSync, readFileSync, readdirSync } from 'node:fs'
27
27
  import { join } from 'node:path'
28
28
 
29
+ import { normalizeFoundationSchema } from '@uniweb/schemas/foundation'
29
30
  import { toDataSchemaDeclaration, SHORTHAND_SECTION } from './data-schema.js'
30
31
  import { checkFoundationName, splitFoundationName } from '../foundation-name.js'
31
32
 
@@ -81,7 +82,7 @@ export function buildRegistryPackage({ schema, foundationDir, scope, exporter, e
81
82
  const foundationEntity = {
82
83
  model: FOUNDATION_SCHEMA,
83
84
  info: buildInfo(self, org, digest, runtime),
84
- schema: buildSchemaBlob(schema),
85
+ schema: buildSchemaBlob(schema, org),
85
86
  i18n: { locales: loadI18nLocales(foundationDir) },
86
87
  'data-schemas': { refs: buildRefs(dataSchemas, scoped) },
87
88
  }
@@ -250,16 +251,27 @@ function buildInfo(self, org, digest, runtime) {
250
251
  return info
251
252
  }
252
253
 
253
- // The whole renderable schema.json MINUS identity and MINUS dataSchemas, shipped
254
- // as one opaque object the backend never reads into (custodian).
255
- function buildSchemaBlob(schema) {
254
+ // The foundation schema an editor reads: the whole renderable schema.json MINUS identity
255
+ // and MINUS dataSchemas, shipped as one object the backend stores and serves, and never
256
+ // reads into (custodian).
257
+ //
258
+ // ⭐ IN ITS NORMALIZED FORM — `schemaFormat: 3` (`@uniweb/schemas/foundation`, ruled
259
+ // 2026-10-01 [Diego]): every entry in one spelling per meaning, so the editor needs no
260
+ // framework grammar, and each `@/x` a `data:` key names qualified with the scope the
261
+ // foundation registers under — the name its Model was stored under. ⛔ Until 2026-10-01 the
262
+ // build's format 2 shipped as built, and `data: { team: '@/member' }` reached the editor
263
+ // as written. `dist/meta/schema.json` stays format 2: the build's own readers read it.
264
+ //
265
+ // It throws for a `data:` value that cannot be normalized — an inline field map that is
266
+ // not a valid data schema — naming the component and the key; the build warns of it first.
267
+ function buildSchemaBlob(schema, org) {
256
268
  const { dataSchemas: _ds, ...rest } = schema
257
269
  // Every key hoisted into `info` is stripped here, so the wire carries each
258
270
  // fact ONCE. Two copies of one fact is a drift liability, and the copy inside
259
271
  // an opaque blob is the one nobody would think to update.
260
272
  const { name: _n, version: _v, description: _d, role: _r, supports: _s, ...selfConfig } =
261
273
  rest._self || {}
262
- return { ...rest, _self: selfConfig }
274
+ return normalizeFoundationSchema({ ...rest, _self: selfConfig }, { scope: org || undefined })
263
275
  }
264
276
 
265
277
  // The data-schemas the foundation renders, by NAME (own + shared), sorted.