@uniweb/build 1.9.0 → 1.10.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniweb/build",
3
- "version": "1.9.0",
3
+ "version": "1.10.0",
4
4
  "description": "Build tooling for the Uniweb Component Web Platform",
5
5
  "type": "module",
6
6
  "exports": {
@@ -53,18 +53,18 @@
53
53
  "dependencies": {
54
54
  "@citestyle/bibtex": "^1.1.0",
55
55
  "esbuild": "^0.21.0 || ^0.23.0 || ^0.24.0 || ^0.25.0 || ^0.27.0 || ^0.28.0",
56
- "js-yaml": "^4.1.0",
57
- "sharp": "^0.35.3",
56
+ "js-yaml": "^4.3.2",
57
+ "sharp": "^0.35.5",
58
58
  "yaml": "^2.5.0",
59
+ "@uniweb/content-reader": "~1.5.7",
60
+ "@uniweb/content-writer": "~1.3.1",
59
61
  "@uniweb/projections": "~1.2.0",
60
62
  "@uniweb/theming": "~1.2.1",
61
63
  "@uniweb/schemas": "~1.6.5",
62
- "@uniweb/content-reader": "~1.5.6",
63
- "@uniweb/content-writer": "~1.3.0",
64
64
  "@uniweb/semantic-parser": "~1.13.0"
65
65
  },
66
66
  "optionalDependencies": {
67
- "@uniweb/runtime": "~1.2.0"
67
+ "@uniweb/runtime": "~1.2.2"
68
68
  },
69
69
  "peerDependencies": {
70
70
  "vite": "^5.0.0 || ^6.0.0 || ^7.0.0",
@@ -73,8 +73,8 @@
73
73
  "@tailwindcss/vite": "^4.0.0",
74
74
  "@vitejs/plugin-react": "^4.0.0 || ^5.0.0",
75
75
  "vite-plugin-svgr": "^4.0.0",
76
- "@uniweb/core": "~1.4.5",
77
- "@uniweb/runtime": "~1.2.0"
76
+ "@uniweb/runtime": "~1.2.2",
77
+ "@uniweb/core": "~1.4.6"
78
78
  },
79
79
  "peerDependenciesMeta": {
80
80
  "vite": {
package/src/i18n/index.js CHANGED
@@ -26,8 +26,8 @@ import {
26
26
  RECORD_LOCALES_DIR
27
27
  } from './records.js'
28
28
  import { resolveDefaultLocale } from '@uniweb/core'
29
- import { generateSearchIndex } from '@uniweb/projections'
30
- import { searchDeclaredOn } from '../site/search-declared.js'
29
+ import { compiledData, recordProjections } from '../site/record-projections.js'
30
+ import { projectionFiles } from '../site/projections.js'
31
31
 
32
32
  // Free-form translation support
33
33
  import {
@@ -346,21 +346,89 @@ function declaredLanguages(config, defaultLocale, locales) {
346
346
  return out
347
347
  }
348
348
 
349
+ /**
350
+ * ⭐ EVERY LANGUAGE OF A SITE BUT ITS DEFAULT, IN THE ORDER ITS FILES NEED: its records, then its
351
+ * content, then — when `projections` asks — its search index, `llms.txt` and `.md` files, which are
352
+ * made from both (`../site/projections.js`). The default language's are the bundle's
353
+ * (`site/plugin.js`); this builds the rest.
354
+ *
355
+ * ⛔ Until 2026-10-25 a build ran content, then records — in two places, one per lane — and a
356
+ * language's search index was written inside the content step, from its pages alone, before its
357
+ * records were translated. So the French index of a site listed none of its record pages while the
358
+ * default one listed all of them, and no language but the default got an `llms.txt` or a `.md`
359
+ * (measured on a starter in three languages: 17 search entries against 20, and no `fr/llms.txt`).
360
+ *
361
+ * @param {string} siteRoot - Site root directory
362
+ * @param {Object} options
363
+ * @param {string[]} options.locales - the languages to build; the default is skipped
364
+ * (`buildLocalizedContent`)
365
+ * @param {string} [options.localesDir='locales'] - where the translations are, from `siteRoot`
366
+ * @param {string} [options.outputDir] - the built site, `<siteRoot>/dist` by default
367
+ * @param {boolean} [options.projections=false] - write each language's projections. The bundle
368
+ * lane's: a static host serves them. The link lane builds for a host, which derives its own.
369
+ * @returns {Promise<{ content: Object, records: Object, projections: Object }>} what each step
370
+ * wrote, by language — `projections[locale]` the files (`projectionFiles`), each `fileName`
371
+ * from `outputDir`
372
+ */
373
+ export async function buildLocalizedSite(siteRoot, options = {}) {
374
+ const { localesDir = DEFAULTS.localesDir, outputDir = join(siteRoot, 'dist'), projections = false } = options
375
+
376
+ // The default language is the root tree, never a subdirectory — by the default the content step
377
+ // reads (`buildLocalizedContent`), so the records step writes no `dist/<default>/data/` either.
378
+ const source = JSON.parse(await readFile(join(outputDir, 'site-content.json'), 'utf-8'))
379
+ const defaultLocale = resolveDefaultLocale(source.config)
380
+ const locales = (options.locales || []).filter((locale) => locale !== defaultLocale)
381
+
382
+ // Records first: a language's projections list its record pages, and translating them reads
383
+ // the site's own files alone — never a translated page.
384
+ const records = await buildLocalizedRecords(siteRoot, {
385
+ locales,
386
+ outputDir,
387
+ recordLocalesDir: join(siteRoot, localesDir, RECORD_LOCALES_DIR),
388
+ localesDir: join(siteRoot, localesDir)
389
+ })
390
+ const content = await buildLocalizedContent(siteRoot, { localesDir, locales, outputDir, fallbackToSource: true })
391
+
392
+ const written = {}
393
+ if (projections) {
394
+ for (const [locale, { content: contentPath }] of Object.entries(content)) {
395
+ const translated = JSON.parse(await readFile(contentPath, 'utf-8'))
396
+ // The language's own records, where `buildLocalizedRecords` wrote them: `dist/<locale>/data/`.
397
+ const pages = await recordProjections(translated, { read: compiledData(join(outputDir, locale)), locale })
398
+ const files = projectionFiles(translated, pages)
399
+ for (const { fileName, source } of files) {
400
+ const target = join(outputDir, fileName)
401
+ await mkdir(dirname(target), { recursive: true })
402
+ await writeFile(target, source)
403
+ }
404
+ written[locale] = files
405
+ }
406
+ }
407
+
408
+ return { content, records, projections: written }
409
+ }
410
+
349
411
  /**
350
412
  * Build translated site content for all locales
351
413
  * @param {string} siteRoot - Site root directory
352
414
  * @param {Object} options - Options
353
- * @param {boolean} [options.generateSearchIndexes=true] - Generate search indexes for each locale
354
415
  * @param {boolean} [options.freeformEnabled=true] - Enable free-form translation support
355
416
  * @returns {Object} Map of locale to output paths
356
417
  */
357
418
  export async function buildLocalizedContent(siteRoot, options = {}) {
419
+ // ⛔ It wrote each language's search index until 2026-10-25, from its pages alone and before its
420
+ // records were translated. A language's projections are `buildLocalizedSite`'s now.
421
+ if ('generateSearchIndexes' in options) {
422
+ throw new Error(
423
+ 'buildLocalizedContent writes no search index: a language\'s search index, llms.txt and .md files are ' +
424
+ "written by buildLocalizedSite (`projections: true`), after the language's records are translated."
425
+ )
426
+ }
358
427
  const {
359
428
  localesDir = DEFAULTS.localesDir,
360
429
  locales = [],
361
430
  outputDir = join(siteRoot, 'dist'),
362
431
  fallbackToSource = true,
363
- generateSearchIndexes = true,
364
432
  freeformEnabled = true
365
433
  } = options
366
434
 
@@ -456,20 +524,6 @@ export async function buildLocalizedContent(siteRoot, options = {}) {
456
524
  await writeFile(contentOutputPath, JSON.stringify(translated, null, 2))
457
525
 
458
526
  outputs[locale] = { content: contentOutputPath }
459
-
460
- // Generate search index for this locale if search is enabled
461
- if (generateSearchIndexes && searchDeclaredOn(translated)) {
462
- const searchConfig = translated.config?.search || {}
463
- const searchIndex = generateSearchIndex(translated, {
464
- locale,
465
- search: searchConfig
466
- })
467
-
468
- const searchOutputPath = join(localeOutputDir, 'search-index.json')
469
- await writeFile(searchOutputPath, JSON.stringify(searchIndex, null, 2))
470
-
471
- outputs[locale].searchIndex = searchOutputPath
472
- }
473
527
  }
474
528
 
475
529
  return outputs
package/src/prerender.js CHANGED
@@ -988,20 +988,30 @@ export async function prerenderSite(siteDir, options = {}) {
988
988
  })
989
989
  const buildDataCache = new DataStore()
990
990
 
991
- // Build-specific: pre-populate DataStore so EntityStore can resolve data during prerender.
992
- // hydrateDataStore handles cache-key derivation + value-shape wrapping
993
- // — same helper used by the browser SPA boot and by the Cloudflare
994
- // Worker SSR isolate, so all three render paths agree on shape.
995
- // ⛔ `siteContent.fetchedData`, not the raw list: only it holds the route-bound
996
- // views, and an expanded page rendered without them paints "not found" (measured).
997
- hydrateDataStore(uniweb.activeWebsite, siteContent.fetchedData)
998
-
999
991
  // Pre-fetch icons for SSR embedding
1000
992
  await prefetchIcons(siteContent, uniweb, onProgress)
1001
993
 
1002
994
  // Pre-render each page
1003
995
  const website = uniweb.activeWebsite
1004
996
 
997
+ /**
998
+ * ⭐ A PAGE RENDERS AGAINST WHAT IT CARRIES — the entries its HTML embeds, which are all the
999
+ * browser's store holds when it boots on that page (`initUniweb` hydrates from them). The
1000
+ * browser renders over the prerendered page, so a render that reads more paints content the
1001
+ * browser does not have, and the browser replaces it with a loading state at boot.
1002
+ *
1003
+ * ⛔ Until 2026-10-25 the store was hydrated once for the whole locale and then took every
1004
+ * page's answers in turn, so a page could render data that only other pages carried. A
1005
+ * section can read the store directly (kit's `useFetched`, `useWholeRecord`), beyond what its
1006
+ * page declares: a page showing records whole through `useWholeRecord` prerendered the ones
1007
+ * the record pages before it had fetched, carried none of them, and showed "loading…" at
1008
+ * boot (measured).
1009
+ */
1010
+ const holdOnly = (carried) => {
1011
+ website.dataStore.clear()
1012
+ hydrateDataStore(website, carried)
1013
+ }
1014
+
1005
1015
  for (const page of website.pages) {
1006
1016
  // Skip dynamic template pages — they exist in the content for runtime
1007
1017
  // route matching but can't be pre-rendered (no concrete route)
@@ -1085,7 +1095,6 @@ export async function prerenderSite(siteDir, options = {}) {
1085
1095
  prerender: 'author',
1086
1096
  cache: buildDataCache,
1087
1097
  })
1088
- hydrateDataStore(website, asked)
1089
1098
  // Filed under this page, the way a route-bound view always has been: `scopeFetchedData`
1090
1099
  // embeds it in this page's HTML and nowhere else, and one address embedded twice collapses.
1091
1100
  for (const entry of asked) {
@@ -1093,6 +1102,15 @@ export async function prerenderSite(siteDir, options = {}) {
1093
1102
  siteContent.fetchedData.push({ ...entry, _scope: page.route, _routeBound: true })
1094
1103
  }
1095
1104
 
1105
+ // scopeRoutes mirrors the runtime data cascade (page → page.parent → site)
1106
+ // so split-mode pages embed only the collection data their first render reads.
1107
+ // An expanded page's own fetch was read under its TEMPLATE's route — the
1108
+ // route it had when the fetches ran — so that route is in its cascade too.
1109
+ const scopeRoutes = new Set([page.route, page.parent?.route, page.dynamicContext?.templateRoute].filter(Boolean))
1110
+ // The same call `injectBuildData` makes below for this page's HTML, so the render holds what
1111
+ // the HTML embeds — the page's own data step's answers among them.
1112
+ holdOnly(scopeFetchedData(siteContent.fetchedData, splitContent ? scopeRoutes : null, page.route))
1113
+
1096
1114
  onProgress(`Rendering ${outputRoute}...`)
1097
1115
 
1098
1116
  // ⭐ `renderer.render` IS the sequence this loop used to spell out —
@@ -1133,11 +1151,6 @@ export async function prerenderSite(siteDir, options = {}) {
1133
1151
  let html = result.html
1134
1152
 
1135
1153
  // Build-specific: theme CSS, __SITE_CONTENT__, icon cache.
1136
- // scopeRoutes mirrors the runtime data cascade (page → page.parent → site)
1137
- // so split-mode pages embed only the collection data their first render reads.
1138
- // An expanded page's own fetch was read under its TEMPLATE's route — the
1139
- // route it had when the fetches ran — so that route is in its cascade too.
1140
- const scopeRoutes = new Set([page.route, page.parent?.route, page.dynamicContext?.templateRoute].filter(Boolean))
1141
1154
  html = injectBuildData(html, siteContent, {
1142
1155
  splitContent,
1143
1156
  currentRoute: page.route,
@@ -1162,6 +1175,8 @@ export async function prerenderSite(siteDir, options = {}) {
1162
1175
  currentRoute: null, // 404 has no current page — manifest only
1163
1176
  scopeRoutes: new Set(), // SPA fallback carries site-level fetched data only
1164
1177
  })
1178
+ // The 404 page renders against what 404.html carries, as every page above does.
1179
+ holdOnly(scopeFetchedData(siteContent.fetchedData, splitContent ? new Set() : null, null))
1165
1180
  const { html: notFoundHtml, hasNotFoundPage } = generate404Html({
1166
1181
  baseHtml: fallbackBaseHtml,
1167
1182
  website,
@@ -15,32 +15,22 @@
15
15
  * This function is the shared data-emission core used by the link-mode
16
16
  * pipeline. The vite plugin keeps its own emission for the bundle-mode
17
17
  * path (where the static-host extras — sitemap, robots, search-index,
18
- * `_pages/*` for split content — are actually consumed). Both paths use
18
+ * `llms.txt` and the `.md` projections, `_pages/*` for split content — are
19
+ * actually consumed). Both paths use
19
20
  * the same underlying building blocks (`collectSiteContent`,
20
21
  * `processQueries`, etc.), so behavior stays consistent without forcing
21
22
  * one path through the other's lifecycle. Media processing — WebP copies,
22
23
  * video posters, PDF thumbnails — is the bundle path's alone (below).
23
24
  */
24
25
 
25
- import { recordProjections, routeOnDisk } from './record-projections.js'
26
- import { writeFile, readFile, mkdir, cp } from 'node:fs/promises'
26
+ import { writeFile, mkdir, cp } from 'node:fs/promises'
27
27
  import { existsSync } from 'node:fs'
28
- import { join, resolve, dirname } from 'node:path'
28
+ import { join, resolve } from 'node:path'
29
29
 
30
30
  import { resolveDefaultLocale, DATA_DIR } from '@uniweb/core'
31
31
  import { collectSiteContent } from './content-collector.js'
32
32
  import { processQueries, writeQueryFiles } from './query-processor.js'
33
33
  import { resolveRecordsDir } from './entity-pool.js'
34
- import {
35
- renderSiteIndex,
36
- renderPageMarkdown,
37
- resolveAgentsConfig,
38
- selectIndexablePages,
39
- selectIndexBranches,
40
- pageMarkdownFilename,
41
- branchIndexFilename,
42
- INDEX_FILENAME
43
- } from '@uniweb/projections'
44
34
 
45
35
  /**
46
36
  * Build the site data for `uniweb build --link`, which `uniweb publish` runs.
@@ -98,6 +88,8 @@ import {
98
88
  * described an emission its own step 5 had already removed, in the
99
89
  * paragraph a reader consults BEFORE the code, and it was read as
100
90
  * evidence by a downstream consumer.
91
+ * - `llms.txt`, a branch's, or any page's `.md`, in any language — see step 6.
92
+ * A host that serves the site derives its own from the content.
101
93
  *
102
94
  * @param {Object} params
103
95
  * @param {string} params.siteRoot - Absolute path to the site directory.
@@ -232,83 +224,11 @@ export async function buildSiteData({
232
224
  // That is what GitHub Pages and every other static target serve, and the
233
225
  // framework has more targets than one backend.
234
226
 
235
- // 6. Agent projections — `llms.txt` and one `.md` per page.
236
- //
237
- // Emitted here as well as in the vite plugin because both lanes publish
238
- // a site, and an artifact derived from site content has to exist
239
- // whichever lane produced it. A projection present after one publish and
240
- // absent after another is worse than none: agents are told to rely on it.
241
- //
242
- // Ungated by `features:` — projections are free, so they carry no
243
- // billing intent and no entitlement to check downstream.
244
- await writeProjections(finalContent, resolvedDistDir, join(resolvedSiteRoot, 'public'))
227
+ // 6. (removed 2026-10-25) This lane wrote `llms.txt` and a `.md` per page, for the default
228
+ // language only, on the ground that both lanes publish a site. Nothing read them: `publish`
229
+ // reads the schema-less query files and the languages, and a host that serves the site
230
+ // derives its projections from the content, as it derives its search. The static host’s are
231
+ // the bundle lane’s, in every language (`./projections.js`).
245
232
 
246
233
  return { siteContent: finalContent, distDir: resolvedDistDir }
247
234
  }
248
-
249
- /**
250
- * Write the agent projections into `distDir`.
251
- *
252
- * Mirrors the vite plugin's `emitProjections`, differing only in how bytes
253
- * reach disk (`writeFile` vs. Rollup's `emitFile`) — the generators, options
254
- * and filenames come from `@uniweb/projections`, so the two lanes cannot
255
- * disagree about what they produce.
256
- *
257
- * @param {Object} siteContent - Final site content for the default locale
258
- * @param {string} distDir - Resolved output directory
259
- * @param {string} dataRoot - where the compiled `/data/…` files are, for the record pages
260
- * @returns {Promise<void>}
261
- */
262
- async function writeProjections(siteContent, distDir, dataRoot) {
263
- const agents = resolveAgentsConfig(siteContent?.config)
264
- if (!agents.index && !agents.markdown) return
265
- if (!siteContent?.pages?.length) return
266
-
267
- const defaultLocale = resolveDefaultLocale(siteContent.config)
268
- // ⭐ No address is assumed here: this lane builds for a host that serves the site, and states where
269
- // (`config.url`). An author's static origin would send an agent to another copy of the site.
270
- const options = {
271
- address: siteContent.config?.url || null,
272
- locale: siteContent.config?.activeLocale || defaultLocale,
273
- defaultLocale
274
- }
275
- // Each record of a parametric page is a page, as a host lists them (`./record-projections.js`).
276
- const records = recordProjections(siteContent, { dataRoot, locale: options.locale })
277
-
278
- if (agents.index) {
279
- const index = renderSiteIndex(siteContent, { ...options, exclude: agents.exclude, records: records.entries })
280
- await writeFile(join(distDir, INDEX_FILENAME), index)
281
-
282
- // Additive scoped indexes; the root one above stays complete. See
283
- // `selectIndexBranches` for why this is not a delegation.
284
- if (agents.branchIndexes) {
285
- const branches = selectIndexBranches(siteContent.pages, {
286
- exclude: agents.exclude,
287
- minPages: agents.branchMinPages
288
- })
289
- for (const branch of branches) {
290
- const target = join(distDir, branchIndexFilename(branch.route))
291
- await mkdir(dirname(target), { recursive: true })
292
- await writeFile(
293
- target,
294
- renderSiteIndex(siteContent, { ...options, exclude: agents.exclude, records: records.entries, branch: branch.route })
295
- )
296
- }
297
- }
298
- }
299
-
300
- if (!agents.markdown) return
301
-
302
- for (const page of selectIndexablePages(siteContent.pages, { exclude: agents.exclude })) {
303
- const markdown = renderPageMarkdown(page)
304
- if (!markdown) continue
305
- const target = join(distDir, pageMarkdownFilename(page.route))
306
- await mkdir(dirname(target), { recursive: true })
307
- await writeFile(target, markdown)
308
- }
309
- for (const { route, markdown } of selectIndexablePages(records.pages, { exclude: agents.exclude })) {
310
- const target = join(distDir, pageMarkdownFilename(routeOnDisk(route)))
311
- await mkdir(dirname(target), { recursive: true })
312
- await writeFile(target, markdown)
313
- }
314
- }
@@ -40,6 +40,7 @@ import { readPreview, PREVIEW_ENV } from './preview.js'
40
40
  import { previewMediaPlugin } from './preview-media.js'
41
41
  import { previewDirectoryIndexPlugin } from './preview-directory-index.js'
42
42
  import { devServerRecordPlugin, requestedDevPort } from './dev-server.js'
43
+ import { refuseSearchOption } from './search-declared.js'
43
44
 
44
45
  /**
45
46
  * Normalize a base path for Vite compatibility
@@ -108,7 +109,6 @@ export function readSiteConfig(siteRoot) {
108
109
  * a site's `vite.config.js` is scaffolding, so nothing a site GETS may depend
109
110
  * on what it passes here.
110
111
  * @param {Object} [options.assets] - Asset processing configuration
111
- * @param {Object} [options.search] - Search index configuration
112
112
  * @param {boolean} [options.tailwind] - Include Tailwind CSS v4 Vite plugin (default: true)
113
113
  * @param {string} [options.base] - Base public path for deployment (e.g., '/demos/mysite/')
114
114
  * @returns {Promise<Object>} Vite configuration
@@ -138,6 +138,7 @@ function runtimeAnchor(siteRoot) {
138
138
  }
139
139
 
140
140
  export async function defineSiteConfig(options = {}) {
141
+ refuseSearchOption(options, 'defineSiteConfig')
141
142
  const {
142
143
  plugins: extraPlugins = [],
143
144
  server: serverOverrides = {},
@@ -146,7 +147,6 @@ export async function defineSiteConfig(options = {}) {
146
147
  optimizeDeps: optimizeDepsOverrides = {},
147
148
  seo = {},
148
149
  assets = {},
149
- search = {},
150
150
  tailwind = true,
151
151
  base: baseOption,
152
152
  ...restOptions
@@ -333,7 +333,6 @@ export async function defineSiteConfig(options = {}) {
333
333
  sitePath: './',
334
334
  seo,
335
335
  assets,
336
- search,
337
336
  foundationPath: foundationInfo.path // For loading foundation theme vars
338
337
  }),
339
338
 
@@ -30,18 +30,7 @@ import { resolve, join, relative, sep } from 'node:path'
30
30
  import { watch, existsSync } from 'node:fs'
31
31
  import { readFile, readdir } from 'node:fs/promises'
32
32
  import { resolveDefaultLocale, resolveFetchConfigs, DATA_DIR } from '@uniweb/core'
33
- import {
34
- renderSiteIndex,
35
- renderPageMarkdown,
36
- resolveAgentsConfig,
37
- selectIndexablePages,
38
- selectIndexBranches,
39
- pageMarkdownFilename,
40
- branchIndexFilename,
41
- applyRouteTranslation,
42
- partitionKnowledgePages,
43
- INDEX_FILENAME
44
- } from '@uniweb/projections'
33
+ import { partitionKnowledgePages, INDEX_FILENAME } from '@uniweb/projections'
45
34
  import { collectSiteContent, mountEntriesOf } from './content-collector.js'
46
35
  import { processAssets, rewriteSiteContentPaths } from './asset-processor.js'
47
36
  import { processAdvancedAssets } from './advanced-processors.js'
@@ -266,20 +255,13 @@ async function processDevSectionFetches(sections, fetchOptions, resolve) {
266
255
  }
267
256
  }
268
257
  }
269
- import { generateSearchIndex, getSearchIndexFilename, mergeSearchIndexes } from '@uniweb/projections'
270
- import { recordProjections, routeOnDisk } from './record-projections.js'
258
+ import { compiledData, recordProjections, routeOnDisk } from './record-projections.js'
259
+ import { agentFiles, describeProjections, projectionFiles, searchFiles } from './projections.js'
271
260
  import { mergeTranslations } from '../i18n/merge.js'
272
261
  import { dataBlockModels, NO_DATA_MODELS } from '../i18n/data-models.js'
273
- import { searchDeclaredOn } from './search-declared.js'
262
+ import { refuseSearchOption } from './search-declared.js'
274
263
  import { mountDevApi } from '../dev/api-mount.js'
275
264
 
276
- /*
277
- * `applyRouteTranslation` now comes from `@uniweb/projections` (imported
278
- * above). It used to be defined here; the agent index builds localized URLs
279
- * from the same rule, and two copies would eventually disagree about where a
280
- * localized page lives.
281
- */
282
-
283
265
  /**
284
266
  * Create the site content plugin
285
267
  *
@@ -303,12 +285,10 @@ import { mountDevApi } from '../dev/api-mount.js'
303
285
  * @param {string} [options.assets.outputDir='assets'] - Output subdirectory for processed assets
304
286
  * @param {boolean} [options.assets.videoPosters=true] - Extract poster frames from videos (requires ffmpeg)
305
287
  * @param {boolean} [options.assets.pdfThumbnails=true] - Generate thumbnails for PDFs (requires pdf-lib)
306
- * @param {Object} [options.search] - Search index configuration
307
- * @param {boolean} [options.search.enabled=true] - Generate search index (uses site.yml config by default)
308
- * @param {string} [options.search.filename='search-index.json'] - Search index filename
309
288
  * @param {string} [options.foundationPath] - Path to foundation directory (for loading theme vars)
310
289
  */
311
290
  export function siteContentPlugin(options = {}) {
291
+ refuseSearchOption(options, 'siteContentPlugin')
312
292
  const {
313
293
  sitePath = './',
314
294
  pagesDir = 'pages',
@@ -318,7 +298,6 @@ export function siteContentPlugin(options = {}) {
318
298
  watch: shouldWatch = true,
319
299
  seo = {},
320
300
  assets: assetsConfig = {},
321
- search: searchPluginConfig = {},
322
301
  foundationPath
323
302
  } = options
324
303
 
@@ -481,9 +460,9 @@ export function siteContentPlugin(options = {}) {
481
460
  }
482
461
 
483
462
  /**
484
- * Materialize every query the site declares into `public/data/` — at dev startup,
485
- * and again whenever what a query file is made from changes (`siteRootChange`, and
486
- * an entity change).
463
+ * Materialize every query the site declares into `public/data/` — at a production
464
+ * build's start, at dev startup, and again whenever what a query file is made from
465
+ * changes (`siteRootChange`, and an entity change).
487
466
  *
488
467
  * @param {Object|undefined} queries - the collected `config.queries`
489
468
  * @param {Object|undefined} config - the collected `config`, for the default locale a `sort` collates in
@@ -560,112 +539,64 @@ export function siteContentPlugin(options = {}) {
560
539
  }
561
540
 
562
541
  /**
563
- * Options shared by every projection call, so the dev middleware and the
564
- * build emit cannot drift apart in how they build URLs.
542
+ * The site's content in `locale`, as a page in that language gets it — or null when the site has
543
+ * no translation for it (`getTranslatedContent`).
565
544
  */
566
- function projectionOptions(content) {
567
- const defaultLocale = resolveDefaultLocale(content.config)
568
- return {
569
- // Absolute on the site's address when it is known, else from the base (`buildPageUrl`).
570
- address: content.config?.url || null,
571
- basePath,
572
- locale: content.config?.activeLocale || defaultLocale,
573
- defaultLocale
574
- }
545
+ async function languageContent(locale) {
546
+ const translated = await getTranslatedContent(locale)
547
+ return translated && { ...translated, config: { ...translated.config, activeLocale: locale } }
575
548
  }
576
549
 
577
550
  /**
578
- * Emit `llms.txt` and the per-page `.md` projections.
579
- *
580
- * Called with the Rollup plugin context, so `this.emitFile` is available.
581
- * Sits beside the sitemap and search-index emits because it is the same kind
582
- * of thing: an artifact derived from the site's content alone.
583
- *
584
- * Unlike the sitemap, this does NOT skip when the site's address is unknown
585
- * — it falls back to root-relative links and `uniweb doctor` warns.
586
- * An agent that arrived via the index can still follow a relative link, and
587
- * a silently-absent index is the failure mode the whole feature exists to
588
- * prevent.
589
- *
590
- * @param {Object} content - Final site content for this locale
591
- * @param {Object} [records] - its record pages (`recordProjections`), listed and written as pages
551
+ * A compiled data file in `locale` — a query's list (`articles.json`) or a record's own file
552
+ * (`articles/<$name>.json`), by its path under `data/` — translated as the localized build
553
+ * translates it (`buildLocalizedRecords`), by the same helpers (`translateRecordData`). Null when
554
+ * the site compiled no such file.
592
555
  */
593
- function emitProjections(content, records = recordProjections(content, { dataRoot: resolvedPublicDir })) {
594
- const agents = resolveAgentsConfig(content?.config)
595
- if (!agents.index && !agents.markdown) return
596
- if (!content?.pages?.length) return
597
-
598
- const options = projectionOptions(content)
599
- const localeDir =
600
- options.locale && options.defaultLocale && options.locale !== options.defaultLocale
601
- ? `${options.locale}/`
602
- : ''
603
-
604
- if (agents.index) {
605
- this.emitFile({
606
- type: 'asset',
607
- fileName: `${localeDir}${INDEX_FILENAME}`,
608
- source: renderSiteIndex(content, { ...options, exclude: agents.exclude, records: records.entries })
609
- })
610
- console.log(`[site-content] Generated ${localeDir}${INDEX_FILENAME}`)
611
-
612
- // Branch indexes are ADDITIVE — the root index above still enumerates
613
- // every page, because the two-hop criterion depends on it. These are a
614
- // scoped entry point for an agent already inside a branch.
615
- if (agents.branchIndexes) {
616
- const branches = selectIndexBranches(content.pages, {
617
- exclude: agents.exclude,
618
- minPages: agents.branchMinPages
619
- })
620
- for (const branch of branches) {
621
- this.emitFile({
622
- type: 'asset',
623
- fileName: `${localeDir}${branchIndexFilename(branch.route)}`,
624
- source: renderSiteIndex(content, {
625
- ...options,
626
- exclude: agents.exclude,
627
- records: records.entries,
628
- branch: branch.route
629
- })
630
- })
631
- }
632
- if (branches.length) {
633
- const names = branches.map(b => `${b.route} (${b.count})`).join(', ')
634
- console.log(`[site-content] Generated ${branches.length} branch index(es): ${names}`)
635
- }
636
- }
637
- }
556
+ async function localizedDataFile(locale, filename) {
557
+ const sourcePath = join(resolvedSitePath, 'public', DATA_DIR, filename)
558
+ if (!existsSync(sourcePath)) return null
559
+ const items = JSON.parse(await readFile(sourcePath, 'utf-8'))
560
+ const record = filename.includes('/') && items && typeof items === 'object' && !Array.isArray(items)
561
+ if (!Array.isArray(items) && !record) return items
562
+ const queryName = record ? filename.slice(0, filename.lastIndexOf('/')) : filename.replace(/\.json$/, '')
563
+ const { translateRecordData } = await import('../i18n/records.js')
564
+ return translateRecordData(items, queryName, resolvedSitePath, {
565
+ locale,
566
+ localesDir: join(resolvedSitePath, localesDir),
567
+ translations: (await loadRecordTranslations(locale)) || {},
568
+ freeformEnabled: existsSync(join(resolvedSitePath, localesDir, 'freeform', locale))
569
+ })
570
+ }
638
571
 
639
- if (agents.markdown) {
640
- const pages = selectIndexablePages(content.pages, { exclude: agents.exclude })
641
- let emitted = 0
642
- for (const page of pages) {
643
- const markdown = renderPageMarkdown(page)
644
- if (!markdown) continue
645
- const route =
646
- options.locale !== options.defaultLocale
647
- ? applyRouteTranslation(page.route, options.locale, content.config?.i18n?.routeTranslations)
648
- : page.route
649
- this.emitFile({
650
- type: 'asset',
651
- fileName: `${localeDir}${pageMarkdownFilename(route)}`,
652
- source: markdown
653
- })
654
- emitted++
655
- }
656
- // Each record's page, under the exclusions a page is held to.
657
- const listed = new Set(selectIndexablePages(records.pages, { exclude: agents.exclude }).map((page) => page.route))
658
- for (const { route, markdown } of records.pages) {
659
- if (!listed.has(route)) continue
660
- this.emitFile({
661
- type: 'asset',
662
- fileName: `${localeDir}${pageMarkdownFilename(routeOnDisk(route))}`,
663
- source: markdown
664
- })
665
- emitted++
666
- }
667
- if (emitted) console.log(`[site-content] Generated ${emitted} page markdown projections`)
668
- }
572
+ /**
573
+ * The projection a dev request names — the search index, an agent index, a page's markdown — made
574
+ * as the build makes it: by the same function, from the same language's content and records
575
+ * (`./projections.js`). A request under a language's prefix is that language's when the site has
576
+ * content in it; any other is the default language's. Null when the build writes no such file.
577
+ *
578
+ * @param {string} url - the request's path
579
+ * @returns {Promise<{ source: string, type: string }|null>}
580
+ */
581
+ async function devProjection(url) {
582
+ const search = url.endsWith('/search-index.json')
583
+ const markdown = url.endsWith('.md')
584
+ if (!search && !markdown && !url.endsWith(`/${INDEX_FILENAME}`)) return null
585
+ if (!siteContent) return null
586
+ // A static host decodes a request's path before it looks for the file.
587
+ const fileName = routeOnDisk(url).replace(/^\/+/, '')
588
+ const locale = fileName.match(new RegExp(`^(${LOCALE_RE})/`))?.[1]
589
+ const localized = locale ? await languageContent(locale) : null
590
+ const content = localized || siteContent
591
+ // A language's records, translated as its build translates them; the default's, as compiled.
592
+ const read = localized
593
+ ? (path) => (path.startsWith(`/${DATA_DIR}/`) ? localizedDataFile(locale, path.slice(DATA_DIR.length + 2)) : null)
594
+ : compiledData(resolvedPublicDir)
595
+ const records = await recordProjections(content, { read })
596
+ const file = (search ? searchFiles(content, records) : agentFiles(content, records)).find((f) => f.fileName === fileName)
597
+ if (!file) return null
598
+ const type = search ? 'application/json' : markdown ? 'text/markdown; charset=utf-8' : 'text/plain; charset=utf-8'
599
+ return { source: file.source, type }
669
600
  }
670
601
 
671
602
  return {
@@ -735,11 +666,12 @@ export function siteContentPlugin(options = {}) {
735
666
 
736
667
  // Materialize each query if the site declares any.
737
668
  // In dev mode, this was already done in configResolved (before server starts)
738
- // In production, do it here
669
+ // In production, do it here — by the same call, so a `sort` collates in the site's default
670
+ // language in both. ⛔ Until 2026-10-25 this called `processQueries` with no language, and
671
+ // the built file was collated in English whatever the site's language.
739
672
  if (isProduction && siteContent.config?.queries) {
740
673
  console.log('[site-content] Materializing queries...')
741
- const byQuery = await processQueries(resolvedSitePath, siteContent.config.queries, resolvedRecordsDir, basePath)
742
- await writeQueryFiles(resolvedSitePath, byQuery, siteContent.config.queries)
674
+ await materializeQueries(siteContent.config.queries, siteContent.config)
743
675
  }
744
676
 
745
677
  // Execute data fetches in dev mode
@@ -1080,100 +1012,19 @@ export function siteContentPlugin(options = {}) {
1080
1012
  // ⛔ No sitemap.xml or robots.txt here: the prerender writes them from the pages it
1081
1013
  // renders (`./seo-files.js`), and a dev server renders none ahead of a visit.
1082
1014
 
1083
- // Serve search-index.json in dev mode (supports locale prefixes)
1084
- const searchIndexMatch = req.url.match(new RegExp(`^(?:\\/(${LOCALE_RE}))?\\/search-index\\.json$`))
1085
- if (searchIndexMatch && siteContent) {
1086
- const searchEnabled = searchPluginConfig.enabled !== false && searchDeclaredOn(siteContent)
1087
- if (searchEnabled) {
1088
- const searchConfig = siteContent.config?.search || {}
1089
- const defaultLocale = resolveDefaultLocale(siteContent.config)
1090
- // Use requested locale from URL, fall back to active or default
1091
- const requestedLocale = searchIndexMatch[1]
1092
- const activeLocale = requestedLocale || siteContent.config?.activeLocale || defaultLocale
1093
-
1094
- const pagesIndex = generateSearchIndex(siteContent, {
1095
- locale: activeLocale,
1096
- search: searchConfig
1097
- })
1098
- // The record pages, as the build lists them — the default locale's, whose records these are.
1099
- const searchIndex = activeLocale === defaultLocale
1100
- ? mergeSearchIndexes(pagesIndex, recordProjections(siteContent, { dataRoot: resolvedPublicDir, locale: activeLocale }).indexes)
1101
- : pagesIndex
1102
-
1103
- res.setHeader('Content-Type', 'application/json')
1104
- res.end(JSON.stringify(searchIndex, null, 2))
1105
- return
1106
- }
1107
- }
1108
-
1109
- // Serve the agent projections in dev mode.
1110
- //
1111
- // Without this the artifacts can only be inspected by deploying, and
1112
- // `uniweb doctor`'s address warning describes something invisible
1113
- // locally. Both routes reuse the build's generators, so what a
1114
- // developer sees here is what ships.
1115
- if (siteContent) {
1116
- const agents = resolveAgentsConfig(siteContent.config)
1117
- const url = req.url.split('?')[0]
1118
-
1119
- // `/llms.txt`, `/fr/llms.txt`, and the branch form `/docs/llms.txt`.
1120
- // The optional middle group is the branch route; an empty one is the
1121
- // site index, so a single pattern serves both rather than two that
1122
- // could drift.
1123
- const indexMatch = url.match(
1124
- new RegExp(`^(?:\\/(${LOCALE_RE}))?((?:\\/[^/]+)*)\\/${INDEX_FILENAME}$`)
1125
- )
1126
- if (indexMatch && agents.index) {
1127
- const localized = (indexMatch[1] ? await getTranslatedContent(indexMatch[1]) : null) || siteContent
1128
- const branch = indexMatch[2] || null
1129
-
1130
- // Serve a branch index only where the build would emit one, or dev
1131
- // and the built output disagree about which URLs exist.
1132
- const served =
1133
- !branch ||
1134
- (agents.branchIndexes &&
1135
- selectIndexBranches(localized.pages, {
1136
- exclude: agents.exclude,
1137
- minPages: agents.branchMinPages
1138
- }).some(b => b.route === branch))
1139
-
1140
- if (served) {
1141
- // The record pages, as the build lists them — the default locale's, whose records these are.
1142
- const records = localized === siteContent
1143
- ? recordProjections(siteContent, { dataRoot: resolvedPublicDir }).entries
1144
- : undefined
1145
- res.setHeader('Content-Type', 'text/plain; charset=utf-8')
1146
- res.end(
1147
- renderSiteIndex(localized, {
1148
- ...projectionOptions(localized),
1149
- locale: indexMatch[1] || projectionOptions(localized).locale,
1150
- exclude: agents.exclude,
1151
- records,
1152
- branch
1153
- })
1154
- )
1155
- return
1156
- }
1157
- }
1158
-
1159
- const markdownMatch = url.match(new RegExp(`^(?:\\/(${LOCALE_RE}))?\\/(.+)\\.md$`))
1160
- if (markdownMatch && agents.markdown) {
1161
- const localized = (markdownMatch[1] ? await getTranslatedContent(markdownMatch[1]) : null) || siteContent
1162
- const requested = markdownMatch[2] === 'index' ? '/' : `/${markdownMatch[2]}`
1163
- const pages = selectIndexablePages(localized.pages, { exclude: agents.exclude })
1164
- const page = pages.find(p => p.route === requested)
1165
- const markdown = page
1166
- ? renderPageMarkdown(page)
1167
- : localized === siteContent
1168
- ? selectIndexablePages(recordProjections(siteContent, { dataRoot: resolvedPublicDir }).pages, { exclude: agents.exclude })
1169
- .find((record) => routeOnDisk(record.route) === routeOnDisk(requested))?.markdown
1170
- : null
1171
- if (markdown) {
1172
- res.setHeader('Content-Type', 'text/markdown; charset=utf-8')
1173
- res.end(markdown)
1174
- return
1175
- }
1176
- }
1015
+ // The projections — the search index, `llms.txt` (and a branch's), each page as markdown —
1016
+ // in every language: the file the build writes at this path, made by the same function from
1017
+ // the same language's content and records (`devProjection`), so what a developer sees here
1018
+ // is what ships. Without it they could be inspected only by deploying, and `uniweb doctor`'s
1019
+ // address warning would describe something invisible locally. ⛔ Until 2026-10-25 dev made
1020
+ // another language's search index from the default language's content, and found its `.md`
1021
+ // by the source route, so the translated links its `llms.txt` gave answered with the page
1022
+ // shell.
1023
+ const projection = await devProjection(req.url.split('?')[0])
1024
+ if (projection) {
1025
+ res.setHeader('Content-Type', projection.type)
1026
+ res.end(projection.source)
1027
+ return
1177
1028
  }
1178
1029
 
1179
1030
  // Handle localized collection data (e.g., /fr/data/articles.json), and a
@@ -1181,45 +1032,16 @@ export function siteContentPlugin(options = {}) {
1181
1032
  // each, translated the same way.
1182
1033
  const localeDataMatch = req.url.match(new RegExp(`^\\/(${LOCALE_RE})\\/${DATA_DIR}\\/(.+\\.json)$`))
1183
1034
  if (localeDataMatch) {
1184
- const locale = localeDataMatch[1]
1185
- const filename = localeDataMatch[2]
1186
- const sourcePath = join(resolvedSitePath, 'public', DATA_DIR, filename)
1187
-
1188
- if (existsSync(sourcePath)) {
1189
- try {
1190
- const raw = await readFile(sourcePath, 'utf-8')
1191
- const items = JSON.parse(raw)
1192
- const record = filename.includes('/') && items && typeof items === 'object' && !Array.isArray(items)
1193
- if (!Array.isArray(items) && !record) {
1194
- res.setHeader('Content-Type', 'application/json')
1195
- res.end(raw)
1196
- return
1197
- }
1198
- const queryName = record ? filename.slice(0, filename.lastIndexOf('/')) : filename.replace(/\.json$/, '')
1199
-
1200
- // Load collection translations for this locale
1201
- const translations = await loadRecordTranslations(locale) || {}
1202
-
1203
- // Check for free-form translations
1204
- const freeformDir = join(resolvedSitePath, localesDir, 'freeform', locale)
1205
- const hasFreeform = existsSync(freeformDir)
1206
-
1207
- // Translate using the collections module
1208
- const { translateRecordData } = await import('../i18n/records.js')
1209
- const translated = await translateRecordData(items, queryName, resolvedSitePath, {
1210
- locale,
1211
- localesDir: join(resolvedSitePath, localesDir),
1212
- translations,
1213
- freeformEnabled: hasFreeform
1214
- })
1215
-
1035
+ try {
1036
+ const translated = await localizedDataFile(localeDataMatch[1], localeDataMatch[2])
1037
+ if (translated !== null) {
1216
1038
  res.setHeader('Content-Type', 'application/json')
1217
1039
  res.end(JSON.stringify(translated, null, 2))
1218
1040
  return
1219
- } catch (err) {
1220
- console.warn(`[site-content] Failed to serve localized records ${filename}: ${err.message}`)
1221
- // Fall through to Vite's static server
1222
1041
  }
1042
+ } catch (err) {
1043
+ console.warn(`[site-content] Failed to serve localized records ${localeDataMatch[2]}: ${err.message}`)
1044
+ // Fall through to Vite's static server
1223
1045
  }
1224
1046
  }
1225
1047
 
@@ -1446,40 +1268,14 @@ export function siteContentPlugin(options = {}) {
1446
1268
  // sitemap.xml and robots.txt are the prerender's: it writes them from the pages it wrote,
1447
1269
  // record pages included (`./seo-files.js`).
1448
1270
 
1449
- // The site's record pages — each record of a parametric page — for its search index and its
1450
- // agent projections, as a host makes them (`./record-projections.js`).
1451
- const records = recordProjections(finalContent, {
1452
- dataRoot: resolvedPublicDir,
1453
- locale: finalContent.config?.activeLocale || resolveDefaultLocale(finalContent.config)
1454
- })
1455
-
1456
- // Generate search index if enabled
1457
- const searchEnabled = searchPluginConfig.enabled !== false && searchDeclaredOn(finalContent)
1458
- if (searchEnabled) {
1459
- const searchConfig = finalContent.config?.search || {}
1460
- const defaultLocale = resolveDefaultLocale(finalContent.config)
1461
- const activeLocale = finalContent.config?.activeLocale || defaultLocale
1462
-
1463
- // Generate search index for current locale — its pages, and its records' pages
1464
- const searchIndex = mergeSearchIndexes(
1465
- generateSearchIndex(finalContent, { locale: activeLocale, search: searchConfig }),
1466
- records.indexes
1467
- )
1468
-
1469
- const searchFilename = getSearchIndexFilename(activeLocale, defaultLocale)
1470
- this.emitFile({
1471
- type: 'asset',
1472
- fileName: searchFilename,
1473
- source: JSON.stringify(searchIndex, null, 2)
1474
- })
1475
-
1476
- console.log(`[site-content] Generated ${searchFilename} (${searchIndex.count} entries)`)
1477
- }
1478
-
1479
- // Agent-readable projections: the index (discovery) and per-page
1480
- // markdown (retrieval). Free and on by default; a site opts out under
1481
- // `agents:` in site.yml.
1482
- emitProjections.call(this, finalContent, records)
1271
+ // The default language's projections — its search index, its agent index and its pages as
1272
+ // markdown, each record of a parametric page among them — by the rule every language's are
1273
+ // made by (`./projections.js`). Each other language's are written after the bundle, once its
1274
+ // records are translated (`i18n/index.js::buildLocalizedSite`).
1275
+ const records = await recordProjections(finalContent, { read: compiledData(resolvedPublicDir) })
1276
+ const projected = projectionFiles(finalContent, records)
1277
+ for (const { fileName, source } of projected) this.emitFile({ type: 'asset', fileName, source })
1278
+ for (const line of describeProjections(projected)) console.log(`[site-content] ${line}`)
1483
1279
 
1484
1280
  // A site-relative extension (`/effects/entry.js`) is served from the
1485
1281
  // site's OWN origin, so the site's build is what has to put it there.
@@ -0,0 +1,188 @@
1
+ /**
2
+ * A LANGUAGE'S PROJECTIONS — the files a static host serves about a site in one of its languages:
3
+ * its search index, its agent index (`llms.txt`, and a branch's), and each of its pages as markdown,
4
+ * a record's page included. Made from that language's content and its records alone; the
5
+ * generators are `@uniweb/projections`', and this decides which of them a site gets and where each
6
+ * file goes.
7
+ *
8
+ * ⭐ ONE RULE, EVERY WRITER. The bundle build emits the default language's through Rollup
9
+ * (`./plugin.js`), the localized build writes each other language's once its records are translated
10
+ * (`i18n/index.js::buildLocalizedSite`), and the dev server answers a request with the file named
11
+ * for it here — so what a developer sees is what ships, in every language. ⛔ Until 2026-10-25 four
12
+ * writers made these files, and they disagreed: another language's search index was made from its
13
+ * pages alone, before its records were translated, and it got no `llms.txt` or `.md`; dev made every
14
+ * language's search index from the default language's content. Measured on a starter in three
15
+ * languages with three record pages: 20 search entries in English, 17 in French and Spanish, and no
16
+ * `fr/llms.txt`.
17
+ *
18
+ * ⭐ A LANGUAGE'S FILES ARE AT ITS ADDRESSES: under its prefix, at its translated routes
19
+ * (`applyRouteTranslation`) — `fr/actualites/x.md` for `/blog/x` — where its `llms.txt` links them
20
+ * (`buildPageUrl`). A static host decodes a request's path before it looks for the file, so a
21
+ * record's file is named for its decoded route (`routeOnDisk`).
22
+ *
23
+ * @module
24
+ */
25
+
26
+ import { resolveDefaultLocale } from '@uniweb/core'
27
+ import {
28
+ renderSiteIndex,
29
+ renderPageMarkdown,
30
+ resolveAgentsConfig,
31
+ selectIndexablePages,
32
+ selectIndexBranches,
33
+ pageMarkdownFilename,
34
+ branchIndexFilename,
35
+ applyRouteTranslation,
36
+ generateSearchIndex,
37
+ getSearchIndexFilename,
38
+ mergeSearchIndexes,
39
+ INDEX_FILENAME
40
+ } from '@uniweb/projections'
41
+ import { routeOnDisk } from './record-projections.js'
42
+ import { searchDeclaredOn } from './search-declared.js'
43
+
44
+ /**
45
+ * The language `content` is in — its `config.activeLocale`, else the site's default — the prefix
46
+ * its files go under, and the route a visitor reaches a page at in it.
47
+ */
48
+ function languageOf(content) {
49
+ const config = content?.config || {}
50
+ const defaultLocale = resolveDefaultLocale(config)
51
+ const locale = config.activeLocale || defaultLocale
52
+ const other = Boolean(locale && defaultLocale && locale !== defaultLocale)
53
+ const routeTranslations = config.i18n?.routeTranslations
54
+ return {
55
+ locale,
56
+ defaultLocale,
57
+ prefix: other ? `${locale}/` : '',
58
+ at: other ? (route) => applyRouteTranslation(route, locale, routeTranslations) : (route) => route
59
+ }
60
+ }
61
+
62
+ /**
63
+ * The language's search index, when the site has search (`services.search`, `./search-declared.js`):
64
+ * its pages, then its records' pages.
65
+ *
66
+ * @param {Object} content - one language's site content
67
+ * @param {Object} [records] - its record pages (`recordProjections`)
68
+ * @returns {Array<{ kind: 'search', fileName: string, source: string, count: number }>}
69
+ */
70
+ export function searchFiles(content, records) {
71
+ if (!searchDeclaredOn(content)) return []
72
+ const { locale, defaultLocale } = languageOf(content)
73
+ const index = mergeSearchIndexes(
74
+ generateSearchIndex(content, { locale, search: content.config?.search || {} }),
75
+ records?.indexes
76
+ )
77
+ return [
78
+ {
79
+ kind: 'search',
80
+ fileName: getSearchIndexFilename(locale, defaultLocale),
81
+ source: JSON.stringify(index, null, 2),
82
+ count: index.count
83
+ }
84
+ ]
85
+ }
86
+
87
+ /**
88
+ * The language's agent projections, unless the site turns them off (`agents:`): `llms.txt` — and a
89
+ * branch's, where `branchIndexes` asks for them — and each page it lists, as markdown.
90
+ *
91
+ * Unlike the sitemap, written when the site's address is unknown too: the links are root-relative
92
+ * then, and `uniweb doctor` warns. An agent that arrived through the index can follow a relative
93
+ * link, and a silently absent index is the failure the whole feature exists to prevent.
94
+ *
95
+ * @param {Object} content - one language's site content
96
+ * @param {Object} [records] - its record pages (`recordProjections`), listed and written as pages
97
+ * @returns {Array<{ kind: 'index'|'branch'|'markdown', fileName: string, source: string }>}
98
+ */
99
+ export function agentFiles(content, records) {
100
+ const agents = resolveAgentsConfig(content?.config)
101
+ if (!agents.index && !agents.markdown) return []
102
+ if (!content?.pages?.length) return []
103
+
104
+ const { locale, defaultLocale, prefix, at } = languageOf(content)
105
+ // The address and base path are the content's own (`config.url`, `config.base`), which
106
+ // `renderSiteIndex` reads.
107
+ const options = { locale, defaultLocale, exclude: agents.exclude, records: records?.entries }
108
+ const files = []
109
+
110
+ if (agents.index) {
111
+ files.push({ kind: 'index', fileName: `${prefix}${INDEX_FILENAME}`, source: renderSiteIndex(content, options) })
112
+
113
+ // Branch indexes are ADDITIVE — the root index above still lists every page, because the
114
+ // two-hop criterion depends on it. These are a scoped entry point for an agent already inside
115
+ // a branch, at the branch's own address.
116
+ if (agents.branchIndexes) {
117
+ const branches = selectIndexBranches(content.pages, { exclude: agents.exclude, minPages: agents.branchMinPages })
118
+ for (const branch of branches) {
119
+ files.push({
120
+ kind: 'branch',
121
+ fileName: `${prefix}${branchIndexFilename(at(branch.route))}`,
122
+ source: renderSiteIndex(content, { ...options, branch: branch.route }),
123
+ route: branch.route,
124
+ count: branch.count
125
+ })
126
+ }
127
+ }
128
+ }
129
+
130
+ if (agents.markdown) {
131
+ for (const page of selectIndexablePages(content.pages, { exclude: agents.exclude })) {
132
+ const markdown = renderPageMarkdown(page)
133
+ if (markdown) files.push({ kind: 'markdown', fileName: `${prefix}${pageMarkdownFilename(at(page.route))}`, source: markdown })
134
+ }
135
+ // Each record's page, held to the exclusions a page is held to — the site's pages among them,
136
+ // so a record under a branch `llms.txt` leaves out gets no file, as it gets no line there.
137
+ const listed = indexableRecordRoutes(content.pages, records?.pages, agents.exclude)
138
+ for (const { route, markdown } of records?.pages || []) {
139
+ if (!listed.has(route)) continue
140
+ files.push({ kind: 'markdown', fileName: `${prefix}${pageMarkdownFilename(routeOnDisk(at(route)))}`, source: markdown })
141
+ }
142
+ }
143
+
144
+ return files
145
+ }
146
+
147
+ /**
148
+ * Every projection of one language of a site: its search index, then its agent projections.
149
+ *
150
+ * @param {Object} content - one language's site content
151
+ * @param {Object} [records] - its record pages (`recordProjections`)
152
+ */
153
+ export function projectionFiles(content, records) {
154
+ return [...searchFiles(content, records), ...agentFiles(content, records)]
155
+ }
156
+
157
+ /**
158
+ * What a build says it wrote — one line per kind of file.
159
+ *
160
+ * @param {Array<{ kind: string, fileName: string }>} files - `projectionFiles`
161
+ * @returns {string[]}
162
+ */
163
+ export function describeProjections(files) {
164
+ const lines = []
165
+ for (const file of files) {
166
+ if (file.kind === 'search') lines.push(`Generated ${file.fileName} (${file.count} entries)`)
167
+ if (file.kind === 'index') lines.push(`Generated ${file.fileName}`)
168
+ }
169
+ const branches = files.filter((file) => file.kind === 'branch')
170
+ if (branches.length) {
171
+ lines.push(`Generated ${branches.length} branch index(es): ${branches.map((b) => `${b.route} (${b.count})`).join(', ')}`)
172
+ }
173
+ const markdown = files.filter((file) => file.kind === 'markdown').length
174
+ if (markdown) lines.push(`Generated ${markdown} page markdown projections`)
175
+ return lines
176
+ }
177
+
178
+ /** The routes of the record pages `llms.txt` lists, judged among the site's own pages. */
179
+ function indexableRecordRoutes(pages = [], recordPages = [], exclude) {
180
+ if (!recordPages?.length) return new Set()
181
+ const routes = new Set(recordPages.map((page) => page.route))
182
+ const candidates = [...pages, ...recordPages.map(({ route }) => ({ route }))]
183
+ return new Set(
184
+ selectIndexablePages(candidates, { exclude })
185
+ .map((page) => page.route)
186
+ .filter((route) => routes.has(route))
187
+ )
188
+ }
@@ -8,10 +8,16 @@
8
8
  * ⛔ Until 2026-10-25 the static build made its projections from the site's pages alone: llms.txt
9
9
  * and the search index held none of a site's books, talks or posts, while a host's held all of them
10
10
  * (measured on a site with 16 record pages: 0 in llms.txt, 0 in the search index).
11
+ *
12
+ * ⭐ In every language, each from its own records: the caller says where a language's compiled files
13
+ * are by how it reads them (`read`) — the default language's under `public/`, another's under
14
+ * `dist/<locale>/` once they are translated, and dev's translated as they are asked for. ⛔ The same
15
+ * day, the static build listed the record pages of its default language only (`./projections.js`).
11
16
  */
12
17
 
13
18
  import { readFileSync } from 'node:fs'
14
19
  import { join } from 'node:path'
20
+ import { resolveDefaultLocale } from '@uniweb/core/locale-config'
15
21
  import { fillRoutePattern, recordHandle } from '@uniweb/core/route-match'
16
22
  import { recordRoutes, generateRecordSearchIndex, renderPageMarkdown } from '@uniweb/projections'
17
23
 
@@ -37,18 +43,35 @@ function readJson(file) {
37
43
  }
38
44
  }
39
45
 
46
+ /**
47
+ * A reader of the compiled data files under `root` — the directory a query's compiled `path`
48
+ * (`/data/talks.json`) is under: `public/` for the default language, `dist/<locale>/` for another.
49
+ *
50
+ * @param {string} root
51
+ * @returns {(path: string) => *} the file's JSON, or null when there is none
52
+ */
53
+ export function compiledData(root) {
54
+ return (path) => readJson(join(root, path))
55
+ }
56
+
40
57
  /**
41
58
  * The record pages of a site: each query whose records have a page of their own, with its search
42
59
  * index and each record's page — its route, and its markdown (the parametric page's, then the
43
60
  * record).
44
61
  *
45
- * @param {Object} content - one locale's site content
62
+ * @param {Object} content - one language's site content
46
63
  * @param {Object} options
47
- * @param {string} options.dataRoot - the directory a query's compiled `path` (`/data/talks.json`) is under
48
- * @param {string} [options.locale]
49
- * @returns {{ indexes: Object[], entries: Object[], pages: Array<{ route: string, markdown: string }> }}
64
+ * @param {(path: string) => *} options.read - a compiled data file's JSON in that language, by its
65
+ * site path (`/data/talks.json`, a record's own `/data/talks/<$name>.json`), or null — a promise of
66
+ * one too (`compiledData`)
67
+ * @param {string} [options.locale] - the content's language, `config.activeLocale` by default, else
68
+ * the site's default
69
+ * @returns {Promise<{ indexes: Object[], entries: Object[], pages: Array<{ route: string, markdown: string }> }>}
50
70
  */
51
- export function recordProjections(content, { dataRoot, locale } = {}) {
71
+ export async function recordProjections(
72
+ content,
73
+ { read, locale = content?.config?.activeLocale || resolveDefaultLocale(content?.config || {}) } = {}
74
+ ) {
52
75
  const queries = content?.config?.queries || {}
53
76
  const indexes = []
54
77
  const pages = []
@@ -56,19 +79,19 @@ export function recordProjections(content, { dataRoot, locale } = {}) {
56
79
  // No compiled file: a query answered by a records service, or an external one — its records
57
80
  // are not this build's to list.
58
81
  if (!path) continue
59
- const records = readJson(join(dataRoot, path))
82
+ const records = await read(path)
60
83
  if (!Array.isArray(records)) continue
61
84
  indexes.push(generateRecordSearchIndex(name, { ...(queries[name] || {}), pattern, field }, records, locale))
62
85
 
63
86
  const template = (content.pages || []).find((page) => page.route === pattern)
64
87
  if (!template) continue
65
88
  // A record's own file holds it whole; the list holds its brief.
66
- const wholeDir = join(dataRoot, path.replace(/\.json$/, ''))
89
+ const wholeDir = path.replace(/\.json$/, '')
67
90
  for (const brief of records) {
68
91
  const route = fillRoutePattern(pattern, brief, { field: field ?? null })
69
92
  if (!route) continue
70
93
  const handle = recordHandle(brief)
71
- const record = (handle && readJson(join(wholeDir, `${handle}.json`))) || brief
94
+ const record = (handle && (await read(`${wholeDir}/${handle}.json`))) || brief
72
95
  const markdown = renderPageMarkdown(template, { record })
73
96
  if (markdown) pages.push({ route, markdown })
74
97
  }
@@ -32,3 +32,21 @@ export function searchDeclaredOn(siteContent) {
32
32
  if (typeof search === 'boolean') return search
33
33
  return search?.enabled !== false
34
34
  }
35
+
36
+ /**
37
+ * ⛔ A site's Vite config no longer says whether the site has search — retired 2026-10-25. Its
38
+ * `search` option reached the default language's index alone: every other language's is written
39
+ * after the bundle, where no Vite config is read (`i18n/index.js::buildLocalizedSite`). And a site's
40
+ * `vite.config.js` is scaffolding, so nothing a site gets may depend on it. `site.yml` decides, for
41
+ * every language (`searchDeclaredOn`).
42
+ *
43
+ * @param {Object} options - what `defineSiteConfig` or `siteContentPlugin` was given
44
+ * @param {string} where - which of them
45
+ */
46
+ export function refuseSearchOption(options, where) {
47
+ if (options?.search === undefined) return
48
+ throw new Error(
49
+ `${where}: the \`search\` option is retired. Whether a site has search is \`services.search\` in site.yml, ` +
50
+ 'for every language. Remove `search` from vite.config.js.'
51
+ )
52
+ }