@uniweb/build 0.73.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.
- package/package.json +5 -5
- package/src/i18n/records.js +69 -11
- package/src/resolve-data-schema.js +10 -2
- package/src/site/entity-pool.js +29 -2
- package/src/site/entry-label.js +110 -0
- package/src/site/fetch-shapes.js +9 -0
- package/src/site/file-records.js +109 -0
- package/src/site/queries-config.js +4 -3
- package/src/site/query-processor.js +23 -4
- package/src/site/records-config.js +19 -5
- package/src/uwx/entity-source.js +2 -1
- package/src/uwx/file-records.js +178 -0
- package/src/uwx/folder.js +38 -8
- package/src/uwx/index.js +8 -0
- package/src/uwx/link-records.js +233 -0
- package/src/uwx/records-project.js +54 -11
- package/src/uwx/records.js +76 -9
- package/src/uwx/site.js +19 -3
- package/src/uwx/sync-package.js +20 -2
- package/src/uwx/sync-store.js +1 -1
- package/src/validate-data.js +8 -0
|
@@ -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
|
-
|
|
281
|
-
|
|
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
|
-
|
|
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)
|
|
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) {
|
package/src/uwx/entity-source.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
101
|
-
// site's source locale
|
|
102
|
-
|
|
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 =
|
|
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
|
+
}
|