@uniweb/build 0.15.13 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -21,7 +21,7 @@ import { writeFile, readFile, mkdir, cp } from 'node:fs/promises'
21
21
  import { existsSync } from 'node:fs'
22
22
  import { join, resolve, dirname } from 'node:path'
23
23
 
24
- import { resolveDefaultLocale } from '@uniweb/core'
24
+ import { resolveDefaultLocale, DATA_DIR } from '@uniweb/core'
25
25
  import { collectSiteContent } from './content-collector.js'
26
26
  import { processCollections, writeCollectionFiles } from './collection-processor.js'
27
27
  import { processAssets, rewriteSiteContentPaths } from './asset-processor.js'
@@ -35,7 +35,9 @@ import {
35
35
  renderPageMarkdown,
36
36
  resolveAgentsConfig,
37
37
  selectIndexablePages,
38
+ selectIndexBranches,
38
39
  pageMarkdownFilename,
40
+ branchIndexFilename,
39
41
  INDEX_FILENAME
40
42
  } from '@uniweb/projections'
41
43
 
@@ -133,8 +135,8 @@ export async function buildSiteData({
133
135
  )
134
136
  await writeCollectionFiles(resolvedSiteRoot, collections, siteContent.config.collections)
135
137
 
136
- const publicDataDir = join(resolvedSiteRoot, 'public', 'data')
137
- const distDataDir = join(resolvedDistDir, 'data')
138
+ const publicDataDir = join(resolvedSiteRoot, 'public', DATA_DIR)
139
+ const distDataDir = join(resolvedDistDir, DATA_DIR)
138
140
  if (existsSync(publicDataDir)) {
139
141
  await cp(publicDataDir, distDataDir, { recursive: true })
140
142
  }
@@ -228,7 +230,7 @@ export async function buildSiteData({
228
230
  const collectionIndexes = []
229
231
  for (const [collName, collConfig] of Object.entries(collections)) {
230
232
  if (!collConfig.search?.enabled || !collConfig.route) continue
231
- const cascadeFile = join(resolvedDistDir, 'data', `${collName}.json`)
233
+ const cascadeFile = join(resolvedDistDir, DATA_DIR, `${collName}.json`)
232
234
  if (!existsSync(cascadeFile)) continue
233
235
  let collectionData
234
236
  try {
@@ -297,6 +299,23 @@ async function writeProjections(siteContent, distDir) {
297
299
  if (agents.index) {
298
300
  const index = renderSiteIndex(siteContent, { ...options, exclude: agents.exclude })
299
301
  await writeFile(join(distDir, INDEX_FILENAME), index)
302
+
303
+ // Additive scoped indexes; the root one above stays complete. See
304
+ // `selectIndexBranches` for why this is not a delegation.
305
+ if (agents.branchIndexes) {
306
+ const branches = selectIndexBranches(siteContent.pages, {
307
+ exclude: agents.exclude,
308
+ minPages: agents.branchMinPages
309
+ })
310
+ for (const branch of branches) {
311
+ const target = join(distDir, branchIndexFilename(branch.route))
312
+ await mkdir(dirname(target), { recursive: true })
313
+ await writeFile(
314
+ target,
315
+ renderSiteIndex(siteContent, { ...options, exclude: agents.exclude, branch: branch.route })
316
+ )
317
+ }
318
+ }
300
319
  }
301
320
 
302
321
  if (!agents.markdown) return
@@ -32,11 +32,12 @@
32
32
  * await writeCollectionFiles(siteDir, collections)
33
33
  */
34
34
 
35
- import { readFile, readdir, stat, writeFile, mkdir, copyFile } from 'node:fs/promises'
36
- import { join, basename, extname, dirname, relative, resolve } from 'node:path'
35
+ import { readFile, readdir, stat, writeFile, mkdir, copyFile, rm } from 'node:fs/promises'
36
+ import { join, basename, extname, dirname, relative, resolve, sep } from 'node:path'
37
37
  import { existsSync } from 'node:fs'
38
38
  import yaml from 'js-yaml'
39
39
  import { parseBibtex } from '@citestyle/bibtex'
40
+ import { DATA_DIR } from '@uniweb/core'
40
41
  import { applyFilter, applySort } from './data-fetcher.js'
41
42
  import { resolveAssetPath, walkContentAssets, isLocalAssetPath } from './assets.js'
42
43
 
@@ -642,6 +643,62 @@ export async function processCollections(siteDir, collectionsConfig, collections
642
643
  return results
643
644
  }
644
645
 
646
+ /**
647
+ * Reconcile a deferred collection's per-record directory with the records it
648
+ * should hold this run — delete the `<slug>.json` files that are no longer
649
+ * backed by a record.
650
+ *
651
+ * Why this is not optional. `public/data/` is a persistent, normally-committed
652
+ * directory, so anything written there survives until something removes it.
653
+ * Without this, unpublishing a record (`published: false`, which the build
654
+ * honours automatically) or deleting its source file drops it from the cascade
655
+ * listing — it vanishes from the site — while its per-record file stays on
656
+ * disk with the full body, gets committed, and gets deployed. The author has
657
+ * every reason to believe the content is gone. It is still fetchable at a URL
658
+ * that was public a moment ago.
659
+ *
660
+ * `public/data/` is the build's output directory and nothing else — authors
661
+ * provide structured data through `collections/`, which is the only supported
662
+ * way. So `<name>/` is entirely ours and the reconciliation is total: anything
663
+ * in it that this run did not write is stale by definition. `expected` is
664
+ * empty when a collection stops declaring `deferred:`, which correctly clears
665
+ * a directory that will otherwise never be written again.
666
+ *
667
+ * NOT covered: a collection removed from `site.yml` entirely. There is no
668
+ * declaration left to reconcile against, so pruning it would mean the build
669
+ * asserting ownership of a directory on a name match alone. That needs the
670
+ * ownership question answered on purpose, not as a side effect of this.
671
+ *
672
+ * @param {string} dataDir - `public/data/`, the containing output directory
673
+ * @param {string} name - the declared collection name
674
+ * @param {Set<string>} expected - filenames this run wrote, e.g. `hello.json`
675
+ * @returns {Promise<string[]>} the entry names removed
676
+ */
677
+ async function pruneOrphanedRecords(dataDir, name, expected) {
678
+ const recordsDir = join(dataDir, name)
679
+
680
+ // This routine deletes, and `name` reaches it from site.yml. A name that
681
+ // resolves outside the output directory would make the traversal somebody
682
+ // else's files, so refuse rather than trust the caller.
683
+ const contained = resolve(recordsDir)
684
+ if (contained !== resolve(dataDir, name) || !contained.startsWith(resolve(dataDir) + sep)) {
685
+ console.warn(
686
+ `[collection-processor] Refusing to prune "${name}" — it does not resolve ` +
687
+ `inside ${dataDir}`
688
+ )
689
+ return []
690
+ }
691
+ if (!existsSync(recordsDir)) return []
692
+
693
+ const removed = []
694
+ for (const entry of await readdir(recordsDir, { withFileTypes: true })) {
695
+ if (expected.has(entry.name)) continue
696
+ await rm(join(recordsDir, entry.name), { recursive: true, force: true })
697
+ removed.push(entry.isDirectory() ? `${entry.name}/` : entry.name)
698
+ }
699
+ return removed
700
+ }
701
+
645
702
  /**
646
703
  * Write collection data to JSON files in public/data/
647
704
  *
@@ -660,7 +717,7 @@ export async function writeCollectionFiles(siteDir, collections, collectionsConf
660
717
  return
661
718
  }
662
719
 
663
- const dataDir = join(siteDir, 'public', 'data')
720
+ const dataDir = join(siteDir, 'public', DATA_DIR)
664
721
  await mkdir(dataDir, { recursive: true })
665
722
 
666
723
  for (const [name, items] of Object.entries(collections)) {
@@ -677,13 +734,15 @@ export async function writeCollectionFiles(siteDir, collections, collectionsConf
677
734
  const recordsDir = join(dataDir, name)
678
735
  await mkdir(recordsDir, { recursive: true })
679
736
 
680
- let perRecordCount = 0
737
+ const written = new Set()
681
738
  for (const item of items) {
682
739
  if (!item || typeof item !== 'object' || !item.slug) continue
683
- const recordPath = join(recordsDir, `${item.slug}.json`)
684
- await writeFile(recordPath, JSON.stringify(item, null, 2))
685
- perRecordCount++
740
+ const filename = `${item.slug}.json`
741
+ await writeFile(join(recordsDir, filename), JSON.stringify(item, null, 2))
742
+ written.add(filename)
686
743
  }
744
+ const perRecordCount = written.size
745
+ const pruned = await pruneOrphanedRecords(dataDir, name, written)
687
746
 
688
747
  const stripped = items.map((item) => {
689
748
  if (!item || typeof item !== 'object') return item
@@ -697,10 +756,29 @@ export async function writeCollectionFiles(siteDir, collections, collectionsConf
697
756
  `[collection-processor] Generated ${cascadePath} (${items.length} items, ` +
698
757
  `deferred: [${deferred.join(', ')}]) + ${perRecordCount} per-record files`
699
758
  )
759
+ if (pruned.length > 0) {
760
+ // A deletion is always worth naming. These files were public a moment
761
+ // ago, so "which ones went" is the question an author will have.
762
+ console.log(
763
+ `[collection-processor] Removed ${pruned.length} stale per-record ` +
764
+ `file(s) from ${recordsDir}: ${pruned.join(', ')}`
765
+ )
766
+ }
700
767
  } else {
701
768
  const filepath = join(dataDir, `${name}.json`)
702
769
  await writeFile(filepath, JSON.stringify(items, null, 2))
703
770
  console.log(`[collection-processor] Generated ${filepath} (${items.length} items)`)
771
+
772
+ // This collection is not deferred, so it has no per-record files. If it
773
+ // used to, the directory is still there and will never be written again
774
+ // — every file in it is stale. Same reconciliation, empty expected set.
775
+ const pruned = await pruneOrphanedRecords(dataDir, name, new Set())
776
+ if (pruned.length > 0) {
777
+ console.log(
778
+ `[collection-processor] Removed ${pruned.length} per-record file(s) ` +
779
+ `from ${join(dataDir, name)} — "${name}" no longer declares deferred:`
780
+ )
781
+ }
704
782
  }
705
783
  }
706
784
  }
@@ -2152,9 +2152,10 @@ export async function collectSiteContent(sitePath, options = {}) {
2152
2152
  // base prefix while the hydrated browser routes were fine.
2153
2153
  //
2154
2154
  // Only a real base is written. At '/' the field stays absent, because in
2155
- // shell mode `config.base` is the SERVING layer's channel (it injects the
2156
- // served subpath, e.g. /gateway/site/<uuid>/) and a build-time '/' would
2157
- // be a meaningless value sitting in its slot.
2155
+ // shell mode `config.base` is the SERVING layer's channel the host injects
2156
+ // whatever subpath it serves the site under, and a build-time '/' would be a
2157
+ // meaningless value sitting in its slot. The build deliberately does not model
2158
+ // what that subpath looks like; that is the host's shape, not ours.
2158
2159
  if (base && base !== '/') {
2159
2160
  siteConfig.base = base
2160
2161
  }
@@ -19,6 +19,7 @@
19
19
  import { existsSync } from 'node:fs'
20
20
  import { readFile, readdir } from 'node:fs/promises'
21
21
  import { join, relative, sep } from 'node:path'
22
+ import { DATA_DIR } from '@uniweb/core'
22
23
  import { isLocalAssetPath } from './assets.js'
23
24
 
24
25
  // Walk a dist subdir for *.json → { "<posix-relpath>": <parsedJson> }. Unparseable
@@ -58,7 +59,7 @@ function collectionOf(relPath) {
58
59
  */
59
60
  export async function assembleDataBall(distDir, schemalessNames = []) {
60
61
  const schemaless = new Set(schemalessNames)
61
- const allData = await readJsonTree(join(distDir, 'data'))
62
+ const allData = await readJsonTree(join(distDir, DATA_DIR))
62
63
  const data = {}
63
64
  for (const [relPath, value] of Object.entries(allData)) {
64
65
  if (schemaless.has(collectionOf(relPath))) data[relPath] = value
@@ -20,7 +20,7 @@ import { readFile } from 'node:fs/promises'
20
20
  import { join } from 'node:path'
21
21
  import { existsSync } from 'node:fs'
22
22
  import yaml from 'js-yaml'
23
- import { matchWhere } from '@uniweb/core'
23
+ import { matchWhere, collectionDataUrl } from '@uniweb/core'
24
24
 
25
25
  /**
26
26
  * Infer schema name from path or URL
@@ -290,7 +290,7 @@ export function parseFetchConfig(fetch) {
290
290
  if (fetch.collection) {
291
291
  if (fetch.filter !== undefined) warnFilterDeprecated()
292
292
  return {
293
- path: `/data/${fetch.collection}.json`,
293
+ path: collectionDataUrl(fetch.collection),
294
294
  url: undefined,
295
295
  schema: fetch.schema || fetch.collection,
296
296
  prerender: fetch.prerender ?? true,
@@ -1,16 +1,26 @@
1
1
  /**
2
2
  * Stable markers for head content the build injects.
3
3
  *
4
- * Two stages can write the same block: the vite plugin's `transformIndexHtml`
5
- * (which produces `dist/index.html`) and the prerenderer (which post-processes
6
- * that HTML per page). The marker lets the second stage tell "already injected"
7
- * from "never injected" instead of guessing, so a page rendered through both
8
- * paths gets exactly one copy.
4
+ * Several stages can write the same block: this package's vite plugin
5
+ * (`transformIndexHtml`, which produces `dist/index.html`), this package's
6
+ * prerenderer (which post-processes that HTML per page), and since the
7
+ * 2026-07-28 seam fix `@uniweb/runtime`'s `injectPageContent()`, the prerender
8
+ * seam every lane shares. The marker lets a later stage tell "already injected"
9
+ * from "never injected" instead of guessing, so a page passing through several
10
+ * gets exactly one copy.
9
11
  *
10
12
  * The theme CSS uses `id="uniweb-theme"` on its <style> for the same purpose;
11
13
  * <link> tags have no natural id to hang that on, hence the comment marker.
12
14
  *
15
+ * **The marker itself now lives in `@uniweb/theming`**, beside the code that
16
+ * generates the block it delimits. This package and `@uniweb/runtime` cannot
17
+ * import one another, but both depend on `@uniweb/theming` — so that is the one
18
+ * home neither has to reach across a dependency boundary to read, and the
19
+ * literal is never duplicated (two halves of a dedupe check that drift apart
20
+ * stop deduping, silently). Re-exported here so every existing import in this
21
+ * package keeps working unchanged.
22
+ *
13
23
  * @module @uniweb/build/site
14
24
  */
15
25
 
16
- export const FONT_LINKS_MARKER = '<!--uniweb-fonts-->'
26
+ export { FONT_LINKS_MARKER } from '@uniweb/theming'
@@ -33,13 +33,15 @@
33
33
  import { resolve, join } from 'node:path'
34
34
  import { watch, existsSync } from 'node:fs'
35
35
  import { readFile, readdir } from 'node:fs/promises'
36
- import { resolveDefaultLocale } from '@uniweb/core'
36
+ import { resolveDefaultLocale, DATA_DIR } from '@uniweb/core'
37
37
  import {
38
38
  renderSiteIndex,
39
39
  renderPageMarkdown,
40
40
  resolveAgentsConfig,
41
41
  selectIndexablePages,
42
+ selectIndexBranches,
42
43
  pageMarkdownFilename,
44
+ branchIndexFilename,
43
45
  applyRouteTranslation,
44
46
  INDEX_FILENAME
45
47
  } from '@uniweb/projections'
@@ -275,19 +277,68 @@ function escapeXml(str) {
275
277
  .replace(/'/g, '&apos;')
276
278
  }
277
279
 
280
+ /** The three defined Content Signals, in the order they are emitted. */
281
+ const CONTENT_SIGNAL_KEYS = ['search', 'ai-input', 'ai-train']
282
+
283
+ /**
284
+ * Format the `Content-Signal:` directive from `seo.robots.contentSignals`.
285
+ *
286
+ * Content Signals express what a site permits its content to be *used for*,
287
+ * which is a different axis from `Disallow:` — that governs fetching, this
288
+ * governs use after fetching. The three defined signals:
289
+ *
290
+ * - `search` — appear in search results
291
+ * - `ai-input` — be retrieved at inference time (RAG, grounding)
292
+ * - `ai-train` — be used to train a model
293
+ *
294
+ * **Emitted only when declared.** There is no default: a preference the site
295
+ * owner did not state is not ours to assert, in either direction. An absent
296
+ * signal means "unstated", which is not the same as `no`.
297
+ *
298
+ * Unknown keys are ignored rather than passed through — the vocabulary is a
299
+ * closed set, and forwarding an invented signal would produce a directive no
300
+ * crawler honors while reading as though it were doing something.
301
+ *
302
+ * @param {Object|null} signals - e.g. `{ search: true, 'ai-input': true, 'ai-train': false }`
303
+ * @returns {string} The directive line, or `''` when nothing is declared
304
+ */
305
+ export function formatContentSignals(signals) {
306
+ if (!signals || typeof signals !== 'object') return ''
307
+
308
+ const parts = []
309
+ for (const key of CONTENT_SIGNAL_KEYS) {
310
+ if (!(key in signals)) continue
311
+ const value = signals[key]
312
+ const yes = value === true || value === 'yes'
313
+ const no = value === false || value === 'no'
314
+ if (!yes && !no) continue
315
+ parts.push(`${key}=${yes ? 'yes' : 'no'}`)
316
+ }
317
+
318
+ return parts.length ? `Content-Signal: ${parts.join(', ')}` : ''
319
+ }
320
+
278
321
  /**
279
322
  * Generate robots.txt content
280
323
  */
281
- function generateRobotsTxt(baseUrl, options = {}) {
324
+ export function generateRobotsTxt(baseUrl, options = {}) {
282
325
  const {
283
326
  disallow = [],
284
327
  allow = [],
285
328
  crawlDelay = null,
286
- additionalSitemaps = []
329
+ additionalSitemaps = [],
330
+ contentSignals = null
287
331
  } = options
288
332
 
289
333
  let content = 'User-agent: *\n'
290
334
 
335
+ const signals = formatContentSignals(contentSignals)
336
+ if (signals) {
337
+ // Inside the User-agent group, before the rules — the directive applies to
338
+ // the group it sits in.
339
+ content += `${signals}\n`
340
+ }
341
+
291
342
  for (const path of allow) {
292
343
  content += `Allow: ${path}\n`
293
344
  }
@@ -622,6 +673,31 @@ export function siteContentPlugin(options = {}) {
622
673
  source: renderSiteIndex(content, { ...options, exclude: agents.exclude })
623
674
  })
624
675
  console.log(`[site-content] Generated ${localeDir}${INDEX_FILENAME}`)
676
+
677
+ // Branch indexes are ADDITIVE — the root index above still enumerates
678
+ // every page, because the two-hop criterion depends on it. These are a
679
+ // scoped entry point for an agent already inside a branch.
680
+ if (agents.branchIndexes) {
681
+ const branches = selectIndexBranches(content.pages, {
682
+ exclude: agents.exclude,
683
+ minPages: agents.branchMinPages
684
+ })
685
+ for (const branch of branches) {
686
+ this.emitFile({
687
+ type: 'asset',
688
+ fileName: `${localeDir}${branchIndexFilename(branch.route)}`,
689
+ source: renderSiteIndex(content, {
690
+ ...options,
691
+ exclude: agents.exclude,
692
+ branch: branch.route
693
+ })
694
+ })
695
+ }
696
+ if (branches.length) {
697
+ const names = branches.map(b => `${b.route} (${b.count})`).join(', ')
698
+ console.log(`[site-content] Generated ${branches.length} branch index(es): ${names}`)
699
+ }
700
+ }
625
701
  }
626
702
 
627
703
  if (agents.markdown) {
@@ -1006,18 +1082,39 @@ export function siteContentPlugin(options = {}) {
1006
1082
  const agents = resolveAgentsConfig(siteContent.config)
1007
1083
  const url = req.url.split('?')[0]
1008
1084
 
1009
- const indexMatch = url.match(new RegExp(`^(?:\\/(${LOCALE_RE}))?\\/${INDEX_FILENAME}$`))
1085
+ // `/llms.txt`, `/fr/llms.txt`, and the branch form `/docs/llms.txt`.
1086
+ // The optional middle group is the branch route; an empty one is the
1087
+ // site index, so a single pattern serves both rather than two that
1088
+ // could drift.
1089
+ const indexMatch = url.match(
1090
+ new RegExp(`^(?:\\/(${LOCALE_RE}))?((?:\\/[^/]+)*)\\/${INDEX_FILENAME}$`)
1091
+ )
1010
1092
  if (indexMatch && agents.index) {
1011
1093
  const localized = (indexMatch[1] ? await getTranslatedContent(indexMatch[1]) : null) || siteContent
1012
- res.setHeader('Content-Type', 'text/plain; charset=utf-8')
1013
- res.end(
1014
- renderSiteIndex(localized, {
1015
- ...projectionOptions(localized),
1016
- locale: indexMatch[1] || projectionOptions(localized).locale,
1017
- exclude: agents.exclude
1018
- })
1019
- )
1020
- return
1094
+ const branch = indexMatch[2] || null
1095
+
1096
+ // Serve a branch index only where the build would emit one, or dev
1097
+ // and the built output disagree about which URLs exist.
1098
+ const served =
1099
+ !branch ||
1100
+ (agents.branchIndexes &&
1101
+ selectIndexBranches(localized.pages, {
1102
+ exclude: agents.exclude,
1103
+ minPages: agents.branchMinPages
1104
+ }).some(b => b.route === branch))
1105
+
1106
+ if (served) {
1107
+ res.setHeader('Content-Type', 'text/plain; charset=utf-8')
1108
+ res.end(
1109
+ renderSiteIndex(localized, {
1110
+ ...projectionOptions(localized),
1111
+ locale: indexMatch[1] || projectionOptions(localized).locale,
1112
+ exclude: agents.exclude,
1113
+ branch
1114
+ })
1115
+ )
1116
+ return
1117
+ }
1021
1118
  }
1022
1119
 
1023
1120
  const markdownMatch = url.match(new RegExp(`^(?:\\/(${LOCALE_RE}))?\\/(.+)\\.md$`))
@@ -1038,12 +1135,12 @@ export function siteContentPlugin(options = {}) {
1038
1135
  }
1039
1136
 
1040
1137
  // Handle localized collection data (e.g., /fr/data/articles.json)
1041
- const localeDataMatch = req.url.match(new RegExp(`^\\/(${LOCALE_RE})\\/data\\/(.+\\.json)$`))
1138
+ const localeDataMatch = req.url.match(new RegExp(`^\\/(${LOCALE_RE})\\/${DATA_DIR}\\/(.+\\.json)$`))
1042
1139
  if (localeDataMatch) {
1043
1140
  const locale = localeDataMatch[1]
1044
1141
  const filename = localeDataMatch[2]
1045
1142
  const collectionName = filename.replace('.json', '')
1046
- const sourcePath = join(resolvedSitePath, 'public', 'data', filename)
1143
+ const sourcePath = join(resolvedSitePath, 'public', DATA_DIR, filename)
1047
1144
 
1048
1145
  if (existsSync(sourcePath)) {
1049
1146
  try {
@@ -108,29 +108,50 @@ export function loadLocaleTranslations(siteRoot, locales, subdir = '') {
108
108
  * map lives only on disk (`locales/{locale}.json`) and is recovered on pull (see
109
109
  * unwrapLocalizedContent).
110
110
  *
111
- * Returns the bare doc unchanged when there are no target locales / no translations
112
- * for any of them (single-locale and pre-localization sites are untouched). A
113
- * target locale with no translation for THIS doc is omitted (it falls back to the
114
- * source locale), so the payload stays lean.
111
+ * ALWAYS returns the per-locale map `{ [sourceLocale]: doc }` at minimum. A target
112
+ * locale with no translation for THIS doc is omitted (it falls back to the source
113
+ * locale through the delivery locale chain), so the payload stays lean.
114
+ *
115
+ * It used to return the bare doc when nothing had been translated, which made the
116
+ * wire shape depend on *whether a given section happened to have a translation* —
117
+ * so one push could carry a map for a translated section and a bare doc for its
118
+ * untranslated neighbour, on the same page. The field declares `localized: true`,
119
+ * and a bare doc makes that declaration false: `{type, content}` is an object, so
120
+ * it satisfies "must be a map" with `type` and `content` sitting where locale codes
121
+ * belong, and a store cannot validate the one property the field declares. Every
122
+ * reader then has to disambiguate by asking "does this object happen to look like a
123
+ * ProseMirror doc" — a heuristic that has to be implemented identically in three
124
+ * codebases forever, and has already fail-opened in one of them.
125
+ *
126
+ * `localizeScalar` below has always wrapped unconditionally, so the producer was
127
+ * also inconsistent with itself: a section's `title` shipped as `{en: …}` while its
128
+ * `content` shipped bare, in the same payload.
129
+ *
130
+ * Readers stay tolerant of the bare form permanently — it exists in stores, in
131
+ * `.uwx` files on disk, and in every backup taken before this change. This changes
132
+ * what we WRITE, never what we accept (see `isLocalizedContent`, and
133
+ * `unwrapLocalizedContent`, which returns `content[sourceLocale]` so a source-only
134
+ * map round-trips back to a bare doc on the file lane).
115
135
  *
116
136
  * @param {object} doc - the source-locale ProseMirror content doc
117
137
  * @param {string} sourceLocale
118
- * @param {string[]} targetLocales
119
- * @param {object} translations - `{ locale: { hash: tgt } }` from loadLocaleTranslations
138
+ * @param {string[]} [targetLocales]
139
+ * @param {object} [translations] - `{ locale: { hash: tgt } }` from loadLocaleTranslations
120
140
  */
121
141
  export function localizeContentDoc(doc, sourceLocale, targetLocales, translations) {
122
- if (!isProseMirrorDoc(doc) || !targetLocales || targetLocales.length === 0 || !translations) {
123
- return doc
124
- }
142
+ // Non-docs (null, an already-localized map) pass through untouched.
143
+ if (!isProseMirrorDoc(doc)) return doc
144
+
125
145
  const result = { [sourceLocale]: doc }
126
- for (const locale of targetLocales) {
127
- const table = translations[locale]
128
- if (!table) continue
129
- const resolved = resolveDocForLocale(doc, table)
130
- if (resolved) result[locale] = resolved
146
+ if (targetLocales && translations) {
147
+ for (const locale of targetLocales) {
148
+ const table = translations[locale]
149
+ if (!table) continue
150
+ const resolved = resolveDocForLocale(doc, table)
151
+ if (resolved) result[locale] = resolved
152
+ }
131
153
  }
132
- // Only wrap when at least one target carried a translation — else stay a bare doc.
133
- return Object.keys(result).length > 1 ? result : doc
154
+ return result
134
155
  }
135
156
 
136
157
  /**
package/src/uwx/site.js CHANGED
@@ -54,7 +54,7 @@ import {
54
54
  processMarkdownFile,
55
55
  } from '../site/content-collector.js'
56
56
  import { normalizeHideIn } from '../site/nav-visibility.js'
57
- import { resolveDefaultLocale, validateLanguageConfig } from '@uniweb/core'
57
+ import { resolveDefaultLocale, validateLanguageConfig, collectionDataUrl } from '@uniweb/core'
58
58
  import { emitEntitySyncPackage } from './entity-document.js'
59
59
  import { loadLocaleTranslations, localizeScalar, localizeScalarList, localizeContentDoc, localesDir, isLocalizedContent } from './locale-sync.js'
60
60
  import { unwrapLocalized } from './backfill.js'
@@ -182,7 +182,7 @@ function buildPageData(config, ctx) {
182
182
  // `schema` (the collection name) is BOTH the content.data key and part of the
183
183
  // dataStore cache key (deriveCacheKey hashes {path,url,schema,…}; `collection`
184
184
  // is ignored). Mirrors the static build's parseFetchConfig resolution.
185
- fetch = { path: `/data/${collection}.json`, schema: collection, ...rest }
185
+ fetch = { path: collectionDataUrl(collection), schema: collection, ...rest }
186
186
  }
187
187
  setIf(data, 'fetch', fetch)
188
188
  if (isDynamic) {
@@ -663,11 +663,14 @@ export async function siteProjectToDocument(siteRoot, opts = {}) {
663
663
 
664
664
  // Wrap each section's content into its per-locale form (source doc + target
665
665
  // structural maps from locales/{locale}.json, or a free-form body override from
666
- // locales/freeform/**) when the site is multi-locale. A non-invasive post-pass
667
- // over the built tree — single-locale sites are untouched.
668
- if (targetLocales.length > 0) {
669
- await localizeContentTree(pages, layoutSections, sourceLocale, targetLocales, translations, siteRoot)
670
- }
666
+ // locales/freeform/**). A non-invasive post-pass over the built tree.
667
+ //
668
+ // Runs for EVERY site, including single-locale ones: `content` declares
669
+ // `localized: true`, so it ships as `{ [sourceLocale]: doc }` whatever the
670
+ // language count. Guarding this on `targetLocales.length` is what used to send
671
+ // a bare doc from single-locale sites — see localizeContentDoc for why one
672
+ // field with two shapes is a store that cannot validate its own declaration.
673
+ await localizeContentTree(pages, layoutSections, sourceLocale, targetLocales, translations, siteRoot)
671
674
 
672
675
  // Collection DECLARATIONS — the merged collections.yml + site.yml::collections
673
676
  // config (the records themselves are separate entities; this is just the config).