@uniweb/build 1.8.5 → 1.9.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.
@@ -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
@@ -619,8 +332,22 @@ export function siteContentPlugin(options = {}) {
619
332
  pdfThumbnails: assetsConfig.pdfThumbnails !== false // Default true
620
333
  }
621
334
 
622
- /** This plugin's SEO resolution — `site.yml::seo`, with `options.seo` over it. */
623
- const resolveSeo = content => resolveEffectiveSeo(content, seo)
335
+ /**
336
+ * The collected site with its effective `seo` — `site.yml::seo`, with `options.seo` over it — and
337
+ * the address this build states for it (`config.url`, `./site-address.js`), which the runtime
338
+ * reads every absolute URL from, so a page's head, the sitemap and the agent index state the same
339
+ * ones. ⭐ `config.trailingSlash`: this lane writes each page as `<route>/index.html`, which a
340
+ * static host answers at `<route>/` — and at `<route>` with a redirect to it.
341
+ */
342
+ const locate = (content) => {
343
+ const config = content?.config
344
+ if (!config) return content
345
+ config.seo = effectiveSeo(config.seo, seo)
346
+ const url = resolveSiteAddress({ config, base: basePath })
347
+ if (url) config.url = url
348
+ config.trailingSlash = true
349
+ return content
350
+ }
624
351
 
625
352
  // Warn once per build, not once per collection — the dev server re-collects
626
353
  // on every content change and would otherwise repeat this on every keystroke.
