@uniweb/build 0.72.0 → 0.73.1

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.
@@ -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'
@@ -0,0 +1,233 @@
1
+ // ⭐ A LINK RECORD IS A FOLDER ENTRY, NEVER AN ENTITY — `@uniweb/link` (2026-09-28).
2
+ //
3
+ // On the file side a link is a record like any other: a file in `records/uniweb/link/`, holding its
4
+ // `url` (and, once it has reached a backend, its `$uuid`), placed in the folder by
5
+ // `records/folder.yml`, which also gives its `label` and `tags` — they are its ENTRY's, not the
6
+ // Model's. A static build compiles it like any record (`@uniweb/schemas/system`).
7
+ //
8
+ // On the wire a link is not an entity at all: a backend holds it as an item of the site's
9
+ // `@uniweb/folder` of kind `link`, its data on the item — `{ kind: 'link', name, url, label?, tags? }`
10
+ // — and the item's `$uuid` is the record's identity. A push that sent an entity of `@uniweb/link`
11
+ // would be refused. So the push lowers each link record into such an entry (`folder.js`), the CLI
12
+ // banks the uuid the backend gives it the way a record's is banked — in the file the first time,
13
+ // mapped per backend after that — and a pull writes each link entry back as its file.
14
+ //
15
+ // `@uniweb/file` is not built: how a file reference is stored is still open.
16
+
17
+ import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs'
18
+ import { extname, join } from 'node:path'
19
+ import yaml from 'js-yaml'
20
+ import { YAML_OPTIONS } from '../utils/yaml-schema.js'
21
+ import { labelOnWire } from '../site/entry-label.js'
22
+ import { poolDirsForSchema } from '../site/entity-pool.js'
23
+ import { backfillUuid } from './backfill.js'
24
+
25
+ export const LINK_MODEL = '@uniweb/link'
26
+
27
+ /** Where link records live, below the records directory: `uniweb/link`. */
28
+ export const LINK_DIRS = poolDirsForSchema(LINK_MODEL)
29
+
30
+ /**
31
+ * Why a link record cannot be sent as written, or null when it can.
32
+ *
33
+ * @param {{ data?: object, body?: string }} record - a record as its file reader returns it
34
+ * @param {string} where - the file, for the message
35
+ * @returns {string|null}
36
+ */
37
+ export function linkRecordRefusal(record, where) {
38
+ const data = record?.data && typeof record.data === 'object' ? record.data : {}
39
+ if (typeof record?.body === 'string' && record.body.trim()) {
40
+ return `${where}: a link record has no body — it holds its \`url\` alone.`
41
+ }
42
+ if (data.draft !== undefined) {
43
+ return (
44
+ `${where}: a link cannot be kept off a published site yet, so a draft link is not sent. ` +
45
+ `Remove \`draft:\` to send it, or the file to leave it out.`
46
+ )
47
+ }
48
+ const placement = ['label', 'tags'].filter((k) => data[k] !== undefined)
49
+ if (placement.length) {
50
+ const keys = placement.map((k) => `\`${k}:\``).join(' and ')
51
+ return (
52
+ `${where}: a link's ${keys} ${placement.length === 1 ? 'is' : 'are'} its folder entry's, not the ` +
53
+ `record's — give ${placement.length === 1 ? 'it' : 'them'} in records/folder.yml, on the entry ` +
54
+ `\`- path: ${LINK_DIRS.join('/')}/<file>\`.`
55
+ )
56
+ }
57
+ const extra = Object.keys(data).filter((k) => k !== 'url' && k !== '$uuid')
58
+ if (extra.length) {
59
+ return `${where}: a link record holds its \`url\` and nothing else — not ${extra.map((k) => `\`${k}:\``).join(', ')}.`
60
+ }
61
+ if (typeof data.url !== 'string' || !data.url.trim()) return `${where}: a link record needs a \`url\`.`
62
+ // ⭐ ABSOLUTE — a scheme and `://` — as a backend holds a link's url (`format: url`, 2026-09-28): a
63
+ // relative path or a `mailto:` is refused there, so it is refused here, before anything is sent.
64
+ if (!/^[a-z][a-z0-9+.-]*:\/\//i.test(data.url.trim())) {
65
+ return `${where}: a link's \`url\` is absolute — a scheme and \`://\`, like \`https://…\` — not "${data.url.trim()}".`
66
+ }
67
+ return null
68
+ }
69
+
70
+ /**
71
+ * A link entry as the wire carries it: `{ kind: 'link', name, url }`, with the uuid this backend
72
+ * holds it under, and the label and tags its placement gives.
73
+ *
74
+ * @param {{ slug: string, url: string, uuid?: string|null }} link
75
+ * @param {{ label?: *, tags?: string[] }} node - its placement (`records-config.js::resolveFolder`)
76
+ * @param {string} sourceLocale
77
+ */
78
+ export function linkLeaf(link, node, sourceLocale) {
79
+ const leaf = { kind: 'link', name: link.slug, url: link.url }
80
+ if (link.uuid) leaf.$uuid = link.uuid
81
+ if (node?.label !== undefined) leaf.label = labelOnWire(node.label, sourceLocale)
82
+ if (Array.isArray(node?.tags) && node.tags.length) leaf.tags = [...node.tags]
83
+ return leaf
84
+ }
85
+
86
+ /**
87
+ * Every link entry in a folder document, with the branch it sits in.
88
+ *
89
+ * @param {object} folderDoc - `{ contents }`, nesting through `$children`
90
+ * @returns {Array<{ item: object, branch: string }>}
91
+ */
92
+ export function linkItems(folderDoc) {
93
+ const out = []
94
+ const walk = (nodes, branch) => {
95
+ for (const node of nodes || []) {
96
+ if (!node || typeof node !== 'object') continue
97
+ if (node.kind === 'branch') walk(node.$children, branch ? `${branch}/${node.name}` : node.name)
98
+ else if (node.kind === 'link') out.push({ item: node, branch })
99
+ }
100
+ }
101
+ walk(folderDoc?.contents, '')
102
+ return out
103
+ }
104
+
105
+ /**
106
+ * After a push: the uuid the backend holds each link entry under, banked as a record's is.
107
+ *
108
+ * A link that has an own id maps it to this backend's uuid. A link with none — the first backend it
109
+ * reaches — takes that uuid as its own: it is written into the file, and the map is identity.
110
+ * Matched by name: a link's name is its file's, so no two links share one.
111
+ *
112
+ * @param {object} params
113
+ * @param {Array<{ slug: string, ownId: string|null, sourceFile: string|null }>} params.links
114
+ * @param {object} params.folderDoc - the folder document the backend returned
115
+ * @returns {{ mapped: Object<string,string>, updated: string[], warnings: string[] }}
116
+ */
117
+ export function backfillLinkUuids({ links = [], folderDoc }) {
118
+ const bySlug = new Map(links.map((l) => [l.slug, l]))
119
+ const mapped = {}
120
+ const updated = []
121
+ const warnings = []
122
+ for (const { item } of linkItems(folderDoc)) {
123
+ const link = bySlug.get(item.name)
124
+ if (!link || typeof item.$uuid !== 'string' || !item.$uuid) continue
125
+ if (link.ownId) {
126
+ mapped[link.ownId] = item.$uuid
127
+ continue
128
+ }
129
+ if (!link.sourceFile) continue
130
+ const res = backfillUuid(link.sourceFile, item.$uuid)
131
+ if (res.status === 'error' || res.status === 'deferred') {
132
+ warnings.push(`${link.sourceFile}: ${res.message}`)
133
+ continue
134
+ }
135
+ if (res.status === 'updated') updated.push(link.sourceFile)
136
+ mapped[item.$uuid] = item.$uuid
137
+ }
138
+ return { mapped, updated, warnings }
139
+ }
140
+
141
+ /** A link file's `$uuid` and `url`, or null when it does not read as one. */
142
+ function readLinkFile(path) {
143
+ try {
144
+ const text = readFileSync(path, 'utf8')
145
+ const data = extname(path).toLowerCase() === '.json' ? JSON.parse(text) : yaml.load(text, YAML_OPTIONS)
146
+ return data && typeof data === 'object' && !Array.isArray(data) ? data : null
147
+ } catch {
148
+ return null
149
+ }
150
+ }
151
+
152
+ const LINK_EXTENSIONS = ['.yml', '.yaml', '.json']
153
+
154
+ /**
155
+ * A pull: each link entry of the folder written as its record file in `records/uniweb/link/` —
156
+ * `$uuid` (the link's own id) and `url` — the inverse of what a push sends.
157
+ *
158
+ * ⛔ A FILE OF THAT NAME HOLDING ANOTHER LINK IS NOT WRITTEN OVER: the entry is reported and left
159
+ * out, rather than one link taking another's file.
160
+ *
161
+ * @param {object} params
162
+ * @param {object} params.folderDoc
163
+ * @param {string} params.recordsRoot - the records directory, absolute
164
+ * @param {Map<string,string>} params.ownIdByTheirs - this backend's uuid → the record's own id
165
+ * @returns {{ pathByUuid: Map<string,string>, learned: Object<string,string>, placed: string[], updated: string[], unchanged: string[], warnings: string[] }}
166
+ * `pathByUuid` — the backend's uuid → the file, relative to the records directory
167
+ */
168
+ export function writeLinkRecords({ folderDoc, recordsRoot, ownIdByTheirs = new Map() }) {
169
+ const dir = join(recordsRoot, ...LINK_DIRS)
170
+ const pathByUuid = new Map()
171
+ const learned = {}
172
+ const placed = []
173
+ const updated = []
174
+ const unchanged = []
175
+ const warnings = []
176
+
177
+ // The link files already here, by own id.
178
+ const held = new Map()
179
+ const files = existsSync(dir)
180
+ ? readdirSync(dir).sort().filter((name) => !name.startsWith('_') && LINK_EXTENSIONS.includes(extname(name).toLowerCase()))
181
+ : []
182
+ for (const name of files) {
183
+ const data = readLinkFile(join(dir, name))
184
+ if (typeof data?.$uuid === 'string' && data.$uuid) held.set(data.$uuid, name)
185
+ }
186
+
187
+ for (const { item } of linkItems(folderDoc)) {
188
+ const theirs = typeof item.$uuid === 'string' && item.$uuid ? item.$uuid : null
189
+ if (!theirs || typeof item.name !== 'string' || !item.name || typeof item.url !== 'string') {
190
+ warnings.push(`the folder holds a link ("${item.name ?? '?'}") with no identity or no url — not written.`)
191
+ continue
192
+ }
193
+ const ownId = ownIdByTheirs.get(theirs) || theirs
194
+ let name = held.get(ownId)
195
+ let status
196
+ if (name) {
197
+ const path = join(dir, name)
198
+ const data = readLinkFile(path) || {}
199
+ if (data.url === item.url) {
200
+ status = 'unchanged'
201
+ } else {
202
+ writeLinkFile(path, { ...data, $uuid: ownId, url: item.url })
203
+ status = 'updated'
204
+ }
205
+ } else {
206
+ name = `${item.name}.yml`
207
+ const path = join(dir, name)
208
+ const there = existsSync(path) ? readLinkFile(path) : null
209
+ if (there) {
210
+ warnings.push(
211
+ `${[...LINK_DIRS, name].join('/')} holds another link than the folder's "${item.name}" — ` +
212
+ `it was not written over, and the folder's link was not written.`
213
+ )
214
+ continue
215
+ }
216
+ mkdirSync(dir, { recursive: true })
217
+ writeLinkFile(path, { $uuid: ownId, url: item.url })
218
+ status = 'placed'
219
+ }
220
+ const path = join(dir, name)
221
+ ;(status === 'placed' ? placed : status === 'updated' ? updated : unchanged).push(path)
222
+ pathByUuid.set(theirs, [...LINK_DIRS, name].join('/'))
223
+ learned[ownId] = theirs
224
+ }
225
+ return { pathByUuid, learned, placed, updated, unchanged, warnings }
226
+ }
227
+
228
+ function writeLinkFile(path, data) {
229
+ const { $uuid, url, ...rest } = data
230
+ const ordered = { ...($uuid ? { $uuid } : {}), url, ...rest }
231
+ const text = extname(path).toLowerCase() === '.json' ? JSON.stringify(ordered, null, 2) + '\n' : yaml.dump(ordered)
232
+ writeFileSync(path, text)
233
+ }