@uniweb/build 0.51.0 → 0.52.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.
@@ -32,9 +32,10 @@ import { join } from 'node:path'
32
32
  import { existsSync, readFileSync } from 'node:fs'
33
33
  import { briefFields, flatRecordFields } from '@uniweb/schemas/conform'
34
34
  import { detectFoundationType } from './foundation-ref.js'
35
- import { refuseQueryRoute } from './data-fetcher.js'
35
+ import { refuseQueryRoute, refuseLimit } from './data-fetcher.js'
36
36
  import { readFile } from 'node:fs/promises'
37
37
  import yaml from 'js-yaml'
38
+ import { YAML_OPTIONS } from '../utils/yaml-schema.js'
38
39
 
39
40
  // Read its own YAML rather than importing the site build's helper. That import
40
41
  // pointed the wrong way — a config resolver reaching into the collector that
@@ -44,7 +45,7 @@ import yaml from 'js-yaml'
44
45
  async function readYamlFile(filePath) {
45
46
  if (!existsSync(filePath)) return {}
46
47
  try {
47
- return yaml.load(await readFile(filePath, 'utf8')) || {}
48
+ return yaml.load(await readFile(filePath, 'utf8'), YAML_OPTIONS) || {}
48
49
  } catch {
49
50
  return {}
50
51
  }
@@ -203,8 +204,9 @@ const RECORD_KEYS = ['url', 'method', 'body', 'transform']
203
204
  * `transform` (a dot-path to its records), `where` / `sort` / `limit` evaluated over
204
205
  * them, and `record:` — `{ url, method, body, transform }` — for one record on a
205
206
  * parametric page. What describes the site's records — `schema`, `scope`,
206
- * `deferred`, `excerpt` — is refused beside `url:`. ⛔ `detailUrl:` is
207
- * retired everywhere: its one real case is `record.url`.
207
+ * `deferred`, `excerpt` — is refused beside `url:`. ⛔ `detailUrl:` and `detail:` are
208
+ * retired everywhere: their one real case is `record.url`. ⛔ And `limit:` is a whole
209
+ * number, 0 or more (`refuseLimit`).
208
210
  *
209
211
  * ⛔ `route:` is retired everywhere too (2026-09-14 [Diego]): a record links to the page
210
212
  * whose route query is its query, as `$route`, which the runtime fills at render time
@@ -222,7 +224,16 @@ export function refuseQueryDeclaration(decl) {
222
224
  `on an external query — one with \`url:\`.`
223
225
  )
224
226
  }
227
+ // ⛔ `detail:` too — retired on a fetch 2026-09-13, and accepted here in silence until
228
+ // 2026-09-14, carried into `config.queries` where nothing read it.
229
+ if (decl.detail !== undefined) {
230
+ throw new Error(
231
+ `[uniweb] ${where}: \`detail:\` is retired. An API's single-record request is \`record: { url: … }\` ` +
232
+ `on an external query — one with \`url:\`.`
233
+ )
234
+ }
225
235
  refuseQueryRoute(decl, where)
236
+ refuseLimit(decl.limit, where)
226
237
  const external = decl.url !== undefined
