@uniweb/build 0.72.0 → 0.73.1
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/resolve-data-schema.js +10 -2
- 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-project.js +9 -10
- 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.
|
|
3
|
+
"version": "0.73.1",
|
|
4
4
|
"description": "Build tooling for the Uniweb Component Web Platform",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -58,10 +58,10 @@
|
|
|
58
58
|
"sharp": "^0.35.3",
|
|
59
59
|
"yaml": "^2.5.0",
|
|
60
60
|
"@uniweb/content-reader": "^1.2.7",
|
|
61
|
-
"@uniweb/
|
|
61
|
+
"@uniweb/schemas": "^0.9.2",
|
|
62
62
|
"@uniweb/projections": "^0.7.11",
|
|
63
63
|
"@uniweb/semantic-parser": "^1.4.1",
|
|
64
|
-
"@uniweb/
|
|
64
|
+
"@uniweb/content-writer": "^0.3.4",
|
|
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
|
// ---------------------------------------------------------------------------
|
|
@@ -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/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
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// ⭐ A FILE RECORD — `@uniweb/file` (2026-09-28 [Diego]). The file itself is the record: any file
|
|
2
|
+
// placed in `<records>/uniweb/file/` (`entity-pool.js::FILE_RECORD_DIRS`) is a record named by its
|
|
3
|
+
// stem, and its label and tags are its `folder.yml` entry's.
|
|
4
|
+
//
|
|
5
|
+
// ⭐ ITS VALUE IS AN ASSET, the same object on every lane:
|
|
6
|
+
//
|
|
7
|
+
// { url, name, mime, size, preview? }
|
|
8
|
+
//
|
|
9
|
+
// — `url` where the file is served, `name` its file name, `mime` and `size` from the file, and for a
|
|
10
|
+
// PDF a `preview` image. Uploaded to a host, the asset's identity rides beside each URL as media's
|
|
11
|
+
// does (`assetId`/`assetExt`, `previewAssetId`/`previewAssetExt` — `@uniweb/semantic-parser`'s
|
|
12
|
+
// `ASSET_SLOTS`), so a host that moves its assets costs a config edit, not a migration. A component
|
|
13
|
+
// reads `file.url` wherever it renders.
|
|
14
|
+
//
|
|
15
|
+
// ⚠️ A PDF's preview is a PLACEHOLDER card ("PDF · N pages", `advanced-processors.js::
|
|
16
|
+
// generatePdfThumbnail`), not a render of the page — and none at all without `pdf-lib`.
|
|
17
|
+
|
|
18
|
+
import { statSync } from 'node:fs'
|
|
19
|
+
import { extname } from 'node:path'
|
|
20
|
+
import { generatePdfThumbnail } from './advanced-processors.js'
|
|
21
|
+
|
|
22
|
+
export const FILE_MODEL = '@uniweb/file'
|
|
23
|
+
|
|
24
|
+
// What a file is, by its extension — for the `mime` a file record carries and the type its upload
|
|
25
|
+
// declares. Anything not here is `application/octet-stream`.
|
|
26
|
+
const MIME = {
|
|
27
|
+
pdf: 'application/pdf',
|
|
28
|
+
doc: 'application/msword',
|
|
29
|
+
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
30
|
+
xls: 'application/vnd.ms-excel',
|
|
31
|
+
xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
32
|
+
ppt: 'application/vnd.ms-powerpoint',
|
|
33
|
+
pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
|
34
|
+
odt: 'application/vnd.oasis.opendocument.text',
|
|
35
|
+
ods: 'application/vnd.oasis.opendocument.spreadsheet',
|
|
36
|
+
odp: 'application/vnd.oasis.opendocument.presentation',
|
|
37
|
+
rtf: 'application/rtf',
|
|
38
|
+
epub: 'application/epub+zip',
|
|
39
|
+
zip: 'application/zip',
|
|
40
|
+
gz: 'application/gzip',
|
|
41
|
+
tar: 'application/x-tar',
|
|
42
|
+
csv: 'text/csv',
|
|
43
|
+
tsv: 'text/tab-separated-values',
|
|
44
|
+
txt: 'text/plain',
|
|
45
|
+
md: 'text/markdown',
|
|
46
|
+
json: 'application/json',
|
|
47
|
+
xml: 'application/xml',
|
|
48
|
+
yml: 'application/yaml',
|
|
49
|
+
yaml: 'application/yaml',
|
|
50
|
+
html: 'text/html',
|
|
51
|
+
png: 'image/png',
|
|
52
|
+
jpg: 'image/jpeg',
|
|
53
|
+
jpeg: 'image/jpeg',
|
|
54
|
+
gif: 'image/gif',
|
|
55
|
+
svg: 'image/svg+xml',
|
|
56
|
+
webp: 'image/webp',
|
|
57
|
+
avif: 'image/avif',
|
|
58
|
+
mp3: 'audio/mpeg',
|
|
59
|
+
wav: 'audio/wav',
|
|
60
|
+
ogg: 'audio/ogg',
|
|
61
|
+
m4a: 'audio/mp4',
|
|
62
|
+
mp4: 'video/mp4',
|
|
63
|
+
webm: 'video/webm',
|
|
64
|
+
mov: 'video/quicktime',
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The media type of a file, by its extension.
|
|
69
|
+
*
|
|
70
|
+
* @param {string} name - a file name or path
|
|
71
|
+
* @returns {string}
|
|
72
|
+
*/
|
|
73
|
+
export function mimeFor(name) {
|
|
74
|
+
const ext = extname(String(name || '')).slice(1).toLowerCase()
|
|
75
|
+
return MIME[ext] || 'application/octet-stream'
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Is this file a PDF — the one type a preview is made for? */
|
|
79
|
+
export function isPdfFile(name) {
|
|
80
|
+
return extname(String(name || '')).toLowerCase() === '.pdf'
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* A file record's value: `{ url, name, mime, size }`, with `preview` when one is given.
|
|
85
|
+
*
|
|
86
|
+
* @param {object} params
|
|
87
|
+
* @param {string} params.absPath - the file
|
|
88
|
+
* @param {string} params.name - its file name (`brochure.pdf`)
|
|
89
|
+
* @param {string} params.url - where it is served, or the key a push rewrites to that
|
|
90
|
+
* @param {string} [params.preview] - where its preview is served, or the key a push rewrites
|
|
91
|
+
* @returns {{ url: string, name: string, mime: string, size: number, preview?: string }}
|
|
92
|
+
*/
|
|
93
|
+
export function fileRecordValue({ absPath, name, url, preview }) {
|
|
94
|
+
const value = { url, name, mime: mimeFor(name), size: statSync(absPath).size }
|
|
95
|
+
if (preview) value.preview = preview
|
|
96
|
+
return value
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Make a PDF's preview image at `outputPath` — a placeholder card, not a render of the page — or
|
|
101
|
+
* report that none was made (not a PDF, or `pdf-lib` not installed).
|
|
102
|
+
*
|
|
103
|
+
* @returns {Promise<boolean>} whether a preview now exists at `outputPath`
|
|
104
|
+
*/
|
|
105
|
+
export async function makeFilePreview(absPath, outputPath) {
|
|
106
|
+
if (!isPdfFile(absPath)) return false
|
|
107
|
+
const result = await generatePdfThumbnail(absPath, outputPath)
|
|
108
|
+
return Boolean(result?.success)
|
|
109
|
+
}
|
|
@@ -31,6 +31,7 @@
|
|
|
31
31
|
import { join } from 'node:path'
|
|
32
32
|
import { existsSync, readFileSync } from 'node:fs'
|
|
33
33
|
import { recordLayout } from '@uniweb/schemas/conform'
|
|
34
|
+
import { isSystemRecordRef } from '@uniweb/schemas/system'
|
|
34
35
|
import { detectFoundationType, parseCatalogRef } from './foundation-ref.js'
|
|
35
36
|
import { readRegisteredFoundation } from './registered-foundation.js'
|
|
36
37
|
import { buildDataSchemaMap, SCHEMA_NOT_FOUND } from '../resolve-data-schema.js'
|
|
@@ -348,8 +349,8 @@ export function toConfigQueries(declarations) {
|
|
|
348
349
|
* A `@/name` ref the foundation defines no schema for is absent without a word: a query
|
|
349
350
|
* is named after no schema when its records have none. Any other ref that does not
|
|
350
351
|
* resolve is absent too, and reported in `failures`. With no local foundation only
|
|
351
|
-
* `@std/*` refs resolve — from the build's own copy of the standard schemas —
|
|
352
|
-
* nothing else is on disk.
|
|
352
|
+
* `@std/*` refs resolve — from the build's own copy of the standard schemas — and the
|
|
353
|
+
* system Models a record may name (`@uniweb/link`), since nothing else is on disk.
|
|
353
354
|
*
|
|
354
355
|
* @param {string} siteRoot
|
|
355
356
|
* @param {Iterable<string>} refs - the `schema:` refs of the site's queries
|
|
@@ -364,7 +365,7 @@ export async function resolveRecordSchemas(siteRoot, refs, opts = {}) {
|
|
|
364
365
|
const siteYml = opts.siteYml || (await readYamlFile(join(siteRoot, 'site.yml')))
|
|
365
366
|
const srcDir = localFoundationSrcDir(siteRoot, siteYml)
|
|
366
367
|
for (const ref of wanted) {
|
|
367
|
-
if (!srcDir && !ref.startsWith('@std/')) continue
|
|
368
|
+
if (!srcDir && !ref.startsWith('@std/') && !isSystemRecordRef(ref)) continue
|
|
368
369
|
try {
|
|
369
370
|
Object.assign(schemas, await buildDataSchemaMap([ref], { srcDir: srcDir ?? siteRoot }))
|
|
370
371
|
} catch (err) {
|
|
@@ -70,6 +70,8 @@ import { dataKeyTypes } from '../uwx/data-key-types.js'
|
|
|
70
70
|
import { parseFrontmatter } from '../utils/frontmatter.js'
|
|
71
71
|
import { toDeliveredRecord, toStoredRecord, storedPath, briefFieldMap, contentBodyField, misplacedFields, mapReferences, recordLayout } from '@uniweb/schemas/conform'
|
|
72
72
|
import { collectNestedRefs } from '@uniweb/schemas/format'
|
|
73
|
+
import { labelIn } from './entry-label.js'
|
|
74
|
+
import { fileRecordValue, makeFilePreview } from './file-records.js'
|
|
73
75
|
|
|
74
76
|
// Try to import content-reader for markdown parsing
|
|
75
77
|
let markdownToProseMirror
|
|
@@ -824,6 +826,8 @@ function warnDuplicateSlugs(items, queryName) {
|
|
|
824
826
|
function readPooledRecords(pooled, config, siteDir, recordsRoot, basePath) {
|
|
825
827
|
return Promise.all(
|
|
826
828
|
pooled.map((e) => {
|
|
829
|
+
// ⭐ The file itself is the record (`file-records.js`) — nothing in it is read.
|
|
830
|
+
if (e.fileRecord) return processFileRecord(e, config, siteDir, recordsRoot, basePath)
|
|
827
831
|
const dir = resolve(recordsRoot, ...e.dirs)
|
|
828
832
|
const file = `${e.slug}${e.ext}`
|
|
829
833
|
if (e.ext === '.bib') {
|
|
@@ -840,6 +844,18 @@ function readPooledRecords(pooled, config, siteDir, recordsRoot, basePath) {
|
|
|
840
844
|
)
|
|
841
845
|
}
|
|
842
846
|
|
|
847
|
+
/**
|
|
848
|
+
* A file record — `@uniweb/file` (`file-records.js`): the file published under `public/records/`
|
|
849
|
+
* as a record's co-located asset is (`publishRecordAsset`), a PDF's placeholder preview beside it,
|
|
850
|
+
* and the record delivered as `{ file: { url, name, mime, size, preview? } }`.
|
|
851
|
+
*/
|
|
852
|
+
async function processFileRecord(e, config, siteDir, recordsRoot, basePath) {
|
|
853
|
+
const { url, copied } = await publishRecordAsset(e.absPath, siteDir, recordsRoot, basePath)
|
|
854
|
+
const preview = (await makeFilePreview(e.absPath, `${copied}.preview.webp`)) ? `${url}.preview.webp` : undefined
|
|
855
|
+
const record = { slug: e.slug, file: fileRecordValue({ absPath: e.absPath, name: e.file, url, preview }) }
|
|
856
|
+
return deliverRecord(record, config, e.relPath)
|
|
857
|
+
}
|
|
858
|
+
|
|
843
859
|
/**
|
|
844
860
|
* References, delivered as a host's records service delivers them — measured 2026-09-24
|
|
845
861
|
* on a local backend: `{ entity, brief }`, the record a reference names reduced to its
|
|
@@ -933,12 +949,15 @@ function pickBrief(record, keys) {
|
|
|
933
949
|
}
|
|
934
950
|
|
|
935
951
|
// What a record's folder entry says about it, as the keys a record is answered with — `$tags`
|
|
936
|
-
// (in the order written) and `$label` — or null when it says nothing.
|
|
937
|
-
|
|
952
|
+
// (in the order written) and `$label` — or null when it says nothing. A label given per language
|
|
953
|
+
// answers in the language compiled (`entry-label.js::labelIn`); the site's other languages get
|
|
954
|
+
// theirs where their files are written (`i18n/records.js`).
|
|
955
|
+
function entryKeys(placed, locale = null) {
|
|
938
956
|
if (!placed) return null
|
|
939
957
|
const out = {}
|
|
940
958
|
if (Array.isArray(placed.tags) && placed.tags.length) out.$tags = [...placed.tags]
|
|
941
|
-
|
|
959
|
+
const label = labelIn(placed.label, locale, locale)
|
|
960
|
+
if (label) out.$label = label
|
|
942
961
|
return Object.keys(out).length ? out : null
|
|
943
962
|
}
|
|
944
963
|
|
|
@@ -1001,7 +1020,7 @@ async function collectItems(siteDir, config, recordsRoot, basePath, locale = nul
|
|
|
1001
1020
|
// answers them (ruled 2026-09-27 [Diego]). Absent when the entry says none.
|
|
1002
1021
|
items = items.map((result, i) => {
|
|
1003
1022
|
const placed = pooled[i] ? config.placements?.get(pooled[i].id) : null
|
|
1004
|
-
const said = entryKeys(placed)
|
|
1023
|
+
const said = entryKeys(placed, locale)
|
|
1005
1024
|
const at = (item) => {
|
|
1006
1025
|
if (!item) return item
|
|
1007
1026
|
const out = { ...item, ...said, [BRANCH_KEY]: placed?.path ?? '' }
|