@uniweb/build 0.73.3 → 0.74.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.73.3",
3
+ "version": "0.74.0",
4
4
  "description": "Build tooling for the Uniweb Component Web Platform",
5
5
  "type": "module",
6
6
  "exports": {
@@ -57,15 +57,15 @@
57
57
  "js-yaml": "^4.1.0",
58
58
  "sharp": "^0.35.3",
59
59
  "yaml": "^2.5.0",
60
- "@uniweb/schemas": "^0.9.4",
61
- "@uniweb/content-writer": "^0.3.4",
60
+ "@uniweb/content-reader": "^1.2.7",
62
61
  "@uniweb/projections": "^0.7.11",
63
- "@uniweb/semantic-parser": "^1.5.0",
64
- "@uniweb/theming": "^0.2.0",
65
- "@uniweb/content-reader": "^1.2.7"
62
+ "@uniweb/content-writer": "^0.3.4",
63
+ "@uniweb/semantic-parser": "^1.5.1",
64
+ "@uniweb/schemas": "^0.10.0",
65
+ "@uniweb/theming": "^0.2.0"
66
66
  },
67
67
  "optionalDependencies": {
68
- "@uniweb/runtime": "^0.28.3"
68
+ "@uniweb/runtime": "^0.28.4"
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/runtime": "^0.28.3",
78
- "@uniweb/core": "^0.35.0"
77
+ "@uniweb/core": "^0.35.0",
78
+ "@uniweb/runtime": "^0.28.4"
79
79
  },
