@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.
- package/package.json +5 -5
- package/src/i18n/records.js +69 -11
- package/src/prerender.js +33 -27
- package/src/resolve-data-schema.js +10 -2
- package/src/schema.js +18 -5
- package/src/site/content-collector.js +9 -3
- package/src/site/emit-extensions.js +21 -10
- 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
|
@@ -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 —
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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'
|