@uniweb/build 0.16.18 → 0.16.19
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 +8 -8
- package/src/site/emit-extensions.js +129 -0
- package/src/site/plugin.js +20 -0
- package/src/uwx/data-schema.js +24 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uniweb/build",
|
|
3
|
-
"version": "0.16.
|
|
3
|
+
"version": "0.16.19",
|
|
4
4
|
"description": "Build tooling for the Uniweb Component Web Platform",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -59,16 +59,16 @@
|
|
|
59
59
|
"js-yaml": "^4.1.0",
|
|
60
60
|
"sharp": "^0.35.3",
|
|
61
61
|
"yaml": "^2.5.0",
|
|
62
|
+
"@uniweb/schemas": "0.2.7",
|
|
63
|
+
"@uniweb/theming": "0.1.15",
|
|
62
64
|
"@uniweb/content-writer": "0.3.3",
|
|
63
|
-
"@uniweb/projections": "0.2.5"
|
|
64
|
-
"@uniweb/schemas": "0.2.6",
|
|
65
|
-
"@uniweb/theming": "0.1.15"
|
|
65
|
+
"@uniweb/projections": "0.2.5"
|
|
66
66
|
},
|
|
67
67
|
"optionalDependencies": {
|
|
68
|
-
"@uniweb/
|
|
69
|
-
"@uniweb/runtime": "0.9.
|
|
70
|
-
"@uniweb/
|
|
71
|
-
"@uniweb/
|
|
68
|
+
"@uniweb/schemas": "0.2.7",
|
|
69
|
+
"@uniweb/runtime": "0.9.8",
|
|
70
|
+
"@uniweb/semantic-parser": "1.2.1",
|
|
71
|
+
"@uniweb/content-reader": "1.2.2"
|
|
72
72
|
},
|
|
73
73
|
"peerDependencies": {
|
|
74
74
|
"vite": "^5.0.0 || ^6.0.0 || ^7.0.0",
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Emit a workspace extension's built code into the site's own output.
|
|
3
|
+
*
|
|
4
|
+
* ── The gap this closes ──
|
|
5
|
+
*
|
|
6
|
+
* A site declares an extension by URL. The site-relative form —
|
|
7
|
+
* `extensions: ['/effects/entry.js']` — means "served from this site's own
|
|
8
|
+
* origin", and it is what the `extensions` template ships. But nothing ever put
|
|
9
|
+
* the file there.
|
|
10
|
+
*
|
|
11
|
+
* The result was a build that succeeds and a site that is wrong: prerender
|
|
12
|
+
* loads the extension from the workspace (via `resolveExtensionPath`) and
|
|
13
|
+
* renders its sections into the static HTML, then the browser fetches
|
|
14
|
+
* `/effects/entry.js`, gets a 404, `loadExtensions()` drops it, and hydration
|
|
15
|
+
* REPLACES the correct markup with `Component not found`. A visitor watches a
|
|
16
|
+
* working section break. Measured on the `extensions` template, 2026-08-05.
|
|
17
|
+
*
|
|
18
|
+
* ── Why here ──
|
|
19
|
+
*
|
|
20
|
+
* The build is the only party that knows both the declared URL and where the
|
|
21
|
+
* extension's `dist/` actually is, and the site's output is the only place the
|
|
22
|
+
* two can meet. This is the emission half of "site-hosted linked" — the shape
|
|
23
|
+
* the model doc lists as producible only by hand.
|
|
24
|
+
*
|
|
25
|
+
* ── What is emitted, and what is not ──
|
|
26
|
+
*
|
|
27
|
+
* The BROWSER delivery set. A foundation's `dist/` also carries things only
|
|
28
|
+
* other consumers want, and a static host should not serve them:
|
|
29
|
+
*
|
|
30
|
+
* entry.js, assets/** → emitted; the browser loads these
|
|
31
|
+
* entry-ssr.js → skipped; the single-file SSR twin, for an
|
|
32
|
+
* isolate that loads one module. Nothing on a
|
|
33
|
+
* static host reads it.
|
|
34
|
+
* meta/** → skipped; the editor schema. Authoring-time, and
|
|
35
|
+
* not something to publish to visitors.
|
|
36
|
+
* runtime-pin.json → skipped; build provenance, read by no browser.
|
|
37
|
+
* *.map → skipped; dev-only.
|
|
38
|
+
*
|
|
39
|
+
* Same browser/internal split the runtime's distribution channel draws, for the
|
|
40
|
+
* same reason: what a visitor fetches and what a renderer needs are different
|
|
41
|
+
* sets, and only one of them belongs on a public origin.
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'
|
|
45
|
+
import { join, relative, resolve } from 'node:path'
|
|
46
|
+
|
|
47
|
+
/** True for the `/effects/entry.js` form — the only one this site can serve. */
|
|
48
|
+
export function isSiteRelative(decl) {
|
|
49
|
+
const url = typeof decl === 'string' ? decl : decl?.url
|
|
50
|
+
return typeof url === 'string' && url.startsWith('/') && !url.startsWith('//')
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Every file under `dir`, relative to it. */
|
|
54
|
+
function walk(dir, base = dir) {
|
|
55
|
+
if (!existsSync(dir)) return []
|
|
56
|
+
return readdirSync(dir).flatMap((entry) => {
|
|
57
|
+
const full = join(dir, entry)
|
|
58
|
+
return statSync(full).isDirectory() ? walk(full, base) : [relative(base, full)]
|
|
59
|
+
})
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Is this file part of what a browser fetches? */
|
|
63
|
+
function isBrowserAsset(rel) {
|
|
64
|
+
if (rel.endsWith('.map')) return false
|
|
65
|
+
if (rel === 'runtime-pin.json') return false
|
|
66
|
+
if (rel.startsWith('meta/') || rel.startsWith(`meta\\`)) return false
|
|
67
|
+
if (/(^|[/\\])entry-ssr\.js$/.test(rel)) return false
|
|
68
|
+
return true
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Locate the built `dist/` behind a site-relative extension URL.
|
|
73
|
+
*
|
|
74
|
+
* The same candidates `resolveExtensionPath` walks for prerender, kept in step
|
|
75
|
+
* deliberately: if prerender can load an extension from the workspace but the
|
|
76
|
+
* build cannot find it to emit, that is exactly the split that produced the
|
|
77
|
+
* bug — one lane resolving it and the other not.
|
|
78
|
+
*
|
|
79
|
+
* @returns {{ distDir: string, urlBase: string }|null}
|
|
80
|
+
*/
|
|
81
|
+
export function resolveExtensionDist(url, siteDir) {
|
|
82
|
+
const parts = url.replace(/^\//, '').split('/')
|
|
83
|
+
if (parts.length < 2) return null
|
|
84
|
+
const pkgName = parts[0]
|
|
85
|
+
const projectRoot = resolve(siteDir, '..')
|
|
86
|
+
|
|
87
|
+
for (const candidate of [
|
|
88
|
+
join(projectRoot, pkgName, 'dist'),
|
|
89
|
+
join(projectRoot, 'extensions', pkgName, 'dist')
|
|
90
|
+
]) {
|
|
91
|
+
if (existsSync(candidate)) return { distDir: candidate, urlBase: pkgName }
|
|
92
|
+
}
|
|
93
|
+
return null
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Files to emit for a site's declared extensions.
|
|
98
|
+
*
|
|
99
|
+
* Returns `{ fileName, source }` pairs for Rollup's `emitFile`, plus the
|
|
100
|
+
* declarations that could not be resolved — the caller warns about those rather
|
|
101
|
+
* than failing, because an absolute-URL extension is legitimately not ours to
|
|
102
|
+
* emit and a missing workspace build is a warning the developer can act on.
|
|
103
|
+
*
|
|
104
|
+
* @param {Array} extensions - `site.yml::extensions`, as declared.
|
|
105
|
+
* @param {string} siteDir - the site package directory.
|
|
106
|
+
*/
|
|
107
|
+
export function collectExtensionAssets(extensions, siteDir) {
|
|
108
|
+
const emit = []
|
|
109
|
+
const unresolved = []
|
|
110
|
+
if (!Array.isArray(extensions)) return { emit, unresolved }
|
|
111
|
+
|
|
112
|
+
for (const decl of extensions) {
|
|
113
|
+
if (!isSiteRelative(decl)) continue // absolute URL or a ref — someone else serves it
|
|
114
|
+
const url = typeof decl === 'string' ? decl : decl.url
|
|
115
|
+
const found = resolveExtensionDist(url, siteDir)
|
|
116
|
+
if (!found) {
|
|
117
|
+
unresolved.push(url)
|
|
118
|
+
continue
|
|
119
|
+
}
|
|
120
|
+
for (const rel of walk(found.distDir)) {
|
|
121
|
+
if (!isBrowserAsset(rel)) continue
|
|
122
|
+
emit.push({
|
|
123
|
+
fileName: `${found.urlBase}/${rel.split('\\').join('/')}`,
|
|
124
|
+
source: readFileSync(join(found.distDir, rel))
|
|
125
|
+
})
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return { emit, unresolved }
|
|
129
|
+
}
|
package/src/site/plugin.js
CHANGED
|
@@ -52,6 +52,7 @@ import { processCollections, writeCollectionFiles } from './collection-processor
|
|
|
52
52
|
import { executeFetch, mergeDataIntoContent } from './data-fetcher.js'
|
|
53
53
|
import { shouldSplitContent } from './split-content.js'
|
|
54
54
|
import { FONT_LINKS_MARKER } from './head-markers.js'
|
|
55
|
+
import { collectExtensionAssets } from './emit-extensions.js'
|
|
55
56
|
|
|
56
57
|
// BCP 47 locale code pattern: en, zh-CN, zh-Hant, pt-BR, fr-CA, sr-Latn, etc.
|
|
57
58
|
const LOCALE_RE = '[a-z]{2,3}(?:-[A-Za-z]{2,4})?'
|
|
@@ -1464,6 +1465,25 @@ export function siteContentPlugin(options = {}) {
|
|
|
1464
1465
|
// markdown (retrieval). Free and on by default; a site opts out under
|
|
1465
1466
|
// `agents:` in site.yml.
|
|
1466
1467
|
emitProjections.call(this, finalContent)
|
|
1468
|
+
|
|
1469
|
+
// A site-relative extension (`/effects/entry.js`) is served from the
|
|
1470
|
+
// site's OWN origin, so the site's build is what has to put it there.
|
|
1471
|
+
// Without this the build succeeds, prerender renders the extension's
|
|
1472
|
+
// sections from the workspace, and the browser then 404s and replaces
|
|
1473
|
+
// them with `Component not found` on hydration.
|
|
1474
|
+
const { emit, unresolved } = collectExtensionAssets(
|
|
1475
|
+
finalContent.config?.extensions,
|
|
1476
|
+
resolve(sitePath)
|
|
1477
|
+
)
|
|
1478
|
+
for (const asset of emit) {
|
|
1479
|
+
this.emitFile({ type: 'asset', fileName: asset.fileName, source: asset.source })
|
|
1480
|
+
}
|
|
1481
|
+
for (const url of unresolved) {
|
|
1482
|
+
this.warn(
|
|
1483
|
+
`Extension '${url}' is site-relative but no built extension was found for it. ` +
|
|
1484
|
+
`The site will 404 on it at runtime — build the extension, or reference it by URL.`
|
|
1485
|
+
)
|
|
1486
|
+
}
|
|
1467
1487
|
},
|
|
1468
1488
|
|
|
1469
1489
|
closeBundle() {
|
package/src/uwx/data-schema.js
CHANGED
|
@@ -150,6 +150,15 @@ function lowerSection(def, resolve, optResolve, path = '') {
|
|
|
150
150
|
const out = {}
|
|
151
151
|
if ((def.kind || 'single') === 'multi') out.multiple = true
|
|
152
152
|
if (def.brief === true) out.brief = true
|
|
153
|
+
// Display prose IS a section key — the registry stores it and keys it for
|
|
154
|
+
// translation as `section.<name>.label` / `.description` (confirmed 2026-08-05).
|
|
155
|
+
// Note the asymmetry with a LEAF, which is the opposite way round: a leaf's
|
|
156
|
+
// `label`/`description` are accepted by the registry's parser and then DROPPED,
|
|
157
|
+
// because a field declaration has no slot for prose — field labels live in
|
|
158
|
+
// translation rows (`section.<name>.field.<key>.label`), which this producer
|
|
159
|
+
// does not emit today. So section prose arrives; leaf prose does not.
|
|
160
|
+
if (def.label) out.label = def.label
|
|
161
|
+
if (def.description) out.description = def.description
|
|
153
162
|
if (def.nestable) out.self_nesting = true
|
|
154
163
|
if (def.append_only) out.append_only = true
|
|
155
164
|
|
|
@@ -243,6 +252,7 @@ function lowerField(rawField, resolve, optResolve, path = '') {
|
|
|
243
252
|
...lowerSection(
|
|
244
253
|
{
|
|
245
254
|
kind: 'multi',
|
|
255
|
+
...sectionProse(field),
|
|
246
256
|
// `translatable: false` is load-bearing, not tidiness: a string field is
|
|
247
257
|
// localized by default, and a localized key could differ per locale —
|
|
248
258
|
// which would destroy the identity the key exists to carry. The key is an
|
|
@@ -261,7 +271,7 @@ function lowerField(rawField, resolve, optResolve, path = '') {
|
|
|
261
271
|
}
|
|
262
272
|
return {
|
|
263
273
|
type: 'section',
|
|
264
|
-
...lowerSection({ kind: 'single', fields: field.fields }, resolve, optResolve, path)
|
|
274
|
+
...lowerSection({ kind: 'single', ...sectionProse(field), fields: field.fields }, resolve, optResolve, path)
|
|
265
275
|
}
|
|
266
276
|
}
|
|
267
277
|
if (type === 'array') {
|
|
@@ -269,7 +279,7 @@ function lowerField(rawField, resolve, optResolve, path = '') {
|
|
|
269
279
|
if (items && items.type === 'object') {
|
|
270
280
|
return {
|
|
271
281
|
type: 'section',
|
|
272
|
-
...lowerSection({ kind: 'multi', fields: items.fields }, resolve, optResolve, path)
|
|
282
|
+
...lowerSection({ kind: 'multi', ...sectionProse(field), fields: items.fields }, resolve, optResolve, path)
|
|
273
283
|
}
|
|
274
284
|
}
|
|
275
285
|
// A multi-valued LEAF or REFERENCE. `normalizeField` split this field in two
|
|
@@ -368,6 +378,18 @@ function asField(def) {
|
|
|
368
378
|
return typeof def === 'string' ? { type: def } : (def && typeof def === 'object' ? def : {})
|
|
369
379
|
}
|
|
370
380
|
|
|
381
|
+
// A nested section is authored as a FIELD (`{ type: object, description: … }`),
|
|
382
|
+
// but arrives on the wire as a section — so its prose has to travel from the
|
|
383
|
+
// field declaration onto the section body, where the registry has a slot for it.
|
|
384
|
+
// Without this an authored `description:` on a nested object was dropped twice
|
|
385
|
+
// over: once by the normalizer, then again here.
|
|
386
|
+
function sectionProse(field) {
|
|
387
|
+
const out = {}
|
|
388
|
+
if (field.label) out.label = field.label
|
|
389
|
+
if (field.description) out.description = field.description
|
|
390
|
+
return out
|
|
391
|
+
}
|
|
392
|
+
|
|
371
393
|
function shortName(name) {
|
|
372
394
|
return String(name).split('/').pop()
|
|
373
395
|
}
|