@uniweb/build 0.64.1 → 0.65.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 +25 -12
- package/src/import-map-plugin.js +13 -0
- package/src/prerender.js +9 -4
- package/src/resolve-data-schema.js +4 -2
- package/src/runtime-schema.js +15 -18
- package/src/site/config.js +59 -5
- package/src/site/entity-pool.js +2 -2
- package/src/site/foundation-ref.js +21 -2
- package/src/site/index.js +2 -0
- package/src/site/preview-media.js +90 -0
- package/src/site/preview.js +84 -0
- package/src/site/queries-config.js +46 -83
- package/src/site/query-processor.js +251 -144
- package/src/site/record-file.js +35 -0
- package/src/site/records-config.js +21 -0
- package/src/site/registered-foundation.js +3 -2
- 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.65.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/
|
|
60
|
+
"@uniweb/schemas": "^0.8.0",
|
|
61
|
+
"@uniweb/projections": "^0.7.5",
|
|
62
|
+
"@uniweb/theming": "^0.1.15",
|
|
61
63
|
"@uniweb/content-writer": "^0.3.4",
|
|
62
|
-
"@uniweb/
|
|
63
|
-
"@uniweb/semantic-parser": "^1.4.1"
|
|
64
|
-
"@uniweb/schemas": "^0.7.1",
|
|
65
|
-
"@uniweb/theming": "^0.1.15"
|
|
64
|
+
"@uniweb/content-reader": "^1.2.5",
|
|
65
|
+
"@uniweb/semantic-parser": "^1.4.1"
|
|
66
66
|
},
|
|
67
67
|
"optionalDependencies": {
|
|
68
|
-
"@uniweb/runtime": "^0.27.
|
|
68
|
+
"@uniweb/runtime": "^0.27.7"
|
|
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/core": "^0.
|
|
78
|
-
"@uniweb/runtime": "^0.27.
|
|
77
|
+
"@uniweb/core": "^0.31.0",
|
|
78
|
+
"@uniweb/runtime": "^0.27.7"
|
|
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'
|
|
@@ -392,7 +392,7 @@ function isProseMirrorDoc(value) {
|
|
|
392
392
|
|
|
393
393
|
/**
|
|
394
394
|
* Every ProseMirror document a record holds, with its path. ⭐ A markdown body is
|
|
395
|
-
* delivered in its schema's content body field — `
|
|
395
|
+
* delivered in its schema's content body field — `body.content` for
|
|
396
396
|
* `@std/article` — and at `content` only for a record with no schema; a rich field can
|
|
397
397
|
* sit anywhere else. ⛔ Until 2026-09-24 only `content` at the top was read, which is
|
|
398
398
|
* where a delivered `@std/article` holds no body.
|
|
@@ -796,7 +796,7 @@ export async function extractRecordContent(siteRoot, options = {}) {
|
|
|
796
796
|
// record whole beside its lean list, and a deferred field is exactly what the list
|
|
797
797
|
// does not hold. ⛔ Until 2026-09-14 only the list was read, so a deferred body —
|
|
798
798
|
// one derived from a schema's brief included — never reached the manifest.
|
|
799
|
-
const wholes = await wholeRecords(dataDir, queryName)
|
|
799
|
+
const wholes = await wholeRecords(dataDir, queryName, dataSchemas.get(queryName) ?? null)
|
|
800
800
|
const extract = (item) => {
|
|
801
801
|
const whole = wholes.get(recordHandle(item))
|
|
802
802
|
const skip = derivesExcerpt(item, whole, dataSchemas.get(queryName), excerpts.get(queryName)) ? EXCERPT : null
|
|
@@ -847,15 +847,19 @@ async function recordFileNames(dataDir, queryName) {
|
|
|
847
847
|
}
|
|
848
848
|
|
|
849
849
|
/**
|
|
850
|
-
* A query's per-record files (`recordFileNames`), each record whole, by its handle
|
|
850
|
+
* A query's per-record files (`recordFileNames`), each record whole, by its handle — as the MERGED
|
|
851
|
+
* view this module walks (`mergedFromStored`): a file holds the record as stored, a key per
|
|
852
|
+
* section (2026-09-27), and translation reads a record with its brief's fields at the top.
|
|
851
853
|
*
|
|
854
|
+
* @param {Object|null} [dataSchema] - the query's data schema; with none a file is read as it is
|
|
852
855
|
* @returns {Promise<Map<string, Object>>}
|
|
853
856
|
*/
|
|
854
|
-
async function wholeRecords(dataDir, queryName) {
|
|
857
|
+
async function wholeRecords(dataDir, queryName, dataSchema = null) {
|
|
855
858
|
const out = new Map()
|
|
856
859
|
for (const name of await recordFileNames(dataDir, queryName)) {
|
|
857
860
|
try {
|
|
858
|
-
const
|
|
861
|
+
const stored = JSON.parse(await readFile(join(dataDir, queryName, name), 'utf-8'))
|
|
862
|
+
const record = dataSchema && isPlainObject(stored) ? mergedFromStored(dataSchema, stored) : stored
|
|
859
863
|
if (isPlainObject(record)) out.set(recordHandle(record), record)
|
|
860
864
|
} catch (err) {
|
|
861
865
|
console.warn(`[i18n] Skipping ${queryName}/${name}: ${err.message}`)
|
|
@@ -890,7 +894,7 @@ async function pruneRecordFiles(recordsDir, dataDir, keep) {
|
|
|
890
894
|
|
|
891
895
|
/**
|
|
892
896
|
* Extract from a ProseMirror document — `field` is where the record holds it
|
|
893
|
-
* (`content`, `
|
|
897
|
+
* (`content`, `body.content`), which each unit's `field` starts with.
|
|
894
898
|
*/
|
|
895
899
|
function extractFromProseMirrorDoc(doc, context, units, field = 'content') {
|
|
896
900
|
// The units a page section's body makes (`extract.js::extractUnitsFromDoc`) — the keys translation
|
|
@@ -1043,13 +1047,18 @@ export async function buildLocalizedRecords(siteRoot, options = {}) {
|
|
|
1043
1047
|
const wholes = new Map() // handle → { source, translated }
|
|
1044
1048
|
for (const name of recordNames) {
|
|
1045
1049
|
try {
|
|
1046
|
-
|
|
1050
|
+
// ⭐ A record's file holds it AS STORED (2026-09-27); it is translated as the merged
|
|
1051
|
+
// view and written back as stored (`mergedFromStored`, `storedFromMerged`).
|
|
1052
|
+
const stored = JSON.parse(await readFile(join(dataDir, queryName, name), 'utf-8'))
|
|
1053
|
+
const { dataSchema } = recordOptions
|
|
1054
|
+
const record = dataSchema && isPlainObject(stored) ? mergedFromStored(dataSchema, stored) : stored
|
|
1047
1055
|
const translatedRecord = isPlainObject(record)
|
|
1048
1056
|
? await translateItemAsync(record, recordDir, translations, schema, recordOptions)
|
|
1049
1057
|
: record
|
|
1050
1058
|
if (isPlainObject(record)) wholes.set(recordHandle(record), { source: record, translated: translatedRecord })
|
|
1059
|
+
const written = dataSchema && isPlainObject(translatedRecord) ? storedFromMerged(dataSchema, translatedRecord) : translatedRecord
|
|
1051
1060
|
await mkdir(localeRecordsDir, { recursive: true })
|
|
1052
|
-
await writeFile(join(localeRecordsDir, name), JSON.stringify(
|
|
1061
|
+
await writeFile(join(localeRecordsDir, name), JSON.stringify(written, null, 2))
|
|
1053
1062
|
} catch (err) {
|
|
1054
1063
|
console.error(`[i18n] Failed to translate ${queryName}/${name} for ${locale}: ${err.message}`)
|
|
1055
1064
|
failures.push({ locale, file: `${queryName}/${name}`, message: err.message })
|
|
@@ -1265,7 +1274,6 @@ export async function translateRecordData(items, queryName, siteRoot, options =
|
|
|
1265
1274
|
|
|
1266
1275
|
const one = !!items && typeof items === 'object' && !Array.isArray(items)
|
|
1267
1276
|
if (!Array.isArray(items) && !one) return items
|
|
1268
|
-
const records = one ? [items] : items
|
|
1269
1277
|
|
|
1270
1278
|
const schema = await resolveSchema(queryName, siteRoot)
|
|
1271
1279
|
const { poolDirs, schemas: dataSchemas, models, targets, excerpts } = await recordQueries(siteRoot)
|
|
@@ -1279,11 +1287,15 @@ export async function translateRecordData(items, queryName, siteRoot, options =
|
|
|
1279
1287
|
targets,
|
|
1280
1288
|
excerpt: excerpts.get(queryName),
|
|
1281
1289
|
}
|
|
1290
|
+
const { dataSchema } = recordOptions
|
|
1291
|
+
// ONE record is a record's own file, which holds it AS STORED (2026-09-27): translated as the
|
|
1292
|
+
// merged view, returned as stored (`mergedFromStored`, `storedFromMerged`).
|
|
1293
|
+
const records = one ? [dataSchema ? mergedFromStored(dataSchema, items) : items] : items
|
|
1282
1294
|
|
|
1283
1295
|
// A lean list's derived excerpt is derived from its record's translated body (`EXCERPT`).
|
|
1284
1296
|
const wholes = new Map()
|
|
1285
1297
|
if (!one) {
|
|
1286
|
-
for (const [handle, record] of await wholeRecords(join(siteRoot, 'public', DATA_DIR), queryName)) {
|
|
1298
|
+
for (const [handle, record] of await wholeRecords(join(siteRoot, 'public', DATA_DIR), queryName, dataSchema)) {
|
|
1287
1299
|
wholes.set(handle, { source: record, translated: await translateItemAsync(record, recordDir, translations, schema, recordOptions) })
|
|
1288
1300
|
}
|
|
1289
1301
|
}
|
|
@@ -1291,7 +1303,8 @@ export async function translateRecordData(items, queryName, siteRoot, options =
|
|
|
1291
1303
|
records.map((item) => translateItemAsync(item, recordDir, translations, schema, { ...recordOptions, whole: wholes.get(recordHandle(item)) }))
|
|
1292
1304
|
)
|
|
1293
1305
|
|
|
1294
|
-
|
|
1306
|
+
if (!one) return translated
|
|
1307
|
+
return dataSchema && isPlainObject(translated[0]) ? storedFromMerged(dataSchema, translated[0]) : translated[0]
|
|
1295
1308
|
}
|
|
1296
1309
|
|
|
1297
1310
|
// ---------------------------------------------------------------------------
|
package/src/import-map-plugin.js
CHANGED
|
@@ -85,6 +85,9 @@ export const bridgeFileName = (spec) => `${spec.replace(/\//g, '-')}.js`
|
|
|
85
85
|
* @param {Object} [options.devBridges] - Map of specifier → dev-mode URL for import map injection in dev.
|
|
86
86
|
* When provided, the import map is injected in both dev and prod (with different URLs).
|
|
87
87
|
* When omitted, the import map is only injected in prod (dev uses other mechanisms like transformRequest).
|
|
88
|
+
* @param {boolean} [options.serveInDev] - In dev, point each external at a bridge the dev server serves
|
|
89
|
+
* itself — the module a build would emit, through Vite's pipeline, so its bare imports become the very
|
|
90
|
+
* URLs the host app imports. For a module loaded from a URL, which no dev plugin rewrites.
|
|
88
91
|
* @returns {import('vite').Plugin}
|
|
89
92
|
*/
|
|
90
93
|
export function importMapPlugin({
|
|
@@ -93,14 +96,17 @@ export function importMapPlugin({
|
|
|
93
96
|
basePath = '/',
|
|
94
97
|
resolveFrom,
|
|
95
98
|
devBridges,
|
|
99
|
+
serveInDev = false,
|
|
96
100
|
} = {}) {
|
|
97
101
|
let isBuild = false
|
|
102
|
+
let devBase = '/'
|
|
98
103
|
|
|
99
104
|
return {
|
|
100
105
|
name,
|
|
101
106
|
|
|
102
107
|
configResolved(config) {
|
|
103
108
|
isBuild = config.command === 'build'
|
|
109
|
+
devBase = config.base || '/'
|
|
104
110
|
},
|
|
105
111
|
|
|
106
112
|
resolveId(id, importer) {
|
|
@@ -199,6 +205,13 @@ export function importMapPlugin({
|
|
|
199
205
|
}
|
|
200
206
|
} else if (devBridges) {
|
|
201
207
|
Object.assign(imports, devBridges)
|
|
208
|
+
} else if (serveInDev) {
|
|
209
|
+
// Vite serves a virtual module at `/@id/`, its NUL written `__x00__` — the id `load`
|
|
210
|
+
// answers below, so dev and build bridge one module the same way.
|
|
211
|
+
for (const ext of externals) {
|
|
212
|
+
const spec = specOf(ext)
|
|
213
|
+
imports[spec] = `${devBase}@id/__x00__${IMPORT_MAP_PREFIX.slice(1)}${spec}`
|
|
214
|
+
}
|
|
202
215
|
} else {
|
|
203
216
|
// No dev injection — consumer handles dev mode separately
|
|
204
217
|
return html
|
package/src/prerender.js
CHANGED
|
@@ -24,7 +24,7 @@ import {
|
|
|
24
24
|
deriveCacheKey,
|
|
25
25
|
evaluateQuery,
|
|
26
26
|
} from '@uniweb/core'
|
|
27
|
-
import { recordTitle, routePatternToRegex } from '@uniweb/core/route-match'
|
|
27
|
+
import { recordTitle, routePatternToRegex, routeFieldOf } from '@uniweb/core/route-match'
|
|
28
28
|
import DataStore from '@uniweb/core/datastore'
|
|
29
29
|
import { executeFetch, mergeDataIntoContent, toFetchList } from './site/data-fetcher.js'
|
|
30
30
|
import { createFileTransport } from './site/file-transport.js'
|
|
@@ -258,6 +258,8 @@ export async function executeAllFetches(siteContent, siteDir, onProgress, locale
|
|
|
258
258
|
* @param {Object} [stats] - receives `unrouted[route]`, the records with no param value
|
|
259
259
|
* @param {Object} [options]
|
|
260
260
|
* @param {Object|Array|null} [options.siteFetch] - the site's `fetch`, the last level of a route query
|
|
261
|
+
* @param {Object|null} [options.queries] - the site's `config.queries`: a route query that binds the
|
|
262
|
+
* URL's last segment to a field (`where: { slug: ':slug' }`, `routeFieldOf`) is expanded by that field
|
|
261
263
|
* @returns {Array} Expanded pages array with dynamic pages replaced by concrete instances
|
|
262
264
|
*/
|
|
263
265
|
/**
|
|
@@ -295,7 +297,7 @@ export function localizeRedirectTarget(target, { website, locale, isDefault, rou
|
|
|
295
297
|
return (website.basePath || '') + withSlash
|
|
296
298
|
}
|
|
297
299
|
|
|
298
|
-
export function expandDynamicPages(pages, fetched, onProgress = () => {}, stats = { unrouted: {} }, { siteFetch = null } = {}) {
|
|
300
|
+
export function expandDynamicPages(pages, fetched, onProgress = () => {}, stats = { unrouted: {} }, { siteFetch = null, queries = null } = {}) {
|
|
299
301
|
if (!stats.unrouted) stats.unrouted = {}
|
|
300
302
|
const expandedPages = []
|
|
301
303
|
|
|
@@ -378,6 +380,8 @@ export function expandDynamicPages(pages, fetched, onProgress = () => {}, stats
|
|
|
378
380
|
|
|
379
381
|
const items = data
|
|
380
382
|
const key = route.key
|
|
383
|
+
// The field the route query binds the URL's last segment to, if any — else `[slug]` names `$name`.
|
|
384
|
+
const field = routeFieldOf(queries?.[route.config?.query ?? key])
|
|
381
385
|
|
|
382
386
|
onProgress(` Expanding ${page.route} → ${items.length} pages from ${key}`)
|
|
383
387
|
|
|
@@ -399,7 +403,7 @@ export function expandDynamicPages(pages, fetched, onProgress = () => {}, stats
|
|
|
399
403
|
// value — the case the rule is for — gets exactly one page. Ruled 2026-09-12
|
|
400
404
|
// [Diego]. ⛔ This read `item[paramName]` until 2026-09-11 and the whole array
|
|
401
405
|
// until 2026-09-12, which baked `/tags/a%2Cb`, a URL no lane matches.
|
|
402
|
-
const values = routeParamValues(item, paramName)
|
|
406
|
+
const values = routeParamValues(item, paramName, field)
|
|
403
407
|
if (values.length === 0) {
|
|
404
408
|
unrouted += 1
|
|
405
409
|
continue
|
|
@@ -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/config.js
CHANGED
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
* })
|
|
21
21
|
*/
|
|
22
22
|
|
|
23
|
-
import { existsSync, readFileSync } from 'node:fs'
|
|
23
|
+
import { existsSync, readFileSync, realpathSync } from 'node:fs'
|
|
24
24
|
import { resolve, dirname, join } from 'node:path'
|
|
25
25
|
import yaml from 'js-yaml'
|
|
26
26
|
import { YAML_OPTIONS } from '../utils/yaml-schema.js'
|
|
@@ -34,6 +34,8 @@ import { resolveModuleUrl, resolveExtensionUrls } from './extension-urls.js'
|
|
|
34
34
|
import { resolveFoundationSrcPath } from '../utils/foundation-source-root.js'
|
|
35
35
|
import { checkFoundationResolution } from '../utils/foundation-resolution-check.js'
|
|
36
36
|
import { detectFoundationType } from './foundation-ref.js'
|
|
37
|
+
import { readPreview, PREVIEW_ENV } from './preview.js'
|
|
38
|
+
import { previewMediaPlugin } from './preview-media.js'
|
|
37
39
|
|
|
38
40
|
/**
|
|
39
41
|
* Normalize a base path for Vite compatibility
|
|
@@ -112,6 +114,25 @@ function unique(list) {
|
|
|
112
114
|
return [...new Set(list)]
|
|
113
115
|
}
|
|
114
116
|
|
|
117
|
+
/**
|
|
118
|
+
* The site's `@uniweb/runtime`, as a path to resolve its dependencies from — its REAL
|
|
119
|
+
* directory, since under pnpm a package's dependencies sit beside its real location and
|
|
120
|
+
* not beside the link to it. Null when the site has none installed.
|
|
121
|
+
*/
|
|
122
|
+
function runtimeAnchor(siteRoot) {
|
|
123
|
+
for (let dir = siteRoot; ; dir = dirname(dir)) {
|
|
124
|
+
const candidate = join(dir, 'node_modules', '@uniweb', 'runtime')
|
|
125
|
+
if (existsSync(join(candidate, 'package.json'))) {
|
|
126
|
+
try {
|
|
127
|
+
return join(realpathSync(candidate), 'package.json')
|
|
128
|
+
} catch {
|
|
129
|
+
return null
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
if (dirname(dir) === dir) return null
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
115
136
|
export async function defineSiteConfig(options = {}) {
|
|
116
137
|
const {
|
|
117
138
|
plugins: extraPlugins = [],
|
|
@@ -147,8 +168,15 @@ export async function defineSiteConfig(options = {}) {
|
|
|
147
168
|
const rawBase = baseOption || process.env.UNIWEB_BASE || siteConfig.base
|
|
148
169
|
const base = rawBase ? normalizeBasePath(String(rawBase)) : undefined
|
|
149
170
|
|
|
150
|
-
//
|
|
151
|
-
|
|
171
|
+
// What `uniweb dev` read from the backend the site is on, for this dev server alone
|
|
172
|
+
// (`./preview.js`) — null unless it was handed one.
|
|
173
|
+
const preview = readPreview(process.env)
|
|
174
|
+
|
|
175
|
+
// Detect foundation type. A catalog ref resolves only for a preview, which is handed where
|
|
176
|
+
// the backend serves it.
|
|
177
|
+
const foundationInfo = detectFoundationType(siteConfig.foundation, siteRoot, {
|
|
178
|
+
served: preview?.foundation
|
|
179
|
+
})
|
|
152
180
|
|
|
153
181
|
// Check for runtime mode (env variable or URL-based foundation).
|
|
154
182
|
// Runtime mode means the foundation is loaded by URL at runtime; the
|
|
@@ -376,13 +404,39 @@ export async function defineSiteConfig(options = {}) {
|
|
|
376
404
|
plugins.push(importMapPlugin({
|
|
377
405
|
basePath: base || '/',
|
|
378
406
|
// Under pnpm strict mode, the site may not have @uniweb/core in its own
|
|
379
|
-
// node_modules. Resolve from the foundation directory where it's a direct dep
|
|
407
|
+
// node_modules. Resolve from the foundation directory where it's a direct dep —
|
|
408
|
+
// or, with no local foundation, as the site's runtime resolves it: the runtime is
|
|
409
|
+
// the host app whose instances a foundation must share.
|
|
380
410
|
resolveFrom: foundationInfo.path
|
|
381
411
|
? resolve(foundationInfo.path, 'package.json')
|
|
382
|
-
: resolve(siteRoot, 'main.js'),
|
|
412
|
+
: runtimeAnchor(siteRoot) || resolve(siteRoot, 'main.js'),
|
|
413
|
+
// A foundation served from a URL imports its externals by bare name, and in dev
|
|
414
|
+
// nothing else resolves them: the foundation dev plugin rewrites a LOCAL
|
|
415
|
+
// foundation's imports, and a served one is not ours to rewrite. So the dev server
|
|
416
|
+
// answers the import map a build writes, bridging to the site's own copies.
|
|
417
|
+
serveInDev: foundationInfo.type === 'url'
|
|
383
418
|
}))
|
|
384
419
|
}
|
|
385
420
|
|
|
421
|
+
if (preview) {
|
|
422
|
+
// ⛔ A PREVIEW'S ANSWER NEVER REACHES A BUILT ARTIFACT. Where the backend serves a
|
|
423
|
+
// version is decided at publish and read for one preview (`./preview.js`); written into
|
|
424
|
+
// `dist/` it would outlive the answer.
|
|
425
|
+
plugins.push({
|
|
426
|
+
name: 'uniweb:preview-only',
|
|
427
|
+
config(_config, env) {
|
|
428
|
+
if (env.command === 'build') {
|
|
429
|
+
throw new Error(
|
|
430
|
+
`[site] ${PREVIEW_ENV} is what \`uniweb dev\` read from ${preview.backend} for one ` +
|
|
431
|
+
`preview, and a build does not take it. Unset it to build.`
|
|
432
|
+
)
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
})
|
|
436
|
+
// A media URL the site keeps from its backend is fetched from that backend.
|
|
437
|
+
plugins.push(previewMediaPlugin({ backend: preview.backend, siteRoot }))
|
|
438
|
+
}
|
|
439
|
+
|
|
386
440
|
// Preload hints for runtime-loaded foundations and extensions.
|
|
387
441
|
// In runtime mode, foundation JS is loaded via import() and CSS is injected
|
|
388
442
|
// dynamically in JavaScript — the browser doesn't discover them until JS executes.
|
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
|
|
@@ -66,11 +66,15 @@ export function parseCatalogRef(value) {
|
|
|
66
66
|
*
|
|
67
67
|
* @param {string|Object} foundation - Foundation config from site.yml
|
|
68
68
|
* @param {string} siteRoot - Path to site directory
|
|
69
|
-
* @
|
|
69
|
+
* @param {Object} [options]
|
|
70
|
+
* @param {{ ref: string, url: string, cssUrl?: string|null }|null} [options.served] - where a
|
|
71
|
+
* catalog ref is served, for a preview: the answer `uniweb dev` read from the backend the site
|
|
72
|
+
* is on (`./preview.js`). Honored for the ref it names and no other.
|
|
73
|
+
* @returns {{ type: 'local'|'url', name?: string, url?: string, cssUrl?: string, path?: string, served?: string }}
|
|
70
74
|
* @throws {Error} when the declaration shape is invalid (versionless registry
|
|
71
75
|
* ref, unknown name with no local match, etc.)
|
|
72
76
|
*/
|
|
73
|
-
export function detectFoundationType(foundation, siteRoot) {
|
|
77
|
+
export function detectFoundationType(foundation, siteRoot, { served = null } = {}) {
|
|
74
78
|
// Object form with explicit URL
|
|
75
79
|
if (foundation && typeof foundation === 'object') {
|
|
76
80
|
if (foundation.url) {
|
|
@@ -119,11 +123,26 @@ export function detectFoundationType(foundation, siteRoot) {
|
|
|
119
123
|
// --backend` and the documented env var all left it pinned — and the artifact
|
|
120
124
|
// names were the pre-`entry.js` ones the build stopped emitting.
|
|
121
125
|
if (parseCatalogRef(name)) {
|
|
126
|
+
// ⭐ A PREVIEW is handed the answer instead of guessing it: `uniweb dev` asks the backend
|
|
127
|
+
// the site is on where this version is served, for its one dev server. The answer names
|
|
128
|
+
// the ref it is for, so it resolves the DECLARED foundation and can substitute no other.
|
|
129
|
+
if (served?.url && served.ref === name) {
|
|
130
|
+
return { type: 'url', url: served.url, cssUrl: served.cssUrl || null, served: name }
|
|
131
|
+
}
|
|
132
|
+
if (served?.ref) {
|
|
133
|
+
throw new Error(
|
|
134
|
+
`This dev server was handed where "${served.ref}" is served, and site.yml names "${name}".\n` +
|
|
135
|
+
`Restart \`uniweb dev\`, which asks for the foundation site.yml names.`
|
|
136
|
+
)
|
|
137
|
+
}
|
|
122
138
|
throw new Error(
|
|
123
139
|
[
|
|
124
140
|
`Foundation "${name}" is a catalog ref, and a build cannot resolve it to a URL.`,
|
|
125
141
|
`Where a foundation is served is the host's to declare, so the build does not guess it.`,
|
|
126
142
|
``,
|
|
143
|
+
` • Previewing the site? Run \`uniweb dev\`, which asks the backend the site is on`,
|
|
144
|
+
` where this version is served — and says so when that backend does not tell it.`,
|
|
145
|
+
``,
|
|
127
146
|
` • Deploying to another host (\`uniweb deploy --host=<adapter>\`), or taking`,
|
|
128
147
|
` the build anywhere (\`uniweb export\`)? Declare the served URL in site.yml —`,
|
|
129
148
|
` the runtime accepts any URL, from any host:`,
|
package/src/site/index.js
CHANGED
|
@@ -8,6 +8,8 @@
|
|
|
8
8
|
|
|
9
9
|
export { siteContentPlugin } from './plugin.js'
|
|
10
10
|
export { defineSiteConfig, readSiteConfig, default } from './config.js'
|
|
11
|
+
// What `uniweb dev` hands the dev server of a site a backend holds (`./preview.js`).
|
|
12
|
+
export { PREVIEW_ENV, encodePreview, readPreview } from './preview.js'
|
|
11
13
|
export { collectSiteContent } from './content-collector.js'
|
|
12
14
|
// The `agents:` vocabulary lives in @uniweb/projections (which owns the block);
|
|
13
15
|
// re-exported so the CLI can validate an author's spelling without taking a
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A PREVIEW FETCHES THE MEDIA ITS BACKEND SERVES — from that backend.
|
|
3
|
+
*
|
|
4
|
+
* A clone keeps a serve URL wherever the backend held one the pull cannot map back to an author's
|
|
5
|
+
* file — a record's image, a param (`uwx/asset-map.js::restoreAssetRefs`: *"the URL that works"*).
|
|
6
|
+
* A root-relative one names a file on the origin that served the document holding it. On the dev
|
|
7
|
+
* server's origin it names nothing, and a dev server answers a path it lacks with `index.html` —
|
|
8
|
+
* so the image broke with a 200.
|
|
9
|
+
*
|
|
10
|
+
* So in a preview (`./preview.js`), a request for media the site does not hold is answered by the
|
|
11
|
+
* backend the site is on. ⭐ Nothing is composed: the path is the URL the content holds, resolved
|
|
12
|
+
* against the origin it came from. ⛔ Only media — a request whose destination is an image, a video,
|
|
13
|
+
* audio, a track or a font — and only what the site does not hold, in `public/` or under its root:
|
|
14
|
+
* the site's own files, its routes and Vite's own paths are never sent anywhere. The request carries
|
|
15
|
+
* no credential; a served asset is public.
|
|
16
|
+
*/
|
|
17
|
+
import { existsSync, statSync } from 'node:fs'
|
|
18
|
+
import { join, normalize, sep } from 'node:path'
|
|
19
|
+
import { Readable } from 'node:stream'
|
|
20
|
+
|
|
21
|
+
const MEDIA_DESTINATIONS = new Set(['image', 'video', 'audio', 'track', 'font'])
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* @param {{ backend: string, siteRoot: string }} options - the backend's origin (`readPreview`)
|
|
25
|
+
* @returns {import('vite').Plugin}
|
|
26
|
+
*/
|
|
27
|
+
export function previewMediaPlugin({ backend, siteRoot }) {
|
|
28
|
+
return {
|
|
29
|
+
name: 'uniweb:preview-media',
|
|
30
|
+
apply: 'serve',
|
|
31
|
+
configureServer(server) {
|
|
32
|
+
// Registered before Vite's own middlewares, whose SPA fallback would answer first.
|
|
33
|
+
server.middlewares.use(async (req, res, next) => {
|
|
34
|
+
const pathname = mediaPath(req)
|
|
35
|
+
if (!pathname) return next()
|
|
36
|
+
const base = server.config.base || '/'
|
|
37
|
+
const local = [pathname]
|
|
38
|
+
if (base !== '/' && pathname.startsWith(base)) local.push('/' + pathname.slice(base.length))
|
|
39
|
+
const roots = [server.config.publicDir, siteRoot].filter(Boolean)
|
|
40
|
+
if (local.some((p) => roots.some((root) => holds(root, p)))) return next()
|
|
41
|
+
|
|
42
|
+
let upstream
|
|
43
|
+
try {
|
|
44
|
+
upstream = await fetch(new URL(req.url, backend), { method: req.method, redirect: 'follow' })
|
|
45
|
+
} catch (err) {
|
|
46
|
+
res.statusCode = 502
|
|
47
|
+
return res.end(`The site's backend (${backend}) did not answer: ${err.message}`)
|
|
48
|
+
}
|
|
49
|
+
// Its answer, whatever it is — a 404 here is the backend not having it either, which
|
|
50
|
+
// is the truth; falling through would answer `index.html`.
|
|
51
|
+
res.statusCode = upstream.status
|
|
52
|
+
for (const header of ['content-type', 'content-length', 'cache-control', 'etag', 'last-modified']) {
|
|
53
|
+
const value = upstream.headers.get(header)
|
|
54
|
+
if (value) res.setHeader(header, value)
|
|
55
|
+
}
|
|
56
|
+
if (req.method === 'HEAD' || !upstream.body) return res.end()
|
|
57
|
+
Readable.fromWeb(upstream.body).pipe(res)
|
|
58
|
+
})
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The root-relative path a media request names, or null for any other request. */
|
|
64
|
+
export function mediaPath(req) {
|
|
65
|
+
if (req.method !== 'GET' && req.method !== 'HEAD') return null
|
|
66
|
+
const dest = req.headers['sec-fetch-dest']
|
|
67
|
+
const accept = String(req.headers.accept || '')
|
|
68
|
+
const media = dest ? MEDIA_DESTINATIONS.has(dest) : /^(image|video|audio)\//.test(accept)
|
|
69
|
+
if (!media) return null
|
|
70
|
+
let pathname
|
|
71
|
+
try {
|
|
72
|
+
pathname = decodeURIComponent(new URL(req.url, 'http://preview.invalid').pathname)
|
|
73
|
+
} catch {
|
|
74
|
+
return null
|
|
75
|
+
}
|
|
76
|
+
// Vite's own paths are never the backend's.
|
|
77
|
+
if (/^\/(@|node_modules\/|__)/.test(pathname)) return null
|
|
78
|
+
return pathname
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Whether a file lies at `pathname` under `root`, and inside it. */
|
|
82
|
+
function holds(root, pathname) {
|
|
83
|
+
const file = normalize(join(root, pathname))
|
|
84
|
+
if (file !== root && !file.startsWith(root.endsWith(sep) ? root : root + sep)) return false
|
|
85
|
+
try {
|
|
86
|
+
return existsSync(file) && statSync(file).isFile()
|
|
87
|
+
} catch {
|
|
88
|
+
return false
|
|
89
|
+
}
|
|
90
|
+
}
|