@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniweb/build",
3
- "version": "0.16.18",
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/content-reader": "1.2.2",
69
- "@uniweb/runtime": "0.9.7",
70
- "@uniweb/schemas": "0.2.6",
71
- "@uniweb/semantic-parser": "1.2.1"
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
+ }
@@ -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() {
@@ -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
  }