@uniweb/build 0.73.0 → 0.73.2

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.
@@ -0,0 +1,109 @@
1
+ // ⭐ A FILE RECORD — `@uniweb/file` (2026-09-28 [Diego]). The file itself is the record: any file
2
+ // placed in `<records>/uniweb/file/` (`entity-pool.js::FILE_RECORD_DIRS`) is a record named by its
3
+ // stem, and its label and tags are its `folder.yml` entry's.
4
+ //
5
+ // ⭐ ITS VALUE IS AN ASSET, the same object on every lane:
6
+ //
7
+ // { url, name, mime, size, preview? }
8
+ //
9
+ // — `url` where the file is served, `name` its file name, `mime` and `size` from the file, and for a
10
+ // PDF a `preview` image. Uploaded to a host, the asset's identity rides beside each URL as media's
11
+ // does (`assetId`/`assetExt`, `previewAssetId`/`previewAssetExt` — `@uniweb/semantic-parser`'s
12
+ // `ASSET_SLOTS`), so a host that moves its assets costs a config edit, not a migration. A component
13
+ // reads `file.url` wherever it renders.
14
+ //
15
+ // ⚠️ A PDF's preview is a PLACEHOLDER card ("PDF · N pages", `advanced-processors.js::
16
+ // generatePdfThumbnail`), not a render of the page — and none at all without `pdf-lib`.
17
+
18
+ import { statSync } from 'node:fs'
19
+ import { extname } from 'node:path'
20
+ import { generatePdfThumbnail } from './advanced-processors.js'
21
+
22
+ export const FILE_MODEL = '@uniweb/file'
23
+
24
+ // 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
+ }
77
+
78
+ /** Is this file a PDF — the one type a preview is made for? */
79
+ export function isPdfFile(name) {
80
+ return extname(String(name || '')).toLowerCase() === '.pdf'
81
+ }
82
+
83
+ /**
84
+ * A file record's value: `{ url, name, mime, size }`, with `preview` when one is given.
85
+ *
86
+ * @param {object} params
87
+ * @param {string} params.absPath - the file
88
+ * @param {string} params.name - its file name (`brochure.pdf`)
89
+ * @param {string} params.url - where it is served, or the key a push rewrites to that
90
+ * @param {string} [params.preview] - where its preview is served, or the key a push rewrites
91
+ * @returns {{ url: string, name: string, mime: string, size: number, preview?: string }}
92
+ */
93
+ export function fileRecordValue({ absPath, name, url, preview }) {
94
+ const value = { url, name, mime: mimeFor(name), size: statSync(absPath).size }
95
+ if (preview) value.preview = preview
96
+ return value
97
+ }
98
+
99
+ /**
100
+ * Make a PDF's preview image at `outputPath` — a placeholder card, not a render of the page — or
101
+ * report that none was made (not a PDF, or `pdf-lib` not installed).
102
+ *
103
+ * @returns {Promise<boolean>} whether a preview now exists at `outputPath`
104
+ */
105
+ export async function makeFilePreview(absPath, outputPath) {
106
+ if (!isPdfFile(absPath)) return false
107
+ const result = await generatePdfThumbnail(absPath, outputPath)
108
+ return Boolean(result?.success)
109
+ }
@@ -31,6 +31,7 @@
31
31
  import { join } from 'node:path'
32
32
  import { existsSync, readFileSync } from 'node:fs'
33
33
  import { recordLayout } from '@uniweb/schemas/conform'
34
+ import { isSystemRecordRef } from '@uniweb/schemas/system'
34
35
  import { detectFoundationType, parseCatalogRef } from './foundation-ref.js'
35
36
  import { readRegisteredFoundation } from './registered-foundation.js'
36
37
  import { buildDataSchemaMap, SCHEMA_NOT_FOUND } from '../resolve-data-schema.js'
