@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.
- package/package.json +6 -6
- package/src/content/index.js +3 -0
- package/src/foundation-name.js +63 -0
- package/src/i18n/data-strings.js +1 -1
- package/src/i18n/freeform.js +20 -19
- package/src/i18n/index.js +4 -4
- package/src/i18n/records.js +10 -6
- package/src/index.js +8 -0
- package/src/prerender.js +1 -1
- package/src/schema.js +67 -10
- package/src/site/build-site-data.js +4 -3
- package/src/site/config.js +1 -1
- package/src/site/content-collector.js +5 -4
- package/src/site/entity-pool.js +186 -44
- package/src/site/plugin.js +20 -15
- package/src/site/queries-config.js +16 -6
- package/src/site/query-processor.js +129 -114
- package/src/site/record-draft.js +40 -0
- package/src/site/records-config.js +156 -107
- package/src/utils/numeric-prefix.js +1 -1
- package/src/uwx/asset-map.js +2 -1
- package/src/uwx/backfill.js +24 -4
- package/src/uwx/entity-source.js +4 -4
- package/src/uwx/folder.js +89 -49
- package/src/uwx/index.js +12 -11
- package/src/uwx/project-writer.js +13 -11
- package/src/uwx/records-project.js +108 -75
- package/src/uwx/records.js +319 -227
- package/src/uwx/registry-package.js +8 -1
- package/src/uwx/self-scope.js +5 -5
- package/src/uwx/site-project.js +3 -3
- package/src/uwx/site.js +45 -39
- package/src/uwx/sync-package.js +19 -15
- package/src/validate-data.js +3 -1
|
@@ -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,
|
|
65
|
-
import { readRecordsConfig, resolveFolder
|
|
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
|
-
*
|
|
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 — `
|
|
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
|
|
135
|
-
//
|
|
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 (`
|
|
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
|
|
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
|
-
* `
|
|
276
|
+
* `records/article/` → `shared/logo.svg`).
|
|
276
277
|
*
|
|
277
|
-
* A file OUTSIDE the
|
|
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
|
|
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}
|
|
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,
|
|
287
|
-
const rel = relative(
|
|
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,
|
|
301
|
-
const path = recordAssetPath(resolved,
|
|
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
|
|
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}
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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
|
|
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}
|
|
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,
|
|
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,
|
|
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
|
-
* @
|
|
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,
|
|
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
|
-
|
|
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,
|
|
469
|
+
await processDataItemAssets(item, filepath, siteRoot, recordsRoot, basePath)
|
|
465
470
|
}
|
|
466
471
|
}
|
|
467
|
-
return
|
|
472
|
+
return kept
|
|
468
473
|
}
|
|
469
474
|
|
|
470
475
|
// Mapping → single item
|
|
471
|
-
if (data
|
|
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,
|
|
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
|
-
* @
|
|
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,
|
|
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
|
-
|
|
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,
|
|
508
|
+
await processDataItemAssets(item, filepath, siteRoot, recordsRoot, basePath)
|
|
500
509
|
}
|
|
501
510
|
}
|
|
502
|
-
return
|
|
511
|
+
return kept
|
|
503
512
|
}
|
|
504
513
|
|
|
505
514
|
// Object → single item
|
|
506
|
-
if (data
|
|
515
|
+
if (withheld(data, where, includeDrafts)) return null
|
|
507
516
|
const item = { slug, ...data }
|
|
508
|
-
await processDataItemAssets(item, filepath, siteRoot,
|
|
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}
|
|
544
|
-
* @returns {Promise<Object|null>} Processed item or null
|
|
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,
|
|
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
|
-
//
|
|
555
|
-
if (frontmatter
|
|
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,
|
|
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,
|
|
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
|
|
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
|
|
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,
|
|
654
|
-
// ⭐ THE QUERY NAMES A SCHEMA AND 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
|
|
662
|
-
// (`recordAssetPath`).
|
|
663
|
-
const
|
|
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
|
|
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,
|
|
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,
|
|
699
|
+
return processDataItem(dir, file, siteDir, recordsRoot, basePath, config.includeDrafts)
|
|
680
700
|
}
|
|
681
|
-
return processContentItem(dir, file, config, siteDir, basePath,
|
|
701
|
+
return processContentItem(dir, file, config, siteDir, basePath, recordsRoot)
|
|
682
702
|
})
|
|
683
703
|
)
|
|
684
704
|
|
|
685
|
-
// ⭐ `path` IS THE
|
|
686
|
-
// reason folders exist: `
|
|
687
|
-
//
|
|
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. `
|
|
696
|
-
// an array would match nothing — one placement per
|
|
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 (
|
|
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
|
-
*
|
|
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} [
|
|
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
|
-
* @
|
|
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
|
|
782
|
-
* articles: {
|
|
783
|
-
* products: 'collections/products'
|
|
805
|
+
* const byQuery = await processQueries('/path/to/site', {
|
|
806
|
+
* articles: { schema: '@/article', sort: 'date desc' },
|
|
784
807
|
* })
|
|
785
|
-
* // { articles: [...]
|
|
808
|
+
* // { articles: [...] }
|
|
786
809
|
*/
|
|
787
|
-
export async function processQueries(siteDir, queriesConfig,
|
|
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
|
|
793
|
-
//
|
|
794
|
-
const pool = await readEntityPool(siteDir, { dir:
|
|
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
|
-
//
|
|
800
|
-
//
|
|
801
|
-
//
|
|
802
|
-
//
|
|
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
|
-
// ⛔
|
|
811
|
-
// 2026-09-14
|
|
812
|
-
//
|
|
813
|
-
|
|
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
|
-
`
|
|
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
|
|
823
|
-
const
|
|
824
|
-
|
|
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(
|
|
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
|
|
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 —
|
|
846
|
-
|
|
847
|
-
|
|
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,
|
|
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,
|
|
866
|
-
*
|
|
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
|
+
}
|