@uniweb/build 0.73.0 → 0.73.2
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 +5 -5
- package/src/i18n/records.js +69 -11
- package/src/prerender.js +33 -27
- package/src/resolve-data-schema.js +10 -2
- package/src/schema.js +18 -5
- package/src/site/content-collector.js +9 -3
- package/src/site/emit-extensions.js +21 -10
- package/src/site/entity-pool.js +29 -2
- package/src/site/entry-label.js +110 -0
- package/src/site/fetch-shapes.js +9 -0
- package/src/site/file-records.js +109 -0
- package/src/site/queries-config.js +4 -3
- package/src/site/query-processor.js +23 -4
- package/src/site/records-config.js +19 -5
- package/src/uwx/entity-source.js +2 -1
- package/src/uwx/file-records.js +178 -0
- package/src/uwx/folder.js +38 -8
- package/src/uwx/index.js +8 -0
- package/src/uwx/link-records.js +233 -0
- package/src/uwx/records-project.js +54 -11
- package/src/uwx/records.js +76 -9
- package/src/uwx/site.js +19 -3
- package/src/uwx/sync-package.js +20 -2
- package/src/uwx/sync-store.js +1 -1
- package/src/validate-data.js +8 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uniweb/build",
|
|
3
|
-
"version": "0.73.
|
|
3
|
+
"version": "0.73.2",
|
|
4
4
|
"description": "Build tooling for the Uniweb Component Web Platform",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -57,11 +57,11 @@
|
|
|
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.9.3",
|
|
61
61
|
"@uniweb/content-writer": "^0.3.4",
|
|
62
62
|
"@uniweb/projections": "^0.7.11",
|
|
63
|
-
"@uniweb/schemas": "^0.9.1",
|
|
64
63
|
"@uniweb/semantic-parser": "^1.4.1",
|
|
64
|
+
"@uniweb/content-reader": "^1.2.7",
|
|
65
65
|
"@uniweb/theming": "^0.2.0"
|
|
66
66
|
},
|
|
67
67
|
"optionalDependencies": {
|
|
@@ -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/
|
|
78
|
-
"@uniweb/
|
|
77
|
+
"@uniweb/runtime": "^0.28.3",
|
|
78
|
+
"@uniweb/core": "^0.35.0"
|
|
79
79
|
},
|
|
80
80
|
"peerDependenciesMeta": {
|
|
81
81
|
"vite": {
|
package/src/i18n/records.js
CHANGED
|
@@ -14,7 +14,7 @@ import { readFile, writeFile, readdir, mkdir, rm } from 'fs/promises'
|
|
|
14
14
|
import { existsSync } from 'node:fs'
|
|
15
15
|
import { join, resolve, sep } from 'node:path'
|
|
16
16
|
import { pathToFileURL } from 'node:url'
|
|
17
|
-
import { DATA_DIR } from '@uniweb/core'
|
|
17
|
+
import { DATA_DIR, resolveDefaultLocale } from '@uniweb/core'
|
|
18
18
|
import yaml from 'js-yaml'
|
|
19
19
|
import { YAML_OPTIONS } from '../utils/yaml-schema.js'
|
|
20
20
|
import { markdownToProseMirror } from '@uniweb/content-reader'
|
|
@@ -28,7 +28,9 @@ import { toDeliveredRecord, contentBodyField, misplacedFields, mergedFromStored,
|
|
|
28
28
|
import { resolveDocForLocale } from './merge.js'
|
|
29
29
|
import { extractUnitsFromDoc } from './extract.js'
|
|
30
30
|
import { flatFormRefusal, extractExcerpt } from '../site/query-processor.js'
|
|
31
|
-
import { poolDirsForSchema, schemaForPoolDirs, resolveRecordsDir } from '../site/entity-pool.js'
|
|
31
|
+
import { poolDirsForSchema, schemaForPoolDirs, resolveRecordsDir, readEntityPool, groupPoolBySchema } from '../site/entity-pool.js'
|
|
32
|
+
import { readRecordsConfig, resolveFolder } from '../site/records-config.js'
|
|
33
|
+
import { labelIn } from '../site/entry-label.js'
|
|
32
34
|
// The heuristic judgement about which strings inside structured data are prose.
|
|
33
35
|
// It lives in its own module because the page lane needs exactly the same
|
|
34
36
|
// answer for a tagged data block's payload — a `label` is prose and an `href`
|
|
@@ -679,7 +681,9 @@ async function poolDirsByQuery(siteRoot) {
|
|
|
679
681
|
* none), which says where a free-form body and frontmatter go, and where the body is;
|
|
680
682
|
* - `models` — the model its records are pushed as (`modelOf`), which says which of their fields
|
|
681
683
|
* are translated; `targets` — the model each reference names, by its ref, transitively;
|
|
682
|
-
* - `excerpts` — its excerpt settings, which derive an excerpt (`EXCERPT`)
|
|
684
|
+
* - `excerpts` — its excerpt settings, which derive an excerpt (`EXCERPT`);
|
|
685
|
+
* - `labels` — the labels its records' folder entries give per language, by record name, which
|
|
686
|
+
* each language answers as `$label` (`entry-label.js`); and `defaultLocale`, the site's.
|
|
683
687
|
*
|
|
684
688
|
* ⭐ THE MODEL IS THE TYPE THE RECORDS ARE OF, which is not always the schema they are delivered
|
|
685
689
|
* in. A query whose name-defaulted schema (`@/team`) no data schema answers, for a data key the
|
|
@@ -687,9 +691,11 @@ async function poolDirsByQuery(siteRoot) {
|
|
|
687
691
|
* as its entities (`uwx/data-key-types.js`) — while the build delivers them as they are written.
|
|
688
692
|
* The type is read from the foundation's built `schema.json`, the input a push reads it from.
|
|
689
693
|
*
|
|
690
|
-
* @returns {Promise<{ poolDirs: Map<string, string>, schemas: Map<string, Object|null>, models: Map<string, Object|null>, targets: Map<string, Object|null>, excerpts: Map<string, Object
|
|
694
|
+
* @returns {Promise<{ poolDirs: Map<string, string>, schemas: Map<string, Object|null>, models: Map<string, Object|null>, targets: Map<string, Object|null>, excerpts: Map<string, Object>, labels: Map<string, Map<string, Object>>, defaultLocale: string|null }>}
|
|
691
695
|
*/
|
|
692
696
|
async function recordQueries(siteRoot) {
|
|
697
|
+
const labels = new Map()
|
|
698
|
+
let defaultLocale = null
|
|
693
699
|
const poolDirs = new Map()
|
|
694
700
|
const schemas = new Map()
|
|
695
701
|
const models = new Map()
|
|
@@ -697,6 +703,7 @@ async function recordQueries(siteRoot) {
|
|
|
697
703
|
const excerpts = new Map()
|
|
698
704
|
try {
|
|
699
705
|
const siteYml = await readSiteYml(siteRoot)
|
|
706
|
+
defaultLocale = resolveDefaultLocale(siteYml) ?? null
|
|
700
707
|
const { declarations } = await resolveQueriesConfig(siteRoot, { siteYml })
|
|
701
708
|
const entries = Object.entries(declarations || {})
|
|
702
709
|
for (const [name, decl] of entries) {
|
|
@@ -706,6 +713,24 @@ async function recordQueries(siteRoot) {
|
|
|
706
713
|
excerpts.set(name, { maxLength: decl.excerpt?.maxLength || 160, field: decl.excerpt?.field || null })
|
|
707
714
|
}
|
|
708
715
|
const local = entries.filter(([, d]) => d.url === undefined && d.schema)
|
|
716
|
+
|
|
717
|
+
// ⭐ A LABEL GIVEN PER LANGUAGE (`records/folder.yml`, 2026-09-28) is answered per language:
|
|
718
|
+
// the site's own when its data was compiled (`query-processor.js`), and every other one here,
|
|
719
|
+
// from the same placements the compile read — so the two cannot place a label differently.
|
|
720
|
+
const pool = await readEntityPool(siteRoot)
|
|
721
|
+
const folderCfg = await readRecordsConfig(siteRoot, { dir: pool.dir })
|
|
722
|
+
if (!folderCfg.error) {
|
|
723
|
+
const { placements } = resolveFolder(folderCfg.entries, pool.entities, { dir: pool.dir })
|
|
724
|
+
const bySchema = groupPoolBySchema(pool.entities)
|
|
725
|
+
for (const [name, decl] of local) {
|
|
726
|
+
const byName = new Map()
|
|
727
|
+
for (const entity of bySchema.get(decl.schema) || []) {
|
|
728
|
+
const label = placements.get(entity.id)?.label
|
|
729
|
+
if (isPlainObject(label)) byName.set(String(entity.slug), label)
|
|
730
|
+
}
|
|
731
|
+
if (byName.size) labels.set(name, byName)
|
|
732
|
+
}
|
|
733
|
+
}
|
|
709
734
|
const resolved = (await resolveRecordSchemas(siteRoot, local.map(([, d]) => d.schema), { siteYml })).schemas
|
|
710
735
|
for (const [name, decl] of entries) schemas.set(name, (decl.schema && resolved[decl.schema]) || null)
|
|
711
736
|
|
|
@@ -740,7 +765,7 @@ async function recordQueries(siteRoot) {
|
|
|
740
765
|
// No resolvable config — every record keys by its query name, which is what
|
|
741
766
|
// the extractor did before records existed.
|
|
742
767
|
}
|
|
743
|
-
return { poolDirs, schemas, models, targets, excerpts }
|
|
768
|
+
return { poolDirs, schemas, models, targets, excerpts, labels, defaultLocale }
|
|
744
769
|
}
|
|
745
770
|
|
|
746
771
|
/** A site's `site.yml`, or `{}`. */
|
|
@@ -979,7 +1004,7 @@ export async function buildLocalizedRecords(siteRoot, options = {}) {
|
|
|
979
1004
|
// simply miss and every string falls back to its source. (This was missing for
|
|
980
1005
|
// a while and the failure was swallowed by the per-file catch below — the build
|
|
981
1006
|
// stayed green while nothing was translated.)
|
|
982
|
-
const { poolDirs, schemas: dataSchemas, models, targets, excerpts } = await recordQueries(siteRoot)
|
|
1007
|
+
const { poolDirs, schemas: dataSchemas, models, targets, excerpts, labels, defaultLocale } = await recordQueries(siteRoot)
|
|
983
1008
|
|
|
984
1009
|
const outputs = {}
|
|
985
1010
|
// Reported rather than only logged — see the catch below.
|
|
@@ -1058,7 +1083,12 @@ export async function buildLocalizedRecords(siteRoot, options = {}) {
|
|
|
1058
1083
|
? await translateItemAsync(record, recordDir, translations, schema, recordOptions)
|
|
1059
1084
|
: record
|
|
1060
1085
|
if (isPlainObject(record)) wholes.set(recordHandle(record), { source: record, translated: translatedRecord })
|
|
1061
|
-
const written =
|
|
1086
|
+
const written = relabel(
|
|
1087
|
+
dataSchema && isPlainObject(translatedRecord) ? storedFromMerged(dataSchema, translatedRecord) : translatedRecord,
|
|
1088
|
+
labels.get(queryName),
|
|
1089
|
+
locale,
|
|
1090
|
+
defaultLocale
|
|
1091
|
+
)
|
|
1062
1092
|
await mkdir(localeRecordsDir, { recursive: true })
|
|
1063
1093
|
await writeFile(join(localeRecordsDir, name), JSON.stringify(written, null, 2))
|
|
1064
1094
|
} catch (err) {
|
|
@@ -1077,7 +1107,8 @@ export async function buildLocalizedRecords(siteRoot, options = {}) {
|
|
|
1077
1107
|
)
|
|
1078
1108
|
|
|
1079
1109
|
const destPath = join(localeDataDir, file)
|
|
1080
|
-
|
|
1110
|
+
const labelled = translatedItems.map((item) => relabel(item, labels.get(queryName), locale, defaultLocale))
|
|
1111
|
+
await writeFile(destPath, JSON.stringify(labelled, null, 2))
|
|
1081
1112
|
outputs[locale][queryName] = destPath
|
|
1082
1113
|
} catch (err) {
|
|
1083
1114
|
// ⛔ A FAILURE HERE USED TO BE A `console.warn` AND NOTHING ELSE, and it
|
|
@@ -1278,7 +1309,7 @@ export async function translateRecordData(items, queryName, siteRoot, options =
|
|
|
1278
1309
|
if (!Array.isArray(items) && !one) return items
|
|
1279
1310
|
|
|
1280
1311
|
const schema = await resolveSchema(queryName, siteRoot)
|
|
1281
|
-
const { poolDirs, schemas: dataSchemas, models, targets, excerpts } = await recordQueries(siteRoot)
|
|
1312
|
+
const { poolDirs, schemas: dataSchemas, models, targets, excerpts, labels, defaultLocale } = await recordQueries(siteRoot)
|
|
1282
1313
|
const recordDir = poolDirs.get(queryName) ?? queryName
|
|
1283
1314
|
const recordOptions = {
|
|
1284
1315
|
locale,
|
|
@@ -1305,8 +1336,35 @@ export async function translateRecordData(items, queryName, siteRoot, options =
|
|
|
1305
1336
|
records.map((item) => translateItemAsync(item, recordDir, translations, schema, { ...recordOptions, whole: wholes.get(recordHandle(item)) }))
|
|
1306
1337
|
)
|
|
1307
1338
|
|
|
1308
|
-
|
|
1309
|
-
|
|
1339
|
+
const labelsOf = labels.get(queryName)
|
|
1340
|
+
if (!one) return translated.map((item) => relabel(item, labelsOf, locale, defaultLocale))
|
|
1341
|
+
return relabel(
|
|
1342
|
+
dataSchema && isPlainObject(translated[0]) ? storedFromMerged(dataSchema, translated[0]) : translated[0],
|
|
1343
|
+
labelsOf,
|
|
1344
|
+
locale,
|
|
1345
|
+
defaultLocale
|
|
1346
|
+
)
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1349
|
+
/**
|
|
1350
|
+
* A record in one language, with its entry's label in that language: `$label` as the label a
|
|
1351
|
+
* folder entry gives per language answers it there (`entry-label.js::labelIn`), and no `$label`
|
|
1352
|
+
* when it gives none of that language's chain. A record whose entry gives one text keeps the
|
|
1353
|
+
* `$label` its compile gave it.
|
|
1354
|
+
*
|
|
1355
|
+
* @param {*} record - a list item or a record's own file, with its `$name`
|
|
1356
|
+
* @param {Map<string, Object>|undefined} labelsOf - the query's labels per language, by record name
|
|
1357
|
+
* @param {string} locale
|
|
1358
|
+
* @param {string|null} defaultLocale
|
|
1359
|
+
*/
|
|
1360
|
+
function relabel(record, labelsOf, locale, defaultLocale) {
|
|
1361
|
+
if (!labelsOf || !isPlainObject(record) || record.$name === undefined) return record
|
|
1362
|
+
const label = labelsOf.get(String(record.$name))
|
|
1363
|
+
if (!label) return record
|
|
1364
|
+
const text = labelIn(label, locale, defaultLocale)
|
|
1365
|
+
if (text) return { ...record, $label: text }
|
|
1366
|
+
const { $label: _none, ...rest } = record
|
|
1367
|
+
return rest
|
|
1310
1368
|
}
|
|
1311
1369
|
|
|
1312
1370
|
// ---------------------------------------------------------------------------
|
package/src/prerender.js
CHANGED
|
@@ -32,14 +32,34 @@ import { FONT_LINKS_MARKER } from './site/head-markers.js'
|
|
|
32
32
|
import { getAdapter } from './hosts/index.js'
|
|
33
33
|
import { detectCiContext } from './hosts/detect-ci-context.js'
|
|
34
34
|
import { stripBasePath } from './site/extension-urls.js'
|
|
35
|
+
import { isSiteRelative, resolveExtensionDist } from './site/emit-extensions.js'
|
|
35
36
|
|
|
36
37
|
/**
|
|
37
|
-
* Resolve an extension URL to
|
|
38
|
+
* Resolve an extension URL to the file prerender imports.
|
|
38
39
|
* Browser URLs like "/effects/entry.js" need mapping to local files.
|
|
39
40
|
*
|
|
41
|
+
* ⭐ AN EXTENSION IS IMPORTED FROM ITS OWN BUILD, as the primary foundation is
|
|
42
|
+
* (`<foundation>/dist/entry.js`, in prerenderSite). A foundation build leaves its
|
|
43
|
+
* externals — `@uniweb/core`, `react`, `react/jsx-runtime` — as bare imports, and
|
|
44
|
+
* Node resolves them from where the FILE sits: inside the extension's own
|
|
45
|
+
* package, which depends on them.
|
|
46
|
+
*
|
|
47
|
+
* ⛔ Not from the copy the site build emits into `dist/` (site/emit-extensions.js).
|
|
48
|
+
* That copy is the browser's, which resolves the same imports through the page's
|
|
49
|
+
* import map; here it sits under the site package, which need not depend on
|
|
50
|
+
* `@uniweb/core` at all. It was the first candidate until 2026-09-28 — an order
|
|
51
|
+
* written when nothing put a file there — and once the build began emitting one
|
|
52
|
+
* (2026-08-05), every workspace extension failed here with "Cannot find package
|
|
53
|
+
* '@uniweb/core'", and the static HTML carried `Component not found` where its
|
|
54
|
+
* sections belonged until the page ran in the browser.
|
|
55
|
+
*
|
|
40
56
|
* Resolution order:
|
|
41
|
-
* 1.
|
|
42
|
-
*
|
|
57
|
+
* 1. The extension's own build — `<project>/<name>/dist/` or
|
|
58
|
+
* `<project>/extensions/<name>/dist/` — found by the emitter's lookup
|
|
59
|
+
* (`resolveExtensionDist`), so the two lanes find the same build.
|
|
60
|
+
* 2. A file in the site's dist, for an extension no workspace package builds (a
|
|
61
|
+
* pre-built module placed in `public/`). Its bare imports resolve only where
|
|
62
|
+
* the site's own dependencies carry them.
|
|
43
63
|
* 3. Original URL (absolute or remote — let import() handle it)
|
|
44
64
|
*/
|
|
45
65
|
export function resolveExtensionPath(url, distDir, projectRoot, base) {
|
|
@@ -50,32 +70,18 @@ export function resolveExtensionPath(url, distDir, projectRoot, base) {
|
|
|
50
70
|
// so a payload produced before this change still resolves.
|
|
51
71
|
url = stripBasePath(url, base)
|
|
52
72
|
|
|
53
|
-
//
|
|
54
|
-
if (url
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
// both at the project root and under an `extensions/` parent — the standard
|
|
61
|
-
// multi-foundation layout puts extensions in `extensions/<name>/`, so the
|
|
62
|
-
// bare-root candidate alone misses the built module and prerender can't load
|
|
63
|
-
// the extension.
|
|
64
|
-
const parts = url.slice(1).split('/')
|
|
65
|
-
if (parts.length >= 2) {
|
|
66
|
-
const pkgName = parts[0]
|
|
67
|
-
const rest = parts.slice(1).join('/')
|
|
68
|
-
const candidates = [
|
|
69
|
-
join(projectRoot, pkgName, 'dist', rest),
|
|
70
|
-
join(projectRoot, 'extensions', pkgName, 'dist', rest),
|
|
71
|
-
]
|
|
72
|
-
for (const devPath of candidates) {
|
|
73
|
-
if (existsSync(devPath)) return devPath
|
|
74
|
-
}
|
|
75
|
-
}
|
|
73
|
+
// Return as-is for absolute paths or remote URLs
|
|
74
|
+
if (!isSiteRelative(url)) return url
|
|
75
|
+
|
|
76
|
+
const build = resolveExtensionDist(url, projectRoot)
|
|
77
|
+
if (build) {
|
|
78
|
+
const inBuild = join(build.distDir, build.rest)
|
|
79
|
+
if (existsSync(inBuild)) return inBuild
|
|
76
80
|
}
|
|
77
81
|
|
|
78
|
-
|
|
82
|
+
const inDist = join(distDir, url)
|
|
83
|
+
if (existsSync(inDist)) return inDist
|
|
84
|
+
|
|
79
85
|
return url
|
|
80
86
|
}
|
|
81
87
|
|
|
@@ -39,6 +39,7 @@ 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
41
|
import { dataRefOf } from '@uniweb/core/data-keys'
|
|
42
|
+
import { SYSTEM_RECORD_SCHEMAS } from '@uniweb/schemas/system'
|
|
42
43
|
import { pathToFileURL } from 'node:url'
|
|
43
44
|
import { createRequire } from 'node:module'
|
|
44
45
|
import yaml from 'js-yaml'
|
|
@@ -138,10 +139,17 @@ export async function resolveSchemaRef(ref, { srcDir, aliases }) {
|
|
|
138
139
|
return validateAndNormalizeSchema(await loadSchemaFile(file), ref)
|
|
139
140
|
}
|
|
140
141
|
|
|
142
|
+
// ⭐ THE SYSTEM MODELS A SITE'S RECORDS MAY NAME — `@uniweb/link` — resolve from the build's own
|
|
143
|
+
// copy (`@uniweb/schemas/system`), whatever the foundation holds: a site with no backend builds,
|
|
144
|
+
// validates and renders a link record with it. Every other `@uniweb/` name is platform plumbing,
|
|
145
|
+
// never a data-schema source. (The whole scope was refused until 2026-09-28.)
|
|
141
146
|
if (scope === RESERVED_SYSTEM_SCOPE) {
|
|
147
|
+
const system = SYSTEM_RECORD_SCHEMAS[name]
|
|
148
|
+
if (system) return validateAndNormalizeSchema(system, ref)
|
|
149
|
+
const allowed = Object.keys(SYSTEM_RECORD_SCHEMAS).map((n) => `'@${scope}/${n}'`).join(', ')
|
|
142
150
|
throw new Error(
|
|
143
|
-
`'@${scope}' is the reserved platform system namespace
|
|
144
|
-
`Use '@std/${name}' for the shared standard schemas.`
|
|
151
|
+
`'@${scope}' is the reserved platform system namespace: the only data schemas a site's records ` +
|
|
152
|
+
`may name in it are ${allowed}. Use '@std/${name}' for the shared standard schemas.`
|
|
145
153
|
)
|
|
146
154
|
}
|
|
147
155
|
|
package/src/schema.js
CHANGED
|
@@ -17,7 +17,7 @@ import { isFontVar } from '@uniweb/theming'
|
|
|
17
17
|
import { join, dirname, extname, basename } from 'node:path'
|
|
18
18
|
import { pathToFileURL } from 'node:url'
|
|
19
19
|
import { describeChildren, describeVisuals } from '@uniweb/schemas/component'
|
|
20
|
-
import { SECTION_PARAMS } from '@uniweb/schemas/section'
|
|
20
|
+
import { SECTION_PARAMS, SECTION_KEYS } from '@uniweb/schemas/section'
|
|
21
21
|
import { collectSchemaRefs, buildDataSchemaMap, ownSchemaRefs } from './resolve-data-schema.js'
|
|
22
22
|
import {
|
|
23
23
|
composeSupports,
|
|
@@ -958,19 +958,32 @@ export function reportPlacementDeclarations(components) {
|
|
|
958
958
|
`provides renders "Component not found".`
|
|
959
959
|
)
|
|
960
960
|
}
|
|
961
|
-
// A name framework reserves in a section's params
|
|
962
|
-
//
|
|
961
|
+
// A name framework reserves in a section's params: a setting of the SECTION, which framework
|
|
962
|
+
// applies — `component: false` in `@uniweb/schemas/section`. A component that declares one in
|
|
963
|
+
// its `params:` declares a setting it is never given. ⛔ Until 2026-09-28 this said the param
|
|
964
|
+
// "never receives the author's theme:" and to rename it — read as "the section loses the
|
|
965
|
+
// author's value", the opposite of what happens: the section gets it, and framework applies it.
|
|
963
966
|
const params = entry.params && typeof entry.params === 'object' ? entry.params : {}
|
|
964
967
|
for (const [param, reserved] of Object.entries(SECTION_PARAMS)) {
|
|
965
968
|
if (reserved.component || !Object.hasOwn(params, param)) continue
|
|
966
969
|
console.warn(
|
|
967
|
-
`Warning: ${name} (meta.js):
|
|
968
|
-
`
|
|
970
|
+
`Warning: ${name} (meta.js): params declares "${param}", a setting of the section, not of its ` +
|
|
971
|
+
`component — framework applies an author's ${param}: to the section and never passes it to the ` +
|
|
972
|
+
`component, and an editor offers it for every section (@uniweb/schemas/section). Remove it from params:.` +
|
|
969
973
|
(param === 'grid'
|
|
970
974
|
? ` To offer layouts for the child sections, declare children: { grid: [...] } and lay them out with kit's ChildGrid.`
|
|
971
975
|
: '')
|
|
972
976
|
)
|
|
973
977
|
}
|
|
978
|
+
// …and a key of a section's frontmatter that is not a param at all (`SECTION_KEYS`): the build
|
|
979
|
+
// takes it out before the params are stored, so a param of that name is never given one.
|
|
980
|
+
for (const [key, { is }] of Object.entries(SECTION_KEYS)) {
|
|
981
|
+
if (!Object.hasOwn(params, key)) continue
|
|
982
|
+
console.warn(
|
|
983
|
+
`Warning: ${name} (meta.js): params declares "${key}", a key a section's frontmatter keeps for ` +
|
|
984
|
+
`itself — ${is} — so the component is never given it (@uniweb/schemas/section). Remove it from params:.`
|
|
985
|
+
)
|
|
986
|
+
}
|
|
974
987
|
}
|
|
975
988
|
}
|
|
976
989
|
|
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
|
|
24
24
|
import { readFile, readdir, stat } from 'node:fs/promises'
|
|
25
25
|
import { resolveQueriesConfig, toConfigQueries } from './queries-config.js'
|
|
26
|
+
import { isSectionKey } from '@uniweb/schemas/section'
|
|
26
27
|
import { parseNumericPrefix, compareByNumericPrefix } from '../utils/numeric-prefix.js'
|
|
27
28
|
import { isMarkdownFile, isIgnoredFolder } from '../utils/content-files.js'
|
|
28
29
|
import { readLayoutFolder, DEFAULT_LAYOUT, layoutAreaRoute } from './layout-folder.js'
|
|
@@ -1016,9 +1017,14 @@ async function processMarkdownFile(filePath, id, siteRoot, defaultStableId = nul
|
|
|
1016
1017
|
console.warn(`[content-collector] ${err.message}`)
|
|
1017
1018
|
}
|
|
1018
1019
|
|
|
1019
|
-
//
|
|
1020
|
-
//
|
|
1021
|
-
|
|
1020
|
+
// ⭐ WHAT IS NOT A PARAM is `SECTION_KEYS` (`@uniweb/schemas/section`) — the list the build's
|
|
1021
|
+
// warning on a component's `params:` reads too — and `fetch`, one of the section's settings,
|
|
1022
|
+
// kept on the section as a field of its own. `query:` / `fetch:` declare the section's own
|
|
1023
|
+
// data, and a leftover `data:` is refused (`declaredFetch`).
|
|
1024
|
+
const { type, hidden, preset, input, props, fetch, query, data, id: frontmatterId } = frontMatter
|
|
1025
|
+
const params = Object.fromEntries(
|
|
1026
|
+
Object.entries(frontMatter).filter(([key]) => !isSectionKey(key) && key !== 'fetch')
|
|
1027
|
+
)
|
|
1022
1028
|
// ⭐ `hidden: true` — a draft section, the page's meaning one level down: left out of a
|
|
1023
1029
|
// published build, kept by `uniweb dev`, and synced as the section's own `hidden`
|
|
1024
1030
|
// [Diego, 2026-09-28]. ⛔ Until then it was an ordinary param: the component received it
|
|
@@ -39,6 +39,11 @@
|
|
|
39
39
|
* Same browser/internal split the runtime's distribution channel draws, for the
|
|
40
40
|
* same reason: what a visitor fetches and what a renderer needs are different
|
|
41
41
|
* sets, and only one of them belongs on a public origin.
|
|
42
|
+
*
|
|
43
|
+
* ⛔ The emitted copy is the BROWSER's, and only the browser's. Prerender imports
|
|
44
|
+
* the extension from its own build (`resolveExtensionPath`), because the copy
|
|
45
|
+
* sits under the site package, where the extension's bare imports
|
|
46
|
+
* (`@uniweb/core`, `react`) do not resolve.
|
|
42
47
|
*/
|
|
43
48
|
|
|
44
49
|
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'
|
|
@@ -69,26 +74,31 @@ function isBrowserAsset(rel) {
|
|
|
69
74
|
}
|
|
70
75
|
|
|
71
76
|
/**
|
|
72
|
-
* Locate the built `dist/` behind a site-relative extension URL
|
|
77
|
+
* Locate the built `dist/` behind a site-relative extension URL:
|
|
78
|
+
* `/effects/entry.js` → `<project>/effects/dist/` or
|
|
79
|
+
* `<project>/extensions/effects/dist/`, with `rest` the path inside it.
|
|
73
80
|
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
*
|
|
81
|
+
* One lookup for both lanes that need the extension's build: this emitter, which
|
|
82
|
+
* copies it into the site's output, and prerender, which imports it from where it
|
|
83
|
+
* was built (`resolveExtensionPath`). Neither can find an extension the other
|
|
84
|
+
* cannot, by construction rather than by two lists kept in step by hand.
|
|
78
85
|
*
|
|
79
|
-
* @
|
|
86
|
+
* @param {string} url - a site-relative URL, with any deployment base removed
|
|
87
|
+
* @param {string} projectRoot - the directory holding the site and its extensions
|
|
88
|
+
* @returns {{ distDir: string, urlBase: string, rest: string }|null}
|
|
80
89
|
*/
|
|
81
|
-
export function resolveExtensionDist(url,
|
|
90
|
+
export function resolveExtensionDist(url, projectRoot) {
|
|
82
91
|
const parts = url.replace(/^\//, '').split('/')
|
|
83
92
|
if (parts.length < 2) return null
|
|
84
93
|
const pkgName = parts[0]
|
|
85
|
-
const projectRoot = resolve(siteDir, '..')
|
|
86
94
|
|
|
87
95
|
for (const candidate of [
|
|
88
96
|
join(projectRoot, pkgName, 'dist'),
|
|
89
97
|
join(projectRoot, 'extensions', pkgName, 'dist')
|
|
90
98
|
]) {
|
|
91
|
-
if (existsSync(candidate))
|
|
99
|
+
if (existsSync(candidate)) {
|
|
100
|
+
return { distDir: candidate, urlBase: pkgName, rest: parts.slice(1).join('/') }
|
|
101
|
+
}
|
|
92
102
|
}
|
|
93
103
|
return null
|
|
94
104
|
}
|
|
@@ -108,11 +118,12 @@ export function collectExtensionAssets(extensions, siteDir) {
|
|
|
108
118
|
const emit = []
|
|
109
119
|
const unresolved = []
|
|
110
120
|
if (!Array.isArray(extensions)) return { emit, unresolved }
|
|
121
|
+
const projectRoot = resolve(siteDir, '..')
|
|
111
122
|
|
|
112
123
|
for (const decl of extensions) {
|
|
113
124
|
if (!isSiteRelative(decl)) continue // absolute URL or a ref — someone else serves it
|
|
114
125
|
const url = typeof decl === 'string' ? decl : decl.url
|
|
115
|
-
const found = resolveExtensionDist(url,
|
|
126
|
+
const found = resolveExtensionDist(url, projectRoot)
|
|
116
127
|
if (!found) {
|
|
117
128
|
unresolved.push(url)
|
|
118
129
|
continue
|
package/src/site/entity-pool.js
CHANGED
|
@@ -172,6 +172,16 @@ export function resolveRecordsDir(siteRoot, paths) {
|
|
|
172
172
|
/** Source extensions a record file may have. Mirrors the sync-lane reader. */
|
|
173
173
|
export const ENTITY_EXTENSIONS = new Set(['.md', '.yml', '.yaml', '.json', '.bib'])
|
|
174
174
|
|
|
175
|
+
/**
|
|
176
|
+
* ⭐ WHERE THE FILE ITSELF IS THE RECORD — `<records>/uniweb/file/`, `@uniweb/file` (2026-09-28
|
|
177
|
+
* [Diego]). Any file placed there, of any type, is a record named by its stem, and nothing in it is
|
|
178
|
+
* read: a `.yml` there is a file to deliver, not a record's data. What the folder says about it — its
|
|
179
|
+
* label and tags — is its `folder.yml` entry.
|
|
180
|
+
*/
|
|
181
|
+
export const FILE_RECORD_DIRS = Object.freeze(['uniweb', 'file'])
|
|
182
|
+
const isFileRecordDir = (dirs) =>
|
|
183
|
+
dirs.length === 2 && dirs[0] === FILE_RECORD_DIRS[0] && dirs[1] === FILE_RECORD_DIRS[1]
|
|
184
|
+
|
|
175
185
|
const isHidden = (name) => name.startsWith('_') || name.startsWith('.')
|
|
176
186
|
|
|
177
187
|
/**
|
|
@@ -267,6 +277,8 @@ export async function readEntityPool(siteRoot, opts = {}) {
|
|
|
267
277
|
|
|
268
278
|
const entities = []
|
|
269
279
|
const errors = []
|
|
280
|
+
// A file record is named by its stem, so two files of one stem cannot both be one.
|
|
281
|
+
const fileStems = new Map()
|
|
270
282
|
|
|
271
283
|
const walk = async (dir, dirs) => {
|
|
272
284
|
let listing
|
|
@@ -299,7 +311,8 @@ export async function readEntityPool(siteRoot, opts = {}) {
|
|
|
299
311
|
// The folder's organization, not a record — see the header.
|
|
300
312
|
if (dirs.length === 0 && e.name === FOLDER_YML) continue
|
|
301
313
|
const ext = extname(e.name).toLowerCase()
|
|
302
|
-
|
|
314
|
+
const fileRecord = isFileRecordDir(dirs)
|
|
315
|
+
if (!fileRecord && !ENTITY_EXTENSIONS.has(ext)) continue
|
|
303
316
|
if (dirs.length === 0) {
|
|
304
317
|
errors.push(
|
|
305
318
|
`${rel}/${e.name} sits directly in \`${rel}/\`, which names no model. ` +
|
|
@@ -314,7 +327,20 @@ export async function readEntityPool(siteRoot, opts = {}) {
|
|
|
314
327
|
// DATE (`2026-03-…`), and the two are indistinguishable by shape, so
|
|
315
328
|
// consuming one into the record's name mangles the other. A number is read
|
|
316
329
|
// to SORT by (`compareByNumericPrefix`) and never to rename.
|
|
317
|
-
|
|
330
|
+
// The extension is cut as written: `ext` is lowercased to be compared, and `basename` cuts
|
|
331
|
+
// only an exact suffix — so `Scan.PDF` was the record "Scan.PDF" until 2026-09-28.
|
|
332
|
+
const slug = basename(e.name, extname(e.name))
|
|
333
|
+
if (fileRecord) {
|
|
334
|
+
const first = fileStems.get(slug)
|
|
335
|
+
if (first) {
|
|
336
|
+
errors.push(
|
|
337
|
+
`${rel}/${dirs.join('/')}/${e.name}: a file record is named by its file, and ${first} is ` +
|
|
338
|
+
`named "${slug}" too — rename one of them. This one is not a record until then.`
|
|
339
|
+
)
|
|
340
|
+
continue
|
|
341
|
+
}
|
|
342
|
+
fileStems.set(slug, e.name)
|
|
343
|
+
}
|
|
318
344
|
entities.push({
|
|
319
345
|
id: [...dirs, slug].join('/'),
|
|
320
346
|
schema: schemaForPoolDirs(dirs),
|
|
@@ -325,6 +351,7 @@ export async function readEntityPool(siteRoot, opts = {}) {
|
|
|
325
351
|
poolPath: [...dirs, e.name].join('/'),
|
|
326
352
|
absPath: full,
|
|
327
353
|
ext,
|
|
354
|
+
...(fileRecord ? { fileRecord: true } : {}),
|
|
328
355
|
})
|
|
329
356
|
}
|
|
330
357
|
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// A folder entry's `label` — what an entry is called in its folder — in its two forms, and the one
|
|
2
|
+
// rule for which text a language gets. Every lane that touches a label reads it here: the authored
|
|
3
|
+
// file (`records-config.js`), the push (`uwx/folder.js`), the pull (`uwx/records-project.js`) and
|
|
4
|
+
// the static lane (`query-processor.js` for the site's language, `i18n/records.js` for the others).
|
|
5
|
+
//
|
|
6
|
+
// ⭐ A LABEL KEEPS EVERY LANGUAGE IT HOLDS [Diego, 2026-09-28]. `records/folder.yml` writes it as one
|
|
7
|
+
// text — the site's default language — or as one text per language, `{ en: …, fr: … }`. The form
|
|
8
|
+
// is framework's to choose; a map of languages is the start. ⛔ Until then the file held the
|
|
9
|
+
// default language only: a pull dropped a label's other languages, and the next push sent the one
|
|
10
|
+
// it had kept, so a label translated in an editor lost its translations on a round trip.
|
|
11
|
+
//
|
|
12
|
+
// On the wire a label is always a map of languages (`{ <locale>: text }`); a backend may also hold
|
|
13
|
+
// one plain text for every language, and a pull reads that too.
|
|
14
|
+
|
|
15
|
+
/** A text, as YAML may type one (`2024` is a number): a non-empty trimmed string, or null. */
|
|
16
|
+
function textOf(value) {
|
|
17
|
+
if (typeof value !== 'string' && typeof value !== 'number') return null
|
|
18
|
+
const text = String(value).trim()
|
|
19
|
+
return text || null
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const isPlainObject = (value) => Boolean(value) && typeof value === 'object' && !Array.isArray(value)
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* A label as an author writes it in `records/folder.yml`, normalized: one text, or a map of
|
|
26
|
+
* languages to texts — an empty one of either is no label.
|
|
27
|
+
*
|
|
28
|
+
* @param {*} value - the entry's `label:`
|
|
29
|
+
* @returns {{ label: string|Object<string,string>|null } | { error: string }}
|
|
30
|
+
*/
|
|
31
|
+
export function readLabel(value) {
|
|
32
|
+
if (value === undefined || value === null) return { label: null }
|
|
33
|
+
if (typeof value === 'string' || typeof value === 'number') return { label: textOf(value) }
|
|
34
|
+
if (!isPlainObject(value)) return { error: 'is neither a text nor one text per language' }
|
|
35
|
+
const label = {}
|
|
36
|
+
for (const [locale, text] of Object.entries(value)) {
|
|
37
|
+
if (!locale.trim()) return { error: 'names a language with no code' }
|
|
38
|
+
if (text === undefined || text === null) continue
|
|
39
|
+
if (typeof text !== 'string' && typeof text !== 'number') {
|
|
40
|
+
return { error: `gives \`${locale}\` something that is not text` }
|
|
41
|
+
}
|
|
42
|
+
const t = textOf(text)
|
|
43
|
+
if (t) label[locale.trim()] = t
|
|
44
|
+
}
|
|
45
|
+
return { label: Object.keys(label).length ? label : null }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* A label as the wire carries it — a map of languages. One text is the source language's.
|
|
50
|
+
*
|
|
51
|
+
* @param {string|Object<string,string>} label - as `readLabel` returns it
|
|
52
|
+
* @param {string} sourceLocale - the site's default language
|
|
53
|
+
* @returns {Object<string,string>}
|
|
54
|
+
*/
|
|
55
|
+
export function labelOnWire(label, sourceLocale) {
|
|
56
|
+
if (isPlainObject(label)) return { ...label }
|
|
57
|
+
return { [sourceLocale]: String(label) }
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* A label as `records/folder.yml` holds it, from what a store holds: one text when it holds the
|
|
62
|
+
* source language's alone — the form an author writes most — and every language it holds
|
|
63
|
+
* otherwise, the source language first. Null when it holds no text.
|
|
64
|
+
*
|
|
65
|
+
* @param {*} stored - the entry's `label` off the wire: a map of languages, or one text
|
|
66
|
+
* @param {string} sourceLocale - the site's default language
|
|
67
|
+
* @returns {string|Object<string,string>|null}
|
|
68
|
+
*/
|
|
69
|
+
export function labelForFile(stored, sourceLocale) {
|
|
70
|
+
if (stored === undefined || stored === null) return null
|
|
71
|
+
if (!isPlainObject(stored)) return textOf(stored)
|
|
72
|
+
const texts = Object.entries(stored)
|
|
73
|
+
.map(([locale, text]) => [locale, textOf(text)])
|
|
74
|
+
.filter(([, text]) => text)
|
|
75
|
+
if (texts.length === 0) return null
|
|
76
|
+
if (texts.length === 1 && texts[0][0] === sourceLocale) return texts[0][1]
|
|
77
|
+
// The source language first, then the others as stored.
|
|
78
|
+
texts.sort(([a], [b]) => (a === sourceLocale ? -1 : b === sourceLocale ? 1 : 0))
|
|
79
|
+
return Object.fromEntries(texts)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** `fr-CA` → `fr`; a language with no region is its own base. */
|
|
83
|
+
const baseOf = (locale) => (typeof locale === 'string' ? locale.split(/[-_]/)[0] : null)
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* The text a language gets of a label: that language, else its base language (`fr-CA` → `fr`),
|
|
87
|
+
* else the site's default language and its base; else none — as a records service answers `$label`
|
|
88
|
+
* in the read's language and not at all when the label holds none of its chain. One text is every
|
|
89
|
+
* language's.
|
|
90
|
+
*
|
|
91
|
+
* With no language known at all (a caller that did not say), the first text the label holds.
|
|
92
|
+
*
|
|
93
|
+
* @param {string|Object<string,string>|null|undefined} label
|
|
94
|
+
* @param {string|null} locale - the language being answered
|
|
95
|
+
* @param {string|null} [defaultLocale] - the site's default language
|
|
96
|
+
* @returns {string|null}
|
|
97
|
+
*/
|
|
98
|
+
export function labelIn(label, locale, defaultLocale = null) {
|
|
99
|
+
if (label === undefined || label === null) return null
|
|
100
|
+
if (!isPlainObject(label)) return textOf(label)
|
|
101
|
+
const chain = [locale, baseOf(locale), defaultLocale, baseOf(defaultLocale)].filter(Boolean)
|
|
102
|
+
for (const code of chain) {
|
|
103
|
+
const text = textOf(label[code])
|
|
104
|
+
if (text) return text
|
|
105
|
+
}
|
|
106
|
+
if (!locale && !defaultLocale) {
|
|
107
|
+
for (const text of Object.values(label)) if (textOf(text)) return textOf(text)
|
|
108
|
+
}
|
|
109
|
+
return null
|
|
110
|
+
}
|
package/src/site/fetch-shapes.js
CHANGED
|
@@ -65,6 +65,14 @@ const RETIRED_KEYS = ['merge']
|
|
|
65
65
|
* The declaration as an author would have written it — the wire's resolved form
|
|
66
66
|
* minus what the build derived, and minus a retired key the build would refuse.
|
|
67
67
|
*
|
|
68
|
+
* ⭐ `as` EQUAL TO THE QUERY NAME IS THE DEFAULT, so it is dropped too: the parse fills
|
|
69
|
+
* it (`data-fetcher.js`, `as: fetch.as || fetch.query`) and a fetch's key is "its
|
|
70
|
+
* query's name unless the author wrote another" (`@uniweb/core/data-keys`), so writing
|
|
71
|
+
* it back changes nothing but the file. ⛔ Until 2026-09-28 it was kept on a `fetch:`
|
|
72
|
+
* object while `queryNamesOf` already dropped it for the `query:` shorthand, so a round
|
|
73
|
+
* trip of `fetch: { query: posts, limit: 3 }` came back with `as: posts` added. An `as`
|
|
74
|
+
* naming another key is the author's, and stays.
|
|
75
|
+
*
|
|
68
76
|
* @param {object} fetch a `fetch:` declaration off the sync wire
|
|
69
77
|
* @returns {object} the same declaration, safe to write into authored config
|
|
70
78
|
*/
|
|
@@ -75,6 +83,7 @@ export function authorableFetch(fetch) {
|
|
|
75
83
|
const out = {}
|
|
76
84
|
for (const [k, v] of Object.entries(fetch)) {
|
|
77
85
|
if (drop.includes(k)) continue
|
|
86
|
+
if (shape === 'query' && k === 'as' && v === fetch.query) continue
|
|
78
87
|
out[k] = v
|
|
79
88
|
}
|
|
80
89
|
return out
|