@@ -839,7 +566,8 @@ export function siteContentPlugin(options = {}) {
839
566
  function projectionOptions(content) {
840
567
  const defaultLocale = resolveDefaultLocale(content.config)
841
568
  return {
842
- baseUrl: resolveSeo(content).baseUrl,
569
+ // Absolute on the site's address when it is known, else from the base (`buildPageUrl`).
570
+ address: content.config?.url || null,
843
571
  basePath,
844
572
  locale: content.config?.activeLocale || defaultLocale,
845
573
  defaultLocale
@@ -853,15 +581,16 @@ export function siteContentPlugin(options = {}) {
853
581
  * Sits beside the sitemap and search-index emits because it is the same kind
854
582
  * of thing: an artifact derived from the site's content alone.
855
583
  *
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.
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.
858
586
  * An agent that arrived via the index can still follow a relative link, and
859
587
  * a silently-absent index is the failure mode the whole feature exists to
860
588
  * prevent.
861
589
  *
862
590
  * @param {Object} content - Final site content for this locale
591
+ * @param {Object} [records] - its record pages (`recordProjections`), listed and written as pages
863
592
  */
864
- function emitProjections(content) {
593
+ function emitProjections(content, records = recordProjections(content, { dataRoot: resolvedPublicDir })) {
865
594
  const agents = resolveAgentsConfig(content?.config)
866
595
  if (!agents.index && !agents.markdown) return
867
596
  if (!content?.pages?.length) return
@@ -876,7 +605,7 @@ export function siteContentPlugin(options = {}) {
876
605
  this.emitFile({
877
606
  type: 'asset',
878
607
  fileName: `${localeDir}${INDEX_FILENAME}`,
879
- source: renderSiteIndex(content, { ...options, exclude: agents.exclude })
608
+ source: renderSiteIndex(content, { ...options, exclude: agents.exclude, records: records.entries })
880
609
  })
881
610
  console.log(`[site-content] Generated ${localeDir}${INDEX_FILENAME}`)
882
611
 
@@ -895,6 +624,7 @@ export function siteContentPlugin(options = {}) {
895
624
  source: renderSiteIndex(content, {
896
625
  ...options,
897
626
  exclude: agents.exclude,
627
+ records: records.entries,
898
628
  branch: branch.route
899
629
  })
900
630
  })
@@ -923,6 +653,17 @@ export function siteContentPlugin(options = {}) {
923
653
  })
924
654
  emitted++
925
655
  }
656
+ // Each record's page, under the exclusions a page is held to.
657
+ const listed = new Set(selectIndexablePages(records.pages, { exclude: agents.exclude }).map((page) => page.route))
658
+ for (const { route, markdown } of records.pages) {
659
+ if (!listed.has(route)) continue
660
+ this.emitFile({
661
+ type: 'asset',
662
+ fileName: `${localeDir}${pageMarkdownFilename(routeOnDisk(route))}`,
663
+ source: markdown
664
+ })
665
+ emitted++
666
+ }
926
667
  if (emitted) console.log(`[site-content] Generated ${emitted} page markdown projections`)
927
668
  }
928
669
  }
@@ -987,7 +728,7 @@ export function siteContentPlugin(options = {}) {
987
728
  // pages stay in the graph so in-progress drafts remain previewable.
988
729
  // strict on a production build: a mount that contributes no pages is a
989
730
  // warning while you author and a broken deploy once you ship.
990
- siteContent = withDevApi(await collectForBundle(resolvedSitePath, { foundationPath, dropUnpublished: isProduction, base: basePath, strict: isProduction }))
731
+ siteContent = locate(withDevApi(await collectForBundle(resolvedSitePath, { foundationPath, dropUnpublished: isProduction, base: basePath, strict: isProduction })))
991
732
  dataModelPromise = null
992
733
  headHtml = await loadHeadHtml()
993
734
  console.log(`[site-content] Collected ${siteContent.pages?.length || 0} pages`)
@@ -1123,7 +864,7 @@ export function siteContentPlugin(options = {}) {
1123
864
  rebuildQueries = false
1124
865
  console.log('[site-content] Content changed, rebuilding...')
1125
866
  try {
1126
- siteContent = withDevApi(await collectForBundle(resolvedSitePath, { foundationPath, base: basePath }))
867
+ siteContent = locate(withDevApi(await collectForBundle(resolvedSitePath, { foundationPath, base: basePath })))
1127
868
  dataModelPromise = null
1128
869
  headHtml = await loadHeadHtml()
1129
870
  if (materialize) {
@@ -1336,24 +1077,8 @@ export function siteContentPlugin(options = {}) {
1336
1077
  // If no translations, fall through to serve default content
1337
1078
  }
1338
1079
 
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)
1343
-
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
- }
1080
+ // ⛔ No sitemap.xml or robots.txt here: the prerender writes them from the pages it
1081
+ // renders (`./seo-files.js`), and a dev server renders none ahead of a visit.
1357
1082
 
1358
1083
  // Serve search-index.json in dev mode (supports locale prefixes)
1359
1084
  const searchIndexMatch = req.url.match(new RegExp(`^(?:\\/(${LOCALE_RE}))?\\/search-index\\.json$`))
@@ -1366,10 +1091,14 @@ export function siteContentPlugin(options = {}) {
1366
1091
  const requestedLocale = searchIndexMatch[1]
1367
1092
  const activeLocale = requestedLocale || siteContent.config?.activeLocale || defaultLocale
1368
1093
 
1369
- const searchIndex = generateSearchIndex(siteContent, {
1094
+ const pagesIndex = generateSearchIndex(siteContent, {
1370
1095
  locale: activeLocale,
1371
1096
  search: searchConfig
1372
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
1373
1102
 
1374
1103
  res.setHeader('Content-Type', 'application/json')
1375
1104
  res.end(JSON.stringify(searchIndex, null, 2))
@@ -1380,7 +1109,7 @@ export function siteContentPlugin(options = {}) {
1380
1109
  // Serve the agent projections in dev mode.
1381
1110
  //
1382
1111
  // Without this the artifacts can only be inspected by deploying, and
1383
- // `uniweb doctor`'s baseUrl warning describes something invisible
1112
+ // `uniweb doctor`'s address warning describes something invisible
1384
1113
  // locally. Both routes reuse the build's generators, so what a
1385
1114
  // developer sees here is what ships.
1386
1115
  if (siteContent) {
@@ -1409,12 +1138,17 @@ export function siteContentPlugin(options = {}) {
1409
1138
  }).some(b => b.route === branch))
1410
1139
 
1411
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
1412
1145
  res.setHeader('Content-Type', 'text/plain; charset=utf-8')
1413
1146
  res.end(
1414
1147
  renderSiteIndex(localized, {
1415
1148
  ...projectionOptions(localized),
1416
1149
  locale: indexMatch[1] || projectionOptions(localized).locale,
1417
1150
  exclude: agents.exclude,
1151
+ records,
1418
1152
  branch
1419
1153
  })
1420
1154
  )
@@ -1428,13 +1162,16 @@ export function siteContentPlugin(options = {}) {
1428
1162
  const requested = markdownMatch[2] === 'index' ? '/' : `/${markdownMatch[2]}`
1429
1163
  const pages = selectIndexablePages(localized.pages, { exclude: agents.exclude })
1430
1164
  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
- }
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
1438
1175
  }
1439
1176
  }
1440
1177
  }
@@ -1553,14 +1290,9 @@ export function siteContentPlugin(options = {}) {
1553
1290
  headInjection += ` <style id="uniweb-theme">\n${contentToInject.theme.css}\n </style>\n`
1554
1291
  }
1555
1292
 
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
- }
1293
+ // ⛔ No page's head tags — canonical, Open Graph, hreflang — go into the shell, which every
1294
+ // page is rendered into: the prerender writes each page's own (`injectPageContent`), and the
1295
+ // browser keeps them (`useHeadMeta`). Until 2026-10-25 the home page's were written here.
1564
1296
 
1565
1297
  // Inject favicon link
1566
1298
  // Source 1: contentToInject.config.favicon (matches Cloudflare publish schema)
@@ -1711,27 +1443,15 @@ export function siteContentPlugin(options = {}) {
1711
1443
  source: JSON.stringify(finalContent, null, 2)
1712
1444
  })
1713
1445
 
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')
1446
+ // sitemap.xml and robots.txt are the prerender's: it writes them from the pages it wrote,
1447
+ // record pages included (`./seo-files.js`).
1725
1448
 
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
- }
1449
+ // The site's record pages — each record of a parametric page — for its search index and its
1450
+ // agent projections, as a host makes them (`./record-projections.js`).
1451
+ const records = recordProjections(finalContent, {
1452
+ dataRoot: resolvedPublicDir,
1453
+ locale: finalContent.config?.activeLocale || resolveDefaultLocale(finalContent.config)
1454
+ })
1735
1455
 
1736
1456
  // Generate search index if enabled
1737
1457
  const searchEnabled = searchPluginConfig.enabled !== false && searchDeclaredOn(finalContent)
@@ -1740,11 +1460,11 @@ export function siteContentPlugin(options = {}) {
1740
1460
  const defaultLocale = resolveDefaultLocale(finalContent.config)
1741
1461
  const activeLocale = finalContent.config?.activeLocale || defaultLocale
1742
1462
 
1743
- // Generate search index for current locale
1744
- const searchIndex = generateSearchIndex(finalContent, {
1745
- locale: activeLocale,
1746
- search: searchConfig
1747
- })
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
+ )
1748
1468
 
1749
1469
  const searchFilename = getSearchIndexFilename(activeLocale, defaultLocale)
1750
1470
  this.emitFile({
@@ -1759,7 +1479,7 @@ export function siteContentPlugin(options = {}) {
1759
1479
  // Agent-readable projections: the index (discovery) and per-page
1760
1480
  // markdown (retrieval). Free and on by default; a site opts out under
1761
1481
  // `agents:` in site.yml.
1762
- emitProjections.call(this, finalContent)
1482
+ emitProjections.call(this, finalContent, records)
1763
1483
 
1764
1484
  // A site-relative extension (`/effects/entry.js`) is served from the
1765
1485
  // site's OWN origin, so the site's build is what has to put it there.