@uniweb/build 0.74.3 → 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 +6 -6
- package/src/generate-entry.js +34 -40
- package/src/hosts/cloudflare-pages.js +8 -1
- package/src/i18n/audit.js +18 -20
- package/src/i18n/merge.js +128 -5
- package/src/prerender.js +11 -3
- package/src/runtime-schema.js +84 -0
- package/src/schema.js +51 -5
- package/src/site/config.js +4 -0
- package/src/site/content-collector.js +4 -5
- package/src/site/preview-directory-index.js +67 -0
- package/src/uwx/registry-package.js +17 -5
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uniweb/build",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.75.0",
|
|
4
4
|
"description": "Build tooling for the Uniweb Component Web Platform",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -59,13 +59,13 @@
|
|
|
59
59
|
"yaml": "^2.5.0",
|
|
60
60
|
"@uniweb/content-reader": "^1.2.8",
|
|
61
61
|
"@uniweb/content-writer": "^0.3.4",
|
|
62
|
-
"@uniweb/
|
|
63
|
-
"@uniweb/
|
|
62
|
+
"@uniweb/projections": "^0.7.13",
|
|
63
|
+
"@uniweb/schemas": "^0.10.2",
|
|
64
64
|
"@uniweb/theming": "^0.2.0",
|
|
65
65
|
"@uniweb/semantic-parser": "^1.5.1"
|
|
66
66
|
},
|
|
67
67
|
"optionalDependencies": {
|
|
68
|
-
"@uniweb/runtime": "^0.28.
|
|
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.
|
|
78
|
-
"@uniweb/runtime": "^0.28.
|
|
77
|
+
"@uniweb/core": "^0.36.0",
|
|
78
|
+
"@uniweb/runtime": "^0.28.10"
|
|
79
79
|
},
|
|
80
80
|
"peerDependenciesMeta": {
|
|
81
81
|
"vite": {
|
package/src/generate-entry.js
CHANGED
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
*
|
|
6
6
|
* Exports:
|
|
7
7
|
* - `components` - Object map of component name -> React component
|
|
8
|
-
* - `capabilities` -
|
|
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
|
-
*
|
|
92
|
-
*
|
|
93
|
-
*
|
|
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
|
-
|
|
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
|
-
//
|
|
164
|
-
//
|
|
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(
|
|
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
|
|
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(
|
|
194
|
-
|
|
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
|
-
//
|
|
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,
|
|
@@ -30,6 +30,7 @@
|
|
|
30
30
|
import { readFile, writeFile } from 'node:fs/promises'
|
|
31
31
|
import { existsSync } from 'node:fs'
|
|
32
32
|
import { join } from 'node:path'
|
|
33
|
+
import { resolveRoute } from '@uniweb/core/resolve-route'
|
|
33
34
|
|
|
34
35
|
import { DeployError, spawnTool, readCredential, credentialHint } from './deploy-utils.js'
|
|
35
36
|
import {
|
|
@@ -61,7 +62,13 @@ async function collectRoutingEntries(localeConfigs) {
|
|
|
61
62
|
const prefix = localeConfig.routePrefix || ''
|
|
62
63
|
for (const page of siteContent.pages || []) {
|
|
63
64
|
if (page.redirect) {
|
|
64
|
-
|
|
65
|
+
// Where the app sends a visitor of this locale (`resolveRoute`): a page of the site in their
|
|
66
|
+
// language, anything else as written. ⛔ Until 2026-09-30 the target was written raw, so
|
|
67
|
+
// `/fr/old` redirected a French reader to the English page.
|
|
68
|
+
const route = `${prefix}${page.route}`
|
|
69
|
+
const resolved = resolveRoute(siteContent, route, { activeLocale: prefix ? prefix.slice(1) : undefined })
|
|
70
|
+
const location = resolved.kind === 'redirect' ? resolved.location : page.redirect
|
|
71
|
+
entries.push(`${route} ${location} 302`)
|
|
65
72
|
}
|
|
66
73
|
if (page.rewrite) {
|
|
67
74
|
entries.push(`${prefix}${page.route}/* ${page.rewrite}/:splat 200`)
|
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
|
|
71
|
-
//
|
|
72
|
-
//
|
|
73
|
-
//
|
|
74
|
-
//
|
|
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 &&
|
|
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
|
|
117
|
+
* Does the merge drop the inline markup this translation's source element carries?
|
|
115
118
|
*
|
|
116
|
-
* Asks the
|
|
117
|
-
*
|
|
118
|
-
*
|
|
119
|
-
*
|
|
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
|
|
122
|
-
if (!markdownToProseMirror) return
|
|
124
|
+
function dropsSourceMarkup(markup, value) {
|
|
125
|
+
if (!markdownToProseMirror) return false
|
|
123
126
|
try {
|
|
124
|
-
|
|
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
|
|
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
|
|
349
|
-
//
|
|
350
|
-
// whitespace and returns the source on a miss, so `value === key`
|
|
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 =
|
|
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
|
package/src/prerender.js
CHANGED
|
@@ -1003,10 +1003,18 @@ export async function prerenderSite(siteDir, options = {}) {
|
|
|
1003
1003
|
const translatedPageRoute = isDefault ? page.route : website.translateRoute(page.route, locale)
|
|
1004
1004
|
const outputRoute = routePrefix + translatedPageRoute
|
|
1005
1005
|
|
|
1006
|
-
// Redirect pages: emit a redirect HTML instead of rendering content
|
|
1006
|
+
// Redirect pages: emit a redirect HTML instead of rendering content — to where the app sends
|
|
1007
|
+
// a visitor of this locale (`resolveRoute`): a page of the site in their language, anything
|
|
1008
|
+
// else as written. ⛔ Until 2026-09-30 the stub wrote the target raw, so a French reader was
|
|
1009
|
+
// sent to the English page and a relative target resolved against the stub's own URL.
|
|
1007
1010
|
if (page.redirect) {
|
|
1008
|
-
|
|
1009
|
-
const
|
|
1011
|
+
const resolved = website.resolveRoute(page.route)
|
|
1012
|
+
const location = resolved.kind === 'redirect' ? resolved.location : page.redirect
|
|
1013
|
+
const target = location.startsWith('/') && !location.startsWith('//')
|
|
1014
|
+
? (website.basePath || '') + location
|
|
1015
|
+
: location
|
|
1016
|
+
onProgress(` Redirect ${outputRoute} → ${target}`)
|
|
1017
|
+
const redirectHtml = `<!DOCTYPE html><html><head><meta charset="utf-8"><meta http-equiv="refresh" content="0;url=${target}"><link rel="canonical" href="${target}"><title>Redirecting...</title></head><body><p>Redirecting to <a href="${target}">${target}</a></p></body></html>`
|
|
1010
1018
|
const outputPath = getOutputPath(distDir, outputRoute)
|
|
1011
1019
|
await mkdir(dirname(outputPath), { recursive: true })
|
|
1012
1020
|
await writeFile(outputPath, redirectHtml)
|
package/src/runtime-schema.js
CHANGED
|
@@ -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`
|
|
332
|
-
*
|
|
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,
|
|
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
|
package/src/site/config.js
CHANGED
|
@@ -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.
|
|
@@ -38,6 +38,7 @@ import { parseFetchConfig, toFetchList } from './data-fetcher.js'
|
|
|
38
38
|
import { resolveExtensionUrls } from './extension-urls.js'
|
|
39
39
|
import { buildTheme, extractFoundationVars } from '../theme/index.js'
|
|
40
40
|
import { resolveDefaultLocale, resolvePublishableLocales, validateLanguageConfig, pageRouteQuery, currentFor } from '@uniweb/core'
|
|
41
|
+
import { authoredRedirectTarget } from '@uniweb/core/resolve-route'
|
|
41
42
|
import { parseFrontmatter } from '../utils/frontmatter.js'
|
|
42
43
|
import { parseGrid } from '@uniweb/schemas/grid'
|
|
43
44
|
|
|
@@ -1333,11 +1334,9 @@ async function processPage(pagePath, pageName, siteRoot, { isIndex = false, pare
|
|
|
1333
1334
|
if (pageConfig.redirect) {
|
|
1334
1335
|
const route = isIndex ? parentRoute
|
|
1335
1336
|
: parentRoute === '/' ? `/${pageName}` : `${parentRoute}/${pageName}`
|
|
1336
|
-
|
|
1337
|
-
//
|
|
1338
|
-
const target =
|
|
1339
|
-
? redirect
|
|
1340
|
-
: (route === '/' ? `/${redirect}` : `${route}/${redirect}`)
|
|
1337
|
+
// A relative redirect resolves against the page's own route — core's rule, which a host
|
|
1338
|
+
// resolving a pushed payload applies too (`@uniweb/core/resolve-route`).
|
|
1339
|
+
const target = authoredRedirectTarget(route, pageConfig.redirect)
|
|
1341
1340
|
|
|
1342
1341
|
return {
|
|
1343
1342
|
page: {
|
|
@@ -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
|
|
254
|
-
// as one
|
|
255
|
-
|
|
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.
|