@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.
- package/package.json +7 -7
- package/src/dev/api-mount.js +2 -1
- package/src/dev/plugin.js +15 -11
- package/src/generate-entry.js +29 -2
- package/src/i18n/freeform.js +116 -28
- package/src/i18n/index.js +11 -19
- package/src/i18n/records.js +144 -28
- package/src/prerender.js +4 -13
- package/src/resolve-data-schema.js +2 -1
- package/src/runtime-schema.js +3 -2
- package/src/schema.js +13 -3
- package/src/site/build-site-data.js +11 -5
- package/src/site/config.js +2 -1
- package/src/site/content-collector.js +17 -3
- package/src/site/data-fetcher.js +68 -144
- package/src/site/deploy-config.js +2 -1
- package/src/site/fetch-shapes.js +19 -7
- package/src/site/plugin.js +97 -41
- package/src/site/queries-config.js +15 -4
- package/src/site/query-processor.js +110 -62
- package/src/site/records-config.js +3 -2
- package/src/utils/frontmatter.js +2 -1
- package/src/utils/yaml-schema.js +44 -0
- package/src/uwx/backfill.js +3 -2
- package/src/uwx/entity-source.js +2 -1
- package/src/uwx/project-writer.js +3 -2
- package/src/uwx/records-project.js +9 -3
- package/src/uwx/records.js +7 -4
- package/src/uwx/site-project.js +2 -1
- package/src/uwx/site.js +6 -5
- package/src/validate-data.js +9 -8
|
@@ -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:`
|
|
207
|
-
* retired everywhere:
|
|
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
|
|
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}
|
|
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,
|
|
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/
|
|
302
|
-
const
|
|
303
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
403
|
+
* @param {string} itemPath - Path to the record's file
|
|
374
404
|
* @param {string} siteRoot - Site root directory
|
|
375
|
-
* @param {string}
|
|
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,
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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
|
|
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
|
-
|
|
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,
|
|
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,
|
|
679
|
+
return processDataItem(dir, file, siteDir, entitiesRoot, basePath)
|
|
643
680
|
}
|
|
644
|
-
return processContentItem(dir, file, config, siteDir, basePath,
|
|
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)
|
|
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
|
}
|
package/src/utils/frontmatter.js
CHANGED
|
@@ -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 })
|
package/src/uwx/backfill.js
CHANGED
|
@@ -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
|
}
|
package/src/uwx/entity-source.js
CHANGED
|
@@ -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
|
}
|
package/src/uwx/records.js
CHANGED
|
@@ -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
|
|
171
|
-
// `
|
|
172
|
-
//
|
|
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
|
package/src/uwx/site-project.js
CHANGED
|
@@ -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
|