227
238
  if (!external) {
228
239
  for (const key of ['method', 'body', 'transform', 'record']) {
@@ -52,12 +52,14 @@
52
52
  */
53
53
 
54
54
  import { readFile, readdir, stat, writeFile, mkdir, copyFile, rm } from 'node:fs/promises'
55
- import { join, basename, extname, dirname, relative, resolve, sep } from 'node:path'
55
+ import { join, basename, extname, dirname, relative, resolve, sep, isAbsolute } from 'node:path'
56
56
  import { existsSync } from 'node:fs'
57
+ import { createHash } from 'node:crypto'
57
58
  import yaml from 'js-yaml'
59
+ import { YAML_OPTIONS } from '../utils/yaml-schema.js'
58
60
  import { parseBibtex } from '@citestyle/bibtex'
59
61
  import { DATA_DIR, withoutRouteVariables } from '@uniweb/core'
60
- import { applyWhere, applySort, refuseUnder, refuseOutsideLanguage, refuseQueryRoute } from './data-fetcher.js'
62
+ import { applyWhere, applySort, refuseUnder, refuseOutsideLanguage, refuseQueryRoute, refuseLimit } from './data-fetcher.js'
61
63
  import { resolveAssetPath, walkContentAssets, isLocalAssetPath } from './assets.js'
62
64
  import { readEntityPool, groupPoolBySchema, ENTITIES_DIR } from './entity-pool.js'
63
65
  import { readRecordsConfig, resolveFolder, FOLDER_MISSING } from './records-config.js'
@@ -126,6 +128,7 @@ function parseQueryConfig(name, config) {
126
128
  refuseUnder(config.where, `queries.${name}`)
127
129
  refuseOutsideLanguage(config.where, `queries.${name}`)
128
130
  refuseQueryRoute(config, `queries.${name}`)
131
+ refuseLimit(config.limit, `queries.${name}`)
129
132
  return {
130
133
  name,
131
134
  // The query's schema selects its records from the pool — `entities/{schema}/`
@@ -254,29 +257,71 @@ function isExternalUrl(src) {
254
257
  return /^(https?:)?\/\//.test(src) || src.startsWith('data:')
255
258
  }
256
259
 
260
+ /**
261
+ * Where a record's co-located asset is published — its path below `public/records/`.
262
+ *
263
+ * ⭐ THE RECORD'S OWN HOME, keyed by where the file sits in the pool. It was
264
+ * `public/collections/<queryName>/`, which meant the SAME image was copied once per
265
+ * query that returned the record, under two URLs. Third instance of the same
266
+ * conflation (after the freeform locale tree and the translation manifest): an asset
267
+ * belongs to a record, and which query selects it is not a fact about it.
268
+ *
269
+ * ⭐ ITS PATH UNDER THE ENTITIES ROOT, whole. ⛔ Until 2026-09-14 it was the record's
270
+ * schema folder plus the asset's BASENAME, so `./a/pic.png` and `./b/pic.png` under
271
+ * one schema folder were copied to one file — the last one won — and a record showed
272
+ * another record's picture. Now a file beside its record keeps the URL it always had
273
+ * (`article/pic.png`), a file in a subfolder keeps its subfolder (`article/img/pic.png`),
274
+ * and one elsewhere under the root keeps its own path (`../shared/logo.svg` from
275
+ * `entities/article/` → `shared/logo.svg`).
276
+ *
277
+ * A file OUTSIDE the entities root has no such path, so it is named for where it is:
278
+ * `_external/<hash>-<name>`, the hash being the first 8 hex digits of the sha-256 of
279
+ * its path relative to the entities root — the same on every machine with the same
280
+ * layout, and different for two files that share a name.
281
+ *
282
+ * @param {string} resolved - the asset's absolute path
283
+ * @param {string} entitiesRoot - the site's entities root, absolute
284
+ * @returns {string} a `/`-separated path below `records/`
285
+ */
286
+ function recordAssetPath(resolved, entitiesRoot) {
287
+ const rel = relative(entitiesRoot, resolved)
288
+ const posix = rel.split(sep).join('/')
289
+ const inside = rel !== '' && rel !== '..' && !rel.startsWith(`..${sep}`) && !isAbsolute(rel)
290
+ if (inside) return posix
291
+ const hash = createHash('sha256').update(posix).digest('hex').slice(0, 8)
292
+ return `_external/${hash}-${basename(resolved)}`
293
+ }
294
+
295
+ /**
296
+ * Copy one of a record's assets to its home under `public/records/` (`recordAssetPath`).
297
+ *
298
+ * @returns {Promise<{ url: string, copied: string }>} its URL under `basePath`, and the copy
299
+ */
300
+ async function publishRecordAsset(resolved, siteRoot, entitiesRoot, basePath) {
301
+ const path = recordAssetPath(resolved, entitiesRoot)
302
+ const copied = join(siteRoot, 'public', 'records', ...path.split('/'))
303
+ await mkdir(dirname(copied), { recursive: true })
304
+ await copyFile(resolved, copied)
305
+ return { url: `${basePath}records/${path}`, copied }
306
+ }
307
+
257
308
  /**
258
309
  * Process assets in collection content
259
310
  * - Resolves relative paths to site-root-relative paths
260
- * - Copies co-located assets to public/records/<schema>/
311
+ * - Copies co-located assets to public/records/, by their path under the entities
312
+ * root (`recordAssetPath`)
261
313
  * - Updates paths in the content in place
262
314
  *
263
315
  * @param {Object} content - ProseMirror document
264
316
  * @param {string} itemPath - Path to the markdown file
265
317
  * @param {string} siteRoot - Site root directory
266
- * @param {string} queryName - Name of the collection (e.g., 'articles')
318
+ * @param {string} entitiesRoot - The site's entities root, absolute
319
+ * @param {string} basePath - Site base path (e.g., '/' or '/docs/')
267
320
  * @returns {Promise<Object>} Asset manifest for this item
268
321
  */
269
- async function processRecordAssets(content, itemPath, siteRoot, poolDirs, basePath) {
322
+ async function processRecordAssets(content, itemPath, siteRoot, entitiesRoot, basePath) {
270
323
  const assets = {}
271
- const itemDir = dirname(itemPath)
272
324
  const publicDir = join(siteRoot, 'public')
273
- // ⭐ THE RECORD'S OWN HOME, keyed by its pool position — `public/records/<schema
274
- // dirs>/`. It was `public/collections/<queryName>/`, which meant the SAME image
275
- // was copied once per query that returned the record, under two URLs. Third
276
- // instance of the same conflation (after the freeform locale tree and the
277
- // translation manifest): an asset belongs to a record, and which query selects
278
- // it is not a fact about it.
279
- const targetDir = join(publicDir, 'records', poolDirs)
280
325
 
281
326
  // Walk content and collect asset paths
282
327
  const assetNodes = []
@@ -298,23 +343,14 @@ async function processRecordAssets(content, itemPath, siteRoot, poolDirs, basePa
298
343
  if (src.startsWith('./') || src.startsWith('../')) {
299
344
  // Check if file exists at resolved location
300
345
  if (existsSync(result.resolved)) {
301
- // Copy to public/collections/<collection>/
302
- const assetFilename = basename(result.resolved)
303
- const targetPath = join(targetDir, assetFilename)
304
-
305
- // Ensure target directory exists
306
- await mkdir(targetDir, { recursive: true })
307
-
308
- // Copy the asset
309
- await copyFile(result.resolved, targetPath)
310
-
311
- // Update path to site-root-relative
312
- finalPath = `${basePath}records/${poolDirs}/${assetFilename}`
346
+ // Copy to public/records/, and point the content at the copy
347
+ const { url, copied } = await publishRecordAsset(result.resolved, siteRoot, entitiesRoot, basePath)
348
+ finalPath = url
313
349
 
314
350
  assets[src] = {
315
351
  original: src,
316
352
  resolved: result.resolved,
317
- copied: targetPath,
353
+ copied,
318
354
  publicPath: finalPath
319
355
  }
320
356
  }
@@ -340,22 +376,14 @@ async function processRecordAssets(content, itemPath, siteRoot, poolDirs, basePa
340
376
  if (node.attrs.poster && !isExternalUrl(node.attrs.poster)) {
341
377
  const posterResult = resolveAssetPath(node.attrs.poster, itemPath, siteRoot)
342
378
  if (posterResult.resolved && existsSync(posterResult.resolved)) {
343
- const posterFilename = basename(posterResult.resolved)
344
- const posterTarget = join(targetDir, posterFilename)
345
- await mkdir(targetDir, { recursive: true })
346
- await copyFile(posterResult.resolved, posterTarget)
347
- node.attrs.poster = `${basePath}records/${poolDirs}/${posterFilename}`
379
+ node.attrs.poster = (await publishRecordAsset(posterResult.resolved, siteRoot, entitiesRoot, basePath)).url
348
380
  }
349
381
  }
350
382
 
351
383
  if (node.attrs.preview && !isExternalUrl(node.attrs.preview)) {
352
384
  const previewResult = resolveAssetPath(node.attrs.preview, itemPath, siteRoot)
353
385
  if (previewResult.resolved && existsSync(previewResult.resolved)) {
354
- const previewFilename = basename(previewResult.resolved)
355
- const previewTarget = join(targetDir, previewFilename)
356
- await mkdir(targetDir, { recursive: true })
357
- await copyFile(previewResult.resolved, previewTarget)
358
- node.attrs.preview = `${basePath}records/${poolDirs}/${previewFilename}`
386
+ node.attrs.preview = (await publishRecordAsset(previewResult.resolved, siteRoot, entitiesRoot, basePath)).url
359
387
  }
360
388
  }
361
389
  }
@@ -364,30 +392,27 @@ async function processRecordAssets(content, itemPath, siteRoot, poolDirs, basePa
364
392
  }
365
393
 
366
394
  /**
367
- * Process assets in a data item (YAML/JSON)
395
+ * Process assets in a record's data — a YAML/JSON record, or a markdown record's
396
+ * frontmatter
368
397
  * - Recursively walks the data object looking for local asset paths
369
- * - Copies co-located assets to public/records/<schema>/
398
+ * - Copies co-located assets to public/records/, by their path under the entities
399
+ * root (`recordAssetPath`)
370
400
  * - Rewrites paths to absolute URLs (with base path)
371
401
  *
372
402
  * @param {Object} data - Parsed data object (mutated in place)
373
- * @param {string} itemPath - Path to the data file
403
+ * @param {string} itemPath - Path to the record's file
374
404
  * @param {string} siteRoot - Site root directory
375
- * @param {string} queryName - Name of the collection
405
+ * @param {string} entitiesRoot - The site's entities root, absolute
376
406
  * @param {string} basePath - Site base path (e.g., '/' or '/docs/')
377
407
  */
378
- async function processDataItemAssets(data, itemPath, siteRoot, poolDirs, basePath) {
379
- const targetDir = join(siteRoot, 'public', 'records', poolDirs)
380
-
408
+ async function processDataItemAssets(data, itemPath, siteRoot, entitiesRoot, basePath) {
381
409
  async function walk(parent, key) {
382
410
  const val = parent[key]
383
411
  if (typeof val === 'string' && isLocalAssetPath(val)) {
384
412
  if (val.startsWith('./') || val.startsWith('../')) {
385
413
  const resolved = resolve(dirname(itemPath), val)
386
414
  if (existsSync(resolved)) {
387
- const filename = basename(resolved)
388
- await mkdir(targetDir, { recursive: true })
389
- await copyFile(resolved, join(targetDir, filename))
390
- parent[key] = `${basePath}records/${poolDirs}/${filename}`
415
+ parent[key] = (await publishRecordAsset(resolved, siteRoot, entitiesRoot, basePath)).url
391
416
  }
392
417
  } else if (val.startsWith('/')) {
393
418
  // Absolute site path — just prepend base
@@ -427,16 +452,16 @@ async function processDataItemAssets(data, itemPath, siteRoot, poolDirs, basePat
427
452
  * @param {string} filename - YAML filename (.yml or .yaml)
428
453
  * @returns {Promise<Object|Array|null>} Processed item(s) or null if unpublished
429
454
  */
430
- async function processDataItem(dir, filename, siteRoot, poolDirs, basePath) {
455
+ async function processDataItem(dir, filename, siteRoot, entitiesRoot, basePath) {
431
456
  const filepath = join(dir, filename)
432
457
  const raw = await readFile(filepath, 'utf-8')
433
- const data = yaml.load(raw) || {}
458
+ const data = yaml.load(raw, YAML_OPTIONS) || {}
434
459
 
435
460
  // Array → multiple items (single-file collection)
436
461
  if (Array.isArray(data)) {
437
462
  for (const item of data) {
438
463
  if (item && typeof item === 'object') {
439
- await processDataItemAssets(item, filepath, siteRoot, poolDirs, basePath)
464
+ await processDataItemAssets(item, filepath, siteRoot, entitiesRoot, basePath)
440
465
  }
441
466
  }
442
467
  return data
@@ -446,7 +471,7 @@ async function processDataItem(dir, filename, siteRoot, poolDirs, basePath) {
446
471
  if (data.published === false) return null
447
472
  const slug = basename(filename, extname(filename))
448
473
  const item = { slug, ...data }
449
- await processDataItemAssets(item, filepath, siteRoot, poolDirs, basePath)
474
+ await processDataItemAssets(item, filepath, siteRoot, entitiesRoot, basePath)
450
475
  return item
451
476
  }
452
477
 
@@ -461,7 +486,7 @@ async function processDataItem(dir, filename, siteRoot, poolDirs, basePath) {
461
486
  * @param {string} filename - JSON filename
462
487
  * @returns {Promise<Object|Array|null>} Processed item(s) or null if unpublished
463
488
  */
464
- async function processJsonItem(dir, filename, siteRoot, poolDirs, basePath) {
489
+ async function processJsonItem(dir, filename, siteRoot, entitiesRoot, basePath) {
465
490
  const filepath = join(dir, filename)
466
491
  const raw = await readFile(filepath, 'utf-8')
467
492
  const slug = basename(filename, '.json')
@@ -471,7 +496,7 @@ async function processJsonItem(dir, filename, siteRoot, poolDirs, basePath) {
471
496
  if (Array.isArray(data)) {
472
497
  for (const item of data) {
473
498
  if (item && typeof item === 'object') {
474
- await processDataItemAssets(item, filepath, siteRoot, poolDirs, basePath)
499
+ await processDataItemAssets(item, filepath, siteRoot, entitiesRoot, basePath)
475
500
  }
476
501
  }
477
502
  return data
@@ -480,7 +505,7 @@ async function processJsonItem(dir, filename, siteRoot, poolDirs, basePath) {
480
505
  // Object → single item
481
506
  if (data.published === false) return null
482
507
  const item = { slug, ...data }
483
- await processDataItemAssets(item, filepath, siteRoot, poolDirs, basePath)
508
+ await processDataItemAssets(item, filepath, siteRoot, entitiesRoot, basePath)
484
509
  return item
485
510
  }
486
511
 
@@ -514,9 +539,11 @@ async function processBibtexItem(dir, filename) {
514
539
  * @param {string} filename - Markdown filename
515
540
  * @param {Object} config - Collection configuration
516
541
  * @param {string} siteRoot - Site root directory for asset resolution
542
+ * @param {string} basePath - Site base path (e.g., '/' or '/docs/')
543
+ * @param {string} entitiesRoot - The site's entities root, absolute
517
544
  * @returns {Promise<Object|null>} Processed item or null if unpublished
518
545
  */
519
- async function processContentItem(dir, filename, config, siteRoot, basePath, poolDirs) {
546
+ async function processContentItem(dir, filename, config, siteRoot, basePath, entitiesRoot) {
520
547
  const filepath = join(dir, filename)
521
548
  const raw = await readFile(filepath, 'utf-8')
522
549
  const slug = basename(filename, extname(filename))
@@ -529,18 +556,25 @@ async function processContentItem(dir, filename, config, siteRoot, basePath, poo
529
556
  return null
530
557
  }
531
558
 
559
+ // ⭐ THE FRONTMATTER IS THE RECORD'S DATA, so a co-located path in it is published
560
+ // exactly as a YAML or JSON record's field is. ⛔ Until 2026-09-14 it was neither
561
+ // copied nor rewritten: `image: ./cover.jpg` reached the compiled record as written,
562
+ // a path relative to a file no visitor can reach, while the same line in a `.yml`
563
+ // record was published.
564
+ await processDataItemAssets(frontmatter, filepath, siteRoot, entitiesRoot, basePath)
565
+
532
566
  // Parse markdown body to ProseMirror
533
567
  const content = markdownToProseMirror(body)
534
568
 
535
569
  // Process assets (resolve paths, copy co-located files)
536
570
  // This modifies content in place, updating paths to site-root-relative
537
- await processRecordAssets(content, filepath, siteRoot, poolDirs, basePath)
571
+ await processRecordAssets(content, filepath, siteRoot, entitiesRoot, basePath)
538
572
 
539
573
  // Extract excerpt
540
574
  const excerpt = extractExcerpt(frontmatter, content, config.excerpt)
541
575
 
542
576
  // Extract first image (frontmatter takes precedence)
543
- // Note: paths in content have already been updated by processRecordAssets
577
+ // Note: paths in the frontmatter and the content have already been rewritten above
544
578
  const image = frontmatter.image || extractFirstImage(content)
545
579
 
546
580
  return {
@@ -624,7 +658,10 @@ async function collectItems(siteDir, config, entitiesDir, basePath, locale = nul
624
658
  const pooled = config.poolEntities || []
625
659
  if (pooled.length === 0) return []
626
660
 
627
- const dirOf = (e) => resolve(siteDir, entitiesDir || ENTITIES_DIR, ...e.dirs)
661
+ // A record's co-located assets are published by their path under this root
662
+ // (`recordAssetPath`).
663
+ const entitiesRoot = resolve(siteDir, entitiesDir || ENTITIES_DIR)
664
+ const dirOf = (e) => resolve(entitiesRoot, ...e.dirs)
628
665
 
629
666
  // Process all entity files (markdown → content items, YAML/JSON → data items,
630
667
  // BibTeX → CSL-JSON bibliography items).
@@ -636,12 +673,12 @@ async function collectItems(siteDir, config, entitiesDir, basePath, locale = nul
636
673
  return processBibtexItem(dir, file)
637
674
  }
638
675
  if (e.ext === '.json') {
639
- return processJsonItem(dir, file, siteDir, e.dirs.join('/'), basePath)
676
+ return processJsonItem(dir, file, siteDir, entitiesRoot, basePath)
640
677
  }
641
678
  if (e.ext === '.yml' || e.ext === '.yaml') {
642
- return processDataItem(dir, file, siteDir, e.dirs.join('/'), basePath)
679
+ return processDataItem(dir, file, siteDir, entitiesRoot, basePath)
643
680
  }
644
- return processContentItem(dir, file, config, siteDir, basePath, e.dirs.join('/'))
681
+ return processContentItem(dir, file, config, siteDir, basePath, entitiesRoot)
645
682
  })
646
683
  )
647
684
 
@@ -769,8 +806,19 @@ export async function processQueries(siteDir, queriesConfig, entitiesDir, basePa
769
806
  // with no `records.yml` is simply not managing publication, and its whole pool
770
807
  // is delivered. (Making missing mean "publish nothing" would turn every site
771
808
  // without the file into a silently empty one.)
809
+ //
810
+ // ⛔ AND A MALFORMED FILE IS NOT A MISSING ONE. It read as `missing` here until
811
+ // 2026-09-14 — invalid YAML, or a mapping where the list goes — so the whole pool was
812
+ // delivered, drafts included, with one warning, while the sync lane refused the same
813
+ // file (`uwx/records.js`). An author who wrote `records.yml` is managing publication;
814
+ // what it says cannot be guessed, so the build stops.
772
815
  const recordsCfg = await readRecordsConfig(siteDir)
773
- if (recordsCfg.error) console.warn(`[query-processor] ${recordsCfg.error}`)
816
+ if (recordsCfg.error) {
817
+ throw new Error(
818
+ `[uniweb] ${recordsCfg.error}\n` +
819
+ ` records.yml decides which entities are records, so nothing in entities/ is published until it is fixed.`
820
+ )
821
+ }
774
822
  const managed = recordsCfg.state !== FOLDER_MISSING
775
823
  const folder = managed ? resolveFolder(recordsCfg.entries, pool.entities) : null
776
824
  if (folder) {
@@ -48,6 +48,7 @@ import { existsSync } from 'node:fs'
48
48
  import { readFile } from 'node:fs/promises'
49
49
  import { join } from 'node:path'
50
50
  import yaml from 'js-yaml'
51
+ import { YAML_OPTIONS } from '../utils/yaml-schema.js'
51
52
  import { compareByNumericPrefix } from '../utils/numeric-prefix.js'
52
53
 
53
54
  export const RECORDS_YML_RELPATH = 'records.yml'
@@ -114,7 +115,7 @@ export async function readRecordsConfig(siteRoot) {
114
115
 
115
116
  let doc
116
117
  try {
117
- doc = yaml.load(await readFile(file, 'utf8'))
118
+ doc = yaml.load(await readFile(file, 'utf8'), YAML_OPTIONS)
118
119
  } catch (err) {
119
120
  return { state: FOLDER_MISSING, entries: [], error: `${RECORDS_YML_RELPATH}: ${err.message}` }
120
121
  }
@@ -127,7 +128,7 @@ export async function readRecordsConfig(siteRoot) {
127
128
  state: FOLDER_MISSING,
128
129
  entries: [],
129
130
  error:
130
- `${RECORDS_YML_RELPATH} must be a LIST of what is in the folder, not a mapping. ` +
131
+ `${RECORDS_YML_RELPATH} must be a LIST of what is in the folder, not ${typeof doc === 'object' ? 'a mapping' : 'a single value'}. ` +
131
132
  `The common case is three lines:\n - person/*.md\n - publication/*.md`,
132
133
  }
133
134
  }
@@ -39,6 +39,7 @@
39
39
  */
40
40
 
41
41
  import yaml from 'js-yaml'
42
+ import { YAML_OPTIONS } from './yaml-schema.js'
42
43
 
43
44
  /**
44
45
  * Split YAML frontmatter from a markdown body.
@@ -65,7 +66,7 @@ export function parseFrontmatter(raw, filepath) {
65
66
  }
66
67
 
67
68
  try {
68
- const frontmatter = yaml.load(parts[1]) || {}
69
+ const frontmatter = yaml.load(parts[1], YAML_OPTIONS) || {}
69
70
  const body = parts.slice(2).join('---\n')
70
71
  return { frontmatter, body }
71
72
  } catch (err) {
@@ -0,0 +1,44 @@
1
+ /**
2
+ * How the build reads YAML — ONE set of types, passed to every `yaml.load`.
3
+ *
4
+ * ⛔ AN UNQUOTED DATE IS THE STRING THE AUTHOR WROTE. js-yaml's default schema resolves
5
+ * `date: 2025-06-01` (and `2025-06-01T10:20:30Z`) to a JS `Date`, so the same value
6
+ * reached the build as a string when quoted and as a Date when not. A Date is neither a
7
+ * string nor a number to `@uniweb/core`'s query evaluator, so, measured before this
8
+ * schema existed:
9
+ *
10
+ * - a query's fixed `where: { date: { gte: '2025-01-01' } }` compiled to no records,
11
+ * with no warning;
12
+ * - `sort: date desc` left the compiled file in filename order;
13
+ * - the compiled JSON said `2025-06-01T00:00:00.000Z` where the author wrote
14
+ * `2025-06-01`.
15
+ *
16
+ * ⭐ So this is js-yaml's default schema minus its IMPLICIT timestamp type: numbers,
17
+ * booleans, null and `<<` merge keys resolve exactly as before, and a date or timestamp
18
+ * stays the text it was written as. An explicit `!!timestamp` tag is accepted and yields
19
+ * that text too, so no reader of site content ever receives a Date.
20
+ *
21
+ * ⚠️ READING ONLY. The writers that dump YAML (`uwx/project-writer.js`, `uwx/backfill.js`)
22
+ * keep js-yaml's default, which QUOTES a string that looks like a date or a number — so
23
+ * a written file reads back as the same string in any YAML reader, not only this one.
24
+ *
25
+ * @module @uniweb/build/utils/yaml-schema
26
+ */
27
+
28
+ import yaml from 'js-yaml'
29
+
30
+ /** `!!timestamp`, explicit only, constructed as the text it was written as. */
31
+ const timestampAsWritten = new yaml.Type('tag:yaml.org,2002:timestamp', {
32
+ kind: 'scalar',
33
+ resolve: (data) => yaml.types.timestamp.resolve(data),
34
+ construct: (data) => data,
35
+ })
36
+
37
+ /** The build's YAML types: the default schema's, with no implicit timestamp. */
38
+ export const YAML_SCHEMA = yaml.CORE_SCHEMA.extend({
39
+ implicit: [yaml.types.merge],
40
+ explicit: [timestampAsWritten, yaml.types.binary, yaml.types.omap, yaml.types.pairs, yaml.types.set],
41
+ })
42
+
43
+ /** `yaml.load(text, YAML_OPTIONS)` — the options every reader passes. */
44
+ export const YAML_OPTIONS = Object.freeze({ schema: YAML_SCHEMA })
@@ -21,6 +21,7 @@
21
21
  import { readFileSync, writeFileSync, existsSync } from 'node:fs'
22
22
  import { join } from 'node:path'
23
23
  import yaml from 'js-yaml'
24
+ import { YAML_OPTIONS } from '../utils/yaml-schema.js'
24
25
  import { proseMirrorToMarkdown } from '@uniweb/content-writer'
25
26
  import { parseFrontmatter } from './entity-source.js'
26
27
  import { isProseMirrorField, isContentBodyField } from './data-schema.js'
@@ -85,7 +86,7 @@ export function backfillUuid(filePath, uuid) {
85
86
  } else if (ext === '.yml' || ext === '.yaml') {
86
87
  let obj
87
88
  try {
88
- obj = yaml.load(text)
89
+ obj = yaml.load(text, YAML_OPTIONS)
89
90
  } catch (err) {
90
91
  return { status: 'error', message: `invalid YAML in ${filePath}: ${err.message}` }
91
92
  }
@@ -127,7 +128,7 @@ export function backfillArrayFile(filePath, uuidBySlug) {
127
128
  }
128
129
  let arr
129
130
  try {
130
- arr = ext === '.json' ? JSON.parse(text) : yaml.load(text)
131
+ arr = ext === '.json' ? JSON.parse(text) : yaml.load(text, YAML_OPTIONS)
131
132
  } catch (err) {
132
133
  return { status: 'error', message: `invalid ${ext || '(no extension)'} in ${filePath}: ${err.message}` }
133
134
  }
@@ -23,6 +23,7 @@
23
23
  import { readFile } from 'node:fs/promises'
24
24
  import { basename, extname } from 'node:path'
25
25
  import yaml from 'js-yaml'
26
+ import { YAML_OPTIONS } from '../utils/yaml-schema.js'
26
27
  import { parseBibtex } from '@citestyle/bibtex'
27
28
 
28
29
  const SOURCE_EXTENSIONS = new Set(['.md', '.yml', '.yaml', '.json', '.bib'])
@@ -89,7 +90,7 @@ async function readOneFile(filepath) {
89
90
  }
90
91
 
91
92
  // yaml / json
92
- const data = format === 'json' ? JSON.parse(raw) : yaml.load(raw)
93
+ const data = format === 'json' ? JSON.parse(raw) : yaml.load(raw, YAML_OPTIONS)
93
94
  if (Array.isArray(data)) {
94
95
  // Many records in one file — each carries its own slug. Write-back deferred.
95
96
  return data
@@ -14,6 +14,7 @@ import { readFileSync, writeFileSync, renameSync, readdirSync, existsSync, mkdir
14
14
  import { join, resolve, dirname } from 'node:path'
15
15
  import { randomBytes } from 'node:crypto'
16
16
  import yaml from 'js-yaml'
17
+ import { YAML_OPTIONS } from '../utils/yaml-schema.js'
17
18
  import { proseMirrorToMarkdown, serializeFrontmatter } from '@uniweb/content-writer'
18
19
  import { parseFrontmatter } from './entity-source.js'
19
20
  import { renderEntityDocument } from './backfill.js'
@@ -203,7 +204,7 @@ export function writeSectionFile({ filePath, content, params, reserved = DEFAULT
203
204
  function mergeYamlConfig(filePath, changes, { replace = [] } = {}) {
204
205
  let existing = {}
205
206
  try {
206
- existing = yaml.load(readFileSync(filePath, 'utf8')) || {}
207
+ existing = yaml.load(readFileSync(filePath, 'utf8'), YAML_OPTIONS) || {}
207
208
  } catch {
208
209
  // missing / invalid → start fresh
209
210
  }
@@ -263,7 +264,7 @@ export function writeYamlFile(filePath, obj) {
263
264
  export function writeMergedYaml(filePath, projected, managedKeys) {
264
265
  let existing = {}
265
266
  try {
266
- existing = yaml.load(readFileSync(filePath, 'utf8')) || {}
267
+ existing = yaml.load(readFileSync(filePath, 'utf8'), YAML_OPTIONS) || {}
267
268
  } catch {
268
269
  // missing / invalid → start fresh
269
270
  }
@@ -35,6 +35,7 @@
35
35
  import { readFileSync, readdirSync, existsSync } from 'node:fs'
36
36
  import { join, resolve, relative, extname, basename, sep } from 'node:path'
37
37
  import yaml from 'js-yaml'
38
+ import { YAML_OPTIONS } from '../utils/yaml-schema.js'
38
39
  import { parseFrontmatter } from './entity-source.js'
39
40
  import { writeRecordFile, writeQueriesConfig, writeRecordsConfig } from './project-writer.js'
40
41
  import { defaultSchema, deferredFromSchema, foundationDataSchemas } from './queries-config.js'
@@ -66,7 +67,7 @@ function readFileUuid(filePath, format) {
66
67
  }
67
68
  try {
68
69
  if (format === 'md') return parseFrontmatter(raw, filePath).frontmatter?.$uuid ?? null
69
- const parsed = format === 'json' ? JSON.parse(raw) : yaml.load(raw)
70
+ const parsed = format === 'json' ? JSON.parse(raw) : yaml.load(raw, YAML_OPTIONS)
70
71
  if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null
71
72
  return parsed.$uuid ?? null
72
73
  } catch {
@@ -176,7 +177,7 @@ function locate(document, folderIndex) {
176
177
  /** `site.yml::$org`, bare (`acme`), or null. Stored bare — see `writeSiteOrg`. */
177
178
  function readSiteOrg(siteRoot) {
178
179
  try {
179
- const y = yaml.load(readFileSync(join(siteRoot, 'site.yml'), 'utf8')) || {}
180
+ const y = yaml.load(readFileSync(join(siteRoot, 'site.yml'), 'utf8'), YAML_OPTIONS) || {}
180
181
  return typeof y.$org === 'string' && y.$org ? y.$org : null
181
182
  } catch {
182
183
  return null
@@ -207,6 +208,10 @@ function setIf(obj, key, value) {
207
208
  // resolves as `items` — a round trip that silently relocates a query's pool.
208
209
  // Wire keys `declToFileShape` consumes explicitly — mapped, renamed, or folded into
209
210
  // the file-side `path`/`url`. `$id`/`$uuid`/`name` are identity, not content.
211
+ // ⛔ `detail_url` and `detail` are consumed and never written back: both are retired on a
212
+ // query and the build refuses them, so a pull that restored a stored one would hand the
213
+ // author a file that does not build. (`detail:` was pushed as an unmodelled field until
214
+ // 2026-09-14, so a store can hold it.)
210
215
  const DECL_WIRE_CONSUMED = new Set([
211
216
  'name',
212
217
  '$id',
@@ -219,6 +224,7 @@ const DECL_WIRE_CONSUMED = new Set([
219
224
  'excerpt',
220
225
  'deferred',
221
226
  'detail_url',
227
+ 'detail',
222
228
  'queryable'
223
229
  ])
224
230
 
@@ -342,7 +348,7 @@ export function declarationsToQueriesYml({ document, siteRoot, org }) {
342
348
  // where dropping an AUTHORED one would.
343
349
  let siteYml = null
344
350
  try {
345
- siteYml = yaml.load(readFileSync(join(siteRoot, 'site.yml'), 'utf8')) || null
351
+ siteYml = yaml.load(readFileSync(join(siteRoot, 'site.yml'), 'utf8'), YAML_OPTIONS) || null
346
352
  } catch {
347
353
  siteYml = null
348
354
  }
@@ -41,6 +41,7 @@
41
41
 
42
42
  import { readFileSync, existsSync } from 'node:fs'
43
43
  import yaml from 'js-yaml'
44
+ import { YAML_OPTIONS } from '../utils/yaml-schema.js'
44
45
  import { detectFoundationType } from '../site/foundation-ref.js'
45
46
  import { join, resolve } from 'node:path'
46
47
 
@@ -167,9 +168,11 @@ function encodeFieldValue(value, field, sourceLocale, translations) {
167
168
  ? localize(value, sourceLocale)
168
169
  : localizeScalar(value, sourceLocale, translations)
169
170
  }
170
- // A YAML scalar date parses to a Date. The backend validates `date` as
171
- // `YYYY-MM-DD` and `datetime` as RFC3339 — emitting full ISO for a `date`
172
- // field is rejected before storage, so split by kind.
171
+ // A Date handed in by a caller. The backend validates `date` as `YYYY-MM-DD` and
172
+ // `datetime` as RFC3339 — emitting full ISO for a `date` field is rejected before
173
+ // storage, so split by kind. ⚠️ A record READ from a file never carries one: the
174
+ // build's YAML resolves no timestamps (`utils/yaml-schema.js`), so an unquoted
175
+ // `2026-03-01` is the string as written, the same value a quoted one always was.
173
176
  if (DATE_KINDS.has(field.type) && value instanceof Date) {
174
177
  return field.type === 'date' ? value.toISOString().slice(0, 10) : value.toISOString()
175
178
  }
@@ -429,7 +432,7 @@ function syncableQueries(declarations) {
429
432
  function resolveFoundationDir(siteRoot, opts) {
430
433
  if (opts.foundationDir) return resolve(opts.foundationDir)
431
434
  try {
432
- const siteYml = yaml.load(readFileSync(join(siteRoot, 'site.yml'), 'utf8')) || {}
435
+ const siteYml = yaml.load(readFileSync(join(siteRoot, 'site.yml'), 'utf8'), YAML_OPTIONS) || {}
433
436
  if (!siteYml.foundation) return null
434
437
  const info = detectFoundationType(siteYml.foundation, siteRoot)
435
438
  return info?.type === 'local' && info.path ? info.path : null
@@ -38,6 +38,7 @@ import { readFileSync, existsSync, unlinkSync, renameSync, rmSync, readdirSync,
38
38
  import { isMarkdownFile, isIgnoredFolder } from '../utils/content-files.js'
39
39
  import { createHash } from 'node:crypto'
40
40
  import yaml from 'js-yaml'
41
+ import { YAML_OPTIONS } from '../utils/yaml-schema.js'
41
42
  import { writeSiteConfig, writeThemeFile, writeIfChanged, writeSectionFile, writeMergedYaml } from './project-writer.js'
42
43
  import { declarationsToQueriesYml } from './records-project.js'
43
44
  import { authorableDeclaration, DECLARATION_KEYS } from '../site/fetch-shapes.js'
@@ -203,7 +204,7 @@ const SETTINGS_TO_SITE_YML = {
203
204
  /** An authored YAML config as it stands, or null when there is none to read. */
204
205
  function readAuthoredYaml(filePath) {
205
206
  try {
206
- const value = yaml.load(readFileSync(filePath, 'utf8'))
207
+ const value = yaml.load(readFileSync(filePath, 'utf8'), YAML_OPTIONS)
207
208
  return value && typeof value === 'object' && !Array.isArray(value) ? value : null
208
209
  } catch {
209
210
  return null