@uniweb/build 1.9.1 → 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.1",
3
+ "version": "1.10.0",
4
4
  "description": "Build tooling for the Uniweb Component Web Platform",
5
5
  "type": "module",
6
6
  "exports": {
@@ -53,15 +53,15 @@
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.6",
59
+ "@uniweb/content-reader": "~1.5.7",
60
+ "@uniweb/content-writer": "~1.3.1",
60
61
  "@uniweb/projections": "~1.2.0",
61
- "@uniweb/content-writer": "~1.3.0",
62
+ "@uniweb/theming": "~1.2.1",
62
63
  "@uniweb/schemas": "~1.6.5",
63
- "@uniweb/semantic-parser": "~1.13.0",
64
- "@uniweb/theming": "~1.2.1"
64
+ "@uniweb/semantic-parser": "~1.13.0"
65
65
  },
66
66
  "optionalDependencies": {
67
67
  "@uniweb/runtime": "~1.2.2"
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
@@ -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,11 +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
288
  * @param {string} [options.foundationPath] - Path to foundation directory (for loading theme vars)
309
289
  */
310
290
  export function siteContentPlugin(options = {}) {
291
+ refuseSearchOption(options, 'siteContentPlugin')
311
292
  const {
312
293
  sitePath = './',
313
294
  pagesDir = 'pages',
@@ -317,7 +298,6 @@ export function siteContentPlugin(options = {}) {
317
298
  watch: shouldWatch = true,
318
299
  seo = {},
319
300
  assets: assetsConfig = {},
320
- search: searchPluginConfig = {},
321
301
  foundationPath
322
302
  } = options
323
303
 
@@ -480,9 +460,9 @@ export function siteContentPlugin(options = {}) {
480
460
  }
481
461
 
482
462
  /**
483
- * Materialize every query the site declares into `public/data/` — at dev startup,
484
- * and again whenever what a query file is made from changes (`siteRootChange`, and
485
- * 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).
486
466
  *
487
467
  * @param {Object|undefined} queries - the collected `config.queries`
488
468
  * @param {Object|undefined} config - the collected `config`, for the default locale a `sort` collates in
@@ -559,112 +539,64 @@ export function siteContentPlugin(options = {}) {
559
539
  }
560
540
 
561
541
  /**
562
- * Options shared by every projection call, so the dev middleware and the
563
- * 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`).
564
544
  */
565
- function projectionOptions(content) {
566
- const defaultLocale = resolveDefaultLocale(content.config)
567
- return {
568
- // Absolute on the site's address when it is known, else from the base (`buildPageUrl`).
569
- address: content.config?.url || null,
570
- basePath,
571
- locale: content.config?.activeLocale || defaultLocale,
572
- defaultLocale
573
- }
545
+ async function languageContent(locale) {
546
+ const translated = await getTranslatedContent(locale)
547
+ return translated && { ...translated, config: { ...translated.config, activeLocale: locale } }
574
548
  }
575
549
 
576
550
  /**
577
- * Emit `llms.txt` and the per-page `.md` projections.
578
- *
579
- * Called with the Rollup plugin context, so `this.emitFile` is available.
580
- * Sits beside the sitemap and search-index emits because it is the same kind
581
- * of thing: an artifact derived from the site's content alone.
582
- *
583
- * Unlike the sitemap, this does NOT skip when the site's address is unknown
584
- * — it falls back to root-relative links and `uniweb doctor` warns.
585
- * An agent that arrived via the index can still follow a relative link, and
586
- * a silently-absent index is the failure mode the whole feature exists to
587
- * prevent.
588
- *
589
- * @param {Object} content - Final site content for this locale
590
- * @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.
591
555
  */
592
- function emitProjections(content, records = recordProjections(content, { dataRoot: resolvedPublicDir })) {
593
- const agents = resolveAgentsConfig(content?.config)
594
- if (!agents.index && !agents.markdown) return
595
- if (!content?.pages?.length) return
596
-
597
- const options = projectionOptions(content)
598
- const localeDir =
599
- options.locale && options.defaultLocale && options.locale !== options.defaultLocale
600
- ? `${options.locale}/`
601
- : ''
602
-
603
- if (agents.index) {
604
- this.emitFile({
605
- type: 'asset',
606
- fileName: `${localeDir}${INDEX_FILENAME}`,
607
- source: renderSiteIndex(content, { ...options, exclude: agents.exclude, records: records.entries })
608
- })
609
- console.log(`[site-content] Generated ${localeDir}${INDEX_FILENAME}`)
610
-
611
- // Branch indexes are ADDITIVE — the root index above still enumerates
612
- // every page, because the two-hop criterion depends on it. These are a
613
- // scoped entry point for an agent already inside a branch.
614
- if (agents.branchIndexes) {
615
- const branches = selectIndexBranches(content.pages, {
616
- exclude: agents.exclude,
617
- minPages: agents.branchMinPages
618
- })
619
- for (const branch of branches) {
620
- this.emitFile({
621
- type: 'asset',
622
- fileName: `${localeDir}${branchIndexFilename(branch.route)}`,
623
- source: renderSiteIndex(content, {
624
- ...options,
625
- exclude: agents.exclude,
626
- records: records.entries,
627
- branch: branch.route
628
- })
629
- })
630
- }
631
- if (branches.length) {
632
- const names = branches.map(b => `${b.route} (${b.count})`).join(', ')
633
- console.log(`[site-content] Generated ${branches.length} branch index(es): ${names}`)
634
- }
635
- }
636
- }
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
+ }
637
571
 
638
- if (agents.markdown) {
639
- const pages = selectIndexablePages(content.pages, { exclude: agents.exclude })
640
- let emitted = 0
641
- for (const page of pages) {
642
- const markdown = renderPageMarkdown(page)
643
- if (!markdown) continue
644
- const route =
645
- options.locale !== options.defaultLocale
646
- ? applyRouteTranslation(page.route, options.locale, content.config?.i18n?.routeTranslations)
647
- : page.route
648
- this.emitFile({
649
- type: 'asset',
650
- fileName: `${localeDir}${pageMarkdownFilename(route)}`,
651
- source: markdown
652
- })
653
- emitted++
654
- }
655
- // Each record's page, under the exclusions a page is held to.
656
- const listed = new Set(selectIndexablePages(records.pages, { exclude: agents.exclude }).map((page) => page.route))
657
- for (const { route, markdown } of records.pages) {
658
- if (!listed.has(route)) continue
659
- this.emitFile({
660
- type: 'asset',
661
- fileName: `${localeDir}${pageMarkdownFilename(routeOnDisk(route))}`,
662
- source: markdown
663
- })
664
- emitted++
665
- }
666
- if (emitted) console.log(`[site-content] Generated ${emitted} page markdown projections`)
667
- }
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 }
668
600
  }
669
601
 
670
602
  return {
@@ -734,11 +666,12 @@ export function siteContentPlugin(options = {}) {
734
666
 
735
667
  // Materialize each query if the site declares any.
736
668
  // In dev mode, this was already done in configResolved (before server starts)
737
- // 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.
738
672
  if (isProduction && siteContent.config?.queries) {
739
673
  console.log('[site-content] Materializing queries...')
740
- const byQuery = await processQueries(resolvedSitePath, siteContent.config.queries, resolvedRecordsDir, basePath)
741
- await writeQueryFiles(resolvedSitePath, byQuery, siteContent.config.queries)
674
+ await materializeQueries(siteContent.config.queries, siteContent.config)
742
675
  }
743
676
 
744
677
  // Execute data fetches in dev mode
@@ -1079,106 +1012,19 @@ export function siteContentPlugin(options = {}) {
1079
1012
  // ⛔ No sitemap.xml or robots.txt here: the prerender writes them from the pages it
1080
1013
  // renders (`./seo-files.js`), and a dev server renders none ahead of a visit.
1081
1014
 
1082
- // Serve search-index.json in dev mode, under a language's prefix too.
1083
- // ⚠️ Made from the default language's content whatever the prefix, and with no records for
1084
- // another language: the build's `/fr/search-index.json` is in French, this one is not
1085
- // (measured 2026-10-25).
1086
- const searchIndexMatch = req.url.match(new RegExp(`^(?:\\/(${LOCALE_RE}))?\\/search-index\\.json$`))
1087
- if (searchIndexMatch && siteContent) {
1088
- const searchEnabled = searchPluginConfig.enabled !== false && searchDeclaredOn(siteContent)
1089
- if (searchEnabled) {
1090
- const searchConfig = siteContent.config?.search || {}
1091
- const defaultLocale = resolveDefaultLocale(siteContent.config)
1092
- // Use requested locale from URL, fall back to active or default
1093
- const requestedLocale = searchIndexMatch[1]
1094
- const activeLocale = requestedLocale || siteContent.config?.activeLocale || defaultLocale
1095
-
1096
- const pagesIndex = generateSearchIndex(siteContent, {
1097
- locale: activeLocale,
1098
- search: searchConfig
1099
- })
1100
- // The record pages, as the build lists them — the default locale's, whose records these are.
1101
- const searchIndex = activeLocale === defaultLocale
1102
- ? mergeSearchIndexes(pagesIndex, recordProjections(siteContent, { dataRoot: resolvedPublicDir, locale: activeLocale }).indexes)
1103
- : pagesIndex
1104
-
1105
- res.setHeader('Content-Type', 'application/json')
1106
- res.end(JSON.stringify(searchIndex, null, 2))
1107
- return
1108
- }
1109
- }
1110
-
1111
- // Serve the agent projections in dev mode.
1112
- //
1113
- // Without this the artifacts can only be inspected by deploying, and
1114
- // `uniweb doctor`'s address warning describes something invisible
1115
- // locally. Both routes reuse the build's generators, so what a
1116
- // developer sees here is what ships — for the default language.
1117
- // ⚠️ Not for another one (measured 2026-10-25): the build writes it no `llms.txt` or `.md`,
1118
- // and the `.md` route below finds a page by its source route, so the translated links this
1119
- // `llms.txt` gives (`/fr/a-propos.md`) are answered with the page shell.
1120
- if (siteContent) {
1121
- const agents = resolveAgentsConfig(siteContent.config)
1122
- const url = req.url.split('?')[0]
1123
-
1124
- // `/llms.txt`, `/fr/llms.txt`, and the branch form `/docs/llms.txt`.
1125
- // The optional middle group is the branch route; an empty one is the
1126
- // site index, so a single pattern serves both rather than two that
1127
- // could drift.
1128
- const indexMatch = url.match(
1129
- new RegExp(`^(?:\\/(${LOCALE_RE}))?((?:\\/[^/]+)*)\\/${INDEX_FILENAME}$`)
1130
- )
1131
- if (indexMatch && agents.index) {
1132
- const localized = (indexMatch[1] ? await getTranslatedContent(indexMatch[1]) : null) || siteContent
1133
- const branch = indexMatch[2] || null
1134
-
1135
- // Serve a branch index only where the build would emit one, or dev
1136
- // and the built output disagree about which URLs exist.
1137
- const served =
1138
- !branch ||
1139
- (agents.branchIndexes &&
1140
- selectIndexBranches(localized.pages, {
1141
- exclude: agents.exclude,
1142
- minPages: agents.branchMinPages
1143
- }).some(b => b.route === branch))
1144
-
1145
- if (served) {
1146
- // The record pages, as the build lists them — the default locale's, whose records these are.
1147
- const records = localized === siteContent
1148
- ? recordProjections(siteContent, { dataRoot: resolvedPublicDir }).entries
1149
- : undefined
1150
- res.setHeader('Content-Type', 'text/plain; charset=utf-8')
1151
- res.end(
1152
- renderSiteIndex(localized, {
1153
- ...projectionOptions(localized),
1154
- locale: indexMatch[1] || projectionOptions(localized).locale,
1155
- exclude: agents.exclude,
1156
- records,
1157
- branch
1158
- })
1159
- )
1160
- return
1161
- }
1162
- }
1163
-
1164
- const markdownMatch = url.match(new RegExp(`^(?:\\/(${LOCALE_RE}))?\\/(.+)\\.md$`))
1165
- if (markdownMatch && agents.markdown) {
1166
- const localized = (markdownMatch[1] ? await getTranslatedContent(markdownMatch[1]) : null) || siteContent
1167
- const requested = markdownMatch[2] === 'index' ? '/' : `/${markdownMatch[2]}`
1168
- const pages = selectIndexablePages(localized.pages, { exclude: agents.exclude })
1169
- const page = pages.find(p => p.route === requested)
1170
- const markdown = page
1171
- ? renderPageMarkdown(page)
1172
- : localized === siteContent
1173
- ? selectIndexablePages(recordProjections(siteContent, { dataRoot: resolvedPublicDir }).pages, { exclude: agents.exclude })
1174
- .find((record) => routeOnDisk(record.route) === routeOnDisk(requested))?.markdown
1175
- : null
1176
- if (markdown) {
1177
- res.setHeader('Content-Type', 'text/markdown; charset=utf-8')
1178
- res.end(markdown)
1179
- return
1180
- }
1181
- }
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
1182
1028
  }
1183
1029
 
1184
1030
  // Handle localized collection data (e.g., /fr/data/articles.json), and a
@@ -1186,45 +1032,16 @@ export function siteContentPlugin(options = {}) {
1186
1032
  // each, translated the same way.
1187
1033
  const localeDataMatch = req.url.match(new RegExp(`^\\/(${LOCALE_RE})\\/${DATA_DIR}\\/(.+\\.json)$`))
1188
1034
  if (localeDataMatch) {
1189
- const locale = localeDataMatch[1]
1190
- const filename = localeDataMatch[2]
1191
- const sourcePath = join(resolvedSitePath, 'public', DATA_DIR, filename)
1192
-
1193
- if (existsSync(sourcePath)) {
1194
- try {
1195
- const raw = await readFile(sourcePath, 'utf-8')
1196
- const items = JSON.parse(raw)
1197
- const record = filename.includes('/') && items && typeof items === 'object' && !Array.isArray(items)
1198
- if (!Array.isArray(items) && !record) {
1199
- res.setHeader('Content-Type', 'application/json')
1200
- res.end(raw)
1201
- return
1202
- }
1203
- const queryName = record ? filename.slice(0, filename.lastIndexOf('/')) : filename.replace(/\.json$/, '')
1204
-
1205
- // Load collection translations for this locale
1206
- const translations = await loadRecordTranslations(locale) || {}
1207
-
1208
- // Check for free-form translations
1209
- const freeformDir = join(resolvedSitePath, localesDir, 'freeform', locale)
1210
- const hasFreeform = existsSync(freeformDir)
1211
-
1212
- // Translate using the collections module
1213
- const { translateRecordData } = await import('../i18n/records.js')
1214
- const translated = await translateRecordData(items, queryName, resolvedSitePath, {
1215
- locale,
1216
- localesDir: join(resolvedSitePath, localesDir),
1217
- translations,
1218
- freeformEnabled: hasFreeform
1219
- })
1220
-
1035
+ try {
1036
+ const translated = await localizedDataFile(localeDataMatch[1], localeDataMatch[2])
1037
+ if (translated !== null) {
1221
1038
  res.setHeader('Content-Type', 'application/json')
1222
1039
  res.end(JSON.stringify(translated, null, 2))
1223
1040
  return
1224
- } catch (err) {
1225
- console.warn(`[site-content] Failed to serve localized records ${filename}: ${err.message}`)
1226
- // Fall through to Vite's static server
1227
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
1228
1045
  }
1229
1046
  }
1230
1047
 
@@ -1451,40 +1268,14 @@ export function siteContentPlugin(options = {}) {
1451
1268
  // sitemap.xml and robots.txt are the prerender's: it writes them from the pages it wrote,
1452
1269
  // record pages included (`./seo-files.js`).
1453
1270
 
1454
- // The site's record pages — each record of a parametric page — for its search index and its
1455
- // agent projections, as a host makes them (`./record-projections.js`).
1456
- const records = recordProjections(finalContent, {
1457
- dataRoot: resolvedPublicDir,
1458
- locale: finalContent.config?.activeLocale || resolveDefaultLocale(finalContent.config)
1459
- })
1460
-
1461
- // Generate search index if enabled
1462
- const searchEnabled = searchPluginConfig.enabled !== false && searchDeclaredOn(finalContent)
1463
- if (searchEnabled) {
1464
- const searchConfig = finalContent.config?.search || {}
1465
- const defaultLocale = resolveDefaultLocale(finalContent.config)
1466
- const activeLocale = finalContent.config?.activeLocale || defaultLocale
1467
-
1468
- // Generate search index for current locale — its pages, and its records' pages
1469
- const searchIndex = mergeSearchIndexes(
1470
- generateSearchIndex(finalContent, { locale: activeLocale, search: searchConfig }),
1471
- records.indexes
1472
- )
1473
-
1474
- const searchFilename = getSearchIndexFilename(activeLocale, defaultLocale)
1475
- this.emitFile({
1476
- type: 'asset',
1477
- fileName: searchFilename,
1478
- source: JSON.stringify(searchIndex, null, 2)
1479
- })
1480
-
1481
- console.log(`[site-content] Generated ${searchFilename} (${searchIndex.count} entries)`)
1482
- }
1483
-
1484
- // Agent-readable projections: the index (discovery) and per-page
1485
- // markdown (retrieval). Free and on by default; a site opts out under
1486
- // `agents:` in site.yml.
1487
- 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}`)
1488
1279
 
1489
1280
  // A site-relative extension (`/effects/entry.js`) is served from the
1490
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
+ }
@@ -9,13 +9,15 @@
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
11
  *
12
- * ⚠️ For the default language only. Another language's search index is still made from its pages
13
- * alone, before its records are translated (`i18n/index.js::buildLocalizedContent`), and the build
14
- * writes that language no llms.txt or `.md` (measured 2026-10-25 on a site in three languages).
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`).
15
16
  */
16
17
 
17
18
  import { readFileSync } from 'node:fs'
18
19
  import { join } from 'node:path'
20
+ import { resolveDefaultLocale } from '@uniweb/core/locale-config'
19
21
  import { fillRoutePattern, recordHandle } from '@uniweb/core/route-match'
20
22
  import { recordRoutes, generateRecordSearchIndex, renderPageMarkdown } from '@uniweb/projections'
21
23
 
@@ -41,18 +43,35 @@ function readJson(file) {
41
43
  }
42
44
  }
43
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
+
44
57
  /**
45
58
  * The record pages of a site: each query whose records have a page of their own, with its search
46
59
  * index and each record's page — its route, and its markdown (the parametric page's, then the
47
60
  * record).
48
61
  *
49
- * @param {Object} content - one locale's site content
62
+ * @param {Object} content - one language's site content
50
63
  * @param {Object} options
51
- * @param {string} options.dataRoot - the directory a query's compiled `path` (`/data/talks.json`) is under
52
- * @param {string} [options.locale]
53
- * @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 }> }>}
54
70
  */
55
- export function recordProjections(content, { dataRoot, locale } = {}) {
71
+ export async function recordProjections(
72
+ content,
73
+ { read, locale = content?.config?.activeLocale || resolveDefaultLocale(content?.config || {}) } = {}
74
+ ) {
56
75
  const queries = content?.config?.queries || {}
57
76
  const indexes = []
58
77
  const pages = []
@@ -60,19 +79,19 @@ export function recordProjections(content, { dataRoot, locale } = {}) {
60
79
  // No compiled file: a query answered by a records service, or an external one — its records
61
80
  // are not this build's to list.
62
81
  if (!path) continue
63
- const records = readJson(join(dataRoot, path))
82
+ const records = await read(path)
64
83
  if (!Array.isArray(records)) continue
65
84
  indexes.push(generateRecordSearchIndex(name, { ...(queries[name] || {}), pattern, field }, records, locale))
66
85
 
67
86
  const template = (content.pages || []).find((page) => page.route === pattern)
68
87
  if (!template) continue
69
88
  // A record's own file holds it whole; the list holds its brief.
70
- const wholeDir = join(dataRoot, path.replace(/\.json$/, ''))
89
+ const wholeDir = path.replace(/\.json$/, '')
71
90
  for (const brief of records) {
72
91
  const route = fillRoutePattern(pattern, brief, { field: field ?? null })
73
92
  if (!route) continue
74
93
  const handle = recordHandle(brief)
75
- const record = (handle && readJson(join(wholeDir, `${handle}.json`))) || brief
94
+ const record = (handle && (await read(`${wholeDir}/${handle}.json`))) || brief
76
95
  const markdown = renderPageMarkdown(template, { record })
77
96
  if (markdown) pages.push({ route, markdown })
78
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
+ }