@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.
@@ -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
- * The reply is kept whole, as the backend answered it — its `schema` is the foundation schema a
15
- * build would emit, less its data schemas, which travel as Models.
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'
@@ -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
- // Multi-record files — array-form YAML/JSON and BibTeX (many records in one
25
- // file) — are grouped by file and written once, one `$uuid` per record keyed by
26
- // slug/cite-key. Anything genuinely unwritable is reported as 'deferred', never
27
- // silently skipped.
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 and
454
- // array-form YAML/JSON both re-render canonically (sync owns the output);
455
- // the per-file writer is chosen by format below.
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) {
@@ -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` — DERIVED AND AUTHORED, and the two compose in one direction only.
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
- // A brief-less model has no card to hydrate as an `entity_ref` target, so it
147
- // cannot be linkable whatever it says; a model with a brief is linkable by
148
- // default (omit ⇒ true). ⇒ **The derivation is a ceiling and the authored value
149
- // may only lower it.** An author may say `linkable: false` on a model with a
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
@@ -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 single-record file yields one record; array-form YAML/JSON
62
- * and BibTeX yield several, each carrying its own slug.
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
- const slug = frontmatter.slug || slugFromName
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
- if (Array.isArray(data)) {
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
- const slug = mapping.slug || slugFromName
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
 
@@ -5,7 +5,6 @@ export {
5
5
  resolveQueriesConfig,
6
6
  queriesYmlPath,
7
7
  defaultSchema,
8
- deferredFromSchema,
9
8
  foundationDataSchemas,
10
9
  foundationSchemaJson,
11
10
  QUERIES_YML_RELPATH,
@@ -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 `article_body.content`. The first in declared order.
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, YAML_DUMP_OPTS } from './project-writer.js'
44
- import { canonicalJson } from './same-content.js'
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 LIST FILE — a JSON or YAML array, or a BibTeX file: several records in one —
105
- // is found by its entry's `$uuid`, which the push's back-fill writes into each entry. ⛔ Until
106
- // 2026-09-25 a pull looked in single-record files alone, wrote each such record again as a file of
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
- const format = ext === '.bib' ? 'bib' : formatForExt(ext)
115
- if (format !== 'json' && format !== 'yaml' && format !== 'bib') continue
109
+ if (ext !== '.bib') continue
110
+ const format = 'bib'
116
111
  const path = join(poolDir, name)
117
112
  let entries
118
113
  try {
119
- const text = readFileSync(path, 'utf8')
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
- // A standard schema (`@std/<name>`), normalized as the build resolves one — known with no foundation
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 — and the derived-`deferred` lookup would
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
- // ⛔ DO NOT WRITE A DERIVATION INTO THE AUTHOR'S FILE. `deferred:` is derived from
411
- // the schema's brief when unstated (`collections-config.js::deriveDeferredFromSchemas`)
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, models = null, ...rest }) {
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 — they are what
521
- // lets `declToFileShape` tell a derived `deferred:` from an authored one. Absent (no
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, dataSchemas, selfScope, own, keyTypes, authored, models, markedOnly)
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 && existing.format === 'bib') {
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
  }
@@ -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
- // `article_body.content`. One field is the target; zero means a body has nowhere to go.
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
 
@@ -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, models = null }) {
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, models })
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`, `deferred`, `detailUrl` and `queryable`
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), as it sends a derived
1294
- // `deferred:`: a host may serve a site in the languages it declares and no others, and the site's
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.
@@ -258,7 +258,8 @@ export async function validateDataInputs({ siteRoot, foundationPath }) {
258
258
 
259
259
  items.forEach((item, idx) => {
260
260
  recordCount++
261
- if (item && typeof item.slug === 'string') checkedRecords.add(recordKey(entry.ref, item.slug))
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.slug === 'string' && item.slug) return item.slug
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)