@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,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
|
+
}
|
|
@@ -48,7 +48,10 @@ import { folderYmlPath } from '../site/records-config.js'
|
|
|
48
48
|
import { contentBodyTarget } from './record-layout.js'
|
|
49
49
|
import { unresolveSelfScope, resolveSelfScope, ownSchemaNames, refuseOrgOption } from './self-scope.js'
|
|
50
50
|
import { parseCatalogRef } from '../site/foundation-ref.js'
|
|
51
|
-
import {
|
|
51
|
+
import { renderEntityDocument } from './backfill.js'
|
|
52
|
+
import { labelForFile } from '../site/entry-label.js'
|
|
53
|
+
import { LINK_MODEL, writeLinkRecords } from './link-records.js'
|
|
54
|
+
import { FILE_MODEL, filesToPull } from './file-records.js'
|
|
52
55
|
import { parseBibtex } from '@citestyle/bibtex'
|
|
53
56
|
import { createTranslationCollector, writeLocaleTranslations, writeFreeformTranslations } from './locale-sync.js'
|
|
54
57
|
import { buildFreeformRecordPath } from '../i18n/freeform.js'
|
|
@@ -161,6 +164,13 @@ export function indexFolder(folderDoc) {
|
|
|
161
164
|
const uuid = typeof node.entry === 'object' ? node.entry.entity : node.entry
|
|
162
165
|
const schema = typeof node.entry === 'object' ? node.entry.schema : undefined
|
|
163
166
|
if (uuid) byUuid.set(uuid, { folderPath, slug: node.name, schema })
|
|
167
|
+
} else if (node?.kind === 'link' && typeof node.$uuid === 'string' && node.$uuid) {
|
|
168
|
+
// ⭐ A link entry is a record of `@uniweb/link`, its identity the item's own
|
|
169
|
+
// (`./link-records.js`).
|
|
170
|
+
byUuid.set(node.$uuid, { folderPath, slug: node.name, schema: LINK_MODEL })
|
|
171
|
+
} else if (node?.kind === 'file' && typeof node.$uuid === 'string' && node.$uuid) {
|
|
172
|
+
// …and a file entry one of `@uniweb/file` (`./file-records.js`).
|
|
173
|
+
byUuid.set(node.$uuid, { folderPath, slug: node.name, schema: FILE_MODEL })
|
|
164
174
|
}
|
|
165
175
|
}
|
|
166
176
|
}
|
|
@@ -522,25 +532,30 @@ export function folderToFolderYml({ folderDoc, siteRoot, poolPathByUuid, sourceL
|
|
|
522
532
|
if (!node || typeof node !== 'object') continue
|
|
523
533
|
if (node.kind === 'branch') {
|
|
524
534
|
const entry = { folder: node.name }
|
|
525
|
-
//
|
|
526
|
-
//
|
|
527
|
-
//
|
|
528
|
-
|
|
535
|
+
// A folder's label, as a record entry's below: on the wire a map of languages;
|
|
536
|
+
// in folder.yml the source language's text alone, or every language the label
|
|
537
|
+
// holds when it holds another (`entry-label.js`, 2026-09-28). ⛔ This kept the
|
|
538
|
+
// source language's text only until then, and said only a branch takes a label —
|
|
539
|
+
// a record's entry has taken one since 2026-09-27.
|
|
540
|
+
const folderLabel = labelForFile(node.label, sourceLocale)
|
|
541
|
+
if (folderLabel !== null) entry.label = folderLabel
|
|
529
542
|
if (Array.isArray(node.tags) && node.tags.length) entry.tags = node.tags.map(String)
|
|
530
543
|
entry.records = walk(node.$children, true)
|
|
531
544
|
out.push(entry)
|
|
532
545
|
continue
|
|
533
546
|
}
|
|
534
|
-
|
|
547
|
+
// A link or file entry is its own record — its identity is the item's (`./link-records.js`,
|
|
548
|
+
// `./file-records.js`).
|
|
549
|
+
const uuid = node.kind === 'link' || node.kind === 'file' ? node.$uuid : node.entry?.entity ?? node.entry
|
|
535
550
|
const rel = typeof uuid === 'string' ? poolPathByUuid.get(uuid) : null
|
|
536
551
|
// ⭐ What the folder says about the record — its entry's `tags` and `label` — comes
|
|
537
552
|
// back as the entry `{ path, tags?, label? }` (ruled 2026-09-27 [Diego]).
|
|
538
553
|
const said = {}
|
|
539
554
|
if (Array.isArray(node.tags) && node.tags.length) said.tags = node.tags.map(String)
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
555
|
+
// Every language the label holds (`entry-label.js`, 2026-09-28) — ⛔ the source
|
|
556
|
+
// language's alone until then, so a translated label lost the rest on a round trip.
|
|
557
|
+
const label = labelForFile(node.label, sourceLocale)
|
|
558
|
+
if (label !== null) said.label = label
|
|
544
559
|
const says = Object.keys(said).length > 0
|
|
545
560
|
// A record at the top of the folder needs no line unless the folder says something
|
|
546
561
|
// about it — but one that did not land is still said: the next push sends the folder
|
|
@@ -768,6 +783,27 @@ export function recordsToProject({ folderDoc, recordDocs = [], siteRoot, opts =
|
|
|
768
783
|
//
|
|
769
784
|
// The folder ENTITY still carries no `$uuid` we persist: the backend owns the
|
|
770
785
|
// site's folder, keyed by the site-content uuid.
|
|
786
|
+
// ⭐ THE FOLDER'S LINK ENTRIES, written as their record files (`./link-records.js`) — before the
|
|
787
|
+
// folder, which places them by the files they land in. ⛔ Until 2026-09-28 a pull could not write
|
|
788
|
+
// one, and warned that a push from here would remove it.
|
|
789
|
+
const linked = writeLinkRecords({ folderDoc, recordsRoot, ownIdByTheirs })
|
|
790
|
+
for (const [theirs, rel] of linked.pathByUuid) poolPathByUuid.set(theirs, rel)
|
|
791
|
+
Object.assign(learned, linked.learned)
|
|
792
|
+
placed.push(...linked.placed)
|
|
793
|
+
updated.push(...linked.updated)
|
|
794
|
+
unchanged.push(...linked.unchanged)
|
|
795
|
+
warnings.push(...linked.warnings)
|
|
796
|
+
// ⭐ AND ITS FILE ENTRIES (`./file-records.js`): where each file lands in `records/uniweb/file/`,
|
|
797
|
+
// placed like any record — the bytes are the CLI's to fetch (`fileDownloads`), as it fetches media.
|
|
798
|
+
const pulledFiles = filesToPull({
|
|
799
|
+
folderDoc,
|
|
800
|
+
recordsRoot,
|
|
801
|
+
fileMap: backendState.files || {},
|
|
802
|
+
assetMap: backendState.assets || {},
|
|
803
|
+
})
|
|
804
|
+
for (const [theirs, rel] of pulledFiles.pathByUuid) poolPathByUuid.set(theirs, rel)
|
|
805
|
+
warnings.push(...pulledFiles.warnings)
|
|
806
|
+
|
|
771
807
|
const records = folderToFolderYml({ folderDoc, siteRoot, poolPathByUuid, sourceLocale })
|
|
772
808
|
warnings.push(...records.warnings)
|
|
773
809
|
for (const path of keptBib) {
|
|
@@ -782,6 +818,13 @@ export function recordsToProject({ folderDoc, recordDocs = [], siteRoot, opts =
|
|
|
782
818
|
if (opts.backend && Object.keys(learned).length) {
|
|
783
819
|
updateBackendMap(siteRoot, opts.backend, 'records', learned)
|
|
784
820
|
}
|
|
821
|
+
if (opts.backend && Object.keys(pulledFiles.learned).length) {
|
|
822
|
+
updateBackendMap(siteRoot, opts.backend, 'files', pulledFiles.learned)
|
|
823
|
+
}
|
|
785
824
|
|
|
786
|
-
return {
|
|
825
|
+
return {
|
|
826
|
+
updated, placed, unchanged, skipped, warnings, locales, freeform, records: records.status, recordsFile: records.file,
|
|
827
|
+
// The file records' bytes to fetch — `{ url, path, poolPath }` each (`./file-records.js`).
|
|
828
|
+
fileDownloads: pulledFiles.downloads,
|
|
829
|
+
}
|
|
787
830
|
}
|
package/src/uwx/records.js
CHANGED
|
@@ -67,6 +67,8 @@ import { LOCALIZED_FIELD_ASSUMPTION } from './localize.js'
|
|
|
67
67
|
import { localizeScalar, localizeScalarList, localizeContentDoc, loadLocaleTranslations, discoverLocales, discoverFreeformLocales, localesDir, isLocalizedContent } from './locale-sync.js'
|
|
68
68
|
import { loadFreeformRecord } from '../i18n/freeform.js'
|
|
69
69
|
import { isDraftRecord } from '../site/record-draft.js'
|
|
70
|
+
import { LINK_MODEL, linkRecordRefusal } from './link-records.js'
|
|
71
|
+
import { FILE_MODEL } from './file-records.js'
|
|
70
72
|
|
|
71
73
|
const DATE_KINDS = new Set(['date', 'datetime'])
|
|
72
74
|
// The record's OWN keys — identity and transport, never a field of any section, never
|
|
@@ -907,9 +909,11 @@ export async function buildRecordEntities(siteRoot, opts = {}) {
|
|
|
907
909
|
colConfig,
|
|
908
910
|
folder: { ...folder, nodes: [] },
|
|
909
911
|
recordsDirExists: pool.exists,
|
|
910
|
-
sendFolder: sendsFolder(pool, []),
|
|
912
|
+
sendFolder: sendsFolder(pool, [], []),
|
|
911
913
|
declarations: new Map(),
|
|
912
914
|
keyTyped: new Map(),
|
|
915
|
+
links: [],
|
|
916
|
+
files: [],
|
|
913
917
|
}
|
|
914
918
|
}
|
|
915
919
|
|
|
@@ -1073,7 +1077,64 @@ export async function buildRecordEntities(siteRoot, opts = {}) {
|
|
|
1073
1077
|
// (`self-scope.js::unresolveSelfScope`), and a push then reported only a name two
|
|
1074
1078
|
// records share.
|
|
1075
1079
|
const fileByOwnId = new Map()
|
|
1080
|
+
|
|
1081
|
+
// ⭐ A LINK RECORD IS A FOLDER ENTRY, NEVER AN ENTITY (`@uniweb/link`, 2026-09-28 —
|
|
1082
|
+
// `./link-records.js`). Its file holds its `url` and its own `$uuid`; the push sends it as an
|
|
1083
|
+
// item of the folder, `{ kind: 'link', name, url }`, under the uuid this backend holds it by
|
|
1084
|
+
// (`recordMap`), or none on its first push. Placed like any record, so `folder.yml` gives its
|
|
1085
|
+
// label and tags.
|
|
1086
|
+
const links = []
|
|
1087
|
+
const linkNames = new Map() // slug → the file that gave it, so two files cannot claim one name
|
|
1088
|
+
for (const pooled of poolBySchema.get(LINK_MODEL) || []) {
|
|
1089
|
+
const produced = []
|
|
1090
|
+
for (const r of await readEntityFile(pooled.absPath)) {
|
|
1091
|
+
const where = r.multiRecord ? `${pooled.relPath} (${r.slug})` : pooled.relPath
|
|
1092
|
+
const refusal = linkRecordRefusal(r, where)
|
|
1093
|
+
if (refusal) {
|
|
1094
|
+
refusals.push(refusal)
|
|
1095
|
+
continue
|
|
1096
|
+
}
|
|
1097
|
+
const first = linkNames.get(r.slug)
|
|
1098
|
+
if (first) {
|
|
1099
|
+
refusals.push(`${pooled.relPath}: a link named "${r.slug}" is also ${first} — a link is named by its file, so each needs its own name.`)
|
|
1100
|
+
continue
|
|
1101
|
+
}
|
|
1102
|
+
linkNames.set(r.slug, pooled.relPath)
|
|
1103
|
+
const id = [...pooled.dirs, r.slug].join('/')
|
|
1104
|
+
const ownId = typeof r.data?.$uuid === 'string' && r.data.$uuid ? r.data.$uuid : null
|
|
1105
|
+
links.push({
|
|
1106
|
+
id,
|
|
1107
|
+
slug: r.slug,
|
|
1108
|
+
url: r.data.url.trim(),
|
|
1109
|
+
ownId,
|
|
1110
|
+
uuid: ownId ? recordMap[ownId] ?? null : null,
|
|
1111
|
+
sourceFile: r.multiRecord ? null : r.sourceFile ?? pooled.absPath,
|
|
1112
|
+
})
|
|
1113
|
+
produced.push({ id, slug: r.slug })
|
|
1114
|
+
}
|
|
1115
|
+
producedBy.set(pooled.id, produced)
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
// ⭐ A FILE RECORD IS A FOLDER ENTRY OF KIND `file`, NEVER AN ENTITY (`@uniweb/file`, 2026-09-28 —
|
|
1119
|
+
// `./file-records.js`). The file itself is the record; nothing in it is read. Its identity is the
|
|
1120
|
+
// uuid this backend holds its entry by, banked per backend by the file's path (`sync.json` `files`).
|
|
1121
|
+
const files = []
|
|
1122
|
+
const fileMap = opts.backend ? readBackendState(siteRoot, opts.backend).files || {} : {}
|
|
1123
|
+
for (const pooled of poolBySchema.get(FILE_MODEL) || []) {
|
|
1124
|
+
const id = [...pooled.dirs, pooled.slug].join('/')
|
|
1125
|
+
files.push({
|
|
1126
|
+
id,
|
|
1127
|
+
slug: pooled.slug,
|
|
1128
|
+
name: pooled.file,
|
|
1129
|
+
absPath: pooled.absPath,
|
|
1130
|
+
poolPath: pooled.poolPath,
|
|
1131
|
+
uuid: fileMap[pooled.poolPath] ?? null,
|
|
1132
|
+
})
|
|
1133
|
+
producedBy.set(pooled.id, [{ id, slug: pooled.slug }])
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1076
1136
|
for (const [schema, poolEntities] of poolBySchema) {
|
|
1137
|
+
if (schema === LINK_MODEL || schema === FILE_MODEL) continue
|
|
1077
1138
|
const label = poolEntities[0].dirs.join('/')
|
|
1078
1139
|
const modelName = modelFor(schema, `${pool.dir}/${label}/`)
|
|
1079
1140
|
const declaration = (await declarationFor(modelName)) || (await typedFor(schema))
|
|
@@ -1196,9 +1257,10 @@ export async function buildRecordEntities(siteRoot, opts = {}) {
|
|
|
1196
1257
|
refusals.push(...mappedOut.refusals)
|
|
1197
1258
|
}
|
|
1198
1259
|
|
|
1199
|
-
// Queries whose schema resolved to nothing — not pushed as entities.
|
|
1200
|
-
//
|
|
1201
|
-
// route
|
|
1260
|
+
// Queries whose schema resolved to nothing — not pushed as entities. Their compiled
|
|
1261
|
+
// data is delivered as static files instead (`site/schemaless-data.js`), so the caller
|
|
1262
|
+
// can route it there; and their declarations are sent naming no Model
|
|
1263
|
+
// (`site.js::queriesNested`).
|
|
1202
1264
|
//
|
|
1203
1265
|
// ⛔ Deliberately NOT a `warnings` string. This is a product decision the author
|
|
1204
1266
|
// is making — entities or static files — and it needs to be reported at a
|
|
@@ -1210,9 +1272,10 @@ export async function buildRecordEntities(siteRoot, opts = {}) {
|
|
|
1210
1272
|
for (const { name, decl } of queries) {
|
|
1211
1273
|
const schema = decl.schema || decl.model
|
|
1212
1274
|
const hasRecords = poolBySchema.has(schema)
|
|
1213
|
-
|
|
1275
|
+
// A query over link records names a system Model, which resolves whatever the foundation holds.
|
|
1276
|
+
const resolved = schema === LINK_MODEL || schema === FILE_MODEL || (hasRecords
|
|
1214
1277
|
? !unresolved.has(schema)
|
|
1215
|
-
: Boolean((await declarationFor(modelFor(schema, `query "${name}"`))) || (await typedFor(schema)))
|
|
1278
|
+
: Boolean((await declarationFor(modelFor(schema, `query "${name}"`))) || (await typedFor(schema))))
|
|
1216
1279
|
if (!resolved) {
|
|
1217
1280
|
if (decl.schemaExplicit) throw unresolvedExplicit(resolveSelfScope(schema, scope), name)
|
|
1218
1281
|
schemaless.push({ name, model: resolveSelfScope(schema, scope) })
|
|
@@ -1249,13 +1312,17 @@ export async function buildRecordEntities(siteRoot, opts = {}) {
|
|
|
1249
1312
|
colConfig,
|
|
1250
1313
|
folder: { ...folder, nodes },
|
|
1251
1314
|
recordsDirExists: pool.exists,
|
|
1252
|
-
sendFolder: sendsFolder(pool, entities),
|
|
1315
|
+
sendFolder: sendsFolder(pool, entities, [...links, ...files]),
|
|
1253
1316
|
// Each entity's declaration, by its Model — what walks a record's lists
|
|
1254
1317
|
// (`record-items.js`).
|
|
1255
1318
|
declarations: new Map(readSchemas.map(({ declaration }) => [declaration.name, declaration])),
|
|
1256
1319
|
// A name-defaulted schema that stands for the type of the data key of its name
|
|
1257
1320
|
// (`@/team` → `@/member`) — see `typedFor` above.
|
|
1258
1321
|
keyTyped,
|
|
1322
|
+
// The link records, sent as folder entries rather than entities (`./link-records.js`).
|
|
1323
|
+
links,
|
|
1324
|
+
// The file records, likewise (`./file-records.js`).
|
|
1325
|
+
files,
|
|
1259
1326
|
}
|
|
1260
1327
|
}
|
|
1261
1328
|
|
|
@@ -1269,9 +1336,9 @@ export async function buildRecordEntities(siteRoot, opts = {}) {
|
|
|
1269
1336
|
// holds only files that are not records (warned). Neither is an author emptying
|
|
1270
1337
|
// the folder, and sending one would remove the backend's records — including any
|
|
1271
1338
|
// authored there — for a state nobody chose. Inert instead, like no directory.
|
|
1272
|
-
function sendsFolder(pool, entities) {
|
|
1339
|
+
function sendsFolder(pool, entities, links = []) {
|
|
1273
1340
|
if (!pool.exists) return false
|
|
1274
|
-
if (entities.length > 0) return true
|
|
1341
|
+
if (entities.length > 0 || links.length > 0) return true
|
|
1275
1342
|
return pool.entities.length === 0 && pool.errors.length === 0
|
|
1276
1343
|
}
|
|
1277
1344
|
|
package/src/uwx/site.js
CHANGED
|
@@ -1037,7 +1037,7 @@ function externalSource(d) {
|
|
|
1037
1037
|
return source
|
|
1038
1038
|
}
|
|
1039
1039
|
|
|
1040
|
-
function queriesNested(declarations, uuids = null, scope = null, keyTyped = null, queryFields = null) {
|
|
1040
|
+
function queriesNested(declarations, uuids = null, scope = null, keyTyped = null, queryFields = null, schemaless = null) {
|
|
1041
1041
|
const out = []
|
|
1042
1042
|
for (const [name, d] of Object.entries(declarations)) {
|
|
1043
1043
|
refuseUnder(d.where, `queries.${name}`)
|
|
@@ -1058,7 +1058,14 @@ function queriesNested(declarations, uuids = null, scope = null, keyTyped = null
|
|
|
1058
1058
|
// names that type — the data schema its records are sent as (`data-key-types.js`).
|
|
1059
1059
|
const typed = (!d.schemaExplicit && keyTyped?.get(d.schema)) || null
|
|
1060
1060
|
const schema = typed || d.schema
|
|
1061
|
-
|
|
1061
|
+
// ⛔ NO MODEL FOR A QUERY WHOSE RECORDS RESOLVED NONE (`schemaless`, decided by the records
|
|
1062
|
+
// lane — `records.js`). Its name-defaulted `schema` names a Model nothing declares, its records
|
|
1063
|
+
// are not pushed, and its compiled data travels as static files — so the declaration goes up
|
|
1064
|
+
// with no `schema`. ⛔ Until 2026-09-28 it named that Model anyway (`videos` → `@std/videos`),
|
|
1065
|
+
// and a store that checks a query's `schema` against the site's declared types refused the
|
|
1066
|
+
// whole content push for it. An explicit `schema:` that does not resolve never reaches here:
|
|
1067
|
+
// the records lane refuses it.
|
|
1068
|
+
if (!schemaless?.has(name)) setIf(data, 'schema', resolveSelfScope(schema, scope))
|
|
1062
1069
|
// ⭐ …AND SAYS SO, so a pull can tell the author's form (`team:`, no schema) from an explicit
|
|
1063
1070
|
// one — sent only where the deployment's `queries` Section declares the key (`GET /dev/config`
|
|
1064
1071
|
// → `siteContent.queryFields`), since one that does not refuses a push carrying it.
|
|
@@ -1377,6 +1384,8 @@ function settingsNested(siteYml, { headHtml, themeYml, sourceLocale, translation
|
|
|
1377
1384
|
* resolves into (`@/x` → `@scope/x`): the site's foundation's. Defaults to
|
|
1378
1385
|
* `siteSelfScope`; pass the scope the records are emitted with. With none
|
|
1379
1386
|
* known, `@/x` ships as written.
|
|
1387
|
+
* @param {string[]} [opts.schemaless] - the queries whose records resolved no data schema
|
|
1388
|
+
* (the records lane's `schemaless`): their declarations name no `schema`.
|
|
1380
1389
|
* @returns {Promise<object>} the section-keyed `$`-document:
|
|
1381
1390
|
* `{ $uuid?, $id, $schema, info, settings?, pages, layout_sections, extensions,
|
|
1382
1391
|
* queries, services?, secrets? }`
|
|
@@ -1688,7 +1697,14 @@ export async function siteProjectToDocument(siteRoot, opts = {}) {
|
|
|
1688
1697
|
// ⚠️ `queriesNested` keeps its name. §2's rule: rename what an author or a
|
|
1689
1698
|
// consumer sees, leave the identifier alone.
|
|
1690
1699
|
const scope = opts.scope !== undefined ? opts.scope : await siteSelfScope(siteRoot)
|
|
1691
|
-
doc.queries = queriesNested(
|
|
1700
|
+
doc.queries = queriesNested(
|
|
1701
|
+
colConfig.declarations,
|
|
1702
|
+
opts.queryUuids,
|
|
1703
|
+
scope,
|
|
1704
|
+
opts.keyTyped,
|
|
1705
|
+
opts.queryFields,
|
|
1706
|
+
Array.isArray(opts.schemaless) ? new Set(opts.schemaless) : null
|
|
1707
|
+
)
|
|
1692
1708
|
// Emitted ONLY when the file declares the key — see the header above
|
|
1693
1709
|
// `serviceRecords`: on a replaced Section, absent and empty are different
|
|
1694
1710
|
// requests and one of them is destructive.
|
package/src/uwx/sync-package.js
CHANGED
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
import { buildRecordEntities, entityContentHash } from './records.js'
|
|
23
23
|
import { recordItemLists, hasListItems, stampRecordItems } from './record-items.js'
|
|
24
24
|
import { ASSET_SLOTS } from '@uniweb/semantic-parser'
|
|
25
|
+
import { fileRecordUploads } from './file-records.js'
|
|
25
26
|
import { buildFolderEntity } from './folder.js'
|
|
26
27
|
import { siteProjectToDocument } from './site.js'
|
|
27
28
|
import { siteSelfScope, refuseOrgOption } from './self-scope.js'
|
|
@@ -255,7 +256,7 @@ function rewriteEntityAssets(node, map, ids, noStamp = null) {
|
|
|
255
256
|
* @param {object} [opts.exporter] @param {string} [opts.exportedAt]
|
|
256
257
|
* @returns {Promise<{
|
|
257
258
|
* siteContent: { buffer, entityCount, index, models }|null,
|
|
258
|
-
* records: { buffer, entityCount, index, models }|null,
|
|
259
|
+
* records: { buffer, entityCount, index, models, links }|null,
|
|
259
260
|
* hashes: Object<string,string>, warnings: string[], refusals: string[],
|
|
260
261
|
* skipped: number, schemaless: Array<{name: string, model: string}>,
|
|
261
262
|
* localAssets: string[], applied: object, namesNew: string[],
|
|
@@ -314,8 +315,16 @@ export async function emitSyncPackages(siteRoot, opts = {}) {
|
|
|
314
315
|
|
|
315
316
|
// The folder rides over the FULL record set (before filtering) so its references
|
|
316
317
|
// are complete — new records by `$ref`, already-minted ones by `entry: <uuid>`.
|
|
318
|
+
// ⭐ File records ride the folder as entries too, their value an asset: the push writes an upload
|
|
319
|
+
// key where each URL goes, uploads the files (`localFiles`), and its second emit swaps the keys for
|
|
320
|
+
// serve URLs and stamps the assets' identity beside them, as it does media (`./file-records.js`).
|
|
321
|
+
const { values: fileValues, uploads: localFiles } = await fileRecordUploads({ files: col.files || [], siteRoot })
|
|
322
|
+
const files = (col.files || []).map((f) => ({ ...f, value: fileValues.get(f.poolPath) }))
|
|
317
323
|
const folder = buildFolderEntity({
|
|
318
324
|
recordEntities: col.entities,
|
|
325
|
+
// Link records ride the folder as entries of their own kind (`./link-records.js`).
|
|
326
|
+
links: col.links || [],
|
|
327
|
+
files,
|
|
319
328
|
...(sourceLocale ? { sourceLocale } : {}),
|
|
320
329
|
// ⭐ Every record in the directory, at the top of the folder or in the
|
|
321
330
|
// sub-folder `folder.yml` places it in — and NOTHING when there is no folder to
|
|
@@ -359,6 +368,8 @@ export async function emitSyncPackages(siteRoot, opts = {}) {
|
|
|
359
368
|
// …and the same data schema, where a name stands for the type of its data key
|
|
360
369
|
// (`@/team` → `@/member`, `data-key-types.js`): decided once, by the records.
|
|
361
370
|
keyTyped: col.keyTyped,
|
|
371
|
+
// …and which queries resolved NO data schema — their declarations go up naming none.
|
|
372
|
+
schemaless: col.schemaless.map((q) => q.name),
|
|
362
373
|
// …and whether this deployment takes the key that says so (`siteContent.queryFields`) —
|
|
363
374
|
// and the page and `settings` keys that carry Open Graph texts per language.
|
|
364
375
|
...(Array.isArray(opts.queryFields) ? { queryFields: opts.queryFields } : {}),
|
|
@@ -447,6 +458,8 @@ export async function emitSyncPackages(siteRoot, opts = {}) {
|
|
|
447
458
|
// recognizes on pull by fingerprint (asset-map.js → `servedFingerprint`).
|
|
448
459
|
if (siteDoc) rewriteEntityAssets(siteDoc, assetRewrite, assetIds, siteDoc.info)
|
|
449
460
|
for (const e of col.entities) rewriteEntityAssets(e.document, assetRewrite, assetIds)
|
|
461
|
+
// The folder too: a file entry's value names its asset by an upload key until here.
|
|
462
|
+
if (folder) rewriteEntityAssets(folder.document, assetRewrite, assetIds)
|
|
450
463
|
}
|
|
451
464
|
// Collect the site-root local refs the deploy must upload (`/images/x.png`).
|
|
452
465
|
// Co-located refs (`./x`, `../x`) need the source `.md` location to resolve — the
|
|
@@ -560,7 +573,9 @@ export async function emitSyncPackages(siteRoot, opts = {}) {
|
|
|
560
573
|
// filtered out here by send-only-changed. Declare them all (the backend rejects a
|
|
561
574
|
// folder that references an undeclared Model).
|
|
562
575
|
const referencedModels = [...collectReferencedModels(folder.document, new Set())]
|
|
563
|
-
|
|
576
|
+
// `links` rides beside the index: the caller banks each link's uuid from the folder the
|
|
577
|
+
// backend returns (`link-records.js::backfillLinkUuids`).
|
|
578
|
+
records = { ...emitLane(entities, exporter, exportedAt, referencedModels), index, links: col.links || [], files: col.files || [] }
|
|
564
579
|
}
|
|
565
580
|
|
|
566
581
|
// --- site-content lane -------------------------------------------------------
|
|
@@ -607,6 +622,9 @@ export async function emitSyncPackages(siteRoot, opts = {}) {
|
|
|
607
622
|
namesNew,
|
|
608
623
|
refusals,
|
|
609
624
|
schemaless: col.schemaless, localAssets, applied,
|
|
625
|
+
// The files a push uploads for its file records, each `{ ref, path, contentType }` — the file, and
|
|
626
|
+
// a PDF's preview. Uploaded through the media lane; `ref` is the key the second emit rewrites.
|
|
627
|
+
localFiles,
|
|
610
628
|
// { stamped, unknown } when identity was applied; null when the caller passed
|
|
611
629
|
// no map. `unknown > 0` with `stamped === 0` on a site that has been pushed
|
|
612
630
|
// before is the index-loss signature the backend refuses — the caller reports it.
|
package/src/uwx/sync-store.js
CHANGED
|
@@ -48,7 +48,7 @@ const VERSION = 1
|
|
|
48
48
|
* `recordItems` is keyed by a record's uuid here, each entry that record's list items
|
|
49
49
|
* (`record-items.js`) — replaced whole, one record at a time.
|
|
50
50
|
*/
|
|
51
|
-
const MAP_SECTIONS = new Set(['records', 'items', 'queries', 'folders', 'assets', 'recordItems'])
|
|
51
|
+
const MAP_SECTIONS = new Set(['records', 'items', 'queries', 'folders', 'assets', 'recordItems', 'files'])
|
|
52
52
|
|
|
53
53
|
const storePath = (siteDir) => join(siteDir, SYNC_STORE_FILE)
|
|
54
54
|
|
package/src/validate-data.js
CHANGED
|
@@ -365,6 +365,14 @@ async function validateRecordFiles(siteRoot, { srcDir, dataSchemas, keyTypes = n
|
|
|
365
365
|
names.set(ref, known)
|
|
366
366
|
if (ref !== folderRef) names.set(folderRef, known)
|
|
367
367
|
for (const pooled of entities) {
|
|
368
|
+
// ⭐ A FILE RECORD'S FILE IS ITS VALUE, never its data (`site/file-records.js`): nothing in it
|
|
369
|
+
// is read, so there is nothing to check — only its name, which a reference may give.
|
|
370
|
+
// ⛔ Until 2026-09-28 its bytes were parsed as YAML here, and every push of a site holding a
|
|
371
|
+
// PDF warned "directives end mark is expected".
|
|
372
|
+
if (pooled.fileRecord) {
|
|
373
|
+
known.add(String(pooled.slug))
|
|
374
|
+
continue
|
|
375
|
+
}
|
|
368
376
|
let records
|
|
369
377
|
try {
|
|
370
378
|
records = await readEntityFile(pooled.absPath)
|