@uniweb/build 1.8.4 → 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.
- package/package.json +8 -8
- package/src/hosts/detect-ci-context.js +17 -1
- package/src/hosts/github-pages.js +32 -32
- package/src/i18n/audit.js +0 -14
- package/src/i18n/extract.js +4 -16
- package/src/i18n/freeform.js +1 -19
- package/src/i18n/merge.js +1 -12
- package/src/i18n/records.js +6 -10
- package/src/i18n/sync.js +14 -6
- package/src/prerender.js +30 -2
- package/src/site/build-site-data.js +16 -5
- package/src/site/config.js +10 -1
- package/src/site/content-collector.js +4 -20
- package/src/site/dev-server.js +263 -0
- package/src/site/index.js +4 -0
- package/src/site/plugin.js +90 -370
- package/src/site/query-processor.js +11 -27
- package/src/site/record-projections.js +77 -0
- package/src/site/seo-files.js +190 -0
- package/src/site/site-address.js +127 -0
- package/src/uwx/site.js +2 -0
package/src/site/plugin.js
CHANGED
|
@@ -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
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
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
|
-
*
|
|
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, '&')
|
|
351
|
-
.replace(/</g, '<')
|
|
352
|
-
.replace(/>/g, '>')
|
|
353
|
-
.replace(/"/g, '"')
|
|
354
|
-
.replace(/'/g, ''')
|
|
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, '&')
|
|
514
|
-
.replace(/</g, '<')
|
|
515
|
-
.replace(/>/g, '>')
|
|
516
|
-
.replace(/"/g, '"')
|
|
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
|
|
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
|
-
/**
|
|
623
|
-
|
|
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
|
-
|
|
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
|
|
857
|
-
*
|
|
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
|
-
//
|
|
1340
|
-
//
|
|
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
|
|
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
|
|
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
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
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
|
-
//
|
|
1557
|
-
|
|
1558
|
-
|
|
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
|
-
//
|
|
1715
|
-
|
|
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
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
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 =
|
|
1745
|
-
locale: activeLocale,
|
|
1746
|
-
|
|
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.
|