@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniweb/build",
3
- "version": "0.73.0",
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/content-reader": "^1.2.7",
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/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
  // ---------------------------------------------------------------------------
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 a filesystem path for prerender.
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. dist directory (post-build copy target, e.g., site/dist/effects/entry.js)
42
- * 2. Project root with dist subdir (dev layout, e.g., project/effects/dist/entry.js)
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
- // Only resolve URLs that look like root-relative paths
54
- if (url.startsWith('/')) {
55
- // Try dist directory first (production: files copied to site/dist/)
56
- const distPath = join(distDir, url)
57
- if (existsSync(distPath)) return distPath
58
-
59
- // Workspace layouts: "/effects/entry.js" → "<pkg>/dist/entry.js", checked
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
- // Return as-is for absolute paths or remote URLs
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 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
 
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 whose value the component never
962
- // receives — `component: false` in `@uniweb/schemas/section`, as `grid` is.
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): a param named "${param}" never receives the author's ${param}: — framework ` +
968
- `reserves it in a section's params (@uniweb/schemas/section). Rename the param.` +
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
- // `query`, `fetch` and `data` are never params: `query:` / `fetch:` declare the
1020
- // section's own data, and a leftover `data:` is refused (`declaredFetch`).
1021
- const { type, hidden, preset, input, props, fetch, query, data, id: frontmatterId, ...params } = frontMatter
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
- * The same candidates `resolveExtensionPath` walks for prerender, kept in step
75
- * deliberately: if prerender can load an extension from the workspace but the
76
- * build cannot find it to emit, that is exactly the split that produced the
77
- * bug — one lane resolving it and the other not.
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
- * @returns {{ distDir: string, urlBase: string }|null}
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, siteDir) {
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)) return { distDir: candidate, urlBase: pkgName }
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, siteDir)
126
+ const found = resolveExtensionDist(url, projectRoot)
116
127
  if (!found) {
117
128
  unresolved.push(url)
118
129
  continue
@@ -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