80
80
  "peerDependenciesMeta": {
81
81
  "vite": {
package/src/docs.js CHANGED
@@ -8,6 +8,7 @@
8
8
  import { readFile, writeFile } from 'node:fs/promises'
9
9
  import { existsSync } from 'node:fs'
10
10
  import { join, isAbsolute } from 'node:path'
11
+ import { describeContent } from '@uniweb/schemas/content'
11
12
  import { buildSchema } from './schema.js'
12
13
  import { resolveFoundationSrcPath } from './utils/foundation-source-root.js'
13
14
 
@@ -94,25 +95,30 @@ function generateComponentDocs(name, meta) {
94
95
  lines.push('')
95
96
  }
96
97
 
97
- // Content expectations — what an author writes in the markdown. The key is
98
- // `content`; this read `meta.content` as `meta.elements` until 2026-09-16 —
99
- // the pre-rename name, which nothing has produced since. A dead branch reads
100
- // exactly like a component that declares nothing, so the whole section was
101
- // missing from every generated catalog.
98
+ // Content expectations — what an author writes in the markdown, read through
99
+ // `describeContent`: a built schema holds the lowered list (`SCHEMA_FORMAT` 2), a
100
+ // `meta.js` the declaration as written, and both describe alike. ⛔ This read
101
+ // `meta.content` as a map of labels until 2026-09-29 — which the lowered list is not —
102
+ // and as `meta.elements`, the pre-rename name, until 2026-09-16.
102
103
  //
103
- // A value is either a label string ('Description [1-2]') or { label, hint }.
104
- // The LABEL is the point: `paragraphs` alone tells an author nothing, and the
105
- // count hint is the part that says how much to write.
106
- const elements = Object.entries(meta.content || {})
104
+ // The LABEL is the point: `paragraphs` alone tells an author nothing, and the count is
105
+ // the part that says how much to write — shown in the bracket syntax they write.
106
+ const { elements } = describeContent(meta)
107
107
  if (elements.length > 0) {
108
108
  lines.push('### Content')
109
109
  lines.push('')
110
110
 
111
- for (const [key, el] of elements) {
112
- const label = typeof el === 'string' ? el : el?.label || ''
113
- lines.push(`**${key}**${label ? ` — ${label}` : ''}`)
114
- const hint = typeof el === 'object' && el?.hint ? el.hint : ''
115
- if (hint) lines.push(` ${hint}`)
111
+ for (const el of elements) {
112
+ const name = el.kind === 'concept' ? `md:${el.key}` : el.element
113
+ const types = el.element === 'media' && el.types.length < 3 ? ` (${el.types.join(', ')})` : ''
114
+ const count = bracketCount(el)
115
+ const label = [el.label, count].filter(Boolean).join(' ')
116
+ lines.push(`**${name}**${types}${label ? ` — ${label}` : ''}`)
117
+ if (el.hint) lines.push(` ${el.hint}`)
118
+ if (el.except) lines.push(` Leaves out: ${el.except.join(', ')}`)
119
+ if (el.content) {
120
+ lines.push(` Each entry: ${el.content.map((c) => [c.element, c.label].filter(Boolean).join(' — ')).join(' · ')}`)
121
+ }
116
122
  lines.push('')
117
123
  }
118
124
  }
@@ -120,6 +126,18 @@ function generateComponentDocs(name, meta) {
120
126
  return lines.join('\n')
121
127
  }
122
128
 
129
+ /** A lowered entry's count, as a developer writes it: `[1]`, `[3-6]`, `[2+]` — or ''. */
130
+ function bracketCount({ min, max }) {
131
+ if (typeof min !== 'number') return ''
132
+ if (typeof max !== 'number') return `[${min}+]`
133
+ return min === max ? `[${min}]` : `[${min}-${max}]`
134
+ }
135
+
136
+ /** The section types of a foundation schema: every key but `_self`, `_layouts` and `dataSchemas`. */
137
+ function sectionTypesOf(schema) {
138
+ return Object.keys(schema).filter((key) => !key.startsWith('_') && key !== 'dataSchemas')
139
+ }
140
+
123
141
  /**
124
142
  * Generate full markdown documentation for a foundation
125
143
  *
@@ -146,8 +164,10 @@ export function generateDocsFromSchema(schema, options = {}) {
146
164
  lines.push('---')
147
165
  lines.push('')
148
166
 
149
- // Table of contents
150
- const componentNames = Object.keys(schema).filter(k => k !== '_self')
167
+ // Table of contents. A schema's section types are its keys but `_self`, `_layouts` and
168
+ // `dataSchemas` (`buildSchema`); ⛔ until 2026-09-29 only `_self` was left out, so a
169
+ // foundation with layouts or data schemas documented both as components.
170
+ const componentNames = sectionTypesOf(schema)
151
171
 
152
172
  if (componentNames.length > 0) {
153
173
  lines.push('## Components')
@@ -222,7 +242,7 @@ export async function generateDocs(foundationDir, options = {}) {
222
242
  await writeFile(outputPath, markdown)
223
243
 
224
244
  // Count components
225
- const componentCount = Object.keys(schema).filter(k => k !== '_self').length
245
+ const componentCount = sectionTypesOf(schema).length
226
246
 
227
247
  return { outputPath, componentCount }
228
248
  }
@@ -28,6 +28,7 @@ import { isRichSchema } from '@uniweb/core'
28
28
  import { dataRefOf } from '@uniweb/core/data-keys'
29
29
  import { briefFieldMap, wholeFieldMap } from '@uniweb/schemas/conform'
30
30
  import { enumValues } from '@uniweb/schemas/format'
31
+ import { lowerData } from '@uniweb/schemas/content'
31
32
 
32
33
  /**
33
34
  * A `data:` entry's schema ref — a ref string, or `{ schema }` — or null for an inline
@@ -282,10 +283,16 @@ export function extractRuntimeSchema(fullMeta, dataSchemaMap = {}) {
282
283
  // with their refs, because the keys ARE what the section receives, and a key's ref is
283
284
  // what a fetch of another name fills it by. `schemas` carries field defaults for the
284
285
  // keys that have fields. `data: false` declares nothing, as no `data:` does.
285
- if (fullMeta.data === false) {
286
+ //
287
+ // ⭐ A concept block's key is written as its fence is — `'md:faq': 'Questions and
288
+ // answers'` — and lowered to the key a component reads, `faq`, with no schema: the value
289
+ // is the author's label, never a ref (`lowerData`; ruled 2026-09-29). A built entry is
290
+ // already lowered; this holds for a `meta.js` handed in as written.
291
+ const data = lowerData(fullMeta.data)
292
+ if (data === false) {
286
293
  // declares no key
287
- } else if (fullMeta.data && typeof fullMeta.data === 'object' && !Array.isArray(fullMeta.data)) {
288
- for (const [key, value] of Object.entries(fullMeta.data)) {
294
+ } else if (data && typeof data === 'object' && !Array.isArray(data)) {
295
+ for (const [key, value] of Object.entries(data)) {
289
296
  // ⛔ A value that is no schema at all declares a key by accident — it would reach the
290
297
  // component as `null`, silently. `data: { inherit: [...] }` — the opt-in to cascaded
291
298
  // data from 2026-01-31 until declared keys replaced it — is the shape this refuses: it
@@ -307,9 +314,9 @@ export function extractRuntimeSchema(fullMeta, dataSchemaMap = {}) {
307
314
  runtime.schemas[key] = lean
308
315
  }
309
316
  }
310
- } else if (fullMeta.data !== undefined) {
317
+ } else if (data !== undefined) {
311
318
  throw new Error(
312
- `[uniweb] Invalid 'data' in meta.js: expected false or { <key>: <schema> }, got ${JSON.stringify(fullMeta.data)}. ` +
319
+ `[uniweb] Invalid 'data' in meta.js: expected false or { <key>: <schema> }, got ${JSON.stringify(data)}. ` +
313
320
  "A <schema> is a named ref ('@/x'), an inline field map, or a rich-form { fields: [...] }."
314
321
  )
315
322
  }
package/src/schema.js CHANGED
@@ -16,7 +16,8 @@ import { createHash } from 'node:crypto'
16
16
  import { isFontVar } from '@uniweb/theming'
17
17
  import { join, dirname, extname, basename } from 'node:path'
18
18
  import { pathToFileURL } from 'node:url'
19
- import { describeChildren, describeVisuals } from '@uniweb/schemas/component'
19
+ import { describeChildren, describeVisuals, lowerChildren } from '@uniweb/schemas/component'
20
+ import { describeContent, lowerData } from '@uniweb/schemas/content'
20
21
  import { SECTION_PARAMS, SECTION_KEYS } from '@uniweb/schemas/section'
21
22
  import { collectSchemaRefs, buildDataSchemaMap, ownSchemaRefs } from './resolve-data-schema.js'
22
23
  import {
@@ -47,6 +48,20 @@ const SECTIONS_PATH = 'sections'
47
48
  // The layouts path where layout components are discovered
48
49
  export const LAYOUTS_PATH = 'layouts'
49
50
 
51
+ /**
52
+ * Which form a foundation schema's entries hold — `_self.schemaFormat`.
53
+ *
54
+ * (absent) 1 — each entry is its `meta.js`, copied as written
55
+ * 2 `content:` and `children:` lowered (2026-09-29): `content` is the canonical
56
+ * list `describeContent` returns, concept blocks included, and `children` the
57
+ * object form `lowerChildren` returns; `data:` keys are the keys a component
58
+ * reads (`lowerData`). `visuals` and the `content:` element `data` are refused.
59
+ *
60
+ * ⭐ A registered version never changes, so a reader holding one learns its form here,
61
+ * before the form ever changes again — never by sniffing a key's shape.
62
+ */
63
+ export const SCHEMA_FORMAT = 2
64
+
50
65
  /**
51
66
  * Load a meta.js file via dynamic import — as it is on disk now.
52
67
  *
@@ -552,9 +567,17 @@ function createImplicitMeta() {
552
567
  return {}
553
568
  }
554
569
 
570
+ /**
571
+ * What lowering an entry could not read — said once, when the schema is built
572
+ * (`reportPlacementDeclarations`), rather than at every discovery: the entry is also
573
+ * discovered to generate the foundation's entry file.
574
+ */
575
+ const loweringProblems = new WeakMap()
576
+
555
577
  /**
556
578
  * Build a component entry: the `meta.js` default export, plus the build's `name`
557
- * and `path`.
579
+ * and `path`, with its declarations LOWERED — the form a foundation registers
580
+ * (`SCHEMA_FORMAT`).
558
581
  *
559
582
  * ⭐ `title` IS THERE ONLY WHEN THE DEVELOPER WROTE ONE, so its absence says the
560
583
  * component is unnamed. That difference decides whether a name can be translated:
@@ -566,17 +589,73 @@ function createImplicitMeta() {
566
589
  * ⛔ Until 2026-09-27 the build filled `title` from the name whenever `meta.js`
567
590
  * declared none, and added `titleInferred: true` to restore the fact the filling-in
568
591
  * erased. Consumers derive the title where they use it instead [Diego, 2026-09-27].
592
+ *
593
+ * ⭐ `content:` and `children:` ARE LOWERED, and `data:` with them (ruled 2026-09-29):
594
+ * `content` becomes the canonical list `describeContent` returns — `image:` and
595
+ * `videos:` as the `media` element, a count as `min` / `max`, a label only where one
596
+ * was written, and each `'md:<tag>'` concept block as `{ key, kind: 'concept' }` —
597
+ * `children` the object form `lowerChildren` returns, and a `'md:<tag>'` key in `data:`
598
+ * the key a component reads (`lowerData`), so the lean runtime schema delivers
599
+ * `content.data.<tag>`. A reader of the registered schema needs no framework grammar.
600
+ * ⛔ Until then every key was copied as written.
569
601
  */
570
602
  function buildComponentEntry(name, relativePath, meta) {
603
+ refuseRetired(name, meta)
604
+
571
605
  // ⭐ `name` and `path` are the build's facts, so they are set AFTER the spread: a
572
606
  // `meta.js` declaring either cannot change them. ⛔ Until 2026-09-27 they came first,
573
607
  // and a `meta.js` `name` became the entry's `name` — which an editor reads as the
574
608
  // section type — while the schema stayed keyed by the folder's name.
575
- return {
609
+ const entry = {
576
610
  ...meta,
577
611
  name,
578
612
  path: relativePath,
579
613
  }
614
+
615
+ const content = describeContent(meta)
616
+ if (content.elements.length > 0) entry.content = content.elements
617
+ else delete entry.content
618
+
619
+ const children = lowerChildren(meta)
620
+ if (children) entry.children = children
621
+ else delete entry.children
622
+
623
+ if (meta.data !== undefined) entry.data = lowerData(meta.data)
624
+
625
+ const problems = [...content.problems, ...describeChildren(meta).problems]
626
+ if (problems.length > 0) loweringProblems.set(entry, problems)
627
+ return entry
628
+ }
629
+
630
+ /**
631
+ * Refuse a declaration that was retired, naming what replaces it. A refusal, not a
632
+ * warning: a retired key would otherwise register as if it still meant something.
633
+ */
634
+ function refuseRetired(name, meta) {
635
+ if (meta.visuals !== undefined) {
636
+ // ⛔ Retired 2026-09-29: the slot is the `media` element of `content:`.
637
+ const { max, types } = describeVisuals(meta)
638
+ const count = max === null ? '' : ` [0-${max}]`
639
+ const suggestion = types
640
+ ? `media: { label: 'Visual${count}', types: ${JSON.stringify(types).replace(/"/g, "'")} }`
641
+ : `media: 'Visual${count}'`
642
+ throw new Error(
643
+ `[uniweb] ${name} (meta.js): \`visuals\` is retired. Declare the slot in \`content:\` as \`media\` — ` +
644
+ `an image, a video, or an embedded component — here, \`${suggestion}\`.`
645
+ )
646
+ }
647
+ if (meta.content && typeof meta.content === 'object' && meta.content.data !== undefined) {
648
+ // ⛔ Retired 2026-09-29: it named no tag, and declaring it delivered nothing.
649
+ const declared = Object.keys(meta.data && typeof meta.data === 'object' ? meta.data : {})
650
+ throw new Error(
651
+ `[uniweb] ${name} (meta.js): the \`content:\` element \`data\` is retired — it named no tag. ` +
652
+ `Declare the block's tag as a key in \`data:\`: \`'md:<tag>': '<label>'\` for a concept block ` +
653
+ `(\`\`\`md:<tag>), \`<tag>: {}\` for a data block.` +
654
+ (declared.length > 0
655
+ ? ` ${name} already declares \`data: { ${declared.join(', ')} }\`, so remove \`content.data\`.`
656
+ : '')
657
+ )
658
+ }
580
659
  }
581
660
 
582
661
  /**
@@ -932,9 +1011,9 @@ function reportSupports(srcDir, authored, derived, emitted) {
932
1011
  }
933
1012
 
934
1013
  /**
935
- * Warn about `children` and `visuals` declarations an editor cannot use — read with
936
- * `@uniweb/schemas`, the grammar the editor reads them with. The entry is emitted as
937
- * written either way; these are warnings, never refusals.
1014
+ * Warn about `content` and `children` declarations an editor cannot use — what lowering
1015
+ * them could not read (`@uniweb/schemas`, the grammar the editor reads them with). The
1016
+ * entry is emitted lowered either way; these are warnings, never refusals.
938
1017
  *
939
1018
  * ⭐ A `children.types` entry must name one of this foundation's section types. A hidden
940
1019
  * one is not a section type at all, and a child of a type nothing provides renders
@@ -945,9 +1024,15 @@ function reportSupports(srcDir, authored, derived, emitted) {
945
1024
  */
946
1025
  export function reportPlacementDeclarations(components) {
947
1026
  for (const [name, entry] of Object.entries(components)) {
1027
+ // An entry built here was lowered, and kept what it could not read aside; one handed
1028
+ // in as written is read now. (A lowered entry reads as itself, with no problems.)
948
1029
  const children = describeChildren(entry)
949
- const visuals = describeVisuals(entry)
950
- for (const problem of [...children.problems, ...visuals.problems]) {
1030
+ const problems = [
1031
+ ...(loweringProblems.get(entry) || []),
1032
+ ...describeContent(entry).problems,
1033
+ ...children.problems,
1034
+ ]
1035
+ for (const problem of problems) {
951
1036
  console.warn(`Warning: ${name} (meta.js): ${problem}`)
952
1037
  }
953
1038
  for (const type of children.types || []) {
@@ -1082,6 +1167,8 @@ export async function buildSchema(srcDir, sectionPaths, derivedSupports = null)
1082
1167
  ...(foundationConfig.description && { description: foundationConfig.description }),
1083
1168
  ...(foundationConfig.defaultLayout && { defaultLayout: foundationConfig.defaultLayout }),
1084
1169
  ...(isExtension && { role: 'extension' }),
1170
+ // The build's fact, last: which form the entries below hold.
1171
+ schemaFormat: SCHEMA_FORMAT,
1085
1172
  },
1086
1173
  // Layout metadata (full, for editor)
1087
1174
  ...(Object.keys(dataSchemas).length > 0 && { dataSchemas }),
@@ -14,6 +14,7 @@ import { existsSync } from 'node:fs'
14
14
  import { join, basename, extname, dirname } from 'node:path'
15
15
  import { createHash } from 'node:crypto'
16
16
  import sharp from 'sharp'
17
+ import { mimeFor } from '@uniweb/semantic-parser'
17
18
 
18
19
  // Image formats that can be converted to WebP
19
20
  const CONVERTIBLE_FORMATS = ['.png', '.jpg', '.jpeg', '.gif']
@@ -157,10 +158,15 @@ export async function processAsset(asset, options = {}) {
157
158
  * @param {Object} assetManifest - Asset manifest from content collector
158
159
  * @param {Object} options - Processing options
159
160
  * @param {string} [options.basePath='/'] - Site base path for subdirectory deployments
160
- * @returns {Promise<Object>} Mapping of original paths to output URLs
161
+ * @returns {Promise<Object>} `pathMapping` — original paths to output URLs — `results`, and
162
+ * `files`: what the build knows of each file it copied, by original path (`name`, `size`, `mime`)
161
163
  */
162
164
  export async function processAssets(assetManifest, options = {}) {
163
165
  const pathMapping = {}
166
+ // ⭐ A copied file is renamed `{name}-{hash}{ext}`, so its new address no longer names it. A
167
+ // document carries its file's `name`, `mime` and `size` (a file record's field names), and this
168
+ // is the one place that knows all three for a local file — `rewriteContentPaths` stamps them.
169
+ const files = {}
164
170
  const results = {
165
171
  processed: 0,
166
172
  converted: 0,
@@ -175,6 +181,7 @@ export async function processAssets(assetManifest, options = {}) {
175
181
  pathMapping[originalPath] = result.output
176
182
 
177
183
  if (result.processed) {
184
+ files[originalPath] = { name: basename(asset.resolved), size: result.size, mime: mimeFor(result.outputPath) }
178
185
  results.processed++
179
186
  results.totalSize += result.size || 0
180
187
  if (result.converted) {
@@ -185,7 +192,7 @@ export async function processAssets(assetManifest, options = {}) {
185
192
  }
186
193
  }
187
194
 
188
- return { pathMapping, results }
195
+ return { pathMapping, results, files }
189
196
  }
190
197
 
191
198
  /**
@@ -220,9 +227,10 @@ function rewriteDataPaths(data, pathMapping) {
220
227
  *
221
228
  * @param {Object} content - ProseMirror document
222
229
  * @param {Object} pathMapping - Map of original paths to new paths
230
+ * @param {Object} [files] - what the build knows of each file it copied (`processAssets`)
223
231
  * @returns {Object} Content with rewritten paths
224
232
  */
225
- export function rewriteContentPaths(content, pathMapping) {
233
+ export function rewriteContentPaths(content, pathMapping, files = null) {
226
234
  if (!content) return content
227
235
 
228
236
  // Deep clone to avoid mutating original
@@ -231,11 +239,26 @@ export function rewriteContentPaths(content, pathMapping) {
231
239
  function walk(node) {
232
240
  if (!node) return
233
241
 
234
- // Rewrite image src
242
+ // Rewrite image src — and a video's `poster` / a document's `preview`, which
243
+ // the collector processes as assets of their own (`walkContentAssets`). Only
244
+ // `src` was rewritten until 2026-09-29, so an explicit poster was emitted to
245
+ // dist/assets/ while the page still pointed at its source path.
235
246
  if (node.type === 'image' && node.attrs?.src) {
236
- const newPath = pathMapping[node.attrs.src]
237
- if (newPath) {
238
- node.attrs.src = newPath
247
+ const original = node.attrs.src
248
+ for (const attr of ['src', 'poster', 'preview']) {
249
+ const newPath = node.attrs[attr] && pathMapping[node.attrs[attr]]
250
+ if (newPath) {
251
+ node.attrs[attr] = newPath
252
+ }
253
+ }
254
+ // A document whose file the build copied: the file's name as the author gave it — the
255
+ // address now holds a hashed one — its size, and its type (2026-09-29). Only on the built
256
+ // output, never on what an author stored; a value written on the node stays.
257
+ const file = node.attrs.role === 'pdf' ? files?.[original] : null
258
+ if (file) {
259
+ for (const key of ['name', 'mime', 'size']) {
260
+ if (node.attrs[key] === undefined && file[key] !== undefined) node.attrs[key] = file[key]
261
+ }
239
262
  }
240
263
  }
241
264
 
@@ -305,15 +328,16 @@ export function rewriteParamPaths(params, pathMapping) {
305
328
  *
306
329
  * @param {Object} siteContent - Full site content object
307
330
  * @param {Object} pathMapping - Map of original paths to new paths
331
+ * @param {Object} [files] - what the build knows of each file it copied (`processAssets`)
308
332
  * @returns {Object} Site content with rewritten paths
309
333
  */
310
- export function rewriteSiteContentPaths(siteContent, pathMapping) {
334
+ export function rewriteSiteContentPaths(siteContent, pathMapping, files = null) {
311
335
  // Deep clone
312
336
  const result = JSON.parse(JSON.stringify(siteContent))
313
337
 
314
338
  function processSection(section) {
315
339
  if (section.content) {
316
- section.content = rewriteContentPaths(section.content, pathMapping)
340
+ section.content = rewriteContentPaths(section.content, pathMapping, files)
317
341
  }
318
342
  if (section.params) {
319
343
  section.params = rewriteParamPaths(section.params, pathMapping)
@@ -8,7 +8,7 @@
8
8
  * act as implicit imports and should be processed/optimized during build.
9
9
  */
10
10
 
11
- import { join, dirname, isAbsolute, normalize } from 'node:path'
11
+ import { join, dirname, isAbsolute, normalize, relative, sep } from 'node:path'
12
12
  import { existsSync } from 'node:fs'
13
13
 
14
14
  // Image extensions we should process
@@ -107,21 +107,23 @@ export function isMediaFieldReference(value) {
107
107
  * @param {any} data - Parsed JSON/YAML data
108
108
  * @param {Function} visitor - Callback for each asset path: (path) => void
109
109
  */
110
- function walkDataAssets(data, visitor) {
110
+ function walkDataAssets(data, visitor, set) {
111
111
  if (typeof data === 'string') {
112
112
  if (isLocalAssetPath(data)) {
113
- visitor(data)
113
+ visitor(data, set)
114
114
  }
115
115
  return
116
116
  }
117
117
 
118
118
  if (Array.isArray(data)) {
119
- data.forEach(item => walkDataAssets(item, visitor))
119
+ data.forEach((item, i) => walkDataAssets(item, visitor, (v) => { data[i] = v }))
120
120
  return
121
121
  }
122
122
 
123
123
  if (data && typeof data === 'object') {
124
- Object.values(data).forEach(value => walkDataAssets(value, visitor))
124
+ for (const [key, value] of Object.entries(data)) {
125
+ walkDataAssets(value, visitor, (v) => { data[key] = v })
126
+ }
125
127
  }
126
128
  }
127
129
 
@@ -137,7 +139,8 @@ function walkDataBlockAssets(doc, visitor) {
137
139
 
138
140
  // dataBlock nodes have pre-parsed data in attrs.data
139
141
  if (doc.type === 'dataBlock' && doc.attrs?.data) {
140
- walkDataAssets(doc.attrs.data, visitor)
142
+ const attrs = doc.attrs
143
+ walkDataAssets(attrs.data, visitor, (v) => { attrs.data = v })
141
144
  }
142
145
 
143
146
  // Recurse into content
@@ -203,8 +206,9 @@ export function resolveAssetPath(src, contextPath, siteRoot) {
203
206
  * Walk a ProseMirror document and collect all asset references
204
207
  *
205
208
  * @param {Object} doc - ProseMirror document
206
- * @param {Function} visitor - Callback for each asset: (node, path, attrName) => void
207
- * attrName is 'src', 'poster', or 'preview'
209
+ * @param {Function} visitor - Callback for each asset: (node, path, attrName, holder) => void
210
+ * attrName is 'src', 'poster', or 'preview'; `holder` is
211
+ * the attrs object the reference lives on, at `holder[attrName]`
208
212
  * @param {string} [path=''] - Current path in document (for debugging)
209
213
  */
210
214
  export function walkContentAssets(doc, visitor, path = '') {
@@ -212,14 +216,14 @@ export function walkContentAssets(doc, visitor, path = '') {
212
216
 
213
217
  // Check for image nodes
214
218
  if (doc.type === 'image' && doc.attrs?.src) {
215
- visitor(doc, path, 'src')
219
+ visitor(doc, path, 'src', doc.attrs)
216
220
 
217
221
  // Also collect explicit poster/preview attributes as assets
218
222
  if (doc.attrs.poster && !isExternalUrl(doc.attrs.poster)) {
219
- visitor({ type: 'image', attrs: { src: doc.attrs.poster } }, path, 'poster')
223
+ visitor({ type: 'image', attrs: { src: doc.attrs.poster } }, path, 'poster', doc.attrs)
220
224
  }
221
225
  if (doc.attrs.preview && !isExternalUrl(doc.attrs.preview)) {
222
- visitor({ type: 'image', attrs: { src: doc.attrs.preview } }, path, 'preview')
226
+ visitor({ type: 'image', attrs: { src: doc.attrs.preview } }, path, 'preview', doc.attrs)
223
227
  }
224
228
  }
225
229
 
@@ -234,7 +238,7 @@ export function walkContentAssets(doc, visitor, path = '') {
234
238
  if (doc.marks && Array.isArray(doc.marks)) {
235
239
  doc.marks.forEach((mark, index) => {
236
240
  if (mark.attrs?.src) {
237
- visitor(mark, `${path}/marks[${index}]`, 'src')
241
+ visitor(mark, `${path}/marks[${index}]`, 'src', mark.attrs)
238
242
  }
239
243
  })
240
244
  }
@@ -250,42 +254,45 @@ export function walkContentAssets(doc, visitor, path = '') {
250
254
  * - assets: Asset manifest mapping original paths to resolved info
251
255
  * - hasExplicitPoster: Set of video src paths that have explicit poster attributes
252
256
  * - hasExplicitPreview: Set of PDF src paths that have explicit preview attributes
257
+ * - uses: every co-located reference, with the file it resolved to and a setter
258
+ * for the place it is written — what `disambiguateAssetRefs` needs to rename
259
+ * one. In memory only; never part of the site content.
253
260
  */
254
261
  export function collectSectionAssets(section, markdownPath, siteRoot) {
255
262
  const assets = {}
256
263
  const hasExplicitPoster = new Set()
257
264
  const hasExplicitPreview = new Set()
258
-
259
- // Track current image node's src when we encounter poster/preview
260
- let currentImageSrc = null
265
+ const uses = []
266
+
267
+ // One entry per reference. `set` writes a new reference where this one stands;
268
+ // `flags` carries the explicit-poster/preview marks a renamed video/PDF must keep.
269
+ const add = (ref, set, flags = null) => {
270
+ const result = resolveAssetPath(ref, markdownPath, siteRoot)
271
+ if (result.external || !result.resolved) return
272
+ assets[ref] = {
273
+ original: ref,
274
+ resolved: result.resolved,
275
+ isImage: result.isImage,
276
+ isVideo: result.isVideo,
277
+ isPdf: result.isPdf
278
+ }
279
+ // Only a co-located ref depends on WHERE it is written, so only it can name
280
+ // two different files with one string. A site-root ref (`/x.png`) cannot.
281
+ if (!ref.startsWith('/') && set) uses.push({ ref, resolved: result.resolved, set, flags })
282
+ }
261
283
 
262
284
  // Collect from ProseMirror content
263
285
  if (section.content) {
264
- walkContentAssets(section.content, (node, path, attrName) => {
265
- const result = resolveAssetPath(node.attrs.src, markdownPath, siteRoot)
266
-
286
+ walkContentAssets(section.content, (node, path, attrName, holder) => {
287
+ const ref = node.attrs.src
288
+ let flags = null
267
289
  if (attrName === 'src') {
268
- // Main src attribute - track it for potential poster/preview
269
- currentImageSrc = node.attrs.src
270
-
271
290
  // Check if this image has explicit poster/preview
272
- if (node.attrs.poster) {
273
- hasExplicitPoster.add(node.attrs.src)
274
- }
275
- if (node.attrs.preview) {
276
- hasExplicitPreview.add(node.attrs.src)
277
- }
278
- }
279
-
280
- if (!result.external && result.resolved) {
281
- assets[node.attrs.src] = {
282
- original: node.attrs.src,
283
- resolved: result.resolved,
284
- isImage: result.isImage,
285
- isVideo: result.isVideo,
286
- isPdf: result.isPdf
287
- }
291
+ if (node.attrs.poster) hasExplicitPoster.add(ref)
292
+ if (node.attrs.preview) hasExplicitPreview.add(ref)
293
+ flags = { poster: !!node.attrs.poster, preview: !!node.attrs.preview }
288
294
  }
295
+ add(ref, holder ? (v) => { holder[attrName] = v } : null, flags)
289
296
  })
290
297
  }
291
298
 
@@ -301,16 +308,7 @@ export function collectSectionAssets(section, markdownPath, siteRoot) {
301
308
  // Only resolve values that are actually asset references. A dual-use field
302
309
  // like `background: gray` (a CSS color) must not be treated as a file path.
303
310
  if (isMediaFieldReference(value)) {
304
- const result = resolveAssetPath(value, markdownPath, siteRoot)
305
- if (!result.external && result.resolved) {
306
- assets[value] = {
307
- original: value,
308
- resolved: result.resolved,
309
- isImage: result.isImage,
310
- isVideo: result.isVideo,
311
- isPdf: result.isPdf
312
- }
313
- }
311
+ add(value, (v) => { section.params[field] = v })
314
312
  }
315
313
  }
316
314
 
@@ -318,40 +316,66 @@ export function collectSectionAssets(section, markdownPath, siteRoot) {
318
316
  // Background can be { image: { src }, video: { src } } with nested asset paths
319
317
  const bg = section.params?.background
320
318
  if (bg && typeof bg === 'object') {
321
- const bgSources = [bg.image?.src, bg.video?.src].filter(Boolean)
322
- for (const src of bgSources) {
323
- if (typeof src === 'string') {
324
- const result = resolveAssetPath(src, markdownPath, siteRoot)
325
- if (!result.external && result.resolved) {
326
- assets[src] = {
327
- original: src,
328
- resolved: result.resolved,
329
- isImage: result.isImage,
330
- isVideo: result.isVideo,
331
- isPdf: result.isPdf
332
- }
333
- }
334
- }
319
+ for (const media of [bg.image, bg.video]) {
320
+ if (typeof media?.src === 'string') add(media.src, (v) => { media.src = v })
335
321
  }
336
322
  }
337
323
 
338
324
  // Collect from tagged code blocks (JSON/YAML data)
339
325
  if (section.content) {
340
- walkDataBlockAssets(section.content, (assetPath) => {
341
- const result = resolveAssetPath(assetPath, markdownPath, siteRoot)
342
- if (!result.external && result.resolved) {
343
- assets[assetPath] = {
344
- original: assetPath,
345
- resolved: result.resolved,
346
- isImage: result.isImage,
347
- isVideo: result.isVideo,
348
- isPdf: result.isPdf
349
- }
350
- }
351
- })
326
+ walkDataBlockAssets(section.content, (assetPath, set) => add(assetPath, set))
327
+ }
328
+
329
+ return { assets, hasExplicitPoster, hasExplicitPreview, uses }
330
+ }
331
+
332
+ /**
333
+ * Give every co-located reference that names more than one file its own key.
334
+ *
335
+ * The manifest is keyed by the reference AS WRITTEN, site-wide, and a co-located
336
+ * ref means a different file in every folder — so two pages that both wrote
337
+ * `./media/shot.png` shared one entry: the last one collected won, and both pages
338
+ * rendered its image. Each colliding use is renamed to its file's path from the
339
+ * site root (`./pages/alpha/media/shot.png`, or `../…` for a mounted folder
340
+ * outside it), which is one key per file by construction and keeps the extension
341
+ * the later steps classify by. A ref that names one file keeps its key, so a
342
+ * site without a collision is unchanged.
343
+ *
344
+ * Call it on the merged page + layout collection, before the site config's assets
345
+ * (which have no written place to rename) are merged in.
346
+ *
347
+ * @param {Object} collection - merged asset collection (mutated in place)
348
+ * @param {string} siteRoot - Site root directory
349
+ * @returns {number} how many references were renamed
350
+ */
351
+ export function disambiguateAssetRefs(collection, siteRoot) {
352
+ const uses = collection.uses || []
353
+ const filesByRef = new Map()
354
+ for (const use of uses) {
355
+ if (!filesByRef.has(use.ref)) filesByRef.set(use.ref, new Set())
356
+ filesByRef.get(use.ref).add(use.resolved)
352
357
  }
353
358
 
354
- return { assets, hasExplicitPoster, hasExplicitPreview }
359
+ let renamed = 0
360
+ for (const use of uses) {
361
+ if (filesByRef.get(use.ref).size < 2) continue
362
+ const fromRoot = relative(siteRoot, use.resolved).split(sep).join('/')
363
+ const key = fromRoot.startsWith('../') ? fromRoot : `./${fromRoot}`
364
+ const entry = collection.assets[use.ref]
365
+ collection.assets[key] = { ...entry, original: key, resolved: use.resolved }
366
+ if (use.flags?.poster) collection.hasExplicitPoster?.add(key)
367
+ if (use.flags?.preview) collection.hasExplicitPreview?.add(key)
368
+ use.set(key)
369
+ renamed++
370
+ }
371
+ // The shared key no longer names anything written in the content.
372
+ for (const [ref, files] of filesByRef) {
373
+ if (files.size < 2) continue
374
+ delete collection.assets[ref]
375
+ collection.hasExplicitPoster?.delete(ref)
376
+ collection.hasExplicitPreview?.delete(ref)
377
+ }
378
+ return renamed
355
379
  }
356
380
 
357
381
  /**
@@ -426,13 +450,15 @@ export function mergeAssetCollections(...collections) {
426
450
  const merged = {
427
451
  assets: {},
428
452
  hasExplicitPoster: new Set(),
429
- hasExplicitPreview: new Set()
453
+ hasExplicitPreview: new Set(),
454
+ uses: []
430
455
  }
431
456
 
432
457
  for (const collection of collections) {
433
458
  // Handle both old format (plain object) and new format (with sets)
434
459
  if (collection.assets) {
435
460
  Object.assign(merged.assets, collection.assets)
461
+ if (collection.uses) merged.uses.push(...collection.uses)
436
462
  collection.hasExplicitPoster?.forEach(p => merged.hasExplicitPoster.add(p))
437
463
  collection.hasExplicitPreview?.forEach(p => merged.hasExplicitPreview.add(p))
438
464
  } else {
@@ -173,7 +173,7 @@ export async function buildSiteData({
173
173
  if (assetsOpts.process && siteContent?.assets) {
174
174
  const assetCount = Object.keys(siteContent.assets).length
175
175
  if (assetCount > 0) {
176
- const { pathMapping } = await processAssets(siteContent.assets, {
176
+ const { pathMapping, files } = await processAssets(siteContent.assets, {
177
177
  outputDir: resolvedDistDir,
178
178
  assetsSubdir: assetsOpts.outputDir,
179
179
  convertToWebp: assetsOpts.convertToWebp,
@@ -197,7 +197,7 @@ export async function buildSiteData({
197
197
  }
198
198
  )
199
199
 
200
- finalContent = rewriteSiteContentPaths(siteContent, pathMapping)
200
+ finalContent = rewriteSiteContentPaths(siteContent, pathMapping, files)
201
201
  if (Object.keys(posterMapping).length > 0 || Object.keys(thumbnailMapping).length > 0) {
202
202
  finalContent._assetMeta = {
203
203
  posters: posterMapping,
@@ -205,7 +205,7 @@ export async function buildSiteData({
205
205
  }
206
206
  }
207
207
  } else {
208
- finalContent = rewriteSiteContentPaths(siteContent, pathMapping)
208
+ finalContent = rewriteSiteContentPaths(siteContent, pathMapping, files)
209
209
  }
210
210
  }
211
211
  } else {
@@ -31,7 +31,7 @@ import { join, parse, relative, resolve, sep } from 'node:path'
31
31
  import { existsSync, statSync, realpathSync, readdirSync } from 'node:fs'
32
32
  import yaml from 'js-yaml'
33
33
  import { YAML_OPTIONS } from '../utils/yaml-schema.js'
34
- import { collectSectionAssets, mergeAssetCollections, collectConfigAssets } from './assets.js'
34
+ import { collectSectionAssets, mergeAssetCollections, collectConfigAssets, disambiguateAssetRefs } from './assets.js'
35
35
  import { collectSectionIcons, mergeIconCollections, buildIconManifest } from './icons.js'
36
36
  import { normalizeHideIn, dropUnpublishedPages, dropHiddenSections } from './nav-visibility.js'
37
37
  import { parseFetchConfig, toFetchList } from './data-fetcher.js'
@@ -2592,6 +2592,12 @@ export async function collectSiteContent(sitePath, options = {}) {
2592
2592
  assetCollection = mergeAssetCollections(assetCollection, layoutAssets)
2593
2593
  iconCollection = mergeIconCollections(iconCollection, layoutIcons)
2594
2594
 
2595
+ // One manifest key per file: a co-located ref written the same way in two
2596
+ // folders (`./media/shot.png`) names two files, and would otherwise share one
2597
+ // entry — every page rendering the last one collected. Before the config
2598
+ // assets, which have no written place to rename.
2599
+ disambiguateAssetRefs(assetCollection, sitePath)
2600
+
2595
2601
  // Merge top-level config assets (e.g. document.yml's book.covers.front,
2596
2602
  // banner images, logos) into the manifest. The compile pipeline reads
2597
2603
  // website.assets[<original src>] to resolve these to filesystem paths
@@ -17,63 +17,16 @@
17
17
 
18
18
  import { statSync } from 'node:fs'
19
19
  import { extname } from 'node:path'
20
+ import { mimeFor } from '@uniweb/semantic-parser'
20
21
  import { generatePdfThumbnail } from './advanced-processors.js'
21
22
 
22
23
  export const FILE_MODEL = '@uniweb/file'
23
24
 
24
25
  // What a file is, by its extension — for the `mime` a file record carries and the type its upload
25
- // declares. Anything not here is `application/octet-stream`.
26
- const MIME = {
27
- pdf: 'application/pdf',
28
- doc: 'application/msword',
29
- docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
30
- xls: 'application/vnd.ms-excel',
31
- xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
32
- ppt: 'application/vnd.ms-powerpoint',
33
- pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
34
- odt: 'application/vnd.oasis.opendocument.text',
35
- ods: 'application/vnd.oasis.opendocument.spreadsheet',
36
- odp: 'application/vnd.oasis.opendocument.presentation',
37
- rtf: 'application/rtf',
38
- epub: 'application/epub+zip',
39
- zip: 'application/zip',
40
- gz: 'application/gzip',
41
- tar: 'application/x-tar',
42
- csv: 'text/csv',
43
- tsv: 'text/tab-separated-values',
44
- txt: 'text/plain',
45
- md: 'text/markdown',
46
- json: 'application/json',
47
- xml: 'application/xml',
48
- yml: 'application/yaml',
49
- yaml: 'application/yaml',
50
- html: 'text/html',
51
- png: 'image/png',
52
- jpg: 'image/jpeg',
53
- jpeg: 'image/jpeg',
54
- gif: 'image/gif',
55
- svg: 'image/svg+xml',
56
- webp: 'image/webp',
57
- avif: 'image/avif',
58
- mp3: 'audio/mpeg',
59
- wav: 'audio/wav',
60
- ogg: 'audio/ogg',
61
- m4a: 'audio/mp4',
62
- mp4: 'video/mp4',
63
- webm: 'video/webm',
64
- mov: 'video/quicktime',
65
- }
66
-
67
- /**
68
- * The media type of a file, by its extension.
69
- *
70
- * @param {string} name - a file name or path
71
- * @returns {string}
72
- */
73
- export function mimeFor(name) {
74
- const ext = extname(String(name || '')).slice(1).toLowerCase()
75
- return MIME[ext] || 'application/octet-stream'
76
- }
26
+ // declares. ⭐ The table is `@uniweb/semantic-parser`'s: the parser reads it for a document's `mime`,
27
+ // and could not reach one here — it has no dependencies. One table; anything not in it is
28
+ // `application/octet-stream`.
29
+ export { mimeFor }
77
30
 
78
31
  /** Is this file a PDF — the one type a preview is made for? */
79
32
  export function isPdfFile(name) {
@@ -1496,7 +1496,7 @@ export function siteContentPlugin(options = {}) {
1496
1496
  console.log(`[site-content] Processing ${assetCount} assets...`)
1497
1497
 
1498
1498
  // Process standard assets (images)
1499
- const { pathMapping, results } = await processAssets(siteContent.assets, {
1499
+ const { pathMapping, results, files } = await processAssets(siteContent.assets, {
1500
1500
  outputDir: resolvedOutDir,
1501
1501
  assetsSubdir: assetsOptions.outputDir,
1502
1502
  convertToWebp: assetsOptions.convertToWebp,
@@ -1542,7 +1542,7 @@ export function siteContentPlugin(options = {}) {
1542
1542
  // Merge poster and thumbnail mappings into the path mapping
1543
1543
  // Videos: add a .poster property to the content (not replace the src)
1544
1544
  // PDFs: add a .thumbnail property to the content
1545
- finalContent = rewriteSiteContentPaths(siteContent, pathMapping)
1545
+ finalContent = rewriteSiteContentPaths(siteContent, pathMapping, files)
1546
1546
 
1547
1547
  // Add poster and thumbnail metadata to the site content
1548
1548
  if (Object.keys(posterMapping).length > 0 || Object.keys(thumbnailMapping).length > 0) {
@@ -1553,7 +1553,7 @@ export function siteContentPlugin(options = {}) {
1553
1553
  }
1554
1554
  } else {
1555
1555
  // Rewrite paths in content
1556
- finalContent = rewriteSiteContentPaths(siteContent, pathMapping)
1556
+ finalContent = rewriteSiteContentPaths(siteContent, pathMapping, files)
1557
1557
  }
1558
1558
 
1559
1559
  // Log results