@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniweb/build",
3
- "version": "0.72.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/content-writer": "^0.3.4",
61
+ "@uniweb/schemas": "^0.9.2",
62
62
  "@uniweb/projections": "^0.7.11",
63
63
  "@uniweb/semantic-parser": "^1.4.1",
64
- "@uniweb/schemas": "^0.9.1",
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/core": "^0.35.0",
78
- "@uniweb/runtime": "^0.28.3"
77
+ "@uniweb/runtime": "^0.28.3",
78
+ "@uniweb/core": "^0.35.0"
79
79
  },
80
80
  "peerDependenciesMeta": {
81
81
  "vite": {
@@ -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 = dataSchema && isPlainObject(translatedRecord) ? storedFromMerged(dataSchema, translatedRecord) : translatedRecord
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
- await writeFile(destPath, JSON.stringify(translatedItems, null, 2))
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
- if (!one) return translated
1309
- return dataSchema && isPlainObject(translated[0]) ? storedFromMerged(dataSchema, translated[0]) : translated[0]
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 and is not a data-schema source. ` +
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
 
@@ -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
- if (!ENTITY_EXTENSIONS.has(ext)) continue
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
- const slug = basename(e.name, ext)
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
+ }
@@ -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 — since
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
- function entryKeys(placed) {
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
- if (typeof placed.label === 'string' && placed.label) out.$label = placed.label
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 ?? '' }