@uniweb/build 0.72.0 → 0.73.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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-project.js +9 -10
- 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
|
@@ -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-project.js
CHANGED
|
@@ -387,7 +387,7 @@ export function siteInfoToConfig({ document, siteRoot, backend = null, sourceLoc
|
|
|
387
387
|
* Project one section `$`-record (from `page_sections` / `layout_sections`) to a
|
|
388
388
|
* section `.md` file — the inverse of site.js `mapSectionData`. Frontmatter is
|
|
389
389
|
* `type` + `hidden` + the flat `params` (`background` and `theme` among them) +
|
|
390
|
-
* the data declaration (`params.fetch
|
|
390
|
+
* the data declaration (`params.fetch`) + `id` (`stable_id`);
|
|
391
391
|
* the body is the section's content serialized to markdown. Idempotent.
|
|
392
392
|
*
|
|
393
393
|
* Note: `$children` (a section's child sections) are NOT written here — the page
|
|
@@ -406,10 +406,11 @@ export function siteInfoToConfig({ document, siteRoot, backend = null, sourceLoc
|
|
|
406
406
|
export function sectionRecordToFile({ filePath, record, sourceLocale = LOCALIZED_FIELD_ASSUMPTION.defaultSourceLocale, collector, freeformRelPath, freeformCandidates = null, writeId = true, context = null }) {
|
|
407
407
|
// ⛔ A stored `preset` or `input` is not written back: neither has an effect, and the build
|
|
408
408
|
// would only warn about it (2026-09-27).
|
|
409
|
-
// ⛔ No `background` / `theme_override` / `insets`: the site-content Model dropped all
|
|
410
|
-
// (2026-09-28), with a stored background
|
|
411
|
-
// filled in where `params` lacked the key, and a stored
|
|
412
|
-
|
|
409
|
+
// ⛔ No `background` / `theme_override` / `insets` / `fetch`: the site-content Model dropped all
|
|
410
|
+
// four from a section (2026-09-28), with a stored background, theme and data declaration copied
|
|
411
|
+
// into `params`. Until then a field filled in where `params` lacked the key, and a stored
|
|
412
|
+
// `insets[]` was re-inlined into the body. (A PAGE's `fetch` is its own field — `pageRecordToYml`.)
|
|
413
|
+
const { type, stable_id, hidden, params, content } = record || {}
|
|
413
414
|
|
|
414
415
|
// A localized `content` field unwraps to the source-locale doc for the body; its
|
|
415
416
|
// target-locale structural maps are captured into the locales/ collector, and any
|
|
@@ -429,11 +430,9 @@ export function sectionRecordToFile({ filePath, record, sourceLocale = LOCALIZED
|
|
|
429
430
|
// Invert the build's resolution rather than copy it — see fetch-shapes.js. A new
|
|
430
431
|
// file gets `query:` for a declaration of names alone; a section that declares
|
|
431
432
|
// its data locally keeps it (`writeSectionFile`, the declaration keys).
|
|
432
|
-
// ⭐ From `params.fetch`
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
if (ownFetch !== undefined) {
|
|
436
|
-
const { key, value } = authorableDeclaration(ownFetch)
|
|
433
|
+
// ⭐ From `params.fetch` — where a section carries its data since 2026-09-28.
|
|
434
|
+
if (paramFetch !== undefined) {
|
|
435
|
+
const { key, value } = authorableDeclaration(paramFetch)
|
|
437
436
|
frontmatter[key] = value
|
|
438
437
|
}
|
|
439
438
|
// `id:` only where the file's name does not already give it (`writeId`, `pageSectionsToFiles`).
|
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)
|