@uniweb/build 1.8.5 → 1.9.1

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.
@@ -4,11 +4,10 @@
4
4
  * Collects site content from pages/ directory and injects it into HTML.
5
5
  * Watches for changes in development mode.
6
6
  *
7
- * SEO Features:
8
- * - Generates sitemap.xml from collected pages
9
- * - Generates robots.txt
10
- * - Injects Open Graph, Twitter, and canonical meta tags
11
- * - Supports hreflang for multi-locale sites
7
+ * States where the site lives (`config.url`, `./site-address.js`) and its effective `seo`, which
8
+ * the runtime writes every page's head tags from. No page's tags are written here: the shell is
9
+ * the same for every page, and the prerender writes each page's own (`injectPageContent`). The
10
+ * sitemap and `robots.txt` are the prerender's too, from the pages it wrote (`./seo-files.js`).
12
11
  *
13
12
  * @module @uniweb/build/site
14
13
  *
@@ -20,11 +19,8 @@
20
19
  * siteContentPlugin({
21
20
  * sitePath: './site',
22
21
  * inject: true,
23
- * seo: {
24
- * baseUrl: 'https://example.com',
25
- * defaultImage: '/og-image.png',
26
- * twitterHandle: '@example'
27
- * }
22
+ * // Over `site.yml::seo`, key by key — a staging build's origin, say.
23
+ * seo: { origin: 'https://staging.example.com' }
28
24
  * })
29
25
  * ]
30
26
  * })
@@ -57,6 +53,7 @@ import { jsonForScript } from '@uniweb/core/data-script'
57
53
  // A favicon's MIME type by its extension — the rule a host's page shell types it by too.
58
54
  import { faviconType } from '@uniweb/core/page-shell'
59
55
  import { FONT_LINKS_MARKER } from './head-markers.js'
56
+ import { effectiveSeo, resolveSiteAddress } from './site-address.js'
60
57
  import { collectExtensionAssets, isBrowserAsset } from './emit-extensions.js'
61
58
  import { extensionNameOf, findSiteExtension } from './workspace-extension.js'
62
59
 
@@ -269,7 +266,8 @@ async function processDevSectionFetches(sections, fetchOptions, resolve) {
269
266
  }
270
267
  }
271
268
  }
272
- import { generateSearchIndex, getSearchIndexFilename } from '@uniweb/projections'
269
+ import { generateSearchIndex, getSearchIndexFilename, mergeSearchIndexes } from '@uniweb/projections'
270
+ import { recordProjections, routeOnDisk } from './record-projections.js'
273
271
  import { mergeTranslations } from '../i18n/merge.js'
274
272
  import { dataBlockModels, NO_DATA_MODELS } from '../i18n/data-models.js'
275
273
  import { searchDeclaredOn } from './search-declared.js'
@@ -282,285 +280,6 @@ import { mountDevApi } from '../dev/api-mount.js'
282
280
  * localized page lives.
283
281
  */
284
282
 
