@uniweb/build 0.64.1 → 0.65.0
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 +9 -9
- package/src/i18n/records.js +25 -12
- package/src/import-map-plugin.js +13 -0
- package/src/prerender.js +9 -4
- package/src/resolve-data-schema.js +4 -2
- package/src/runtime-schema.js +15 -18
- package/src/site/config.js +59 -5
- package/src/site/entity-pool.js +2 -2
- package/src/site/foundation-ref.js +21 -2
- package/src/site/index.js +2 -0
- package/src/site/preview-media.js +90 -0
- package/src/site/preview.js +84 -0
- package/src/site/queries-config.js +46 -83
- package/src/site/query-processor.js +251 -144
- package/src/site/record-file.js +35 -0
- package/src/site/records-config.js +21 -0
- package/src/site/registered-foundation.js +3 -2
- package/src/uwx/backfill.js +9 -7
- package/src/uwx/data-schema.js +8 -24
- package/src/uwx/entity-source.js +9 -20
- package/src/uwx/queries-config.js +0 -1
- package/src/uwx/record-layout.js +1 -1
- package/src/uwx/records-project.js +17 -94
- package/src/uwx/records.js +1 -26
- package/src/uwx/site-project.js +2 -5
- package/src/uwx/site.js +6 -6
- package/src/validate-data.js +3 -2
- package/src/vite-foundation-plugin.js +39 -193
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ⛔ A RECORD'S NAME IS ITS FILE'S NAME — and a file holds one record.
|
|
3
|
+
*
|
|
4
|
+
* [Diego, 2026-09-27] — *"`slug` is a concept that is WRONG. We do not build slugs from this."* A
|
|
5
|
+
* record's name is its name as an entry in its folder (`$name`): the file's name, whole, with no
|
|
6
|
+
* key inside the file to override it. And a file holds ONE record, so that name is always its own.
|
|
7
|
+
* ⛔ Until then a record file's top-level `slug:` renamed the record, and a YAML or JSON file
|
|
8
|
+
* holding a list was one record per entry, each named by its own `slug`.
|
|
9
|
+
*
|
|
10
|
+
* Refused where a record file is read — by the build (`query-processor.js`) and by a push
|
|
11
|
+
* (`uwx/entity-source.js`) alike — naming the file and what to do. A BibTeX file still holds its
|
|
12
|
+
* entries, each named by its cite key: that is the format's own name for an entry, not a key a
|
|
13
|
+
* record carries. A `slug` INSIDE a section is that section's field, the author's data like any
|
|
14
|
+
* other; only the record's own level is refused.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* @param {*} data - a record file's content: a markdown file's frontmatter, a YAML or JSON file's
|
|
19
|
+
* @param {string} where - the file, for the message
|
|
20
|
+
* @param {string} name - the file's name without its extension — the record's name
|
|
21
|
+
*/
|
|
22
|
+
export function refuseNotOneRecord(data, where, name) {
|
|
23
|
+
if (Array.isArray(data)) {
|
|
24
|
+
throw new Error(
|
|
25
|
+
`[uniweb] ${where} holds a list of records. A file holds one record, named by the file — ` +
|
|
26
|
+
`write each entry to a file of its own (\`<name>.yml\`, \`<name>.json\`), named as the record is.`
|
|
27
|
+
)
|
|
28
|
+
}
|
|
29
|
+
if (data && typeof data === 'object' && Object.prototype.hasOwnProperty.call(data, 'slug')) {
|
|
30
|
+
throw new Error(
|
|
31
|
+
`[uniweb] ${where}: \`slug:\` names nothing — a record's name is its file's name ("${name}"). ` +
|
|
32
|
+
`Remove the key; to give the record another name, rename the file.`
|
|
33
|
+
)
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -352,3 +352,24 @@ export function resolveFolder(entries, pool, { dir = RECORDS_DIR } = {}) {
|
|
|
352
352
|
|
|
353
353
|
return { nodes: [...branches, ...top], placements, errors, warnings }
|
|
354
354
|
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* The records of a folder tree in FOLDER-TREE ORDER — depth-first, each folder's records and
|
|
358
|
+
* folders in the order they sit, a folder's records where the folder sits. It is the order a
|
|
359
|
+
* question with no `sort` answers in, on both lanes (ruled 2026-09-27 [Diego]): the static lane
|
|
360
|
+
* reads the tree `resolveFolder` builds, which is the tree a push places in a backend's folder.
|
|
361
|
+
*
|
|
362
|
+
* @param {Array} nodes - a folder tree: `{ kind: 'ref', $entityId }` and `{ kind: 'branch', $children }`
|
|
363
|
+
* @returns {string[]} the records' ids, in folder-tree order
|
|
364
|
+
*/
|
|
365
|
+
export function folderTreeOrder(nodes) {
|
|
366
|
+
const out = []
|
|
367
|
+
const walk = (list) => {
|
|
368
|
+
for (const node of list || []) {
|
|
369
|
+
if (node?.kind === 'ref') out.push(node.$entityId)
|
|
370
|
+
else if (node?.kind === 'branch') walk(node.$children)
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
walk(nodes)
|
|
374
|
+
return out
|
|
375
|
+
}
|
|
@@ -11,8 +11,9 @@
|
|
|
11
11
|
* trip, never a wrong answer: the next push or pull reads it again. That is what makes `.uniweb/`
|
|
12
12
|
* (gitignored, per copy) the right home for it.
|
|
13
13
|
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
14
|
+
* Only its `schema` is kept — the foundation schema a build would emit, less its data schemas, which
|
|
15
|
+
* travel as Models. ⛔ The reply carries more than a project reads, a serve location among it; a serve
|
|
16
|
+
* location is decided at publish and read from the response that carries it, never kept here.
|
|
16
17
|
*/
|
|
17
18
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
18
19
|
import { dirname, join } from 'node:path'
|
package/src/uwx/backfill.js
CHANGED
|
@@ -21,10 +21,12 @@
|
|
|
21
21
|
// reaches the file on a pull (`records-project.js`), which restores asset paths first.
|
|
22
22
|
//
|
|
23
23
|
// Single-record YAML/JSON/markdown files are rendered/back-filled in place.
|
|
24
|
-
//
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
//
|
|
24
|
+
// A BibTeX file (many records in one file) is grouped by file and written once,
|
|
25
|
+
// one `$uuid` per record keyed by cite key. Anything genuinely unwritable is
|
|
26
|
+
// reported as 'deferred', never silently skipped.
|
|
27
|
+
// ⛔ A YAML or JSON file holding a list is refused before a push since 2026-09-27
|
|
28
|
+
// (`site/record-file.js`: one record per file), so no entry reaches
|
|
29
|
+
// `backfillArrayFile` from a push any more; it stays exported.
|
|
28
30
|
|
|
29
31
|
import { readFileSync, writeFileSync, existsSync } from 'node:fs'
|
|
30
32
|
import { join } from 'node:path'
|
|
@@ -450,9 +452,9 @@ export function backfillEntityUuids({ index, finalized }) {
|
|
|
450
452
|
continue
|
|
451
453
|
}
|
|
452
454
|
if (entry.multiRecord) {
|
|
453
|
-
// Many records in one file → group by file, written once. BibTeX
|
|
454
|
-
//
|
|
455
|
-
//
|
|
455
|
+
// Many records in one file → group by file, written once. BibTeX re-renders
|
|
456
|
+
// canonically (sync owns the output); the per-file writer is chosen by format
|
|
457
|
+
// below. (A YAML/JSON list file is refused before a push — header.)
|
|
456
458
|
const group = entry.format === 'bib' ? bibFiles : arrayFiles
|
|
457
459
|
let m = group.get(entry.sourceFile)
|
|
458
460
|
if (!m) {
|
package/src/uwx/data-schema.js
CHANGED
|
@@ -141,31 +141,15 @@ export function toDataSchemaDeclaration(normalized, { name, resolveName, resolve
|
|
|
141
141
|
// ⛔ No `creatable_by`: retired 2026-09-23 — Models are open, and `@uniweb/schemas`
|
|
142
142
|
// refuses the key. (It was emitted here when authored.)
|
|
143
143
|
|
|
144
|
-
// `linkable` —
|
|
144
|
+
// `linkable` — AUTHORED ONLY (2026-09-27 [Diego]). A model is linkable unless it says
|
|
145
|
+
// `linkable: false` (omitted ⇒ true), with or without a brief: only `linkable: false`
|
|
146
|
+
// keeps its entries out of references and out of a site's folder. A reference to a
|
|
147
|
+
// model with no brief carries no card, and that is not a reason to refuse one.
|
|
145
148
|
//
|
|
146
|
-
//
|
|
147
|
-
//
|
|
148
|
-
//
|
|
149
|
-
|
|
150
|
-
// brief — a real choice, "do not let other models point at this" — and that is
|
|
151
|
-
// honoured.
|
|
152
|
-
//
|
|
153
|
-
// ⛔ The contradiction is refused rather than silently resolved: `linkable: true`
|
|
154
|
-
// on a brief-less model asks for something that cannot exist, and quietly
|
|
155
|
-
// ignoring it is how an author comes to believe a ref target works. Naming it
|
|
156
|
-
// costs one line and the message says which half to change.
|
|
157
|
-
if (!brief) {
|
|
158
|
-
if (normalized.linkable === true) {
|
|
159
|
-
throw new Error(
|
|
160
|
-
`Data schema '${name}': 'linkable: true' needs a brief section — a model with no brief ` +
|
|
161
|
-
`has no card to hydrate when another model references it. Mark a section 'brief: true', ` +
|
|
162
|
-
`or drop 'linkable'.`
|
|
163
|
-
)
|
|
164
|
-
}
|
|
165
|
-
decl.linkable = false
|
|
166
|
-
} else if (normalized.linkable === false) {
|
|
167
|
-
decl.linkable = false
|
|
168
|
-
}
|
|
149
|
+
// ⛔ Until then a brief-less model was emitted `linkable: false` and `linkable: true`
|
|
150
|
+
// on one was refused, on the reasoning that a reference would have no card to show —
|
|
151
|
+
// which kept its records out of every site's folder.
|
|
152
|
+
if (normalized.linkable === false) decl.linkable = false
|
|
169
153
|
|
|
170
154
|
decl.sections = sections
|
|
171
155
|
return decl
|
package/src/uwx/entity-source.js
CHANGED
|
@@ -39,6 +39,7 @@ const SOURCE_EXTENSIONS = new Set(['.md', '.yml', '.yaml', '.json', '.bib'])
|
|
|
39
39
|
// (below), and a bare `export … from` creates NO local binding — the same
|
|
40
40
|
// trap `core/src/index.js` carries a note about. It fails at runtime, not at
|
|
41
41
|
// import time, so the suite is what catches it.
|
|
42
|
+
import { refuseNotOneRecord } from '../site/record-file.js'
|
|
42
43
|
import { parseFrontmatter } from '../utils/frontmatter.js'
|
|
43
44
|
export { parseFrontmatter }
|
|
44
45
|
|
|
@@ -58,8 +59,9 @@ function formatFor(ext) {
|
|
|
58
59
|
* ⭐ THE FILE IS THE UNIT NOW. `records/{schema}/` supplies the model, so
|
|
59
60
|
* nothing above this needs a directory scan to know what a file is — the pool
|
|
60
61
|
* reader (`site/entity-pool.js`) already walked the tree and paired each file
|
|
61
|
-
* with its schema. A
|
|
62
|
-
*
|
|
62
|
+
* with its schema. A record file yields one record, named by the file; a BibTeX file
|
|
63
|
+
* yields its entries, each named by its cite key. ⛔ A list in a YAML or JSON file, and a
|
|
64
|
+
* `slug:` naming a record, are refused (`site/record-file.js`, 2026-09-27).
|
|
63
65
|
*
|
|
64
66
|
* @param {string} filepath - absolute path to one entity source file
|
|
65
67
|
* @returns {Promise<Array<{ slug, format, data, body, sourceFile, multiRecord }>>}
|
|
@@ -76,8 +78,8 @@ async function readOneFile(filepath) {
|
|
|
76
78
|
|
|
77
79
|
if (format === 'md') {
|
|
78
80
|
const { frontmatter, body } = parseFrontmatter(raw, filepath)
|
|
79
|
-
|
|
80
|
-
return [{ slug, format, data: frontmatter, body, sourceFile: filepath, multiRecord: false }]
|
|
81
|
+
refuseNotOneRecord(frontmatter, filepath, slugFromName)
|
|
82
|
+
return [{ slug: slugFromName, format, data: frontmatter, body, sourceFile: filepath, multiRecord: false }]
|
|
81
83
|
}
|
|
82
84
|
|
|
83
85
|
if (format === 'bib') {
|
|
@@ -89,23 +91,10 @@ async function readOneFile(filepath) {
|
|
|
89
91
|
.map((e) => ({ slug: e.id, format, data: e, body: undefined, sourceFile: filepath, multiRecord: true }))
|
|
90
92
|
}
|
|
91
93
|
|
|
92
|
-
// yaml / json
|
|
94
|
+
// yaml / json — one record, named by the file (`site/record-file.js`)
|
|
93
95
|
const data = format === 'json' ? JSON.parse(raw) : yaml.load(raw, YAML_OPTIONS)
|
|
94
|
-
|
|
95
|
-
// Many records in one file — each carries its own slug. Write-back deferred.
|
|
96
|
-
return data
|
|
97
|
-
.filter((item) => item && typeof item === 'object')
|
|
98
|
-
.map((item) => ({
|
|
99
|
-
slug: item.slug,
|
|
100
|
-
format,
|
|
101
|
-
data: item,
|
|
102
|
-
body: undefined,
|
|
103
|
-
sourceFile: filepath,
|
|
104
|
-
multiRecord: true,
|
|
105
|
-
}))
|
|
106
|
-
}
|
|
96
|
+
refuseNotOneRecord(data, filepath, slugFromName)
|
|
107
97
|
const mapping = data && typeof data === 'object' ? data : {}
|
|
108
|
-
|
|
109
|
-
return [{ slug, format, data: mapping, body: undefined, sourceFile: filepath, multiRecord: false }]
|
|
98
|
+
return [{ slug: slugFromName, format, data: mapping, body: undefined, sourceFile: filepath, multiRecord: false }]
|
|
110
99
|
}
|
|
111
100
|
|
package/src/uwx/record-layout.js
CHANGED
|
@@ -40,7 +40,7 @@ export function recordFileLayout(declaration) {
|
|
|
40
40
|
* The schema's CONTENT body field — the one a `.md` record's markdown body fills: a
|
|
41
41
|
* markup `text` field (`format: markdown|html`) or a `format: prosemirror` json field,
|
|
42
42
|
* declared directly on a top-level single section — the brief or another, like
|
|
43
|
-
* `@std/article`'s `
|
|
43
|
+
* `@std/article`'s `body.content`. The first in declared order.
|
|
44
44
|
*
|
|
45
45
|
* ⛔ Not the brief's alone. Until 2026-09-24 the pull and the per-locale body override
|
|
46
46
|
* looked only in the brief, while the push looked in every single section, so a body
|
|
@@ -40,9 +40,8 @@ import { join, resolve, relative, dirname, extname, basename, sep } from 'node:p
|
|
|
40
40
|
import yaml from 'js-yaml'
|
|
41
41
|
import { YAML_OPTIONS } from '../utils/yaml-schema.js'
|
|
42
42
|
import { parseFrontmatter } from './entity-source.js'
|
|
43
|
-
import { writeRecordFile, writeQueriesConfig, writeRecordsConfig
|
|
44
|
-
import {
|
|
45
|
-
import { defaultSchema, deferredFromSchema, foundationDataSchemas, foundationSchemaJson, QUERIES_YML_RELPATH } from './queries-config.js'
|
|
43
|
+
import { writeRecordFile, writeQueriesConfig, writeRecordsConfig } from './project-writer.js'
|
|
44
|
+
import { defaultSchema, foundationDataSchemas, foundationSchemaJson, QUERIES_YML_RELPATH } from './queries-config.js'
|
|
46
45
|
import { dataKeyTypes } from './data-key-types.js'
|
|
47
46
|
import { poolDirsForSchema, schemaForPoolDirs, resolveRecordsDir } from '../site/entity-pool.js'
|
|
48
47
|
import { folderYmlPath } from '../site/records-config.js'
|
|
@@ -51,8 +50,6 @@ import { unresolveSelfScope, resolveSelfScope, ownSchemaNames, refuseOrgOption }
|
|
|
51
50
|
import { parseCatalogRef } from '../site/foundation-ref.js'
|
|
52
51
|
import { unwrapLocalized, renderEntityDocument } from './backfill.js'
|
|
53
52
|
import { parseBibtex } from '@citestyle/bibtex'
|
|
54
|
-
import { getSchema as getStandardSchema, isStandardSchema } from '@uniweb/schemas'
|
|
55
|
-
import { validateAndNormalizeSchema } from '../resolve-data-schema.js'
|
|
56
53
|
import { createTranslationCollector, writeLocaleTranslations, writeFreeformTranslations } from './locale-sync.js'
|
|
57
54
|
import { buildFreeformRecordPath } from '../i18n/freeform.js'
|
|
58
55
|
|
|
@@ -101,23 +98,20 @@ export function findRecordFileByUuid(poolDir, uuid) {
|
|
|
101
98
|
return null
|
|
102
99
|
}
|
|
103
100
|
|
|
104
|
-
// ⭐ A RECORD KEPT IN A
|
|
105
|
-
//
|
|
106
|
-
//
|
|
107
|
-
// its own beside the list, and the next push held it twice — measured on the `international`
|
|
108
|
-
// template's `records/team/team.json`.
|
|
101
|
+
// ⭐ A RECORD KEPT IN A BIBTEX FILE — several entries in one — is found by its entry's `$uuid`, so
|
|
102
|
+
// a pull does not write it again as a file of its own beside the list. ⛔ Until 2026-09-27 a JSON or
|
|
103
|
+
// YAML array was such a list too; a file holds one record now (`site/record-file.js`).
|
|
109
104
|
export function findListEntryByUuid(poolDir, uuid) {
|
|
110
105
|
if (!uuid || !existsSync(poolDir)) return null
|
|
111
106
|
for (const name of readdirSync(poolDir)) {
|
|
112
107
|
if (name.startsWith('_')) continue
|
|
113
108
|
const ext = extname(name).toLowerCase()
|
|
114
|
-
|
|
115
|
-
|
|
109
|
+
if (ext !== '.bib') continue
|
|
110
|
+
const format = 'bib'
|
|
116
111
|
const path = join(poolDir, name)
|
|
117
112
|
let entries
|
|
118
113
|
try {
|
|
119
|
-
|
|
120
|
-
entries = format === 'json' ? JSON.parse(text) : format === 'yaml' ? yaml.load(text, YAML_OPTIONS) : parseBibtex(text)
|
|
114
|
+
entries = parseBibtex(readFileSync(path, 'utf8'))
|
|
121
115
|
} catch {
|
|
122
116
|
continue
|
|
123
117
|
}
|
|
@@ -128,25 +122,6 @@ export function findListEntryByUuid(poolDir, uuid) {
|
|
|
128
122
|
return null
|
|
129
123
|
}
|
|
130
124
|
|
|
131
|
-
// A pulled record written back into its entry of a JSON or YAML list, every other entry untouched.
|
|
132
|
-
// The entry keeps its `slug`, which names it where a single-record file's name would.
|
|
133
|
-
function writeRecordIntoList({ list, document, declaration, sourceLocale, collector, freeformRelPath, refName, context = null }) {
|
|
134
|
-
const text = readFileSync(list.path, 'utf8')
|
|
135
|
-
const entries = list.format === 'json' ? JSON.parse(text) : yaml.load(text, YAML_OPTIONS)
|
|
136
|
-
const rendered = renderEntityDocument({ document, declaration, format: list.format, sourceLocale, collector, freeformRelPath, refName, context })
|
|
137
|
-
const { $uuid, ...fields } = list.format === 'json' ? JSON.parse(rendered) : yaml.load(rendered, YAML_OPTIONS)
|
|
138
|
-
const slug = entries[list.index]?.slug
|
|
139
|
-
const entry = { ...($uuid ? { $uuid } : {}), ...(slug !== undefined ? { slug } : {}), ...fields }
|
|
140
|
-
// ⭐ An entry the pull did not change leaves the file as the author wrote it — its key order, its
|
|
141
|
-
// spacing, and a YAML file's comments. ⛔ Until 2026-09-26 the file was compared as text and re-dumped.
|
|
142
|
-
if (canonicalJson(entries[list.index]) === canonicalJson(entry)) return 'unchanged'
|
|
143
|
-
const next = entries.map((e, i) => (i === list.index ? entry : e))
|
|
144
|
-
const out = list.format === 'json' ? JSON.stringify(next, null, 2) + '\n' : yaml.dump(next, YAML_DUMP_OPTS)
|
|
145
|
-
if (out === text) return 'unchanged'
|
|
146
|
-
writeFileSync(list.path, out)
|
|
147
|
-
return 'updated'
|
|
148
|
-
}
|
|
149
|
-
|
|
150
125
|
// The format to give a NEW record file in a collection: match the collection's
|
|
151
126
|
// existing single-record files, else markdown when the schema has a content body field
|
|
152
127
|
// (so the body has a home), else YAML.
|
|
@@ -327,40 +302,12 @@ const DECL_WIRE_CONSUMED = new Set([
|
|
|
327
302
|
'typed_by_data_key',
|
|
328
303
|
])
|
|
329
304
|
|
|
330
|
-
|
|
331
|
-
// on disk. ⛔ Until 2026-09-26 a clone, which has none, could not tell a derived `deferred:` from an
|
|
332
|
-
// authored one and wrote it into the author's queries (`international`'s `articles`, over `@std/article`).
|
|
333
|
-
function standardDataSchema(ref) {
|
|
334
|
-
const m = typeof ref === 'string' ? /^@std\/([^/]+)$/.exec(ref) : null
|
|
335
|
-
if (!m || !isStandardSchema(m[1])) return null
|
|
336
|
-
try {
|
|
337
|
-
return validateAndNormalizeSchema(getStandardSchema(m[1]), ref)
|
|
338
|
-
} catch {
|
|
339
|
-
return null
|
|
340
|
-
}
|
|
341
|
-
}
|
|
342
|
-
|
|
343
|
-
// Is this wire `deferred` exactly what the schema's brief would have derived? Compared
|
|
344
|
-
// as an ORDER-INSENSITIVE set: the deriver walks the schema's sections, and a round trip
|
|
345
|
-
// through YAML and the store is not obliged to preserve that order. Comparing as a list
|
|
346
|
-
// would classify a reordered-but-identical value as authored, and persist it.
|
|
347
|
-
function isDerivedDeferred(d, dataSchemas, pulledModel = null) {
|
|
348
|
-
if (!Array.isArray(d.deferred)) return false
|
|
349
|
-
// A clone has no foundation to read a schema from: it judges by the Model the pull read from the
|
|
350
|
-
// backend (`pulledModel`), and a standard schema is known in any case (`standardDataSchema`).
|
|
351
|
-
const derived = deferredFromSchema(dataSchemas?.[d.schema] ?? pulledModel ?? standardDataSchema(d.schema))
|
|
352
|
-
if (!derived || derived.length !== d.deferred.length) return false
|
|
353
|
-
const a = new Set(derived)
|
|
354
|
-
return d.deferred.every((f) => a.has(f))
|
|
355
|
-
}
|
|
356
|
-
|
|
357
|
-
function declToFileShape(wire, dataSchemas = null, scope = null, own = null, keyTypes = null, authored = null, models = null, markedOnly = false) {
|
|
305
|
+
function declToFileShape(wire, scope = null, own = null, keyTypes = null, authored = null, markedOnly = false) {
|
|
358
306
|
// ⛔ UNDO THE PRODUCER'S QUALIFICATION FIRST, before anything compares against
|
|
359
307
|
// `schema`. The push qualifies a foundation-relative `@/x` to `@scope/x`
|
|
360
308
|
// (`site.js::queriesNested`), and both checks below are keyed by the author's
|
|
361
309
|
// `@/x`: against `@scope/x` the query-name default would never match — writing an
|
|
362
|
-
// explicit schema the author never had
|
|
363
|
-
// miss, persisting a derivation into their file (the 2026-08-29 defect).
|
|
310
|
+
// explicit schema the author never had.
|
|
364
311
|
const d = scope && typeof wire.schema === 'string'
|
|
365
312
|
? { ...wire, schema: unresolveSelfScope(wire.schema, scope, own) }
|
|
366
313
|
: wire
|
|
@@ -407,26 +354,8 @@ function declToFileShape(wire, dataSchemas = null, scope = null, own = null, key
|
|
|
407
354
|
setIf(decl, 'where', d.where)
|
|
408
355
|
setIf(decl, 'limit', d.limit)
|
|
409
356
|
setIf(decl, 'excerpt', d.excerpt)
|
|
410
|
-
// ⛔
|
|
411
|
-
//
|
|
412
|
-
// — framework's own test opens with "derived from a collection's data schema, NOT
|
|
413
|
-
// written by hand". But the deriver mutates the declaration in place, so by the time
|
|
414
|
-
// it reaches the wire an emitted `deferred` is indistinguishable from an authored one.
|
|
415
|
-
//
|
|
416
|
-
// ⚠️ Measured 2026-08-29: one push + one pull turned an unstated `deferred:` into a
|
|
417
|
-
// hardcoded list in `collections.yml` — a DIFFERENT file, at HIGHER precedence than
|
|
418
|
-
// the `site.yml` the collection was declared in. The collection then stopped tracking
|
|
419
|
-
// its schema's brief permanently, and nothing reported it.
|
|
420
|
-
//
|
|
421
|
-
// ⭐ This is exactly what the `schema` line above already does: emit on push (the
|
|
422
|
-
// backend needs the effective value), drop on pull when it merely restates what would
|
|
423
|
-
// be derived, so a terse author file stays terse and keeps tracking its schema.
|
|
424
|
-
//
|
|
425
|
-
// ⚖️ Only an EQUAL value is dropped. An author who deliberately writes a narrower or
|
|
426
|
-
// wider `deferred:` than the brief implies has expressed intent, and that survives.
|
|
427
|
-
if (d.deferred !== undefined && !isDerivedDeferred(d, dataSchemas, models?.[wire.schema] ?? null)) {
|
|
428
|
-
decl.deferred = d.deferred
|
|
429
|
-
}
|
|
357
|
+
// ⛔ A stored `deferred` is not written back: `deferred:` is retired (2026-09-27) and the build
|
|
358
|
+
// refuses it — a list carries each record's brief. A store may still hold one from an older push.
|
|
430
359
|
// ⛔ A stored `detail_url` is not written back: `detailUrl:` is retired (2026-09-13)
|
|
431
360
|
// and the build refuses it — its case is `record.url` on an external query.
|
|
432
361
|
setIf(decl, 'queryable', d.queryable)
|
|
@@ -511,18 +440,14 @@ function typedQueryFolders(siteRoot, recordsRoot, scope, own, keyTypes) {
|
|
|
511
440
|
return out
|
|
512
441
|
}
|
|
513
442
|
|
|
514
|
-
export function declarationsToQueriesYml({ document, siteRoot, scope,
|
|
443
|
+
export function declarationsToQueriesYml({ document, siteRoot, scope, ...rest }) {
|
|
515
444
|
refuseOrgOption(rest, 'declarationsToQueriesYml')
|
|
516
445
|
const decls = Array.isArray(document?.queries) ? document.queries : []
|
|
517
446
|
const report = {}
|
|
518
447
|
if (decls.length === 0) return report
|
|
519
448
|
|
|
520
|
-
// The foundation's data schemas, loaded ONCE for the whole projection —
|
|
521
|
-
//
|
|
522
|
-
// foundation on disk, unbuilt, unresolvable) the inverter simply never fires and every
|
|
523
|
-
// `deferred` is treated as authored: the pre-2026-08-29 behaviour, which is the safe
|
|
524
|
-
// direction to fail — persisting a value that did not need persisting loses nothing,
|
|
525
|
-
// where dropping an AUTHORED one would.
|
|
449
|
+
// The foundation's data schemas and section types, loaded ONCE for the whole projection — which
|
|
450
|
+
// of the scope's schemas are its own, and the types of its data keys.
|
|
526
451
|
let siteYml = null
|
|
527
452
|
try {
|
|
528
453
|
siteYml = yaml.load(readFileSync(join(siteRoot, 'site.yml'), 'utf8'), YAML_OPTIONS) || null
|
|
@@ -542,7 +467,7 @@ export function declarationsToQueriesYml({ document, siteRoot, scope, models = n
|
|
|
542
467
|
|
|
543
468
|
const queries = {}
|
|
544
469
|
for (const d of decls) {
|
|
545
|
-
const { name, decl } = declToFileShape(d,
|
|
470
|
+
const { name, decl } = declToFileShape(d, selfScope, own, keyTypes, authored, markedOnly)
|
|
546
471
|
if (!name) continue
|
|
547
472
|
queries[name] = decl
|
|
548
473
|
}
|
|
@@ -796,12 +721,10 @@ export function recordsToProject({ folderDoc, recordDocs = [], siteRoot, opts =
|
|
|
796
721
|
const context = { key: [relative(recordsRoot, dirname(existing?.path ?? filePath)).split(sep).join('/'), where.slug].filter(Boolean).join('/') }
|
|
797
722
|
let status
|
|
798
723
|
try {
|
|
799
|
-
if (existing?.list
|
|
724
|
+
if (existing?.list) {
|
|
800
725
|
// Kept as the author has it: a pull does not re-render BibTeX (said once per file, below).
|
|
801
726
|
keptBib.add(existing.path)
|
|
802
727
|
status = 'unchanged'
|
|
803
|
-
} else if (existing?.list) {
|
|
804
|
-
status = writeRecordIntoList({ list: existing, document: toWrite, declaration, sourceLocale, collector, freeformRelPath, refName, context })
|
|
805
728
|
} else {
|
|
806
729
|
status = writeRecordFile({ filePath, document: toWrite, declaration, format, sourceLocale, collector, freeformRelPath, refName, context })
|
|
807
730
|
}
|
package/src/uwx/records.js
CHANGED
|
@@ -255,7 +255,7 @@ export function recordsToEntities({
|
|
|
255
255
|
}
|
|
256
256
|
// The markdown body of a `.md` record is the value of the schema's CONTENT body field,
|
|
257
257
|
// wherever a top-level single section declares it — the brief, or a body section like
|
|
258
|
-
// `
|
|
258
|
+
// `body.content`. One field is the target; zero means a body has nowhere to go.
|
|
259
259
|
const { target: bodyTarget, count: bodyFields } = contentBodyTarget(declaration)
|
|
260
260
|
|
|
261
261
|
const entities = []
|
|
@@ -269,25 +269,6 @@ export function recordsToEntities({
|
|
|
269
269
|
`"${bodyTarget.section}.${bodyTarget.key}"`
|
|
270
270
|
)
|
|
271
271
|
}
|
|
272
|
-
// ⛔ A SITE'S FOLDER PLACES ONLY A RECORD SOMETHING CAN REFERENCE. A folder entry is a
|
|
273
|
-
// reference, and a Model with no brief is not linkable (`toDataSchemaDeclaration`), so a
|
|
274
|
-
// backend refuses to place its records — measured 2026-09-25 as a `409 … linkable: false`,
|
|
275
|
-
// and by design (backend, 2026-09-26). Refused here, once for the schema, before anything
|
|
276
|
-
// is sent — and saying why, which the backend's refusal names only by its design doc.
|
|
277
|
-
if (declaration.linkable === false) {
|
|
278
|
-
const slugs = (records || []).map((r) => r?.slug).filter(Boolean)
|
|
279
|
-
if (slugs.length) {
|
|
280
|
-
const named = slugs.slice(0, 3).join(', ') + (slugs.length > 3 ? ` and ${slugs.length - 3} more` : '')
|
|
281
|
-
refusals.push(
|
|
282
|
-
`${label}: ${declaration.name} has no brief, so a site's folder cannot place its records ` +
|
|
283
|
-
`(${named}) — a folder entry is a reference, and only a record of a schema with a brief ` +
|
|
284
|
-
'can be one. Give the schema a brief (`brief: true` on one of its sections), or keep this ' +
|
|
285
|
-
'data out of records/.'
|
|
286
|
-
)
|
|
287
|
-
}
|
|
288
|
-
return { entities, warnings, refusals }
|
|
289
|
-
}
|
|
290
|
-
|
|
291
272
|
for (const record of records || []) {
|
|
292
273
|
const slug = record.slug
|
|
293
274
|
if (!slug) {
|
|
@@ -415,12 +396,6 @@ function readFlat(layout, record, enc) {
|
|
|
415
396
|
for (const [key, value] of Object.entries(record)) {
|
|
416
397
|
if (!RECORD_KEYS.has(key)) values[key] = value
|
|
417
398
|
}
|
|
418
|
-
// The record's handle fills a `slug` field the section declares, as the flat form
|
|
419
|
-
// always has (`@std/article`'s brief has one). Written by section, the field is
|
|
420
|
-
// written under its section like any other.
|
|
421
|
-
if (record.slug !== undefined && Object.hasOwn(def.fields || {}, 'slug') && values.slug === undefined) {
|
|
422
|
-
values.slug = record.slug
|
|
423
|
-
}
|
|
424
399
|
return { [name]: encodeRecord(def.fields, values, enc, '', null) }
|
|
425
400
|
}
|
|
426
401
|
|
package/src/uwx/site-project.js
CHANGED
|
@@ -1129,12 +1129,9 @@ function projectLayout(layoutSections, layoutBaseDir, report, prune, ctx) {
|
|
|
1129
1129
|
* @param {string|null} [params.scope] - the scope the push qualified a query's
|
|
1130
1130
|
* `@/x` schema with, so it is written back as `@/x` — see
|
|
1131
1131
|
* `declarationsToQueriesYml`, whose default it overrides.
|
|
1132
|
-
* @param {Object<string,object>|null} [params.models] - the Models the site's queries name, as the
|
|
1133
|
-
* pull read them from the backend, keyed by the ref on the wire — what a clone, with no
|
|
1134
|
-
* foundation on disk, judges a query's derived `deferred:` by.
|
|
1135
1132
|
* @returns {{ config: object, collections: object, locales: object, pages: string[], sections: string[], layout: string[], deleted: string[], renamed: object[] }}
|
|
1136
1133
|
*/
|
|
1137
|
-
export function siteContentDocumentToProject({ document, siteRoot, backend = null, sourceLocale = LOCALIZED_FIELD_ASSUMPTION.defaultSourceLocale, prune = false, keepAuthoredFoundation = false, scope
|
|
1134
|
+
export function siteContentDocumentToProject({ document, siteRoot, backend = null, sourceLocale = LOCALIZED_FIELD_ASSUMPTION.defaultSourceLocale, prune = false, keepAuthoredFoundation = false, scope }) {
|
|
1138
1135
|
const report = { config: null, collections: null, locales: null, assets: null, pages: [], sections: [], layout: [], deleted: [], renamed: [] }
|
|
1139
1136
|
|
|
1140
1137
|
// Collects target-locale translations of localized scalars as they're projected;
|
|
@@ -1157,7 +1154,7 @@ export function siteContentDocumentToProject({ document, siteRoot, backend = nul
|
|
|
1157
1154
|
)
|
|
1158
1155
|
|
|
1159
1156
|
report.config = siteInfoToConfig({ document, siteRoot, backend, sourceLocale, collector, keepAuthoredFoundation, deferLanguages: true })
|
|
1160
|
-
report.queries = declarationsToQueriesYml({ document, siteRoot, scope
|
|
1157
|
+
report.queries = declarationsToQueriesYml({ document, siteRoot, scope })
|
|
1161
1158
|
|
|
1162
1159
|
// The uuid identity index (gitignored `.uniweb/`): read the prior map to anchor
|
|
1163
1160
|
// rename detection, build a fresh one as we project, then persist it. Items not
|
package/src/uwx/site.js
CHANGED
|
@@ -939,7 +939,7 @@ export function isSiteRelativeExtensionUrl(decl) {
|
|
|
939
939
|
*
|
|
940
940
|
* The backend destructures exactly two — `name` and `schema` — and projects none of
|
|
941
941
|
* this Section into a published payload, so the rest read as dead weight. ⛔ THEY ARE
|
|
942
|
-
* OURS, AND THAT IS REASON ENOUGH: `excerpt
|
|
942
|
+
* OURS, AND THAT IS REASON ENOUGH: `excerpt` and `queryable`
|
|
943
943
|
* are read across FRAMEWORK's own runtime, build and kit — `useQueryable`
|
|
944
944
|
* is a public hook a foundation calls to render a filter UI. They drive the file
|
|
945
945
|
* lane, where they work. "The backend does not read it" was never an argument that
|
|
@@ -989,7 +989,6 @@ const DECL_EMITTED_ABOVE = new Set([
|
|
|
989
989
|
'where',
|
|
990
990
|
'limit',
|
|
991
991
|
'excerpt',
|
|
992
|
-
'deferred',
|
|
993
992
|
'queryable',
|
|
994
993
|
// Derived by the rule above, never taken from a file.
|
|
995
994
|
'typed_by_data_key',
|
|
@@ -1021,7 +1020,9 @@ const DECL_NOT_ON_WIRE = new Set([
|
|
|
1021
1020
|
'route',
|
|
1022
1021
|
// Legacy predicate, translated to the canonical `where` upstream. No legacy
|
|
1023
1022
|
// fields on the wire.
|
|
1024
|
-
'filter'
|
|
1023
|
+
'filter',
|
|
1024
|
+
// ⛔ RETIRED 2026-09-27 — the build refuses it (`refuseQueryDeclaration`), so it is never sent.
|
|
1025
|
+
'deferred',
|
|
1025
1026
|
])
|
|
1026
1027
|
|
|
1027
1028
|
/** An external query's source, as it rides in the `queries` Section's `source`. */
|
|
@@ -1066,7 +1067,6 @@ function queriesNested(declarations, uuids = null, scope = null, keyTyped = null
|
|
|
1066
1067
|
setIf(data, 'where', d.where)
|
|
1067
1068
|
setIf(data, 'limit', d.limit)
|
|
1068
1069
|
setIf(data, 'excerpt', d.excerpt)
|
|
1069
|
-
setIf(data, 'deferred', d.deferred)
|
|
1070
1070
|
setIf(data, 'queryable', d.queryable)
|
|
1071
1071
|
// ⛔ EMIT WHAT WE DO NOT MODEL. The decl's field set is the BACKEND's Model
|
|
1072
1072
|
// (this document mirrors `@uniweb/site-content` — see the lane header), and
|
|
@@ -1290,8 +1290,8 @@ function settingsNested(siteYml, { headHtml, themeYml, sourceLocale, translation
|
|
|
1290
1290
|
// codes included: sync carries the full working set and only *publish* filters,
|
|
1291
1291
|
// which preserves a locale's publish intent across a remove + re-add in `languages:`.
|
|
1292
1292
|
// ⭐ A site that declares no `languages:` has the languages its translation files make — the build's
|
|
1293
|
-
// rule (`i18n/locales.js`) — and sends that list (`languages`, below)
|
|
1294
|
-
//
|
|
1293
|
+
// rule (`i18n/locales.js`) — and sends that list (`languages`, below): a host may serve a site in
|
|
1294
|
+
// the languages it declares and no others, and the site's
|
|
1295
1295
|
// localized URLs with them. The pull does not write it back where the files already say it
|
|
1296
1296
|
// (`siteContentDocumentToProject`). ⛔ Until 2026-09-26 only a declared list was sent, so the
|
|
1297
1297
|
// `international` template, which declares none, sent no language but its source.
|
package/src/validate-data.js
CHANGED
|
@@ -258,7 +258,8 @@ export async function validateDataInputs({ siteRoot, foundationPath }) {
|
|
|
258
258
|
|
|
259
259
|
items.forEach((item, idx) => {
|
|
260
260
|
recordCount++
|
|
261
|
-
|
|
261
|
+
// A delivered record is named by `$name` — the handle its file's pool entry is keyed by.
|
|
262
|
+
if (item && typeof item.$name === 'string') checkedRecords.add(recordKey(entry.ref, item.$name))
|
|
262
263
|
for (const finding of validateItem(entry.schema, item)) {
|
|
263
264
|
violations.push({
|
|
264
265
|
file: entry.path,
|
|
@@ -620,7 +621,7 @@ function toShippedShape(value) {
|
|
|
620
621
|
|
|
621
622
|
function itemLabel(item, idx) {
|
|
622
623
|
if (item && typeof item === 'object') {
|
|
623
|
-
if (typeof item
|
|
624
|
+
if (typeof item.$name === 'string' && item.$name) return item.$name
|
|
624
625
|
if (typeof item.id === 'string' && item.id) return item.id
|
|
625
626
|
}
|
|
626
627
|
return String(idx)
|