@uniweb/build 0.56.0 → 0.58.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.
@@ -61,8 +61,9 @@ import { parseBibtex } from '@citestyle/bibtex'
61
61
  import { DATA_DIR, withoutRouteVariables } from '@uniweb/core'
62
62
  import { applyWhere, applySort, refuseUnder, refuseOutsideLanguage, refuseQueryRoute, refuseLimit } from './data-fetcher.js'
63
63
  import { resolveAssetPath, walkContentAssets, isLocalAssetPath } from './assets.js'
64
- import { readEntityPool, groupPoolBySchema, ENTITIES_DIR } from './entity-pool.js'
65
- import { readRecordsConfig, resolveFolder, FOLDER_MISSING } from './records-config.js'
64
+ import { readEntityPool, groupPoolBySchema, poolDirsForSchema } from './entity-pool.js'
65
+ import { readRecordsConfig, resolveFolder } from './records-config.js'
66
+ import { isDraftRecord } from './record-draft.js'
66
67
  import { parseFrontmatter } from '../utils/frontmatter.js'
67
68
 
68
69
  // Try to import content-reader for markdown parsing
@@ -98,7 +99,7 @@ try {
98
99
  * parseQueryConfig('articles', {
99
100
  * schema: '@/article',
100
101
  * sort: 'date desc',
101
- * filter: 'published != false',
102
+ * where: { featured: true },
102
103
  * limit: 100
103
104
  * })
104
105
  */
@@ -109,7 +110,7 @@ function parseQueryConfig(name, config) {
109
110
  // shows) would have crashed on the shortest thing an author can write.
110
111
  if (config === null || config === undefined) config = {}
111
112
  if (typeof config === 'string') {
112
- // The string shorthand names the SCHEMA — `entities/{schema}/` supplies the
113
+ // The string shorthand names the SCHEMA — `records/{schema}/` supplies the
113
114
  // records, so there is no directory for a query to name.
114
115
  return {
115
116
  name,
@@ -131,11 +132,11 @@ function parseQueryConfig(name, config) {
131
132
  refuseLimit(config.limit, `queries.${name}`)
132
133
  return {
133
134
  name,
134
- // The query's schema selects its records from the pool — `entities/{schema}/`
135
- // declares the model, so the entities of a schema ARE the query's records.
135
+ // The query's schema selects its records — `records/{schema}/` declares the
136
+ // model, so the site's records of a schema are the query's records.
136
137
  schema: config.schema || null,
137
138
  url: config.url || null,
138
- // The folder branch the query reads (`records.yml` placement). ⛔ Not read
139
+ // The folder branch the query reads (`folder.yml` placement). ⛔ Not read
139
140
  // here until 2026-09-11: a named query's `scope` was ignored on this lane.
140
141
  scope: typeof config.scope === 'string' ? config.scope : null,
141
142
  sort: config.sort || null,
@@ -266,25 +267,25 @@ function isExternalUrl(src) {
266
267
  * conflation (after the freeform locale tree and the translation manifest): an asset
267
268
  * belongs to a record, and which query selects it is not a fact about it.
268
269
  *
269
- * ⭐ ITS PATH UNDER THE ENTITIES ROOT, whole. ⛔ Until 2026-09-14 it was the record's
270
+ * ⭐ ITS PATH UNDER THE RECORDS ROOT, whole. ⛔ Until 2026-09-14 it was the record's
270
271
  * schema folder plus the asset's BASENAME, so `./a/pic.png` and `./b/pic.png` under
271
272
  * one schema folder were copied to one file — the last one won — and a record showed
272
273
  * another record's picture. Now a file beside its record keeps the URL it always had
273
274
  * (`article/pic.png`), a file in a subfolder keeps its subfolder (`article/img/pic.png`),
274
275
  * and one elsewhere under the root keeps its own path (`../shared/logo.svg` from
275
- * `entities/article/` → `shared/logo.svg`).
276
+ * `records/article/` → `shared/logo.svg`).
276
277
  *
277
- * A file OUTSIDE the entities root has no such path, so it is named for where it is:
278
+ * A file OUTSIDE the records root has no such path, so it is named for where it is:
278
279
  * `_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
+ * its path relative to the records root — the same on every machine with the same
280
281
  * layout, and different for two files that share a name.
281
282
  *
282
283
  * @param {string} resolved - the asset's absolute path
283
- * @param {string} entitiesRoot - the site's entities root, absolute
284
+ * @param {string} recordsRoot - the site's records root, absolute
284
285
  * @returns {string} a `/`-separated path below `records/`
285
286
  */
286
- function recordAssetPath(resolved, entitiesRoot) {
287
- const rel = relative(entitiesRoot, resolved)
287
+ function recordAssetPath(resolved, recordsRoot) {
288
+ const rel = relative(recordsRoot, resolved)
288
289
  const posix = rel.split(sep).join('/')
289
290
  const inside = rel !== '' && rel !== '..' && !rel.startsWith(`..${sep}`) && !isAbsolute(rel)
290
291
  if (inside) return posix
@@ -297,8 +298,8 @@ function recordAssetPath(resolved, entitiesRoot) {
297
298
  *
298
299
  * @returns {Promise<{ url: string, copied: string }>} its URL under `basePath`, and the copy
299
300
  */
300
- async function publishRecordAsset(resolved, siteRoot, entitiesRoot, basePath) {
301
- const path = recordAssetPath(resolved, entitiesRoot)
301
+ async function publishRecordAsset(resolved, siteRoot, recordsRoot, basePath) {
302
+ const path = recordAssetPath(resolved, recordsRoot)
302
303
  const copied = join(siteRoot, 'public', 'records', ...path.split('/'))
303
304
  await mkdir(dirname(copied), { recursive: true })
304
305
  await copyFile(resolved, copied)
@@ -308,18 +309,18 @@ async function publishRecordAsset(resolved, siteRoot, entitiesRoot, basePath) {
308
309
  /**
309
310
  * Process assets in collection content
310
311
  * - Resolves relative paths to site-root-relative paths
311
- * - Copies co-located assets to public/records/, by their path under the entities
312
+ * - Copies co-located assets to public/records/, by their path under the records
312
313
  * root (`recordAssetPath`)
313
314
  * - Updates paths in the content in place
314
315
  *
315
316
  * @param {Object} content - ProseMirror document
316
317
  * @param {string} itemPath - Path to the markdown file
317
318
  * @param {string} siteRoot - Site root directory
318
- * @param {string} entitiesRoot - The site's entities root, absolute
319
+ * @param {string} recordsRoot - The site's records directory, absolute
319
320
  * @param {string} basePath - Site base path (e.g., '/' or '/docs/')
320
321
  * @returns {Promise<Object>} Asset manifest for this item
321
322
  */
322
- async function processRecordAssets(content, itemPath, siteRoot, entitiesRoot, basePath) {
323
+ async function processRecordAssets(content, itemPath, siteRoot, recordsRoot, basePath) {
323
324
  const assets = {}
324
325
  const publicDir = join(siteRoot, 'public')
325
326
 
@@ -344,7 +345,7 @@ async function processRecordAssets(content, itemPath, siteRoot, entitiesRoot, ba
344
345
  // Check if file exists at resolved location
345
346
  if (existsSync(result.resolved)) {
346
347
  // Copy to public/records/, and point the content at the copy
347
- const { url, copied } = await publishRecordAsset(result.resolved, siteRoot, entitiesRoot, basePath)
348
+ const { url, copied } = await publishRecordAsset(result.resolved, siteRoot, recordsRoot, basePath)
348
349
  finalPath = url
349
350
 
350
351
  assets[src] = {
@@ -376,14 +377,14 @@ async function processRecordAssets(content, itemPath, siteRoot, entitiesRoot, ba
376
377
  if (node.attrs.poster && !isExternalUrl(node.attrs.poster)) {
377
378
  const posterResult = resolveAssetPath(node.attrs.poster, itemPath, siteRoot)
378
379
  if (posterResult.resolved && existsSync(posterResult.resolved)) {
379
- node.attrs.poster = (await publishRecordAsset(posterResult.resolved, siteRoot, entitiesRoot, basePath)).url
380
+ node.attrs.poster = (await publishRecordAsset(posterResult.resolved, siteRoot, recordsRoot, basePath)).url
380
381
  }
381
382
  }
382
383
 
383
384
  if (node.attrs.preview && !isExternalUrl(node.attrs.preview)) {
384
385
  const previewResult = resolveAssetPath(node.attrs.preview, itemPath, siteRoot)
385
386
  if (previewResult.resolved && existsSync(previewResult.resolved)) {
386
- node.attrs.preview = (await publishRecordAsset(previewResult.resolved, siteRoot, entitiesRoot, basePath)).url
387
+ node.attrs.preview = (await publishRecordAsset(previewResult.resolved, siteRoot, recordsRoot, basePath)).url
387
388
  }
388
389
  }
389
390
  }
@@ -395,24 +396,24 @@ async function processRecordAssets(content, itemPath, siteRoot, entitiesRoot, ba
395
396
  * Process assets in a record's data — a YAML/JSON record, or a markdown record's
396
397
  * frontmatter
397
398
  * - Recursively walks the data object looking for local asset paths
398
- * - Copies co-located assets to public/records/, by their path under the entities
399
+ * - Copies co-located assets to public/records/, by their path under the records
399
400
  * root (`recordAssetPath`)
400
401
  * - Rewrites paths to absolute URLs (with base path)
401
402
  *
402
403
  * @param {Object} data - Parsed data object (mutated in place)
403
404
  * @param {string} itemPath - Path to the record's file
404
405
  * @param {string} siteRoot - Site root directory
405
- * @param {string} entitiesRoot - The site's entities root, absolute
406
+ * @param {string} recordsRoot - The site's records directory, absolute
406
407
  * @param {string} basePath - Site base path (e.g., '/' or '/docs/')
407
408
  */
408
- async function processDataItemAssets(data, itemPath, siteRoot, entitiesRoot, basePath) {
409
+ async function processDataItemAssets(data, itemPath, siteRoot, recordsRoot, basePath) {
409
410
  async function walk(parent, key) {
410
411
  const val = parent[key]
411
412
  if (typeof val === 'string' && isLocalAssetPath(val)) {
412
413
  if (val.startsWith('./') || val.startsWith('../')) {
413
414
  const resolved = resolve(dirname(itemPath), val)
414
415
  if (existsSync(resolved)) {
415
- parent[key] = (await publishRecordAsset(resolved, siteRoot, entitiesRoot, basePath)).url
416
+ parent[key] = (await publishRecordAsset(resolved, siteRoot, recordsRoot, basePath)).url
416
417
  }
417
418
  } else if (val.startsWith('/')) {
418
419
  // Absolute site path — just prepend base
@@ -450,28 +451,32 @@ async function processDataItemAssets(data, itemPath, siteRoot, entitiesRoot, bas
450
451
  *
451
452
  * @param {string} dir - Collection directory path
452
453
  * @param {string} filename - YAML filename (.yml or .yaml)
453
- * @returns {Promise<Object|Array|null>} Processed item(s) or null if unpublished
454
+ * @param {boolean} [includeDrafts] - keep `draft: true` records (a preview)
455
+ * @returns {Promise<Object|Array|null>} Processed item(s), or null for a draft this run withholds
454
456
  */
455
- async function processDataItem(dir, filename, siteRoot, entitiesRoot, basePath) {
457
+ async function processDataItem(dir, filename, siteRoot, recordsRoot, basePath, includeDrafts = false) {
456
458
  const filepath = join(dir, filename)
459
+ const where = relative(siteRoot, filepath)
457
460
  const raw = await readFile(filepath, 'utf-8')
458
461
  const data = yaml.load(raw, YAML_OPTIONS) || {}
459
462
 
460
- // Array → multiple items (single-file collection)
463
+ // Array → multiple items (single-file collection). A draft entry is dropped
464
+ // BEFORE its assets are copied, so nothing of it ships.
461
465
  if (Array.isArray(data)) {
462
- for (const item of data) {
466
+ const kept = data.filter((item, i) => !withheld(item, `${where} [${i}]`, includeDrafts))
467
+ for (const item of kept) {
463
468
  if (item && typeof item === 'object') {
464
- await processDataItemAssets(item, filepath, siteRoot, entitiesRoot, basePath)
469
+ await processDataItemAssets(item, filepath, siteRoot, recordsRoot, basePath)
465
470
  }
466
471
  }
467
- return data
472
+ return kept
468
473
  }
469
474
 
470
475
  // Mapping → single item
471
- if (data.published === false) return null
476
+ if (withheld(data, where, includeDrafts)) return null
472
477
  const slug = basename(filename, extname(filename))
473
478
  const item = { slug, ...data }
474
- await processDataItemAssets(item, filepath, siteRoot, entitiesRoot, basePath)
479
+ await processDataItemAssets(item, filepath, siteRoot, recordsRoot, basePath)
475
480
  return item
476
481
  }
477
482
 
@@ -484,28 +489,32 @@ async function processDataItem(dir, filename, siteRoot, entitiesRoot, basePath)
484
489
  *
485
490
  * @param {string} dir - Collection directory path
486
491
  * @param {string} filename - JSON filename
487
- * @returns {Promise<Object|Array|null>} Processed item(s) or null if unpublished
492
+ * @param {boolean} [includeDrafts] - keep `draft: true` records (a preview)
493
+ * @returns {Promise<Object|Array|null>} Processed item(s), or null for a draft this run withholds
488
494
  */
489
- async function processJsonItem(dir, filename, siteRoot, entitiesRoot, basePath) {
495
+ async function processJsonItem(dir, filename, siteRoot, recordsRoot, basePath, includeDrafts = false) {
490
496
  const filepath = join(dir, filename)
497
+ const where = relative(siteRoot, filepath)
491
498
  const raw = await readFile(filepath, 'utf-8')
492
499
  const slug = basename(filename, '.json')
493
500
  const data = JSON.parse(raw)
494
501
 
495
- // Array → multiple items (single-file collection)
502
+ // Array → multiple items (single-file collection). A draft entry is dropped
503
+ // BEFORE its assets are copied, so nothing of it ships.
496
504
  if (Array.isArray(data)) {
497
- for (const item of data) {
505
+ const kept = data.filter((item, i) => !withheld(item, `${where} [${i}]`, includeDrafts))
506
+ for (const item of kept) {
498
507
  if (item && typeof item === 'object') {
499
- await processDataItemAssets(item, filepath, siteRoot, entitiesRoot, basePath)
508
+ await processDataItemAssets(item, filepath, siteRoot, recordsRoot, basePath)
500
509
  }
501
510
  }
502
- return data
511
+ return kept
503
512
  }
504
513
 
505
514
  // Object → single item
506
- if (data.published === false) return null
515
+ if (withheld(data, where, includeDrafts)) return null
507
516
  const item = { slug, ...data }
508
- await processDataItemAssets(item, filepath, siteRoot, entitiesRoot, basePath)
517
+ await processDataItemAssets(item, filepath, siteRoot, recordsRoot, basePath)
509
518
  return item
510
519
  }
511
520
 
@@ -521,17 +530,28 @@ async function processJsonItem(dir, filename, siteRoot, entitiesRoot, basePath)
521
530
  *
522
531
  * @param {string} dir - Collection directory path
523
532
  * @param {string} filename - BibTeX filename (.bib)
533
+ * @param {string} siteRoot - Site root, for messages
534
+ * @param {boolean} [includeDrafts] - keep `draft: true` records (a preview)
524
535
  * @returns {Promise<Array<Object>>} Array of CSL-JSON items, each with `slug`
525
536
  */
526
- async function processBibtexItem(dir, filename) {
537
+ async function processBibtexItem(dir, filename, siteRoot, includeDrafts = false) {
527
538
  const filepath = join(dir, filename)
539
+ const where = relative(siteRoot, filepath)
528
540
  const raw = await readFile(filepath, 'utf-8')
529
541
  const entries = parseBibtex(raw)
530
542
  return entries
531
- .filter(entry => entry && entry.id)
543
+ .filter(entry => entry && entry.id && !withheld(entry, `${where} @${entry.id}`, includeDrafts))
532
544
  .map(entry => ({ slug: entry.id, ...entry }))
533
545
  }
534
546
 
547
+ /**
548
+ * Is this record left out of this run? A draft is, unless the run previews drafts.
549
+ * `isDraftRecord` also refuses the retired `published: false` — on every run.
550
+ */
551
+ function withheld(data, where, includeDrafts) {
552
+ return isDraftRecord(data, where) && !includeDrafts
553
+ }
554
+
535
555
  /**
536
556
  * Process a single content item from a markdown file
537
557
  *
@@ -540,10 +560,10 @@ async function processBibtexItem(dir, filename) {
540
560
  * @param {Object} config - Collection configuration
541
561
  * @param {string} siteRoot - Site root directory for asset resolution
542
562
  * @param {string} basePath - Site base path (e.g., '/' or '/docs/')
543
- * @param {string} entitiesRoot - The site's entities root, absolute
544
- * @returns {Promise<Object|null>} Processed item or null if unpublished
563
+ * @param {string} recordsRoot - The site's records directory, absolute
564
+ * @returns {Promise<Object|null>} Processed item, or null for a draft this run withholds
545
565
  */
546
- async function processContentItem(dir, filename, config, siteRoot, basePath, entitiesRoot) {
566
+ async function processContentItem(dir, filename, config, siteRoot, basePath, recordsRoot) {
547
567
  const filepath = join(dir, filename)
548
568
  const raw = await readFile(filepath, 'utf-8')
549
569
  const slug = basename(filename, extname(filename))
@@ -551,8 +571,8 @@ async function processContentItem(dir, filename, config, siteRoot, basePath, ent
551
571
  // Parse frontmatter and body
552
572
  const { frontmatter, body } = parseFrontmatter(raw, filepath)
553
573
 
554
- // Skip unpublished items by default
555
- if (frontmatter.published === false) {
574
+ // A draft is left out — before its assets are copied, so nothing of it ships.
575
+ if (withheld(frontmatter, relative(siteRoot, filepath), config.includeDrafts)) {
556
576
  return null
557
577
  }
558
578
 
@@ -561,14 +581,14 @@ async function processContentItem(dir, filename, config, siteRoot, basePath, ent
561
581
  // copied nor rewritten: `image: ./cover.jpg` reached the compiled record as written,
562
582
  // a path relative to a file no visitor can reach, while the same line in a `.yml`
563
583
  // record was published.
564
- await processDataItemAssets(frontmatter, filepath, siteRoot, entitiesRoot, basePath)
584
+ await processDataItemAssets(frontmatter, filepath, siteRoot, recordsRoot, basePath)
565
585
 
566
586
  // Parse markdown body to ProseMirror
567
587
  const content = markdownToProseMirror(body)
568
588
 
569
589
  // Process assets (resolve paths, copy co-located files)
570
590
  // This modifies content in place, updating paths to site-root-relative
571
- await processRecordAssets(content, filepath, siteRoot, entitiesRoot, basePath)
591
+ await processRecordAssets(content, filepath, siteRoot, recordsRoot, basePath)
572
592
 
573
593
  // Extract excerpt
574
594
  const excerpt = extractExcerpt(frontmatter, content, config.excerpt)
@@ -644,47 +664,47 @@ function warnDuplicateSlugs(items, queryName) {
644
664
  }
645
665
 
646
666
  /**
647
- * Collect and process all items in a collection folder
667
+ * Collect and process all of a query's records
648
668
  *
649
669
  * @param {string} siteDir - Site root directory
650
- * @param {Object} config - Parsed collection config
670
+ * @param {Object} config - Parsed query config, carrying its `poolEntities`
671
+ * @param {string} recordsRoot - The site's records directory, absolute
651
672
  * @returns {Promise<Array>} Array of processed items
652
673
  */
653
- async function collectItems(siteDir, config, entitiesDir, basePath, locale = null) {
654
- // ⭐ THE QUERY NAMES A SCHEMA AND THE POOL FOLLOWS — the same resolution the
674
+ async function collectItems(siteDir, config, recordsRoot, basePath, locale = null) {
675
+ // ⭐ THE QUERY NAMES A SCHEMA AND THE RECORDS FOLLOW — the same resolution the
655
676
  // sync lane makes, from the same reader, so the two lanes cannot disagree
656
677
  // about which files are a query's records. They used to: this one recursed
657
678
  // into a collection directory and sync did not.
658
679
  const pooled = config.poolEntities || []
659
680
  if (pooled.length === 0) return []
660
681
 
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)
682
+ // A record's co-located assets are copied by their path under the records
683
+ // directory (`recordAssetPath`).
684
+ const dirOf = (e) => resolve(recordsRoot, ...e.dirs)
665
685
 
666
- // Process all entity files (markdown → content items, YAML/JSON → data items,
686
+ // Process every record file (markdown → content items, YAML/JSON → data items,
667
687
  // BibTeX → CSL-JSON bibliography items).
668
688
  let items = await Promise.all(
669
689
  pooled.map((e) => {
670
690
  const dir = dirOf(e)
671
691
  const file = `${e.slug}${e.ext}`
672
692
  if (e.ext === '.bib') {
673
- return processBibtexItem(dir, file)
693
+ return processBibtexItem(dir, file, siteDir, config.includeDrafts)
674
694
  }
675
695
  if (e.ext === '.json') {
676
- return processJsonItem(dir, file, siteDir, entitiesRoot, basePath)
696
+ return processJsonItem(dir, file, siteDir, recordsRoot, basePath, config.includeDrafts)
677
697
  }
678
698
  if (e.ext === '.yml' || e.ext === '.yaml') {
679
- return processDataItem(dir, file, siteDir, entitiesRoot, basePath)
699
+ return processDataItem(dir, file, siteDir, recordsRoot, basePath, config.includeDrafts)
680
700
  }
681
- return processContentItem(dir, file, config, siteDir, basePath, entitiesRoot)
701
+ return processContentItem(dir, file, config, siteDir, basePath, recordsRoot)
682
702
  })
683
703
  )
684
704
 
685
- // ⭐ `path` IS THE PLACEMENT `records.yml` GAVE THE RECORD, and it is the whole
686
- // reason folders exist: `where: { path: { under: 'archive' } }` is how a query
687
- // asks for a slice. Structure is query scope, not navigation.
705
+ // ⭐ `path` IS THE FOLDER `folder.yml` PLACED THE RECORD IN — `''` at the top —
706
+ // and it is the whole reason folders exist: `scope: archive` is how a query asks
707
+ // for a slice. Structure is query scope, not navigation.
688
708
  //
689
709
  // ⛔ THIS WAS HARDCODED TO `''` FOR A WHILE, AND THE COMMENT SAID "until the
690
710
  // folder producer lands". It landed, and this was not revisited — so every
@@ -692,8 +712,8 @@ async function collectItems(siteDir, config, entitiesDir, basePath, locale = nul
692
712
  // failure mode the whole design is built to prevent. Measured before the fix: a
693
713
  // two-record site with an `archive` folder returned `[]` for its own slice.
694
714
  //
695
- // ⚠️ It stays a SCALAR. `matchUnder` in `core/src/where.js` is string-only, so
696
- // an array would match nothing — one placement per entity is the ruling.
715
+ // ⚠️ It stays a SCALAR. `@uniweb/core`'s `withinScope` matches strings only, so
716
+ // an array would match nothing — one placement per record is the ruling.
697
717
  items = items.map((result, i) => {
698
718
  const path = pooled[i] ? (config.placements?.get(pooled[i].id)?.path ?? '') : ''
699
719
  if (Array.isArray(result)) return result.map((item) => item && { ...item, path })
@@ -704,7 +724,7 @@ async function collectItems(siteDir, config, entitiesDir, basePath, locale = nul
704
724
  // contribute their entries individually.
705
725
  items = items.flat()
706
726
 
707
- // Filter out nulls (unpublished items)
727
+ // Filter out nulls (drafts this run withholds)
708
728
  items = items.filter(Boolean)
709
729
 
710
730
  // ⭐ `$name` IS THE RECORD HANDLE ON EVERY SITE (ruled 2026-09-11 [Diego]) — the
@@ -766,69 +786,61 @@ async function collectItems(siteDir, config, entitiesDir, basePath, locale = nul
766
786
  }
767
787
 
768
788
  /**
769
- * Process all content collections defined in site.yml
789
+ * Compile every query over the site's records
770
790
  *
771
791
  * @param {string} siteDir - Site root directory
772
792
  * @param {Object} queriesConfig - the resolved QUERY declarations
773
- * @param {string} [entitiesDir] - pool directory override (`site.yml::paths.entities`)
793
+ * @param {string} [recordsDir] - the records directory (`site.yml::paths.records`),
794
+ * site-root-relative or absolute; resolved from `site.yml` when absent
774
795
  * @param {string} [basePath='/']
775
796
  * @param {Object} [options]
776
797
  * @param {string|null} [options.locale] - the site's default language, which a query's `sort`
777
798
  * collates texts in when it orders the compiled file
778
- * @returns {Promise<Object>} Map of collection name to items array
799
+ * @param {boolean} [options.includeDrafts=false] - keep `draft: true` records: a preview
800
+ * (`pnpm dev`) or a check (`uniweb validate`). Off for anything a site DELIVERS — a
801
+ * draft is a record that is not delivered while the site is published.
802
+ * @returns {Promise<Object>} Map of query name to items array
779
803
  *
780
804
  * @example
781
- * const collections = await processQueries('/path/to/site', {
782
- * articles: { path: 'collections/articles', sort: 'date desc' },
783
- * products: 'collections/products'
805
+ * const byQuery = await processQueries('/path/to/site', {
806
+ * articles: { schema: '@/article', sort: 'date desc' },
784
807
  * })
785
- * // { articles: [...], products: [...] }
808
+ * // { articles: [...] }
786
809
  */
787
- export async function processQueries(siteDir, queriesConfig, entitiesDir, basePath = '/', { locale = null } = {}) {
810
+ export async function processQueries(siteDir, queriesConfig, recordsDir, basePath = '/', { locale = null, includeDrafts = false } = {}) {
788
811
  if (!queriesConfig || typeof queriesConfig !== 'object') {
789
812
  return {}
790
813
  }
791
814
 
792
- // ⭐ ONE POOL WALK FOR EVERY QUERY. Two queries over the same schema read one
793
- // set of files; a query reads none of another schema's.
794
- const pool = await readEntityPool(siteDir, { dir: entitiesDir })
815
+ // ⭐ ONE WALK FOR EVERY QUERY. Two queries over the same schema read one set of
816
+ // files; a query reads none of another schema's.
817
+ const pool = await readEntityPool(siteDir, recordsDir ? { dir: recordsDir } : {})
795
818
  if (pool.errors.length) {
796
819
  for (const e of pool.errors) console.warn(`[query-processor] ${e}`)
797
820
  }
798
821
 
799
- // ⛔ `records.yml` DECIDES WHAT IS PUBLISHED ON THIS LANE TOO, and it did not
800
- // until now. Only the sync lane honoured it, so removing a record from
801
- // `records.yml` left it shipping in `/data/<name>.json` on every static host —
802
- // an author unpublishes a draft and it stays public. Measured before the fix.
803
- //
804
- // ⚖️ MISSING IS NOT EMPTY HERE EITHER, but it means something different from
805
- // what it means to sync. There is no server folder to leave alone, so a site
806
- // with no `records.yml` is simply not managing publication, and its whole pool
807
- // is delivered. (Making missing mean "publish nothing" would turn every site
808
- // without the file into a silently empty one.)
822
+ // ⭐ EVERY FILE IN `records/` IS A RECORD (ruled 2026-09-21 [Diego]), so every one
823
+ // is compiled; `records/folder.yml` only says which folder each sits in (`path`),
824
+ // which is what a query's `scope` reads. ⛔ Until 2026-09-21 `records.yml`, at the
825
+ // site root, listed the records, and an unlisted file was left out of
826
+ // `/data/<name>.json` here.
809
827
  //
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.
815
- const recordsCfg = await readRecordsConfig(siteDir)
828
+ // ⛔ A MALFORMED FILE STILL STOPS THE BUILD. It read as absent here until
829
+ // 2026-09-14 while the sync lane refused the same file (`uwx/records.js`); what an
830
+ // author meant to organize cannot be guessed.
831
+ const recordsCfg = await readRecordsConfig(siteDir, { dir: pool.dir })
816
832
  if (recordsCfg.error) {
817
833
  throw new Error(
818
834
  `[uniweb] ${recordsCfg.error}\n` +
819
- ` records.yml decides which entities are records, so nothing in entities/ is published until it is fixed.`
835
+ ` ${recordsCfg.file} says which folder each record sits in, and a query's \`scope:\` reads that — fix it to build.`
820
836
  )
821
837
  }
822
- const managed = recordsCfg.state !== FOLDER_MISSING
823
- const folder = managed ? resolveFolder(recordsCfg.entries, pool.entities) : null
824
- if (folder) {
825
- for (const e of folder.errors) console.error(`[query-processor] ${e}`)
826
- }
827
- const published = folder
828
- ? pool.entities.filter((e) => folder.placements.has(e.id))
829
- : pool.entities
838
+ const folder = resolveFolder(recordsCfg.entries, pool.entities, { dir: pool.dir })
839
+ for (const e of folder.errors) console.error(`[query-processor] ${e}`)
840
+ for (const w of folder.warnings) console.warn(`[query-processor] ${w}`)
830
841
 
831
- const poolBySchema = groupPoolBySchema(published)
842
+ const poolBySchema = groupPoolBySchema(pool.entities)
843
+ const recordsRoot = resolve(siteDir, pool.dir)
832
844
 
833
845
  const results = {}
834
846
 
@@ -839,15 +851,18 @@ export async function processQueries(siteDir, queriesConfig, entitiesDir, basePa
839
851
  if (config && typeof config === 'object' && config.url !== undefined) continue
840
852
  const parsed = parseQueryConfig(name, config)
841
853
  parsed.poolEntities = parsed.schema ? poolBySchema.get(parsed.schema) || [] : []
842
- parsed.placements = folder?.placements ?? null
854
+ parsed.placements = folder.placements
855
+ parsed.includeDrafts = includeDrafts
843
856
  if (parsed.poolEntities.length === 0) {
857
+ const dirs = parsed.schema ? poolDirsForSchema(parsed.schema) : null
844
858
  console.warn(
845
- `[query-processor] Query "${name}" matches no records — nothing ` +
846
- `published declares ${parsed.schema || '(no schema)'}. ` +
847
- (managed ? 'Check records.yml lists them.' : 'Check entities/.')
859
+ `[query-processor] Query "${name}" matches no records — ` +
860
+ (dirs
861
+ ? `${pool.dir}/${dirs.join('/')}/ holds none for ${parsed.schema}.`
862
+ : `it declares no schema whose records live in ${pool.dir}/.`)
848
863
  )
849
864
  }
850
- const items = await collectItems(siteDir, parsed, entitiesDir, basePath, locale)
865
+ const items = await collectItems(siteDir, parsed, recordsRoot, basePath, locale)
851
866
  results[name] = items
852
867
  console.log(`[query-processor] Processed ${name}: ${items.length} items`)
853
868
  }
@@ -862,8 +877,8 @@ export async function processQueries(siteDir, queriesConfig, entitiesDir, basePa
862
877
  *
863
878
  * Why this is not optional. `public/data/` is a persistent, normally-committed
864
879
  * directory, so anything written there survives until something removes it.
865
- * Without this, unpublishing a record (`published: false`, which the build
866
- * honours automatically) or deleting its source file drops it from the cascade
880
+ * Without this, making a record a draft (`draft: true`, which the build honours
881
+ * automatically) or deleting its source file drops it from the cascade
867
882
  * listing — it vanishes from the site — while its per-record file stays on
868
883
  * disk with the full body, gets committed, and gets deployed. The author has
869
884
  * every reason to believe the content is gone. It is still fetchable at a URL
@@ -0,0 +1,40 @@
1
+ // A record's `draft: true` — it is a record (it sits in the site's folder) but is
2
+ // not delivered while the site is published (ruled 2026-09-21 [Diego]).
3
+ //
4
+ // ⭐ ONE RULE, READ BY BOTH LANES. The build leaves a draft out of what it delivers —
5
+ // the compiled query files a static host serves — and keeps it in `pnpm dev`, where
6
+ // the author previews it, as a hidden page stays previewable. A backend holds the
7
+ // same state as an entity's `disabled`, which withdraws it from every delivery read.
8
+ //
9
+ // ⚖️ NOT THE SAME AS A LEADING `_`. A file named `_x.md` is not a record at all: it
10
+ // is not read, not pushed, not in the folder. A draft IS a record — the folder has
11
+ // it — and only its delivery is withheld.
12
+ //
13
+ // ⛔ `published: false` WAS THE FILE LANE'S SPELLING until 2026-09-21, and it is
14
+ // refused by name rather than read. It was odd per record (a record is not
15
+ // published — the site is), and nothing on a backend honoured it. Ignoring it now
16
+ // would deliver every record written with it, silently.
17
+
18
+ /**
19
+ * Is this record a draft?
20
+ *
21
+ * @param {object} data - the record's fields (a markdown record's frontmatter)
22
+ * @param {string} where - the record, for messages (`records/article/x.md`)
23
+ * @returns {boolean}
24
+ * @throws on the retired `published: false`, and on a `draft:` that is not a boolean
25
+ */
26
+ export function isDraftRecord(data, where) {
27
+ if (!data || typeof data !== 'object') return false
28
+ if (data.published === false) {
29
+ throw new Error(
30
+ `[uniweb] ${where}: \`published: false\` is retired — a record kept out of what the site ` +
31
+ `delivers is \`draft: true\`. It stays a record, in the folder.`
32
+ )
33
+ }
34
+ const draft = data.draft
35
+ if (draft === undefined || draft === null) return false
36
+ if (typeof draft !== 'boolean') {
37
+ throw new Error(`[uniweb] ${where}: \`draft:\` is true or false, not ${JSON.stringify(draft)}.`)
38
+ }
39
+ return draft
40
+ }