285
- /**
286
- * Restrict authored `seo.locales` hreflang entries to locales the payload
287
- * actually serves. `config.languages` is the publishable intersection on
288
- * production builds (the collector filters by publishLanguages), so draft
289
- * locales never get advertised in sitemap/meta hreflang. The default-locale
290
- * entry always survives (it's served at the root regardless).
291
- */
292
- function filterSeoLocales(seoLocales, config) {
293
- if (!Array.isArray(seoLocales) || seoLocales.length === 0) return seoLocales || []
294
- const langs = Array.isArray(config?.languages) ? config.languages : null
295
- if (!langs) return seoLocales
296
- const served = new Set(langs)
297
- return seoLocales.filter((l) => l.default || served.has(l.code))
298
- }
299
-
300
- /**
301
- * Generate sitemap.xml content
302
- */
303
- function generateSitemap(pages, baseUrl, locales = [], routeTranslations = {}) {
304
- const urls = []
305
-
306
- for (const page of pages) {
307
- // Skip pages marked as noindex
308
- if (page.seo?.noindex) continue
309
-
310
- const loc = baseUrl + (page.route === '/' ? '' : page.route)
311
- const lastmod = page.lastModified || new Date().toISOString().split('T')[0]
312
- const changefreq = page.seo?.changefreq || 'weekly'
313
- const priority = page.seo?.priority ?? (page.route === '/' ? '1.0' : '0.8')
314
-
315
- let urlEntry = ` <url>\n <loc>${escapeXml(loc)}</loc>\n <lastmod>${lastmod.split('T')[0]}</lastmod>\n <changefreq>${changefreq}</changefreq>\n <priority>${priority}</priority>`
316
-
317
- // Add hreflang entries for multi-locale sites
318
- if (locales.length > 1) {
319
- for (const locale of locales) {
320
- let localeLoc
321
- if (locale.default) {
322
- localeLoc = loc
323
- } else {
324
- const translatedRoute = page.route === '/' ? '' : applyRouteTranslation(page.route, locale.code, routeTranslations)
325
- localeLoc = `${baseUrl}/${locale.code}${translatedRoute}`
326
- }
327
- urlEntry += `\n <xhtml:link rel="alternate" hreflang="${locale.code}" href="${escapeXml(localeLoc)}" />`
328
- }
329
- // Add x-default pointing to default locale
330
- urlEntry += `\n <xhtml:link rel="alternate" hreflang="x-default" href="${escapeXml(loc)}" />`
331
- }
332
-
333
- urlEntry += '\n </url>'
334
- urls.push(urlEntry)
335
- }
336
-
337
- const xmlnsExtra = locales.length > 1 ? ' xmlns:xhtml="http://www.w3.org/1999/xhtml"' : ''
338
-
339
- return `<?xml version="1.0" encoding="UTF-8"?>
340
- <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"${xmlnsExtra}>
341
- ${urls.join('\n')}
342
- </urlset>`
343
- }
344
-
345
- /**
346
- * Escape special characters for XML
347
- */
348
- function escapeXml(str) {
349
- return str
350
- .replace(/&/g, '&amp;')
351
- .replace(/</g, '&lt;')
352
- .replace(/>/g, '&gt;')
353
- .replace(/"/g, '&quot;')
354
- .replace(/'/g, '&apos;')
355
- }
356
-
357
- /** The three defined Content Signals, in the order they are emitted. */
358
- const CONTENT_SIGNAL_KEYS = ['search', 'ai-input', 'ai-train']
359
-
360
- /**
361
- * Format the `Content-Signal:` directive from `seo.robots.contentSignals`.
362
- *
363
- * Content Signals express what a site permits its content to be *used for*,
364
- * which is a different axis from `Disallow:` — that governs fetching, this
365
- * governs use after fetching. The three defined signals:
366
- *
367
- * - `search` — appear in search results
368
- * - `ai-input` — be retrieved at inference time (RAG, grounding)
369
- * - `ai-train` — be used to train a model
370
- *
371
- * **Emitted only when declared.** There is no default: a preference the site
372
- * owner did not state is not ours to assert, in either direction. An absent
373
- * signal means "unstated", which is not the same as `no`.
374
- *
375
- * Unknown keys are ignored rather than passed through — the vocabulary is a
376
- * closed set, and forwarding an invented signal would produce a directive no
377
- * crawler honors while reading as though it were doing something.
378
- *
379
- * @param {Object|null} signals - e.g. `{ search: true, 'ai-input': true, 'ai-train': false }`
380
- * @returns {string} The directive line, or `''` when nothing is declared
381
- */
382
- export function formatContentSignals(signals) {
383
- if (!signals || typeof signals !== 'object') return ''
384
-
385
- const parts = []
386
- for (const key of CONTENT_SIGNAL_KEYS) {
387
- if (!(key in signals)) continue
388
- const value = signals[key]
389
- const yes = value === true || value === 'yes'
390
- const no = value === false || value === 'no'
391
- if (!yes && !no) continue
392
- parts.push(`${key}=${yes ? 'yes' : 'no'}`)
393
- }
394
-
395
- return parts.length ? `Content-Signal: ${parts.join(', ')}` : ''
396
- }
397
-
398
- /**
399
- * Generate robots.txt content
400
- */
401
- export function generateRobotsTxt(baseUrl, options = {}) {
402
- const {
403
- disallow = [],
404
- allow = [],
405
- crawlDelay = null,
406
- additionalSitemaps = [],
407
- contentSignals = null
408
- } = options
409
-
410
- let content = 'User-agent: *\n'
411
-
412
- const signals = formatContentSignals(contentSignals)
413
- if (signals) {
414
- // Inside the User-agent group, before the rules — the directive applies to
415
- // the group it sits in.
416
- content += `${signals}\n`
417
- }
418
-
419
- for (const path of allow) {
420
- content += `Allow: ${path}\n`
421
- }
422
-
423
- for (const path of disallow) {
424
- content += `Disallow: ${path}\n`
425
- }
426
-
427
- if (crawlDelay) {
428
- content += `Crawl-delay: ${crawlDelay}\n`
429
- }
430
-
431
- content += `\nSitemap: ${baseUrl}/sitemap.xml\n`
432
-
433
- for (const sitemap of additionalSitemaps) {
434
- content += `Sitemap: ${sitemap}\n`
435
- }
436
-
437
- return content
438
- }
439
-
440
- /**
441
- * Generate meta tags for SEO
442
- */
443
- function generateMetaTags(siteContent, seoOptions) {
444
- const { baseUrl, defaultImage, twitterHandle } = seoOptions
445
- const locales = filterSeoLocales(seoOptions.locales, siteContent.config)
446
- const siteConfig = siteContent.config || {}
447
- const tags = []
448
-
449
- const siteName = siteConfig.name || siteConfig.title || ''
450
- const siteDescription = siteConfig.description || ''
451
- const ogImage = siteConfig.image || defaultImage
452
-
453
- // Basic meta
454
- if (siteDescription) {
455
- tags.push(`<meta name="description" content="${escapeHtml(siteDescription)}">`)
456
- }
457
-
458
- // Canonical URL (for homepage)
459
- if (baseUrl) {
460
- tags.push(`<link rel="canonical" href="${baseUrl}/">`)
461
- }
462
-
463
- // Open Graph
464
- tags.push(`<meta property="og:type" content="website">`)
465
- if (siteName) {
466
- tags.push(`<meta property="og:site_name" content="${escapeHtml(siteName)}">`)
467
- tags.push(`<meta property="og:title" content="${escapeHtml(siteName)}">`)
468
- }
469
- if (siteDescription) {
470
- tags.push(`<meta property="og:description" content="${escapeHtml(siteDescription)}">`)
471
- }
472
- if (baseUrl) {
473
- tags.push(`<meta property="og:url" content="${baseUrl}/">`)
474
- }
475
- if (ogImage) {
476
- const imageUrl = ogImage.startsWith('http') ? ogImage : `${baseUrl}${ogImage}`
477
- tags.push(`<meta property="og:image" content="${imageUrl}">`)
478
- }
479
-
480
- // Twitter Card
481
- tags.push(`<meta name="twitter:card" content="summary_large_image">`)
482
- if (twitterHandle) {
483
- tags.push(`<meta name="twitter:site" content="${twitterHandle}">`)
484
- }
485
- if (siteName) {
486
- tags.push(`<meta name="twitter:title" content="${escapeHtml(siteName)}">`)
487
- }
488
- if (siteDescription) {
489
- tags.push(`<meta name="twitter:description" content="${escapeHtml(siteDescription)}">`)
490
- }
491
- if (ogImage) {
492
- const imageUrl = ogImage.startsWith('http') ? ogImage : `${baseUrl}${ogImage}`
493
- tags.push(`<meta name="twitter:image" content="${imageUrl}">`)
494
- }
495
-
496
- // Hreflang for multi-locale sites
497
- if (baseUrl && locales.length > 1) {
498
- for (const locale of locales) {
499
- const href = locale.default ? `${baseUrl}/` : `${baseUrl}/${locale.code}/`
500
- tags.push(`<link rel="alternate" hreflang="${locale.code}" href="${href}">`)
501
- }
502
- tags.push(`<link rel="alternate" hreflang="x-default" href="${baseUrl}/">`)
503
- }
504
-
505
- return tags.join('\n ')
506
- }
507
-
508
- /**
509
- * Escape HTML special characters
510
- */
511
- function escapeHtml(str) {
512
- return str
513
- .replace(/&/g, '&amp;')
514
- .replace(/</g, '&lt;')
515
- .replace(/>/g, '&gt;')
516
- .replace(/"/g, '&quot;')
517
- }
518
-
519
- /**
520
- * The effective `seo` block for a collected site.
521
- *
522
- * ⛔ **`site.yml::seo` IS THE SOURCE. The plugin option is an OVERRIDE.**
523
- * This plugin read `options.seo` alone until 2026-09-17, and nothing ever put
524
- * `site.yml`'s block there: `defineSiteConfig` passes its own `seo` option
525
- * straight through and defaults it to `{}`, while the scaffolded
526
- * `vite.config.js` calls `defineSiteConfig()` with no arguments. So on the
527
- * bundle lane `seo.baseUrl` and `seo.robots` were authored, documented
528
- * (`cli/partials/agents.md`), read by `uniweb doctor` — and dead. The symptom,
529
- * reported from a real site: `seo.baseUrl` set, doctor's
530
- * `agents-index-relative-links` warning cleared, and `uniweb build` / `uniweb
531
- * export` emitting neither `sitemap.xml` nor `robots.txt` while `llms.txt`
532
- * links stayed root-relative.
533
- *
534
- * ⭐ **The data-only path was never wrong** — `build-site-data.js::writeProjections`
535
- * reads `siteContent.config.seo.baseUrl`. Reading the same place here is what
536
- * makes the two lanes unable to disagree, which is the whole reason
537
- * `projectionOptions` exists.
538
- *
539
- * ⚠️ The generators were unit-tested throughout (`tests/robots-content-signals.test.js`
540
- * calls `generateRobotsTxt` directly), which is why a green suite proved
541
- * nothing: the tests supplied the input the wiring never delivered. That is why
542
- * this is an exported function rather than a closure — the RESOLUTION is the
543
- * part that was missing, so it is the part that gets asserted.
544
- *
545
- * A site's `vite.config.js` is CLI scaffolding — strip it and the site is
546
- * unchanged — so a site's SEO must not depend on what it passes. An explicit
547
- * override still wins, key by key, the way `base` does.
548
- *
549
- * @param {Object} [content] - Collected site content (reads `content.config.seo`)
550
- * @param {Object} [overrides] - Caller overrides, merged OVER the site's block
551
- * @returns {{ baseUrl: string, defaultImage: ?string, twitterHandle: ?string, locales: Array, robots: Object }}
552
- */
553
- export function resolveEffectiveSeo(content, overrides = {}) {
554
- const merged = { ...(content?.config?.seo || {}), ...overrides }
555
- return {
556
- baseUrl: merged.baseUrl?.replace(/\/$/, '') || '', // Remove trailing slash
557
- defaultImage: merged.defaultImage || null,
558
- twitterHandle: merged.twitterHandle || null,
559
- locales: merged.locales || [],
560
- robots: merged.robots || {}
561
- }
562
- }
563
-
564
283
  /**
565
284
  * Create the site content plugin
566
285
  *
@@ -571,18 +290,12 @@ export function resolveEffectiveSeo(content, overrides = {}) {
571
290
  * @param {boolean} [options.inject=true] - Inject content into HTML
572
291
  * @param {string} [options.filename='site-content.json'] - Output filename
573
292
  * @param {boolean} [options.watch=true] - Watch for changes in dev mode
574
- * @param {Object} [options.seo] - SEO OVERRIDES, merged OVER `site.yml::seo`
575
- * key by key. The site's own block is the source; this is for a caller that
293
+ * @param {Object} [options.seo] - SEO OVERRIDES, merged OVER `site.yml::seo` key by
294
+ * key into the site's `config.seo`, so the page heads, the sitemap and the agent
295
+ * index all read them. Its keys are `site.yml::seo`'s (`origin`, `image`, `robots`,
296
+ * `twitterHandle`…). The site's own block is the source; this is for a caller that
576
297
  * must override it (a preview build pointing at a staging origin, say).
577
298
  * ⛔ Do not reach for this to make `site.yml::seo` work — it already does.
578
- * @param {string} [options.seo.baseUrl] - Base URL for sitemap and canonical URLs
579
- * @param {string} [options.seo.defaultImage] - Default OG image path
580
- * @param {string} [options.seo.twitterHandle] - Twitter handle for cards
581
- * @param {Array} [options.seo.locales] - Locales for hreflang [{code: 'en', default: true}, {code: 'es'}]
582
- * @param {Object} [options.seo.robots] - robots.txt configuration
583
- * @param {Array} [options.seo.robots.disallow] - Paths to disallow
584
- * @param {Array} [options.seo.robots.allow] - Paths to explicitly allow
585
- * @param {number} [options.seo.robots.crawlDelay] - Crawl delay in seconds
586
299
  * @param {Object} [options.assets] - Asset processing configuration
587
300
  * @param {boolean} [options.assets.process=true] - Process assets in production builds
588
301
  * @param {boolean} [options.assets.convertToWebp=true] - Convert images to WebP
@@ -592,7 +305,6 @@ export function resolveEffectiveSeo(content, overrides = {}) {
592
305
  * @param {boolean} [options.assets.pdfThumbnails=true] - Generate thumbnails for PDFs (requires pdf-lib)
593
306
  * @param {Object} [options.search] - Search index configuration
594
307
  * @param {boolean} [options.search.enabled=true] - Generate search index (uses site.yml config by default)
595
- * @param {string} [options.search.filename='search-index.json'] - Search index filename
596
308
  * @param {string} [options.foundationPath] - Path to foundation directory (for loading theme vars)
597
309
  */
598
310
  export function siteContentPlugin(options = {}) {
@@ -619,8 +331,22 @@ export function siteContentPlugin(options = {}) {
619
331
  pdfThumbnails: assetsConfig.pdfThumbnails !== false // Default true
620
332
  }
621
333
 
622
- /** This plugin's SEO resolution — `site.yml::seo`, with `options.seo` over it. */
623
- const resolveSeo = content => resolveEffectiveSeo(content, seo)
334
+ /**
335
+ * The collected site with its effective `seo` — `site.yml::seo`, with `options.seo` over it — and
336
+ * the address this build states for it (`config.url`, `./site-address.js`), which the runtime
337
+ * reads every absolute URL from, so a page's head, the sitemap and the agent index state the same
338
+ * ones. ⭐ `config.trailingSlash`: this lane writes each page as `<route>/index.html`, which a
339
+ * static host answers at `<route>/` — and at `<route>` with a redirect to it.
340
+ */
341
+ const locate = (content) => {
342
+ const config = content?.config
343
+ if (!config) return content
344
+ config.seo = effectiveSeo(config.seo, seo)
345
+ const url = resolveSiteAddress({ config, base: basePath })
346
+ if (url) config.url = url
347
+ config.trailingSlash = true
348
+ return content
349
+ }
624
350
 
625
351
  // Warn once per build, not once per collection — the dev server re-collects
626
352
  // on every content change and would otherwise repeat this on every keystroke.
@@ -839,7 +565,8 @@ export function siteContentPlugin(options = {}) {
839
565
  function projectionOptions(content) {
840
566
  const defaultLocale = resolveDefaultLocale(content.config)
841
567
  return {
842
- baseUrl: resolveSeo(content).baseUrl,
568
+ // Absolute on the site's address when it is known, else from the base (`buildPageUrl`).
569
+ address: content.config?.url || null,
843
570
  basePath,
844
571
  locale: content.config?.activeLocale || defaultLocale,
845
572
  defaultLocale
@@ -853,15 +580,16 @@ export function siteContentPlugin(options = {}) {
853
580
  * Sits beside the sitemap and search-index emits because it is the same kind
854
581
  * of thing: an artifact derived from the site's content alone.
855
582
  *
856
- * Unlike the SEO artifacts, this does NOT silently skip when `seo.baseUrl`
857
- * is unset — it falls back to root-relative links and `uniweb doctor` warns.
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.
858
585
  * An agent that arrived via the index can still follow a relative link, and
859
586
  * a silently-absent index is the failure mode the whole feature exists to
860
587
  * prevent.
861
588
  *
862
589
  * @param {Object} content - Final site content for this locale
590
+ * @param {Object} [records] - its record pages (`recordProjections`), listed and written as pages
863
591
  */
864
- function emitProjections(content) {
592
+ function emitProjections(content, records = recordProjections(content, { dataRoot: resolvedPublicDir })) {
865
593
  const agents = resolveAgentsConfig(content?.config)
866
594
  if (!agents.index && !agents.markdown) return
867
595
  if (!content?.pages?.length) return
@@ -876,7 +604,7 @@ export function siteContentPlugin(options = {}) {
876
604
  this.emitFile({
877
605
  type: 'asset',
878
606
  fileName: `${localeDir}${INDEX_FILENAME}`,
879
- source: renderSiteIndex(content, { ...options, exclude: agents.exclude })
607
+ source: renderSiteIndex(content, { ...options, exclude: agents.exclude, records: records.entries })
880
608
  })
881
609
  console.log(`[site-content] Generated ${localeDir}${INDEX_FILENAME}`)
882
610
 
@@ -895,6 +623,7 @@ export function siteContentPlugin(options = {}) {
895
623
  source: renderSiteIndex(content, {
896
624
  ...options,
897
625
  exclude: agents.exclude,
626
+ records: records.entries,
898
627
  branch: branch.route
899
628
  })
900
629
  })
@@ -923,6 +652,17 @@ export function siteContentPlugin(options = {}) {
923
652
  })
924
653
  emitted++
925
654
  }
655
+ // Each record's page, under the exclusions a page is held to.
656
+ const listed = new Set(selectIndexablePages(records.pages, { exclude: agents.exclude }).map((page) => page.route))
657
+ for (const { route, markdown } of records.pages) {
658
+ if (!listed.has(route)) continue
659
+ this.emitFile({
660
+ type: 'asset',
661
+ fileName: `${localeDir}${pageMarkdownFilename(routeOnDisk(route))}`,
662
+ source: markdown
663
+ })
664
+ emitted++
665
+ }
926
666
  if (emitted) console.log(`[site-content] Generated ${emitted} page markdown projections`)
927
667
  }
928
668
  }
@@ -987,7 +727,7 @@ export function siteContentPlugin(options = {}) {
987
727
  // pages stay in the graph so in-progress drafts remain previewable.
988
728
  // strict on a production build: a mount that contributes no pages is a
989
729
  // warning while you author and a broken deploy once you ship.
990
- siteContent = withDevApi(await collectForBundle(resolvedSitePath, { foundationPath, dropUnpublished: isProduction, base: basePath, strict: isProduction }))
730
+ siteContent = locate(withDevApi(await collectForBundle(resolvedSitePath, { foundationPath, dropUnpublished: isProduction, base: basePath, strict: isProduction })))
991
731
  dataModelPromise = null
992
732
  headHtml = await loadHeadHtml()
993
733
  console.log(`[site-content] Collected ${siteContent.pages?.length || 0} pages`)
@@ -1123,7 +863,7 @@ export function siteContentPlugin(options = {}) {
1123
863
  rebuildQueries = false
1124
864
  console.log('[site-content] Content changed, rebuilding...')
1125
865
  try {
1126
- siteContent = withDevApi(await collectForBundle(resolvedSitePath, { foundationPath, base: basePath }))
866
+ siteContent = locate(withDevApi(await collectForBundle(resolvedSitePath, { foundationPath, base: basePath })))
1127
867
  dataModelPromise = null
1128
868
  headHtml = await loadHeadHtml()
1129
869
  if (materialize) {
@@ -1336,26 +1076,13 @@ export function siteContentPlugin(options = {}) {
1336
1076
  // If no translations, fall through to serve default content
1337
1077
  }
1338
1078
 
1339
- // Dev serves the same two SEO artifacts the build emits, off the same
1340
- // resolution — so `uniweb dev` and `uniweb build` cannot disagree about
1341
- // whether a site has them.
1342
- const devSeo = resolveSeo(siteContent)
1079
+ // ⛔ No sitemap.xml or robots.txt here: the prerender writes them from the pages it
1080
+ // renders (`./seo-files.js`), and a dev server renders none ahead of a visit.
1343
1081
 
1344
- // Serve sitemap.xml in dev mode
1345
- if (req.url === '/sitemap.xml' && devSeo.baseUrl && siteContent?.pages) {
1346
- res.setHeader('Content-Type', 'application/xml')
1347
- res.end(generateSitemap(siteContent.pages, devSeo.baseUrl, filterSeoLocales(devSeo.locales, siteContent.config), siteContent.config?.i18n?.routeTranslations))
1348
- return
1349
- }
1350
-
1351
- // Serve robots.txt in dev mode
1352
- if (req.url === '/robots.txt' && devSeo.baseUrl) {
1353
- res.setHeader('Content-Type', 'text/plain')
1354
- res.end(generateRobotsTxt(devSeo.baseUrl, devSeo.robots))
1355
- return
1356
- }
1357
-
1358
- // Serve search-index.json in dev mode (supports locale prefixes)
1082
+ // Serve search-index.json in dev mode, under a language's prefix too.
1083
+ // ⚠️ Made from the default language's content whatever the prefix, and with no records for
1084
+ // another language: the build's `/fr/search-index.json` is in French, this one is not
1085
+ // (measured 2026-10-25).
1359
1086
  const searchIndexMatch = req.url.match(new RegExp(`^(?:\\/(${LOCALE_RE}))?\\/search-index\\.json$`))
1360
1087
  if (searchIndexMatch && siteContent) {
1361
1088
  const searchEnabled = searchPluginConfig.enabled !== false && searchDeclaredOn(siteContent)
@@ -1366,10 +1093,14 @@ export function siteContentPlugin(options = {}) {
1366
1093
  const requestedLocale = searchIndexMatch[1]
1367
1094
  const activeLocale = requestedLocale || siteContent.config?.activeLocale || defaultLocale
1368
1095
 
1369
- const searchIndex = generateSearchIndex(siteContent, {
1096
+ const pagesIndex = generateSearchIndex(siteContent, {
1370
1097
  locale: activeLocale,
1371
1098
  search: searchConfig
1372
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
1373
1104
 
1374
1105
  res.setHeader('Content-Type', 'application/json')
1375
1106
  res.end(JSON.stringify(searchIndex, null, 2))
@@ -1380,9 +1111,12 @@ export function siteContentPlugin(options = {}) {
1380
1111
  // Serve the agent projections in dev mode.
1381
1112
  //
1382
1113
  // Without this the artifacts can only be inspected by deploying, and
1383
- // `uniweb doctor`'s baseUrl warning describes something invisible
1114
+ // `uniweb doctor`'s address warning describes something invisible
1384
1115
  // locally. Both routes reuse the build's generators, so what a
1385
- // developer sees here is what ships.
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.
1386
1120
  if (siteContent) {
1387
1121
  const agents = resolveAgentsConfig(siteContent.config)
1388
1122
  const url = req.url.split('?')[0]
@@ -1409,12 +1143,17 @@ export function siteContentPlugin(options = {}) {
1409
1143
  }).some(b => b.route === branch))
1410
1144
 
1411
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
1412
1150
  res.setHeader('Content-Type', 'text/plain; charset=utf-8')
1413
1151
  res.end(
1414
1152
  renderSiteIndex(localized, {
1415
1153
  ...projectionOptions(localized),
1416
1154
  locale: indexMatch[1] || projectionOptions(localized).locale,
1417
1155
  exclude: agents.exclude,
1156
+ records,
1418
1157
  branch
1419
1158
  })
1420
1159
  )
@@ -1428,13 +1167,16 @@ export function siteContentPlugin(options = {}) {
1428
1167
  const requested = markdownMatch[2] === 'index' ? '/' : `/${markdownMatch[2]}`
1429
1168
  const pages = selectIndexablePages(localized.pages, { exclude: agents.exclude })
1430
1169
  const page = pages.find(p => p.route === requested)
1431
- if (page) {
1432
- const markdown = renderPageMarkdown(page)
1433
- if (markdown) {
1434
- res.setHeader('Content-Type', 'text/markdown; charset=utf-8')
1435
- res.end(markdown)
1436
- return
1437
- }
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
1438
1180
  }
1439
1181
  }
1440
1182
  }
@@ -1553,14 +1295,9 @@ export function siteContentPlugin(options = {}) {
1553
1295
  headInjection += ` <style id="uniweb-theme">\n${contentToInject.theme.css}\n </style>\n`
1554
1296
  }
1555
1297
 
1556
- // Inject SEO meta tags
1557
- const injectSeo = resolveSeo(contentToInject)
1558
- if (injectSeo.baseUrl) {
1559
- const metaTags = generateMetaTags(contentToInject, injectSeo)
1560
- if (metaTags) {
1561
- headInjection += ` ${metaTags}\n`
1562
- }
1563
- }
1298
+ // ⛔ No page's head tags — canonical, Open Graph, hreflang — go into the shell, which every
1299
+ // page is rendered into: the prerender writes each page's own (`injectPageContent`), and the
1300
+ // browser keeps them (`useHeadMeta`). Until 2026-10-25 the home page's were written here.
1564
1301
 
1565
1302
  // Inject favicon link
1566
1303
  // Source 1: contentToInject.config.favicon (matches Cloudflare publish schema)
@@ -1711,27 +1448,15 @@ export function siteContentPlugin(options = {}) {
1711
1448
  source: JSON.stringify(finalContent, null, 2)
1712
1449
  })
1713
1450
 
1714
- // Generate SEO files if enabled
1715
- const buildSeo = resolveSeo(finalContent)
1716
- if (buildSeo.baseUrl && finalContent?.pages) {
1717
- // Generate sitemap.xml
1718
- const sitemap = generateSitemap(finalContent.pages, buildSeo.baseUrl, filterSeoLocales(buildSeo.locales, finalContent.config), finalContent.config?.i18n?.routeTranslations)
1719
- this.emitFile({
1720
- type: 'asset',
1721
- fileName: 'sitemap.xml',
1722
- source: sitemap
1723
- })
1724
- console.log('[site-content] Generated sitemap.xml')
1451
+ // sitemap.xml and robots.txt are the prerender's: it writes them from the pages it wrote,
1452
+ // record pages included (`./seo-files.js`).
1725
1453
 
1726
- // Generate robots.txt
1727
- const robotsTxt = generateRobotsTxt(buildSeo.baseUrl, buildSeo.robots)
1728
- this.emitFile({
1729
- type: 'asset',
1730
- fileName: 'robots.txt',
1731
- source: robotsTxt
1732
- })
1733
- console.log('[site-content] Generated robots.txt')
1734
- }
1454
+ // The site's record pages — each record of a parametric page — for its search index and its
1455
+ // agent projections, as a host makes them (`./record-projections.js`).
1456
+ const records = recordProjections(finalContent, {
1457
+ dataRoot: resolvedPublicDir,
1458
+ locale: finalContent.config?.activeLocale || resolveDefaultLocale(finalContent.config)
1459
+ })
1735
1460
 
1736
1461
  // Generate search index if enabled
1737
1462
  const searchEnabled = searchPluginConfig.enabled !== false && searchDeclaredOn(finalContent)
@@ -1740,11 +1465,11 @@ export function siteContentPlugin(options = {}) {
1740
1465
  const defaultLocale = resolveDefaultLocale(finalContent.config)
1741
1466
  const activeLocale = finalContent.config?.activeLocale || defaultLocale
1742
1467
 
1743
- // Generate search index for current locale
1744
- const searchIndex = generateSearchIndex(finalContent, {
1745
- locale: activeLocale,
1746
- search: searchConfig
1747
- })
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
+ )
1748
1473
 
1749
1474
  const searchFilename = getSearchIndexFilename(activeLocale, defaultLocale)
1750
1475
  this.emitFile({
@@ -1759,7 +1484,7 @@ export function siteContentPlugin(options = {}) {
1759
1484
  // Agent-readable projections: the index (discovery) and per-page
1760
1485
  // markdown (retrieval). Free and on by default; a site opts out under
1761
1486
  // `agents:` in site.yml.
1762
- emitProjections.call(this, finalContent)
1487
+ emitProjections.call(this, finalContent, records)
1763
1488
 
1764
1489
  // A site-relative extension (`/effects/entry.js`) is served from the
1765
1490
  // site's OWN origin, so the site's build is what has to put it there.