@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 +7 -7
- package/src/i18n/index.js +72 -18
- package/src/site/build-site-data.js +11 -91
- package/src/site/config.js +2 -3
- package/src/site/plugin.js +91 -300
- package/src/site/projections.js +188 -0
- package/src/site/record-projections.js +30 -11
- 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,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.
|
|
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.
|
|
59
|
+
"@uniweb/content-reader": "~1.5.7",
|
|
60
|
+
"@uniweb/content-writer": "~1.3.1",
|
|
60
61
|
"@uniweb/projections": "~1.2.0",
|
|
61
|
-
"@uniweb/
|
|
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 {
|
|
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
|
|
@@ -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,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
|
|
484
|
-
* and again whenever what a query file is made from
|
|
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
|
-
*
|
|
563
|
-
*
|
|
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
|
|
566
|
-
const
|
|
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
|
-
*
|
|
578
|
-
*
|
|
579
|
-
*
|
|
580
|
-
*
|
|
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
|
|
593
|
-
const
|
|
594
|
-
if (!
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
const
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
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
|
-
|
|
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
|
-
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
|
-
|
|
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
|
-
//
|
|
1083
|
-
//
|
|
1084
|
-
//
|
|
1085
|
-
//
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
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
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
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
|
|
1455
|
-
//
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
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
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
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
|
|
62
|
+
* @param {Object} content - one language's site content
|
|
50
63
|
* @param {Object} options
|
|
51
|
-
* @param {string} options.
|
|
52
|
-
*
|
|
53
|
-
*
|
|
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(
|
|
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 =
|
|
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 =
|
|
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 &&
|
|
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
|
+
}
|