@@ -348,8 +349,8 @@ export function toConfigQueries(declarations) {
348
349
  * A `@/name` ref the foundation defines no schema for is absent without a word: a query
349
350
  * is named after no schema when its records have none. Any other ref that does not
350
351
  * resolve is absent too, and reported in `failures`. With no local foundation only
351
- * `@std/*` refs resolve — from the build's own copy of the standard schemas — since
352
- * nothing else is on disk.
352
+ * `@std/*` refs resolve — from the build's own copy of the standard schemas — and the
353
+ * system Models a record may name (`@uniweb/link`), since nothing else is on disk.
353
354
  *
354
355
  * @param {string} siteRoot
355
356
  * @param {Iterable<string>} refs - the `schema:` refs of the site's queries
@@ -364,7 +365,7 @@ export async function resolveRecordSchemas(siteRoot, refs, opts = {}) {
364
365
  const siteYml = opts.siteYml || (await readYamlFile(join(siteRoot, 'site.yml')))
365
366
  const srcDir = localFoundationSrcDir(siteRoot, siteYml)
366
367
  for (const ref of wanted) {
367
- if (!srcDir && !ref.startsWith('@std/')) continue
368
+ if (!srcDir && !ref.startsWith('@std/') && !isSystemRecordRef(ref)) continue
368
369
  try {
369
370
  Object.assign(schemas, await buildDataSchemaMap([ref], { srcDir: srcDir ?? siteRoot }))
370
371
  } catch (err) {
@@ -70,6 +70,8 @@ import { dataKeyTypes } from '../uwx/data-key-types.js'
70
70
  import { parseFrontmatter } from '../utils/frontmatter.js'
71
71
  import { toDeliveredRecord, toStoredRecord, storedPath, briefFieldMap, contentBodyField, misplacedFields, mapReferences, recordLayout } from '@uniweb/schemas/conform'
72
72
  import { collectNestedRefs } from '@uniweb/schemas/format'
73
+ import { labelIn } from './entry-label.js'
74
+ import { fileRecordValue, makeFilePreview } from './file-records.js'
73
75
 
74
76
  // Try to import content-reader for markdown parsing
75
77
  let markdownToProseMirror
@@ -824,6 +826,8 @@ function warnDuplicateSlugs(items, queryName) {
824
826
  function readPooledRecords(pooled, config, siteDir, recordsRoot, basePath) {
825
827
  return Promise.all(
826
828
  pooled.map((e) => {
829
+ // ⭐ The file itself is the record (`file-records.js`) — nothing in it is read.
830
+ if (e.fileRecord) return processFileRecord(e, config, siteDir, recordsRoot, basePath)
827
831
  const dir = resolve(recordsRoot, ...e.dirs)
828
832
  const file = `${e.slug}${e.ext}`
829
833
  if (e.ext === '.bib') {
@@ -840,6 +844,18 @@ function readPooledRecords(pooled, config, siteDir, recordsRoot, basePath) {
840
844
  )
841
845
  }
842
846
 
847
+ /**
848
+ * A file record — `@uniweb/file` (`file-records.js`): the file published under `public/records/`
849
+ * as a record's co-located asset is (`publishRecordAsset`), a PDF's placeholder preview beside it,
850
+ * and the record delivered as `{ file: { url, name, mime, size, preview? } }`.
851
+ */
852
+ async function processFileRecord(e, config, siteDir, recordsRoot, basePath) {
853
+ const { url, copied } = await publishRecordAsset(e.absPath, siteDir, recordsRoot, basePath)
854
+ const preview = (await makeFilePreview(e.absPath, `${copied}.preview.webp`)) ? `${url}.preview.webp` : undefined
855
+ const record = { slug: e.slug, file: fileRecordValue({ absPath: e.absPath, name: e.file, url, preview }) }
856
+ return deliverRecord(record, config, e.relPath)
857
+ }
858
+
843
859
  /**
844
860
  * References, delivered as a host's records service delivers them — measured 2026-09-24
845
861
  * on a local backend: `{ entity, brief }`, the record a reference names reduced to its
@@ -933,12 +949,15 @@ function pickBrief(record, keys) {
933
949
  }
934
950
 
935
951
  // What a record's folder entry says about it, as the keys a record is answered with — `$tags`
936
- // (in the order written) and `$label` — or null when it says nothing.
937
- function entryKeys(placed) {
952
+ // (in the order written) and `$label` — or null when it says nothing. A label given per language
953
+ // answers in the language compiled (`entry-label.js::labelIn`); the site's other languages get
954
+ // theirs where their files are written (`i18n/records.js`).
955
+ function entryKeys(placed, locale = null) {
938
956
  if (!placed) return null
939
957
  const out = {}
940
958
  if (Array.isArray(placed.tags) && placed.tags.length) out.$tags = [...placed.tags]
941
- if (typeof placed.label === 'string' && placed.label) out.$label = placed.label
959
+ const label = labelIn(placed.label, locale, locale)
960
+ if (label) out.$label = label
942
961
  return Object.keys(out).length ? out : null
943
962
  }
944
963
 
@@ -1001,7 +1020,7 @@ async function collectItems(siteDir, config, recordsRoot, basePath, locale = nul
1001
1020
  // answers them (ruled 2026-09-27 [Diego]). Absent when the entry says none.
1002
1021
  items = items.map((result, i) => {
1003
1022
  const placed = pooled[i] ? config.placements?.get(pooled[i].id) : null
1004
- const said = entryKeys(placed)
1023
+ const said = entryKeys(placed, locale)
1005
1024
  const at = (item) => {
1006
1025
  if (!item) return item
1007
1026
  const out = { ...item, ...said, [BRANCH_KEY]: placed?.path ?? '' }
@@ -47,6 +47,11 @@ import yaml from 'js-yaml'
47
47
  import { YAML_OPTIONS } from '../utils/yaml-schema.js'
48
48
  import { compareByNumericPrefix } from '../utils/numeric-prefix.js'
49
49
  import { RECORDS_DIR, FOLDER_YML, resolveRecordsDir } from './entity-pool.js'
50
+ import { readLabel } from './entry-label.js'
51
+
52
+ // What a label may be, for every message that refuses one.
53
+ const LABEL_FORMS =
54
+ "A label is the entry's display text: one text, or one per language (`label: { en: …, fr: … }`)."
50
55
 
51
56
  export { FOLDER_YML }
52
57
 
@@ -276,13 +281,15 @@ export function resolveFolder(entries, pool, { dir = RECORDS_DIR } = {}) {
276
281
  }
277
282
  if (tags.length) said.tags = tags
278
283
  }
284
+ // ⭐ One text — the site's default language — or one text per language, `{ en: …, fr: … }`
285
+ // (`entry-label.js`, 2026-09-28).
279
286
  if (entry.label !== undefined && entry.label !== null) {
280
- if (typeof entry.label !== 'string' && typeof entry.label !== 'number') {
281
- errors.push(`${file}: ${where} has a \`label:\` that is not text. A label is the entry's display text.`)
287
+ const read = readLabel(entry.label)
288
+ if (read.error) {
289
+ errors.push(`${file}: ${where} has a \`label:\` that ${read.error}. ${LABEL_FORMS}`)
282
290
  return null
283
291
  }
284
- const label = String(entry.label).trim()
285
- if (label) said.label = label
292
+ if (read.label !== null) said.label = read.label
286
293
  }
287
294
  return said
288
295
  }
@@ -371,7 +378,14 @@ export function resolveFolder(entries, pool, { dir = RECORDS_DIR } = {}) {
371
378
  }
372
379
  folderAt.set(at, where)
373
380
  const branch = { kind: 'branch', name: segment }
374
- if (entry.label !== undefined && entry.label !== null) branch.label = String(entry.label)
381
+ if (entry.label !== undefined && entry.label !== null) {
382
+ const read = readLabel(entry.label)
383
+ if (read.error) {
384
+ errors.push(`${file}: folder "${segment}" (${where}) has a \`label:\` that ${read.error}. ${LABEL_FORMS}`)
385
+ return []
386
+ }
387
+ if (read.label !== null) branch.label = read.label
388
+ }
375
389
  // A folder takes `tags:` as a record's entry does — kept, sent and pulled back, though
376
390
  // no question answers a folder.
377
391
  if (entry.tags !== undefined && entry.tags !== null) {
@@ -73,7 +73,8 @@ export async function readEntityFile(filepath) {
73
73
  async function readOneFile(filepath) {
74
74
  const ext = extname(filepath).toLowerCase()
75
75
  const format = formatFor(ext)
76
- const slugFromName = basename(filepath, ext)
76
+ // Cut as written, as the pool names it (`site/entity-pool.js`) — `ext` is lowercased.
77
+ const slugFromName = basename(filepath, extname(filepath))
77
78
  const raw = await readFile(filepath, 'utf-8')
78
79
 
79
80
  if (format === 'md') {
@@ -0,0 +1,178 @@
1
+ // ⭐ A FILE RECORD IS A FOLDER ENTRY OF KIND `file`, NEVER AN ENTITY — `@uniweb/file` (2026-09-28).
2
+ //
3
+ // On the file side the file itself is the record (`site/file-records.js`): any file in
4
+ // `records/uniweb/file/`, named by its stem, its label and tags on its `folder.yml` entry. On the wire
5
+ // a backend holds it as an item of the site's `@uniweb/folder`:
6
+ //
7
+ // { kind: 'file', name, file: { url, name, mime, size, preview?, assetId, assetExt, … }, label?, tags?, $uuid? }
8
+ //
9
+ // — its value an ASSET: the file goes up through the asset lane the site's media use (owned by the
10
+ // site), and the push writes the serve URL and the asset's identity where the upload key stood,
11
+ // exactly as it does for media (`sync-package.js::rewriteEntityAssets`).
12
+ //
13
+ // ⭐ ITS IDENTITY is the entry's `$uuid`. A file cannot carry it, so the CLI banks it per backend in
14
+ // `sync.json`, keyed by the file's path in the records directory (`files`): a renamed file is a new
15
+ // record, as a renamed record file's name is a new name.
16
+
17
+ import { existsSync, mkdirSync } from 'node:fs'
18
+ import { dirname, join } from 'node:path'
19
+ import { labelOnWire } from '../site/entry-label.js'
20
+ import { FILE_RECORD_DIRS } from '../site/entity-pool.js'
21
+ import { fileRecordValue, makeFilePreview, mimeFor } from '../site/file-records.js'
22
+
23
+ export { FILE_MODEL } from '../site/file-records.js'
24
+
25
+ /** The key a push writes where a file record's URL goes, until its upload maps it to the serve URL. */
26
+ export const fileUploadKey = (poolPath) => `records/${poolPath}`
27
+ /** …and where its preview's goes. */
28
+ export const previewUploadKey = (poolPath) => `records/${poolPath}.preview.webp`
29
+
30
+ /**
31
+ * Each file record's value as a push first writes it — upload keys where the URLs go — and the files
32
+ * the push uploads for them: the file, and a PDF's preview made into `.uniweb/file-previews/`.
33
+ *
34
+ * @param {object} params
35
+ * @param {Array<{ poolPath: string, name: string, absPath: string }>} params.files
36
+ * @param {string} params.siteRoot
37
+ * @returns {Promise<{ values: Map<string, object>, uploads: Array<{ ref: string, path: string, contentType: string }> }>}
38
+ * `values` by pool path
39
+ */
40
+ export async function fileRecordUploads({ files = [], siteRoot }) {
41
+ const values = new Map()
42
+ const uploads = []
43
+ for (const f of files) {
44
+ const previewPath = join(siteRoot, '.uniweb', 'file-previews', `${f.poolPath}.preview.webp`)
45
+ mkdirSync(dirname(previewPath), { recursive: true })
46
+ const hasPreview = (await makeFilePreview(f.absPath, previewPath)) && existsSync(previewPath)
47
+ values.set(
48
+ f.poolPath,
49
+ fileRecordValue({
50
+ absPath: f.absPath,
51
+ name: f.name,
52
+ url: fileUploadKey(f.poolPath),
53
+ ...(hasPreview ? { preview: previewUploadKey(f.poolPath) } : {}),
54
+ })
55
+ )
56
+ uploads.push({ ref: fileUploadKey(f.poolPath), path: f.absPath, contentType: mimeFor(f.name) })
57
+ if (hasPreview) uploads.push({ ref: previewUploadKey(f.poolPath), path: previewPath, contentType: 'image/webp' })
58
+ }
59
+ return { values, uploads }
60
+ }
61
+
62
+ /**
63
+ * A file entry as the wire carries it.
64
+ *
65
+ * @param {{ slug: string, uuid?: string|null, value: object }} file
66
+ * @param {{ label?: *, tags?: string[] }} node - its placement
67
+ * @param {string} sourceLocale
68
+ */
69
+ export function fileLeaf(file, node, sourceLocale) {
70
+ const leaf = { kind: 'file', name: file.slug, file: { ...file.value } }
71
+ if (file.uuid) leaf.$uuid = file.uuid
72
+ if (node?.label !== undefined) leaf.label = labelOnWire(node.label, sourceLocale)
73
+ if (Array.isArray(node?.tags) && node.tags.length) leaf.tags = [...node.tags]
74
+ return leaf
75
+ }
76
+
77
+ /**
78
+ * Every file entry in a folder document.
79
+ *
80
+ * @param {object} folderDoc - `{ contents }`, nesting through `$children`
81
+ * @returns {object[]}
82
+ */
83
+ export function fileItems(folderDoc) {
84
+ const out = []
85
+ const walk = (nodes) => {
86
+ for (const node of nodes || []) {
87
+ if (!node || typeof node !== 'object') continue
88
+ if (node.kind === 'branch') walk(node.$children)
89
+ else if (node.kind === 'file') out.push(node)
90
+ }
91
+ }
92
+ walk(folderDoc?.contents)
93
+ return out
94
+ }
95
+
96
+ /**
97
+ * After a push: the uuid the backend holds each file entry under, by the file's path — what the CLI
98
+ * banks in `sync.json` (`files`). Matched by name: a file record's name is its file's stem, and no
99
+ * two share one.
100
+ *
101
+ * @param {object} params
102
+ * @param {Array<{ slug: string, poolPath: string }>} params.files
103
+ * @param {object} params.folderDoc - the folder document the backend returned
104
+ * @returns {Object<string,string>} pool path → the backend's uuid
105
+ */
106
+ export function bankedFileUuids({ files = [], folderDoc }) {
107
+ const bySlug = new Map(files.map((f) => [f.slug, f]))
108
+ const out = {}
109
+ for (const item of fileItems(folderDoc)) {
110
+ const file = bySlug.get(item.name)
111
+ if (file && typeof item.$uuid === 'string' && item.$uuid) out[file.poolPath] = item.$uuid
112
+ }
113
+ return out
114
+ }
115
+
116
+ /**
117
+ * A pull: where each file entry of the folder lands in `records/uniweb/file/`, and which files have
118
+ * to be fetched — a file the project already holds under that entry's identity is kept, one whose
119
+ * asset has changed is fetched again, and one it does not hold is fetched to `<its name>`.
120
+ *
121
+ * ⛔ A FILE OF THAT NAME HOLDING ANOTHER RECORD IS NOT WRITTEN OVER — reported, and left out.
122
+ *
123
+ * @param {object} params
124
+ * @param {object} params.folderDoc
125
+ * @param {string} params.recordsRoot - the records directory, absolute
126
+ * @param {Object<string,string>} [params.fileMap] - pool path → the backend's uuid (`sync.json` `files`)
127
+ * @param {Object<string,{ id: string }>} [params.assetMap] - upload key → the asset it last uploaded as
128
+ * @returns {{ pathByUuid: Map<string,string>, downloads: Array<{ url: string, path: string, poolPath: string, ref: string, assetId?: string, assetExt?: string }>, learned: Object<string,string>, warnings: string[] }}
129
+ * a download's `ref` is the key its asset is recorded under (`sync.json` `assets`), as a push records it
130
+ */
131
+ export function filesToPull({ folderDoc, recordsRoot, fileMap = {}, assetMap = {} }) {
132
+ const pathByTheirs = new Map(Object.entries(fileMap).map(([poolPath, theirs]) => [theirs, poolPath]))
133
+ const pathByUuid = new Map()
134
+ const downloads = []
135
+ const learned = {}
136
+ const warnings = []
137
+ for (const item of fileItems(folderDoc)) {
138
+ const theirs = typeof item.$uuid === 'string' && item.$uuid ? item.$uuid : null
139
+ const url = typeof item.file?.url === 'string' ? item.file.url : null
140
+ const fileName = fileNameFor(item)
141
+ if (!theirs || !url || !fileName) {
142
+ warnings.push(`the folder holds a file ("${item.name ?? '?'}") with no identity, url or name — not written.`)
143
+ continue
144
+ }
145
+ let poolPath = pathByTheirs.get(theirs) || null
146
+ const held = poolPath && existsSync(join(recordsRoot, ...poolPath.split('/')))
147
+ if (!held) {
148
+ poolPath = [...FILE_RECORD_DIRS, fileName].join('/')
149
+ const at = join(recordsRoot, ...poolPath.split('/'))
150
+ if (existsSync(at) && fileMap[poolPath] !== theirs) {
151
+ warnings.push(`${poolPath} holds another file than the folder's "${item.name}" — it was not written over.`)
152
+ continue
153
+ }
154
+ }
155
+ const path = join(recordsRoot, ...poolPath.split('/'))
156
+ const current = assetMap[fileUploadKey(poolPath)]?.id
157
+ if (!held || (item.file.assetId && current !== item.file.assetId)) {
158
+ downloads.push({
159
+ url,
160
+ path,
161
+ poolPath,
162
+ ref: fileUploadKey(poolPath),
163
+ ...(item.file.assetId ? { assetId: item.file.assetId, assetExt: item.file.assetExt || '' } : {}),
164
+ })
165
+ }
166
+ pathByUuid.set(theirs, poolPath)
167
+ learned[poolPath] = theirs
168
+ }
169
+ return { pathByUuid, downloads, learned, warnings }
170
+ }
171
+
172
+ /** The name a pulled file is written under: its value's `name`, else the entry's name and the asset's extension. */
173
+ function fileNameFor(item) {
174
+ const named = typeof item.file?.name === 'string' ? item.file.name.trim() : ''
175
+ if (named && !named.includes('/') && !named.includes('\\') && !named.startsWith('.')) return named
176
+ const ext = typeof item.file?.assetExt === 'string' && item.file.assetExt ? `.${item.file.assetExt}` : ''
177
+ return typeof item.name === 'string' && item.name ? `${item.name}${ext}` : null
178
+ }
package/src/uwx/folder.js CHANGED
@@ -50,6 +50,10 @@
50
50
  // is keyed by the site's uuid on that backend, from `sync.json`). The framework
51
51
  // never holds a folder uuid.
52
52
 
53
+ import { labelOnWire } from '../site/entry-label.js'
54
+ import { linkLeaf } from './link-records.js'
55
+ import { fileLeaf } from './file-records.js'
56
+
53
57
  export const FOLDER_MODEL_NAME = '@uniweb/folder'
54
58
  export const FOLDER_ENTITY_KEY = '@folder'
55
59
 
@@ -91,20 +95,34 @@ function refLeaf(entity) {
91
95
  * @param {Map<string, object>} byEntityId - record entities, keyed by pool id
92
96
  * @param {string[]} missing - collects ids that resolved to no entity
93
97
  */
94
- function contentsFromNodes(nodes, byEntityId, missing, sourceLocale) {
98
+ function contentsFromNodes(nodes, byEntityId, missing, sourceLocale, byLinkId = new Map(), byFileId = new Map()) {
95
99
  const out = []
96
100
  for (const node of nodes || []) {
97
101
  if (node.kind === 'branch') {
98
102
  const branch = { kind: 'branch', name: node.name }
99
103
  // The display text is a LOCALIZED field on the wire — a `{ locale: value }`
100
- // map, like every localized scalar this producer sends — keyed by the
101
- // site's source locale.
102
- if (node.label !== undefined) branch.label = { [sourceLocale]: String(node.label) }
104
+ // map, like every localized scalar this producer sends: every language the
105
+ // file gives, or the one text it gives as the site's source locale's
106
+ // (`entry-label.js`).
107
+ if (node.label !== undefined) branch.label = labelOnWire(node.label, sourceLocale)
103
108
  if (Array.isArray(node.tags) && node.tags.length) branch.tags = [...node.tags]
104
- branch.$children = contentsFromNodes(node.$children, byEntityId, missing, sourceLocale)
109
+ branch.$children = contentsFromNodes(node.$children, byEntityId, missing, sourceLocale, byLinkId, byFileId)
105
110
  out.push(branch)
106
111
  continue
107
112
  }
113
+ // ⭐ A LINK RECORD IS AN ENTRY OF ITS OWN KIND, holding its data — never a ref to an entity
114
+ // (`./link-records.js`).
115
+ const link = byLinkId.get(node.$entityId)
116
+ if (link) {
117
+ out.push(linkLeaf(link, node, sourceLocale))
118
+ continue
119
+ }
120
+ // …and so is a file record, its value an asset (`./file-records.js`).
121
+ const file = byFileId.get(node.$entityId)
122
+ if (file) {
123
+ out.push(fileLeaf(file, node, sourceLocale))
124
+ continue
125
+ }
108
126
  const entity = byEntityId.get(node.$entityId)
109
127
  if (!entity) {
110
128
  missing.push(node.$entityId)
@@ -114,7 +132,7 @@ function contentsFromNodes(nodes, byEntityId, missing, sourceLocale) {
114
132
  // ⭐ What the folder says about the record (`records/folder.yml`, ruled 2026-09-27
115
133
  // [Diego]): its display text, localized as a branch's is, and its tags, in the order
116
134
  // written. A records service answers them beside the record as `$label` and `$tags`.
117
- if (node.label !== undefined) leaf.label = { [sourceLocale]: String(node.label) }
135
+ if (node.label !== undefined) leaf.label = labelOnWire(node.label, sourceLocale)
118
136
  if (Array.isArray(node.tags) && node.tags.length) leaf.tags = [...node.tags]
119
137
  out.push(leaf)
120
138
  }
@@ -205,7 +223,13 @@ export function stampFolderItemUuids(doc, pathToUuid = {}) {
205
223
  let stamped = 0
206
224
  let unknown = 0
207
225
  const claimed = new Set()
226
+ // ⭐ An item that already carries its identity keeps it — a link entry's `$uuid` is its
227
+ // record's (`./link-records.js`) — and no other item may be stamped with it.
228
+ walkFolderItems(doc?.contents, (_at, item) => {
229
+ if (typeof item.$uuid === 'string' && item.$uuid) claimed.add(item.$uuid)
230
+ })
208
231
  walkFolderItems(doc?.contents, ({ path, unique, record }, item) => {
232
+ if (typeof item.$uuid === 'string' && item.$uuid) return
209
233
  let uuid = record ? pathToUuid[`@${record}`] : undefined
210
234
  if (!uuid && unique) uuid = pathToUuid[path]
211
235
  if (uuid && !claimed.has(uuid)) {
@@ -228,6 +252,10 @@ export function stampFolderItemUuids(doc, pathToUuid = {}) {
228
252
  * @param {object} params
229
253
  * @param {object[]} params.recordEntities - the record entities (full set, BEFORE
230
254
  * send-only-changed filtering), each `{ id, uuid, slug, model }`
255
+ * @param {object[]} [params.links] - the link records, each `{ id, slug, url, uuid }` — sent as
256
+ * entries of kind `link`, never as entities (`./link-records.js`)
257
+ * @param {object[]} [params.files] - the file records, each `{ id, slug, uuid, value }` — sent as
258
+ * entries of kind `file`, their `value` an asset (`./file-records.js`)
231
259
  * @param {Array} params.folderNodes - the placed records: `folder.yml`'s sub-folders,
232
260
  * then every record at the top
233
261
  * @param {boolean} [params.declared] - whether the records DIRECTORY exists. See below.
@@ -238,7 +266,7 @@ export function stampFolderItemUuids(doc, pathToUuid = {}) {
238
266
  * keyed under on the wire
239
267
  * @returns {{ id, uuid, model, file, document, warnings }|null}
240
268
  */
241
- export function buildFolderEntity({ recordEntities, folderNodes = [], declared, itemUuids = null, sourceLocale = 'en' }) {
269
+ export function buildFolderEntity({ recordEntities, links = [], files = [], folderNodes = [], declared, itemUuids = null, sourceLocale = 'en' }) {
242
270
  // ⛔ `missing` AND `empty` ARE DIFFERENT, AND THE ASYMMETRY IS DELIBERATE.
243
271
  //
244
272
  // no records directory → null. INERT: nothing is sent, and the backend's
@@ -256,9 +284,11 @@ export function buildFolderEntity({ recordEntities, folderNodes = [], declared,
256
284
 
257
285
  const byEntityId = new Map()
258
286
  for (const e of recordEntities || []) byEntityId.set(e.id, e)
287
+ const byLinkId = new Map((links || []).map((l) => [l.id, l]))
288
+ const byFileId = new Map((files || []).map((f) => [f.id, f]))
259
289
 
260
290
  const missing = []
261
- const contents = contentsFromNodes(folderNodes, byEntityId, missing, sourceLocale)
291
+ const contents = contentsFromNodes(folderNodes, byEntityId, missing, sourceLocale, byLinkId, byFileId)
262
292
  // ⚠️ `id` IS THE RECORD'S PATH IN THE RECORDS DIRECTORY, NOT A FOLDER PATH — say
263
293
  // so, because the two read identically and a reader who takes it for a placement
264
294
  // concludes the emitter is dropping a branch it never had. *(Measured 2026-08-31:
package/src/uwx/index.js CHANGED
@@ -188,3 +188,11 @@ export {
188
188
  writeRegisteredFoundation,
189
189
  } from '../site/registered-foundation.js'
190
190
  export { parseCatalogRef } from '../site/foundation-ref.js'
191
+
192
+ // Link records — `@uniweb/link`: a file in `records/uniweb/link/` on the file side, a folder entry of
193
+ // kind `link` on the wire, never an entity.
194
+ export { LINK_MODEL, linkItems, backfillLinkUuids, writeLinkRecords, linkRecordRefusal } from './link-records.js'
195
+
196
+ // File records — `@uniweb/file`: the file itself in `records/uniweb/file/` on the file side, a folder
197
+ // entry of kind `file` on the wire, its value an asset.
198
+ export { FILE_MODEL, fileItems, bankedFileUuids, filesToPull } from './file-records.js'