@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 +8 -8
- package/src/i18n/index.js +72 -18
- package/src/prerender.js +29 -14
- package/src/site/build-site-data.js +11 -91
- package/src/site/config.js +2 -3
- package/src/site/plugin.js +91 -295
- package/src/site/projections.js +188 -0
- package/src/site/record-projections.js +31 -8
- package/src/site/search-declared.js +18 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uniweb/build",
|
|
3
|
-
"version": "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,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.
|
|
57
|
-
"sharp": "^0.35.
|
|
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.
|
|
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/
|
|
77
|
-
"@uniweb/
|
|
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 {
|
|
30
|
-
import {
|
|
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
|
|
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 {
|
|
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
|
|
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.
|
|
236
|
-
//
|
|
237
|
-
//
|
|
238
|
-
//
|
|
239
|
-
//
|
|
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
|
-
}
|
package/src/site/config.js
CHANGED
|
@@ -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
|
|
package/src/site/plugin.js
CHANGED
|
@@ -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 {
|
|
270
|
-
import {
|
|
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 {
|
|
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
|
|
485
|
-
* and again whenever what a query file is made from
|
|
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
|
-
*
|
|
564
|
-
*
|
|
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
|
|
567
|
-
const
|
|
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
|
-
*
|
|
579
|
-
*
|
|
580
|
-
*
|
|
581
|
-
*
|
|
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
|
|
594
|
-
const
|
|
595
|
-
if (!
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
const
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
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
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
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
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
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
|
|
1450
|
-
//
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
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
|
|
62
|
+
* @param {Object} content - one language's site content
|
|
46
63
|
* @param {Object} options
|
|
47
|
-
* @param {string} options.
|
|
48
|
-
*
|
|
49
|
-
*
|
|
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(
|
|
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 =
|
|
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 =
|
|
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 &&
|
|
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
|
+
}
|