@uniweb/build 0.64.2 → 0.66.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 +9 -9
- package/src/i18n/records.js +32 -17
- package/src/prerender.js +16 -11
- package/src/resolve-data-schema.js +4 -2
- package/src/runtime-schema.js +15 -18
- package/src/site/data-fetcher.js +1 -1
- package/src/site/entity-pool.js +2 -2
- package/src/site/file-transport.js +3 -2
- package/src/site/queries-config.js +46 -83
- package/src/site/query-processor.js +264 -149
- package/src/site/record-file.js +35 -0
- package/src/site/records-config.js +22 -1
- package/src/uwx/backfill.js +9 -7
- package/src/uwx/data-schema.js +8 -24
- package/src/uwx/entity-source.js +9 -20
- package/src/uwx/queries-config.js +0 -1
- package/src/uwx/record-layout.js +1 -1
- package/src/uwx/records-project.js +17 -94
- package/src/uwx/records.js +1 -26
- package/src/uwx/site-project.js +2 -5
- package/src/uwx/site.js +6 -6
- package/src/validate-data.js +3 -2
- package/src/vite-foundation-plugin.js +39 -193
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uniweb/build",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.66.0",
|
|
4
4
|
"description": "Build tooling for the Uniweb Component Web Platform",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -57,15 +57,15 @@
|
|
|
57
57
|
"js-yaml": "^4.1.0",
|
|
58
58
|
"sharp": "^0.35.3",
|
|
59
59
|
"yaml": "^2.5.0",
|
|
60
|
-
"@uniweb/schemas": "^0.7.2",
|
|
61
|
-
"@uniweb/content-reader": "^1.2.5",
|
|
62
60
|
"@uniweb/content-writer": "^0.3.4",
|
|
63
|
-
"@uniweb/
|
|
64
|
-
"@uniweb/
|
|
65
|
-
"@uniweb/
|
|
61
|
+
"@uniweb/content-reader": "^1.2.5",
|
|
62
|
+
"@uniweb/schemas": "^0.8.0",
|
|
63
|
+
"@uniweb/projections": "^0.7.6",
|
|
64
|
+
"@uniweb/theming": "^0.1.15",
|
|
65
|
+
"@uniweb/semantic-parser": "^1.4.1"
|
|
66
66
|
},
|
|
67
67
|
"optionalDependencies": {
|
|
68
|
-
"@uniweb/runtime": "^0.27.
|
|
68
|
+
"@uniweb/runtime": "^0.27.8"
|
|
69
69
|
},
|
|
70
70
|
"peerDependencies": {
|
|
71
71
|
"vite": "^5.0.0 || ^6.0.0 || ^7.0.0",
|
|
@@ -74,8 +74,8 @@
|
|
|
74
74
|
"@tailwindcss/vite": "^4.0.0",
|
|
75
75
|
"@vitejs/plugin-react": "^4.0.0 || ^5.0.0",
|
|
76
76
|
"vite-plugin-svgr": "^4.0.0",
|
|
77
|
-
"@uniweb/runtime": "^0.27.
|
|
78
|
-
"@uniweb/core": "^0.
|
|
77
|
+
"@uniweb/runtime": "^0.27.8",
|
|
78
|
+
"@uniweb/core": "^0.32.0"
|
|
79
79
|
},
|
|
80
80
|
"peerDependenciesMeta": {
|
|
81
81
|
"vite": {
|
package/src/i18n/records.js
CHANGED
|
@@ -24,7 +24,7 @@ import { loadFreeformRecord } from './freeform.js'
|
|
|
24
24
|
import { resolveQueriesConfig, resolveRecordSchemas, foundationSchemaJson } from '../site/queries-config.js'
|
|
25
25
|
import { dataKeyTypes, keyOfDefaultRef } from '../uwx/data-key-types.js'
|
|
26
26
|
import { toDataSchemaDeclaration, isProseMirrorField, isOpenMapSection } from '../uwx/data-schema.js'
|
|
27
|
-
import { toDeliveredRecord, contentBodyField, misplacedFields } from '@uniweb/schemas/conform'
|
|
27
|
+
import { toDeliveredRecord, contentBodyField, misplacedFields, mergedFromStored, storedFromMerged } from '@uniweb/schemas/conform'
|
|
28
28
|
import { resolveDocForLocale } from './merge.js'
|
|
29
29
|
import { extractUnitsFromDoc } from './extract.js'
|
|
30
30
|
import { flatFormRefusal, extractExcerpt } from '../site/query-processor.js'
|
|
@@ -361,10 +361,12 @@ function derivesExcerpt(record, whole, dataSchema, config) {
|
|
|
361
361
|
/**
|
|
362
362
|
* ⛔ A RECORD'S SYSTEM FIELDS ARE NEVER PROSE — never extracted, never translated, by
|
|
363
363
|
* either path. A `$`-prefixed key at any depth is the system's (`$name`, the handle a
|
|
364
|
-
* parametric page's URL names; `$uuid
|
|
365
|
-
*
|
|
366
|
-
* translation units, and a translation whose source
|
|
367
|
-
* so a record's page stopped matching its URL.
|
|
364
|
+
* parametric page's URL names; `$uuid`; `$branch`, the folder a compiled list holds a
|
|
365
|
+
* record under), and a top-level `slug` is how a record's reader hands the file's name
|
|
366
|
+
* on. Until 2026-09-14 they became translation units, and a translation whose source
|
|
367
|
+
* happened to equal one rewrote it, so a record's page stopped matching its URL.
|
|
368
|
+
* ⛔ A top-level `path` was one too until 2026-09-27, when the placement it named moved to
|
|
369
|
+
* `$branch` [Diego]: a field an author names `path` is theirs, and translated as its schema says.
|
|
368
370
|
*
|
|
369
371
|
* @param {string|number} key - a field name (an array index is never one)
|
|
370
372
|
* @param {boolean} topLevel - whether the key sits at the record's own level
|
|
@@ -372,7 +374,7 @@ function derivesExcerpt(record, whole, dataSchema, config) {
|
|
|
372
374
|
*/
|
|
373
375
|
function isRecordSystemField(key, topLevel) {
|
|
374
376
|
if (typeof key !== 'string') return false
|
|
375
|
-
return key.startsWith('$') || (topLevel &&
|
|
377
|
+
return key.startsWith('$') || (topLevel && key === 'slug')
|
|
376
378
|
}
|
|
377
379
|
|
|
378
380
|
/**
|
|
@@ -392,7 +394,7 @@ function isProseMirrorDoc(value) {
|
|
|
392
394
|
|
|
393
395
|
/**
|
|
394
396
|
* Every ProseMirror document a record holds, with its path. ⭐ A markdown body is
|
|
395
|
-
* delivered in its schema's content body field — `
|
|
397
|
+
* delivered in its schema's content body field — `body.content` for
|
|
396
398
|
* `@std/article` — and at `content` only for a record with no schema; a rich field can
|
|
397
399
|
* sit anywhere else. ⛔ Until 2026-09-24 only `content` at the top was read, which is
|
|
398
400
|
* where a delivered `@std/article` holds no body.
|
|
@@ -796,7 +798,7 @@ export async function extractRecordContent(siteRoot, options = {}) {
|
|
|
796
798
|
// record whole beside its lean list, and a deferred field is exactly what the list
|
|
797
799
|
// does not hold. ⛔ Until 2026-09-14 only the list was read, so a deferred body —
|
|
798
800
|
// one derived from a schema's brief included — never reached the manifest.
|
|
799
|
-
const wholes = await wholeRecords(dataDir, queryName)
|
|
801
|
+
const wholes = await wholeRecords(dataDir, queryName, dataSchemas.get(queryName) ?? null)
|
|
800
802
|
const extract = (item) => {
|
|
801
803
|
const whole = wholes.get(recordHandle(item))
|
|
802
804
|
const skip = derivesExcerpt(item, whole, dataSchemas.get(queryName), excerpts.get(queryName)) ? EXCERPT : null
|
|
@@ -847,15 +849,19 @@ async function recordFileNames(dataDir, queryName) {
|
|
|
847
849
|
}
|
|
848
850
|
|
|
849
851
|
/**
|
|
850
|
-
* A query's per-record files (`recordFileNames`), each record whole, by its handle
|
|
852
|
+
* A query's per-record files (`recordFileNames`), each record whole, by its handle — as the MERGED
|
|
853
|
+
* view this module walks (`mergedFromStored`): a file holds the record as stored, a key per
|
|
854
|
+
* section (2026-09-27), and translation reads a record with its brief's fields at the top.
|
|
851
855
|
*
|
|
856
|
+
* @param {Object|null} [dataSchema] - the query's data schema; with none a file is read as it is
|
|
852
857
|
* @returns {Promise<Map<string, Object>>}
|
|
853
858
|
*/
|
|
854
|
-
async function wholeRecords(dataDir, queryName) {
|
|
859
|
+
async function wholeRecords(dataDir, queryName, dataSchema = null) {
|
|
855
860
|
const out = new Map()
|
|
856
861
|
for (const name of await recordFileNames(dataDir, queryName)) {
|
|
857
862
|
try {
|
|
858
|
-
const
|
|
863
|
+
const stored = JSON.parse(await readFile(join(dataDir, queryName, name), 'utf-8'))
|
|
864
|
+
const record = dataSchema && isPlainObject(stored) ? mergedFromStored(dataSchema, stored) : stored
|
|
859
865
|
if (isPlainObject(record)) out.set(recordHandle(record), record)
|
|
860
866
|
} catch (err) {
|
|
861
867
|
console.warn(`[i18n] Skipping ${queryName}/${name}: ${err.message}`)
|
|
@@ -890,7 +896,7 @@ async function pruneRecordFiles(recordsDir, dataDir, keep) {
|
|
|
890
896
|
|
|
891
897
|
/**
|
|
892
898
|
* Extract from a ProseMirror document — `field` is where the record holds it
|
|
893
|
-
* (`content`, `
|
|
899
|
+
* (`content`, `body.content`), which each unit's `field` starts with.
|
|
894
900
|
*/
|
|
895
901
|
function extractFromProseMirrorDoc(doc, context, units, field = 'content') {
|
|
896
902
|
// The units a page section's body makes (`extract.js::extractUnitsFromDoc`) — the keys translation
|
|
@@ -1043,13 +1049,18 @@ export async function buildLocalizedRecords(siteRoot, options = {}) {
|
|
|
1043
1049
|
const wholes = new Map() // handle → { source, translated }
|
|
1044
1050
|
for (const name of recordNames) {
|
|
1045
1051
|
try {
|
|
1046
|
-
|
|
1052
|
+
// ⭐ A record's file holds it AS STORED (2026-09-27); it is translated as the merged
|
|
1053
|
+
// view and written back as stored (`mergedFromStored`, `storedFromMerged`).
|
|
1054
|
+
const stored = JSON.parse(await readFile(join(dataDir, queryName, name), 'utf-8'))
|
|
1055
|
+
const { dataSchema } = recordOptions
|
|
1056
|
+
const record = dataSchema && isPlainObject(stored) ? mergedFromStored(dataSchema, stored) : stored
|
|
1047
1057
|
const translatedRecord = isPlainObject(record)
|
|
1048
1058
|
? await translateItemAsync(record, recordDir, translations, schema, recordOptions)
|
|
1049
1059
|
: record
|
|
1050
1060
|
if (isPlainObject(record)) wholes.set(recordHandle(record), { source: record, translated: translatedRecord })
|
|
1061
|
+
const written = dataSchema && isPlainObject(translatedRecord) ? storedFromMerged(dataSchema, translatedRecord) : translatedRecord
|
|
1051
1062
|
await mkdir(localeRecordsDir, { recursive: true })
|
|
1052
|
-
await writeFile(join(localeRecordsDir, name), JSON.stringify(
|
|
1063
|
+
await writeFile(join(localeRecordsDir, name), JSON.stringify(written, null, 2))
|
|
1053
1064
|
} catch (err) {
|
|
1054
1065
|
console.error(`[i18n] Failed to translate ${queryName}/${name} for ${locale}: ${err.message}`)
|
|
1055
1066
|
failures.push({ locale, file: `${queryName}/${name}`, message: err.message })
|
|
@@ -1265,7 +1276,6 @@ export async function translateRecordData(items, queryName, siteRoot, options =
|
|
|
1265
1276
|
|
|
1266
1277
|
const one = !!items && typeof items === 'object' && !Array.isArray(items)
|
|
1267
1278
|
if (!Array.isArray(items) && !one) return items
|
|
1268
|
-
const records = one ? [items] : items
|
|
1269
1279
|
|
|
1270
1280
|
const schema = await resolveSchema(queryName, siteRoot)
|
|
1271
1281
|
const { poolDirs, schemas: dataSchemas, models, targets, excerpts } = await recordQueries(siteRoot)
|
|
@@ -1279,11 +1289,15 @@ export async function translateRecordData(items, queryName, siteRoot, options =
|
|
|
1279
1289
|
targets,
|
|
1280
1290
|
excerpt: excerpts.get(queryName),
|
|
1281
1291
|
}
|
|
1292
|
+
const { dataSchema } = recordOptions
|
|
1293
|
+
// ONE record is a record's own file, which holds it AS STORED (2026-09-27): translated as the
|
|
1294
|
+
// merged view, returned as stored (`mergedFromStored`, `storedFromMerged`).
|
|
1295
|
+
const records = one ? [dataSchema ? mergedFromStored(dataSchema, items) : items] : items
|
|
1282
1296
|
|
|
1283
1297
|
// A lean list's derived excerpt is derived from its record's translated body (`EXCERPT`).
|
|
1284
1298
|
const wholes = new Map()
|
|
1285
1299
|
if (!one) {
|
|
1286
|
-
for (const [handle, record] of await wholeRecords(join(siteRoot, 'public', DATA_DIR), queryName)) {
|
|
1300
|
+
for (const [handle, record] of await wholeRecords(join(siteRoot, 'public', DATA_DIR), queryName, dataSchema)) {
|
|
1287
1301
|
wholes.set(handle, { source: record, translated: await translateItemAsync(record, recordDir, translations, schema, recordOptions) })
|
|
1288
1302
|
}
|
|
1289
1303
|
}
|
|
@@ -1291,7 +1305,8 @@ export async function translateRecordData(items, queryName, siteRoot, options =
|
|
|
1291
1305
|
records.map((item) => translateItemAsync(item, recordDir, translations, schema, { ...recordOptions, whole: wholes.get(recordHandle(item)) }))
|
|
1292
1306
|
)
|
|
1293
1307
|
|
|
1294
|
-
|
|
1308
|
+
if (!one) return translated
|
|
1309
|
+
return dataSchema && isPlainObject(translated[0]) ? storedFromMerged(dataSchema, translated[0]) : translated[0]
|
|
1295
1310
|
}
|
|
1296
1311
|
|
|
1297
1312
|
// ---------------------------------------------------------------------------
|
package/src/prerender.js
CHANGED
|
@@ -14,7 +14,6 @@ import { pathToFileURL } from 'node:url'
|
|
|
14
14
|
import {
|
|
15
15
|
resolveDefaultLocale,
|
|
16
16
|
resolveFetchConfigs,
|
|
17
|
-
joinPathCapture,
|
|
18
17
|
pageRouteQuery,
|
|
19
18
|
routeSelection,
|
|
20
19
|
routeParamValues,
|
|
@@ -24,7 +23,7 @@ import {
|
|
|
24
23
|
deriveCacheKey,
|
|
25
24
|
evaluateQuery,
|
|
26
25
|
} from '@uniweb/core'
|
|
27
|
-
import { recordTitle, routePatternToRegex } from '@uniweb/core/route-match'
|
|
26
|
+
import { recordTitle, routePatternToRegex, routeFieldOf } from '@uniweb/core/route-match'
|
|
28
27
|
import DataStore from '@uniweb/core/datastore'
|
|
29
28
|
import { executeFetch, mergeDataIntoContent, toFetchList } from './site/data-fetcher.js'
|
|
30
29
|
import { createFileTransport } from './site/file-transport.js'
|
|
@@ -258,6 +257,8 @@ export async function executeAllFetches(siteContent, siteDir, onProgress, locale
|
|
|
258
257
|
* @param {Object} [stats] - receives `unrouted[route]`, the records with no param value
|
|
259
258
|
* @param {Object} [options]
|
|
260
259
|
* @param {Object|Array|null} [options.siteFetch] - the site's `fetch`, the last level of a route query
|
|
260
|
+
* @param {Object|null} [options.queries] - the site's `config.queries`: a route query that binds the
|
|
261
|
+
* URL's last segment to a field (`where: { slug: ':slug' }`, `routeFieldOf`) is expanded by that field
|
|
261
262
|
* @returns {Array} Expanded pages array with dynamic pages replaced by concrete instances
|
|
262
263
|
*/
|
|
263
264
|
/**
|
|
@@ -295,7 +296,7 @@ export function localizeRedirectTarget(target, { website, locale, isDefault, rou
|
|
|
295
296
|
return (website.basePath || '') + withSlash
|
|
296
297
|
}
|
|
297
298
|
|
|
298
|
-
export function expandDynamicPages(pages, fetched, onProgress = () => {}, stats = { unrouted: {} }, { siteFetch = null } = {}) {
|
|
299
|
+
export function expandDynamicPages(pages, fetched, onProgress = () => {}, stats = { unrouted: {} }, { siteFetch = null, queries = null } = {}) {
|
|
299
300
|
if (!stats.unrouted) stats.unrouted = {}
|
|
300
301
|
const expandedPages = []
|
|
301
302
|
|
|
@@ -378,6 +379,8 @@ export function expandDynamicPages(pages, fetched, onProgress = () => {}, stats
|
|
|
378
379
|
|
|
379
380
|
const items = data
|
|
380
381
|
const key = route.key
|
|
382
|
+
// The field the route query binds the URL's last segment to, if any — else `[slug]` names `$name`.
|
|
383
|
+
const field = routeFieldOf(queries?.[route.config?.query ?? key])
|
|
381
384
|
|
|
382
385
|
onProgress(` Expanding ${page.route} → ${items.length} pages from ${key}`)
|
|
383
386
|
|
|
@@ -399,20 +402,21 @@ export function expandDynamicPages(pages, fetched, onProgress = () => {}, stats
|
|
|
399
402
|
// value — the case the rule is for — gets exactly one page. Ruled 2026-09-12
|
|
400
403
|
// [Diego]. ⛔ This read `item[paramName]` until 2026-09-11 and the whole array
|
|
401
404
|
// until 2026-09-12, which baked `/tags/a%2Cb`, a URL no lane matches.
|
|
402
|
-
const values = routeParamValues(item, paramName)
|
|
405
|
+
const values = routeParamValues(item, paramName, field)
|
|
403
406
|
if (values.length === 0) {
|
|
404
407
|
unrouted += 1
|
|
405
408
|
continue
|
|
406
409
|
}
|
|
407
410
|
for (const paramValue of values) {
|
|
408
411
|
|
|
409
|
-
// Create concrete route: /blog/:slug → /blog/my-post
|
|
410
|
-
// record
|
|
411
|
-
//
|
|
412
|
-
//
|
|
413
|
-
|
|
412
|
+
// Create concrete route: /blog/:slug → /blog/my-post, and the same under
|
|
413
|
+
// `[...path]`: a record carries no branch, so its own URL is its handle alone
|
|
414
|
+
// (ruled 2026-09-27 [Diego]), the URL its `$route` names (`fillRoutePattern`).
|
|
415
|
+
// ⛔ Until then this prefixed the record's `path`. ⛔ A FILE PATH, so decoded: the
|
|
416
|
+
// server decodes the request before looking the file up.
|
|
417
|
+
const capture = catchAll ? String(paramValue) : null
|
|
414
418
|
const concreteRoute = catchAll
|
|
415
|
-
? page.route.replace(new RegExp(`:${catchAll}\\*$`), capture)
|
|
419
|
+
? page.route.replace(new RegExp(`:${catchAll}\\*$`), () => capture)
|
|
416
420
|
: page.route.replace(`:${paramName}`, paramValue)
|
|
417
421
|
|
|
418
422
|
// ⛔ TWO RECORDS, ONE ROUTE — normal the moment the route field is not
|
|
@@ -470,7 +474,7 @@ export function expandDynamicPages(pages, fetched, onProgress = () => {}, stats
|
|
|
470
474
|
if (unrouted > 0) {
|
|
471
475
|
stats.unrouted[page.route] = unrouted
|
|
472
476
|
onProgress(
|
|
473
|
-
` ⚠️ ${unrouted} of ${items.length} ${key} records have no "${paramName}" — no page was ` +
|
|
477
|
+
` ⚠️ ${unrouted} of ${items.length} ${key} records have no "${paramName === 'slug' ? field || '$name' : paramName}" — no page was ` +
|
|
474
478
|
`generated for them under ${page.route}`
|
|
475
479
|
)
|
|
476
480
|
}
|
|
@@ -893,6 +897,7 @@ export async function prerenderSite(siteDir, options = {}) {
|
|
|
893
897
|
onProgress('Expanding dynamic routes...')
|
|
894
898
|
siteContent.pages = expandDynamicPages(siteContent.pages, fetched, onProgress, undefined, {
|
|
895
899
|
siteFetch: siteContent.config?.fetch ?? null,
|
|
900
|
+
queries: siteContent.config?.queries ?? null,
|
|
896
901
|
})
|
|
897
902
|
|
|
898
903
|
// ⭐ AN EXPANDED PAGE ASKS FOR WHAT ITS ROUTE BINDS — its branch's view, a `deferred:`
|
|
@@ -38,6 +38,7 @@
|
|
|
38
38
|
import { readFile } from 'node:fs/promises'
|
|
39
39
|
import { existsSync, readdirSync, statSync } from 'node:fs'
|
|
40
40
|
import { join, resolve, isAbsolute, extname, basename } from 'node:path'
|
|
41
|
+
import { dataRefOf } from '@uniweb/core/data-keys'
|
|
41
42
|
import { pathToFileURL } from 'node:url'
|
|
42
43
|
import { createRequire } from 'node:module'
|
|
43
44
|
import yaml from 'js-yaml'
|
|
@@ -105,8 +106,9 @@ export function collectSchemaRefs(components, foundationData = null) {
|
|
|
105
106
|
const readData = (data) => {
|
|
106
107
|
if (!data || typeof data !== 'object' || data === false) return
|
|
107
108
|
for (const binding of Object.values(data)) {
|
|
108
|
-
|
|
109
|
-
|
|
109
|
+
// `'@std/article/*'` asks for whole records of `@std/article` — the ref is what precedes it.
|
|
110
|
+
const { ref } = dataRefOf(binding)
|
|
111
|
+
if (ref) refs.add(ref)
|
|
110
112
|
}
|
|
111
113
|
}
|
|
112
114
|
for (const meta of Object.values(components || {})) readData(meta?.data)
|
package/src/runtime-schema.js
CHANGED
|
@@ -25,7 +25,8 @@
|
|
|
25
25
|
*/
|
|
26
26
|
|
|
27
27
|
import { isRichSchema } from '@uniweb/core'
|
|
28
|
-
import {
|
|
28
|
+
import { dataRefOf } from '@uniweb/core/data-keys'
|
|
29
|
+
import { briefFieldMap, wholeFieldMap } from '@uniweb/schemas/conform'
|
|
29
30
|
import { enumValues } from '@uniweb/schemas/format'
|
|
30
31
|
|
|
31
32
|
/**
|
|
@@ -189,26 +190,22 @@ function normalizeRichSchemaValue(value) {
|
|
|
189
190
|
* @returns {Object|null}
|
|
190
191
|
*/
|
|
191
192
|
function leanDataSchema(value, dataSchemaMap) {
|
|
192
|
-
// Named ref → the resolved schema's fields.
|
|
193
|
-
const ref =
|
|
194
|
-
? value
|
|
195
|
-
: (value && typeof value.schema === 'string' ? value.schema : null)
|
|
193
|
+
// Named ref → the resolved schema's fields. `'@std/article/*'` asks for whole records of it.
|
|
194
|
+
const { ref, whole } = dataRefOf(value)
|
|
196
195
|
if (ref) {
|
|
197
196
|
const resolved = dataSchemaMap[ref]
|
|
198
|
-
// ⭐ THE
|
|
199
|
-
//
|
|
200
|
-
//
|
|
201
|
-
// `
|
|
202
|
-
//
|
|
203
|
-
// there), so a list of briefs gains no section it was not sent.
|
|
197
|
+
// ⭐ THE FIELDS OF WHAT THE COMPONENT RECEIVES (ruled 2026-09-27 [Diego]): a brief's fields at
|
|
198
|
+
// the top (`briefFieldMap`), or, for `'@x/y/*'`, the whole record as stored — one field per
|
|
199
|
+
// section, the brief's included (`wholeFieldMap`) — so a default lands where the record carries
|
|
200
|
+
// its field: `@std/article`'s `status` inside `body`. A section is filled only when the record
|
|
201
|
+
// holds it (`applySchemaToObject` recurses into what is there).
|
|
204
202
|
//
|
|
205
|
-
// ⛔ It was `
|
|
206
|
-
//
|
|
207
|
-
//
|
|
208
|
-
//
|
|
209
|
-
//
|
|
210
|
-
|
|
211
|
-
const fields = deliveredFields(resolved)
|
|
203
|
+
// ⛔ It was the merged `deliveredFields` until 2026-09-27 — the brief's fields beside the other
|
|
204
|
+
// sections' names, one map for two shapes; `flatRecordFields`, the retired flat form, until
|
|
205
|
+
// 2026-09-24; and before 2026-09-03 `resolved.fields`, which a sections-form schema does not
|
|
206
|
+
// have. `dataSchemaMap` holds each schema normalized in its authored form (`fields:` or
|
|
207
|
+
// `sections:`), and only these readers say what one record of it looks like.
|
|
208
|
+
const fields = whole ? wholeFieldMap(resolved) : briefFieldMap(resolved)
|
|
212
209
|
if (!fields) return null
|
|
213
210
|
const lean = extractSchemaFields(fields)
|
|
214
211
|
return Object.keys(lean).length > 0 ? lean : null
|
package/src/site/data-fetcher.js
CHANGED
|
@@ -88,7 +88,7 @@ export function applyWhere(items, where) {
|
|
|
88
88
|
* `@uniweb/core`'s `evaluateQuery`, the one order of work the runtime's default
|
|
89
89
|
* fetcher uses too:
|
|
90
90
|
*
|
|
91
|
-
* 1. the query's set — `scope` over each record
|
|
91
|
+
* 1. the query's set — `scope` over the branch each record is held with (`$branch`), `where`,
|
|
92
92
|
* `sort`, `limit`;
|
|
93
93
|
* 2. the fetch's `narrow` of it — `where` (and `match`), `sort`, `limit`.
|
|
94
94
|
*
|
package/src/site/entity-pool.js
CHANGED
|
@@ -192,8 +192,8 @@ export function schemaForPoolDirs(dirs) {
|
|
|
192
192
|
* ⛔ ONE IMPLEMENTATION AND ITS INVERSE, IN ONE PLACE, for the reason this file
|
|
193
193
|
* exists at all: the reader derives a model from a path and the pull side derives
|
|
194
194
|
* a path from a model, and if those two ever disagree a pulled record lands
|
|
195
|
-
* somewhere the next build reads as a different schema.
|
|
196
|
-
*
|
|
195
|
+
* somewhere the next build reads as a different schema. A deriver and its
|
|
196
|
+
* recognizer must not be two copies.
|
|
197
197
|
*
|
|
198
198
|
* @param {string} schema - a ref: `@/name` or `@org/name`
|
|
199
199
|
* @returns {string[]|null} the directory segments below `records/`, or null for
|
|
@@ -72,8 +72,9 @@ export function createFileTransport({ siteRoot, distDir = null, publicDir = 'pub
|
|
|
72
72
|
const body = await readFile(file, 'utf8')
|
|
73
73
|
return response(body, path.endsWith('.json') ? 'application/json' : 'text/plain')
|
|
74
74
|
}
|
|
75
|
-
// ⚖️ Not an error here. A page can ask for a per-record file
|
|
76
|
-
// and the step reports the outcome so the caller
|
|
75
|
+
// ⚖️ Not an error here. A page can ask for a per-record file that was never written — a
|
|
76
|
+
// record this run withholds as a draft, say — and the step reports the outcome so the caller
|
|
77
|
+
// can see it rather than crash a build.
|
|
77
78
|
return missing(path)
|
|
78
79
|
}
|
|
79
80
|
}
|
|
@@ -35,6 +35,7 @@ import { detectFoundationType, parseCatalogRef } from './foundation-ref.js'
|
|
|
35
35
|
import { readRegisteredFoundation } from './registered-foundation.js'
|
|
36
36
|
import { buildDataSchemaMap, SCHEMA_NOT_FOUND } from '../resolve-data-schema.js'
|
|
37
37
|
import { resolveFoundationSrcPath } from '../utils/foundation-source-root.js'
|
|
38
|
+
import { buildSchema } from '../schema.js'
|
|
38
39
|
import { refuseQueryRoute, refuseLimit } from './data-fetcher.js'
|
|
39
40
|
import { readFile } from 'node:fs/promises'
|
|
40
41
|
import yaml from 'js-yaml'
|
|
@@ -174,8 +175,6 @@ export async function resolveQueriesConfig(siteRoot, opts = {}) {
|
|
|
174
175
|
}
|
|
175
176
|
}
|
|
176
177
|
|
|
177
|
-
await deriveDeferredFromSchemas(siteRoot, siteYml, declarations)
|
|
178
|
-
|
|
179
178
|
// ⛔ `folderSync` AND `folders` WERE DELETED HERE, 2026-09-06. Both were
|
|
180
179
|
// `collections.yml` survivors kept "for one step" while `records.yml` took
|
|
181
180
|
// over, and both had become literals — `true` and `null` — that no longer
|
|
@@ -194,7 +193,7 @@ export async function resolveQueriesConfig(siteRoot, opts = {}) {
|
|
|
194
193
|
}
|
|
195
194
|
|
|
196
195
|
/** What a query over the site's records declares, which an external query cannot. */
|
|
197
|
-
const SITE_RECORDS_ONLY = ['schema', 'model', 'scope', '
|
|
196
|
+
const SITE_RECORDS_ONLY = ['schema', 'model', 'scope', 'excerpt', 'path']
|
|
198
197
|
/** The keys of an external query's `record:` request. */
|
|
199
198
|
const RECORD_KEYS = ['url', 'method', 'body', 'transform']
|
|
200
199
|
|
|
@@ -207,7 +206,7 @@ const RECORD_KEYS = ['url', 'method', 'body', 'transform']
|
|
|
207
206
|
* `transform` (a dot-path to its records), `where` / `sort` / `limit` evaluated over
|
|
208
207
|
* them, and `record:` — `{ url, method, body, transform }` — for one record on a
|
|
209
208
|
* parametric page. What describes the site's records — `schema`, `scope`,
|
|
210
|
-
* `
|
|
209
|
+
* `excerpt` — is refused beside `url:`. ⛔ `detailUrl:` and `detail:` are
|
|
211
210
|
* retired everywhere: their one real case is `record.url`. ⛔ And `limit:` is a whole
|
|
212
211
|
* number, 0 or more (`refuseLimit`).
|
|
213
212
|
*
|
|
@@ -235,6 +234,17 @@ export function refuseQueryDeclaration(decl) {
|
|
|
235
234
|
`on an external query — one with \`url:\`.`
|
|
236
235
|
)
|
|
237
236
|
}
|
|
237
|
+
// ⛔ `deferred:` IS RETIRED (2026-09-27 [Diego]: "the `deferred:` concept keeps getting in the way
|
|
238
|
+
// and doesn't match a hosted lane"). A list carries each record's brief, on every site, so a
|
|
239
|
+
// field a list should leave out belongs in a section of its own, outside the brief — where a
|
|
240
|
+
// host leaves it out too. `deferred:` only made a static site's lists differ from a hosted one's.
|
|
241
|
+
if (decl.deferred !== undefined) {
|
|
242
|
+
throw new Error(
|
|
243
|
+
`[uniweb] ${where}: \`deferred:\` is retired. A list carries each record's brief — the fields ` +
|
|
244
|
+
`of its schema's brief section — so a field a list should leave out belongs in a section of ` +
|
|
245
|
+
`its own, outside the brief. Remove the key.`
|
|
246
|
+
)
|
|
247
|
+
}
|
|
238
248
|
refuseQueryRoute(decl, where)
|
|
239
249
|
refuseLimit(decl.limit, where)
|
|
240
250
|
// ⛔ `sync:` IS RETIRED (2026-09-21). `sync: false` kept a query's records off a
|
|
@@ -303,6 +313,11 @@ function refuseMethod(method, where) {
|
|
|
303
313
|
* downstream. It is stripped here rather than at each consumer, so the payload
|
|
304
314
|
* has one shape and no consumer has to know the field existed.
|
|
305
315
|
*
|
|
316
|
+
* ⛔ `name` too: a declaration carries its query's name for the build's own messages, and
|
|
317
|
+
* `config.queries` is keyed by it — repeated inside, it was a second copy nothing read, and it
|
|
318
|
+
* held a key a declaration may want for something else [Diego, 2026-09-27]. Until then every
|
|
319
|
+
* query on the payload read `{ name: 'articles', … }` under `articles`.
|
|
320
|
+
*
|
|
306
321
|
* Returns undefined for a site with no queries, so `config.queries`
|
|
307
322
|
* stays absent rather than becoming an empty object — an empty object reads as
|
|
308
323
|
* "declared, and empty" to anything checking for presence.
|
|
@@ -311,90 +326,14 @@ export function toConfigQueries(declarations) {
|
|
|
311
326
|
const names = Object.keys(declarations || {})
|
|
312
327
|
if (names.length === 0) return undefined
|
|
313
328
|
const out = {}
|
|
314
|
-
for (const
|
|
315
|
-
const { schemaExplicit, ...rest } = declarations[
|
|
316
|
-
out[
|
|
329
|
+
for (const key of names) {
|
|
330
|
+
const { schemaExplicit, name, ...rest } = declarations[key]
|
|
331
|
+
out[key] = rest
|
|
317
332
|
}
|
|
318
333
|
return out
|
|
319
334
|
}
|
|
320
335
|
|
|
321
336
|
|
|
322
|
-
/**
|
|
323
|
-
* Fill in `deferred:` from each collection's own data schema.
|
|
324
|
-
*
|
|
325
|
-
* ⭐ A schema's **brief** section already states what a record's summary is — the
|
|
326
|
-
* card, the row, the thing a list shows. Everything else is wanted only when one
|
|
327
|
-
* record is the focus. That is exactly what `deferred:` says, so an author with a
|
|
328
|
-
* schema should not have to say it twice, in a second vocabulary, with nothing
|
|
329
|
-
* checking the two against each other.
|
|
330
|
-
*
|
|
331
|
-
* ⇒ `deferred` = every top-level section of the schema other than its brief — the keys
|
|
332
|
-
* a delivered record holds them under (`article_body` for `@std/article`), which is
|
|
333
|
-
* exactly what a host's records service leaves out of a list and adds for `whole`.
|
|
334
|
-
* ⛔ It was the flat form's fields minus the brief's until 2026-09-24; a delivered
|
|
335
|
-
* record carries those under their section, so stripping them by name stripped nothing.
|
|
336
|
-
*
|
|
337
|
-
* Derived from the SCHEMA, never from a record. That is what keeps the
|
|
338
|
-
* build-derived keys safe without a reserved list: `slug`, `route`, `path`,
|
|
339
|
-
* `excerpt`, `image` and `lastModified` are not sections, so they are never
|
|
340
|
-
* stripped. A markdown body goes where its content field is — inside `article_body`,
|
|
341
|
-
* say — so it is deferred with that section.
|
|
342
|
-
*
|
|
343
|
-
* ⛔ Silent on every path that cannot answer, because none of them is an error:
|
|
344
|
-
*
|
|
345
|
-
* - an author-declared `deferred:` wins outright — this never overrides one;
|
|
346
|
-
* - no local foundation (a linked or cataloged one), or it is unbuilt → nothing
|
|
347
|
-
* to read, and a site must still build;
|
|
348
|
-
* - the schema is not in the foundation's built map → the same soft-skip the
|
|
349
|
-
* sync lane already applies. `dist/meta/schema.json` carries the schemas
|
|
350
|
-
* COMPONENTS reference, so a collection whose schema no component binds is
|
|
351
|
-
* simply not there;
|
|
352
|
-
* - the schema has no brief, or only one section (`recordLayout`) → there is no
|
|
353
|
-
* lean shape to honour, so records stay whole.
|
|
354
|
-
*
|
|
355
|
-
* The last two are why this reads the built artifact rather than resolving
|
|
356
|
-
* schemas itself: it is the same input the sync lane uses, so both lanes agree
|
|
357
|
-
* about which schemas exist.
|
|
358
|
-
*/
|
|
359
|
-
async function deriveDeferredFromSchemas(siteRoot, siteYml, declarations) {
|
|
360
|
-
const pending = Object.values(declarations).filter(
|
|
361
|
-
(d) => d.schema && !Array.isArray(d.deferred)
|
|
362
|
-
)
|
|
363
|
-
if (pending.length === 0) return
|
|
364
|
-
|
|
365
|
-
const dataSchemas = foundationDataSchemas(siteRoot, siteYml)
|
|
366
|
-
if (!dataSchemas) return
|
|
367
|
-
|
|
368
|
-
for (const decl of pending) {
|
|
369
|
-
const heavy = deferredFromSchema(dataSchemas[decl.schema])
|
|
370
|
-
if (heavy) decl.deferred = heavy
|
|
371
|
-
}
|
|
372
|
-
}
|
|
373
|
-
|
|
374
|
-
/**
|
|
375
|
-
* The `deferred:` a schema implies — every top-level section but its **brief**, by name.
|
|
376
|
-
*
|
|
377
|
-
* ⛔ ONE IMPLEMENTATION, TWO CALLERS, and that is the point. `deriveDeferredFromSchemas`
|
|
378
|
-
* above uses it to FILL an unstated `deferred:`; `uwx/records-project.js` uses it to
|
|
379
|
-
* RECOGNIZE a derived value on the way back in, so a pull does not write a derivation
|
|
380
|
-
* into the author's file as though they had typed it.
|
|
381
|
-
*
|
|
382
|
-
* ⚠️ A second copy would drift, and the drift would be invisible: the deriver and the
|
|
383
|
-
* inverter would simply stop agreeing about which values are "the derived one", and the
|
|
384
|
-
* pull would start persisting values it was written to drop.
|
|
385
|
-
*
|
|
386
|
-
* @param {object|undefined} schema a data schema, or undefined when it does not resolve
|
|
387
|
-
* @returns {string[]|null} the implied deferred list, or null when the schema states no
|
|
388
|
-
* brief (a root list, say) or implies nothing heavy — in both cases there is no
|
|
389
|
-
* derivation to recognize
|
|
390
|
-
*/
|
|
391
|
-
export function deferredFromSchema(schema) {
|
|
392
|
-
const layout = schema ? recordLayout(schema) : null
|
|
393
|
-
if (!layout || layout.flat || !layout.brief) return null
|
|
394
|
-
const heavy = layout.sections.map(([name]) => name).filter((name) => name !== layout.brief)
|
|
395
|
-
return heavy.length ? heavy : null
|
|
396
|
-
}
|
|
397
|
-
|
|
398
337
|
/**
|
|
399
338
|
* The data schemas a site's records are shaped by, keyed by ref — each resolved from
|
|
400
339
|
* the LOCAL foundation's SOURCE by the resolver its build uses (`buildDataSchemaMap`),
|
|
@@ -446,6 +385,30 @@ function localFoundationSrcDir(siteRoot, siteYml) {
|
|
|
446
385
|
}
|
|
447
386
|
}
|
|
448
387
|
|
|
388
|
+
/**
|
|
389
|
+
* A site's foundation's section types, each with its `data:` — what types a query named for a data
|
|
390
|
+
* key (`uwx/data-key-types.js`). Read from the foundation's SOURCE when it is local, as
|
|
391
|
+
* `resolveRecordSchemas` reads its data schemas, so `pnpm dev` without a built foundation types a
|
|
392
|
+
* query as a production build does; else from its built or registered `schema.json`
|
|
393
|
+
* (`foundationSchemaJson`). Null when there is neither.
|
|
394
|
+
*
|
|
395
|
+
* @param {string} siteRoot
|
|
396
|
+
* @param {object} [siteYml] - an already-read site.yml
|
|
397
|
+
* @returns {Promise<object|null>}
|
|
398
|
+
*/
|
|
399
|
+
export async function foundationSections(siteRoot, siteYml = null) {
|
|
400
|
+
const yml = siteYml || (await readYamlFile(join(siteRoot, 'site.yml')))
|
|
401
|
+
const srcDir = localFoundationSrcDir(siteRoot, yml)
|
|
402
|
+
if (srcDir) {
|
|
403
|
+
try {
|
|
404
|
+
return await buildSchema(srcDir)
|
|
405
|
+
} catch {
|
|
406
|
+
// a foundation whose source does not read is reported by the build that builds it
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
return foundationSchemaJson(siteRoot, yml)
|
|
410
|
+
}
|
|
411
|
+
|
|
449
412
|
/** The data schemas a site's foundation declares, or null when unresolvable. */
|
|
450
413
|
export function foundationDataSchemas(siteRoot, siteYml) {
|
|
451
414
|
return foundationSchemaJson(siteRoot, siteYml)?.dataSchemas || null
|