@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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniweb/build",
3
- "version": "1.8.5",
3
+ "version": "1.9.1",
4
4
  "description": "Build tooling for the Uniweb Component Web Platform",
5
5
  "type": "module",
6
6
  "exports": {
@@ -57,14 +57,14 @@
57
57
  "sharp": "^0.35.3",
58
58
  "yaml": "^2.5.0",
59
59
  "@uniweb/content-reader": "~1.5.6",
60
+ "@uniweb/projections": "~1.2.0",
60
61
  "@uniweb/content-writer": "~1.3.0",
61
62
  "@uniweb/schemas": "~1.6.5",
62
63
  "@uniweb/semantic-parser": "~1.13.0",
63
- "@uniweb/projections": "~1.1.7",
64
64
  "@uniweb/theming": "~1.2.1"
65
65
  },
66
66
  "optionalDependencies": {
67
- "@uniweb/runtime": "~1.1.19"
67
+ "@uniweb/runtime": "~1.2.2"
68
68
  },
69
69
  "peerDependencies": {
70
70
  "vite": "^5.0.0 || ^6.0.0 || ^7.0.0",
@@ -73,8 +73,8 @@
73
73
  "@tailwindcss/vite": "^4.0.0",
74
74
  "@vitejs/plugin-react": "^4.0.0 || ^5.0.0",
75
75
  "vite-plugin-svgr": "^4.0.0",
76
- "@uniweb/core": "~1.4.4",
77
- "@uniweb/runtime": "~1.1.19"
76
+ "@uniweb/runtime": "~1.2.2",
77
+ "@uniweb/core": "~1.4.6"
78
78
  },
79
79
  "peerDependenciesMeta": {
80
80
  "vite": {
@@ -36,7 +36,14 @@
36
36
  * @property {string|null} sha
37
37
  * @property {boolean|null} isProduction Tri-state: true / false / null (unknown).
38
38
  * @property {string|null} publicUrl Host-assigned URL for this deploy, when
39
- * known. Useful for sitemap / canonical.
39
+ * known — a preview's own, not the site's.
40
+ * @property {string|null} origin The origin the host serves the site's
41
+ * PRODUCTION at, when it says — its custom
42
+ * domain if one is set — even on a preview,
43
+ * so a preview names production as its
44
+ * canonical. A canonical is made on it
45
+ * (`../site/site-address.js`); `publicUrl`
46
+ * is no address for one.
40
47
  * @property {string|null} deploymentId
41
48
  */
42
49
 
@@ -53,6 +60,9 @@ export function detectCiContext(env = process.env) {
53
60
  sha: env.VERCEL_GIT_COMMIT_SHA || null,
54
61
  isProduction: env.VERCEL_ENV ? env.VERCEL_ENV === 'production' : null,
55
62
  publicUrl: env.VERCEL_URL ? `https://${env.VERCEL_URL}` : null,
63
+ // "The shortest production custom domain, or vercel.app domain … always set, even in
64
+ // preview deployments", with no scheme (Vercel's system environment variables).
65
+ origin: env.VERCEL_PROJECT_PRODUCTION_URL ? `https://${env.VERCEL_PROJECT_PRODUCTION_URL}` : null,
56
66
  deploymentId: env.VERCEL_DEPLOYMENT_ID || null,
57
67
  }
58
68
  }
@@ -68,6 +78,8 @@ export function detectCiContext(env = process.env) {
68
78
  // can falsify; leave it null.
69
79
  isProduction: null,
70
80
  publicUrl: env.CF_PAGES_URL || null,
81
+ // A deploy's own URL is all CF Pages states; its production domain it does not.
82
+ origin: null,
71
83
  deploymentId: null,
72
84
  }
73
85
  }
@@ -82,6 +94,8 @@ export function detectCiContext(env = process.env) {
82
94
  sha: env.COMMIT_REF || null,
83
95
  isProduction: env.CONTEXT ? env.CONTEXT === 'production' : null,
84
96
  publicUrl: env.DEPLOY_PRIME_URL || env.URL || null,
97
+ // "The main address to your site … your own custom domain if you set one" (Netlify).
98
+ origin: env.URL || null,
85
99
  deploymentId: env.DEPLOY_ID || null,
86
100
  }
87
101
  }
@@ -95,6 +109,8 @@ export function detectCiContext(env = process.env) {
95
109
  sha: env.GITHUB_SHA || null,
96
110
  isProduction: null,
97
111
  publicUrl: null,
112
+ // The Pages workflow passes what GitHub states (`UNIWEB_ORIGIN`); a runner implies no site.
113
+ origin: null,
98
114
  deploymentId: env.GITHUB_RUN_ID || null,
99
115
  }
100
116
  }
@@ -34,18 +34,21 @@
34
34
  * working tree is never touched, and a normal (non-force) commit so
35
35
  * the branch history stays intact and revertible.
36
36
  *
37
- * UNIWEB_BASE is decided at workflow scaffold time based on the deploy
38
- * target, and becomes the site's base path:
37
+ * ⭐ GITHUB SAYS WHERE IT SERVES THE SITE, and the workflow hands that to the
38
+ * build: the site's origin (`UNIWEB_ORIGIN`) and base path (`UNIWEB_BASE`),
39
+ * which every absolute URL the site states is made from — a page's canonical,
40
+ * its sitemap entry, its agent index link (`../site/site-address.js`):
39
41
  *
40
- * - With `domain`: bakes `UNIWEB_BASE: /` and emits a CNAME file under
41
- * `<sitePath>/public/`. GitHub Pages serves the custom domain at
42
- * root, so no `/repo/` prefix.
43
- * - Without `domain`: derives UNIWEB_BASE from the GitHub repo name at
44
- * workflow runtime. A small bash block special-cases the
45
- * `<user>.github.io` profile-repo shape (served at root).
42
+ * - Without `domain`: from `actions/configure-pages`, which reports the Pages
43
+ * site's origin and base path — `<owner>.github.io` and `/<repo>`, or a
44
+ * custom domain set in Settings → Pages at its root. ⛔ Until 2026-10-25 a
45
+ * bash block guessed the base from the repo name and nothing stated the
46
+ * origin, so a site under `/<repo>/` had no address to state its URLs on.
47
+ * - With `domain`: the domain the developer named, at its root, and a CNAME
48
+ * file under `<sitePath>/public/` — stated, because GitHub reports a custom
49
+ * domain only once a deploy has configured it.
46
50
  *
47
- * site.yml stays clean either way — UNIWEB_BASE only kicks in for this
48
- * deploy target.
51
+ * site.yml stays clean either way — it needs neither a base nor `seo.origin`.
49
52
  */
50
53
 
51
54
  import { writeFile, mkdtemp, rm, cp, readdir } from 'node:fs/promises'
@@ -322,9 +325,10 @@ const adapter = {
322
325
  `On GitHub: Settings → Pages → Source: GitHub Actions.`,
323
326
  `The deploy runs automatically on every push to that branch.`,
324
327
  ``,
325
- `Note: the workflow derives UNIWEB_BASE from the repo name at build`,
326
- `time (e.g., /<repo>/) so site.yml stays clean. For a custom domain,`,
327
- `re-run with --domain=<your-domain.com> to switch to root-served mode.`,
328
+ `Note: the workflow takes the site's address from GitHub at build time`,
329
+ `(<owner>.github.io/<repo>/, or a custom domain set in Settings → Pages),`,
330
+ `so site.yml needs neither a base nor seo.origin. To name a custom domain`,
331
+ `here instead, re-run with --domain=<your-domain.com>.`,
328
332
  ]
329
333
 
330
334
  // Adapter-specific config persisted into deploy.yml under
@@ -478,25 +482,22 @@ function renderWorkflow({ sitePath, packageManager, nodeVersion, pnpmVersion, do
478
482
  const setup = setupSteps({ packageManager, nodeVersion, pnpmVersion })
479
483
  const buildCmd = uniwebBuildCommand({ packageManager, host: 'github-pages' })
480
484
 
481
- // Two shapes:
482
- // - Custom domain: UNIWEB_BASE is hardcoded to '/' (GH Pages serves
483
- // the custom domain at root).
484
- // - Project repo: derive UNIWEB_BASE from the repo name. The bash
485
- // block special-cases the <user>.github.io profile-repo shape
486
- // (also served at root).
487
- const baseStep = domain
488
- ? ` - run: ${buildCmd}
485
+ // Where GitHub serves the site, for the build to state its URLs on (`UNIWEB_ORIGIN`,
486
+ // `UNIWEB_BASE`): the domain the developer named, at its root — or what GitHub reports.
487
+ const pagesStep = domain
488
+ ? ` - uses: actions/configure-pages@v5
489
+ - run: ${buildCmd}
489
490
  env:
491
+ UNIWEB_ORIGIN: https://${domain}
490
492
  UNIWEB_BASE: /`
491
- : ` - name: Resolve UNIWEB_BASE for this repo shape
492
- run: |
493
- REPO='\${{ github.event.repository.name }}'
494
- if [[ "$REPO" == *.github.io ]]; then
495
- echo "UNIWEB_BASE=/" >> $GITHUB_ENV
496
- else
497
- echo "UNIWEB_BASE=/$REPO/" >> $GITHUB_ENV
498
- fi
499
- - run: ${buildCmd}`
493
+ : ` # GitHub reports where it serves the site: <owner>.github.io/<repo>, or a
494
+ # custom domain set in Settings → Pages. The build states its URLs on that.
495
+ - id: pages
496
+ uses: actions/configure-pages@v5
497
+ - run: ${buildCmd}
498
+ env:
499
+ UNIWEB_ORIGIN: \${{ steps.pages.outputs.origin }}
500
+ UNIWEB_BASE: \${{ steps.pages.outputs.base_path }}/`
500
501
 
501
502
  return `${workflowHeader({
502
503
  title: 'Deploy to GitHub Pages',
@@ -524,8 +525,7 @@ jobs:
524
525
  url: \${{ steps.deployment.outputs.page_url }}
525
526
  steps:
526
527
  ${setup}
527
- ${baseStep}
528
- - uses: actions/configure-pages@v5
528
+ ${pagesStep}
529
529
  - uses: actions/upload-pages-artifact@v3
530
530
  with:
531
531
  path: ${sitePath}/dist
package/src/i18n/audit.js CHANGED
@@ -12,17 +12,6 @@ import { existsSync } from 'node:fs'
12
12
  import { join } from 'node:path'
13
13
  import { translationKeepsMarkup } from './merge.js'
14
14
 
15
- // Inline markdown → ProseMirror, so the markup check asks the merge's own question
16
- // ("would this value produce marks?") instead of pattern-matching markdown syntax.
17
- // Same lazy-import-with-fallback pattern as merge.js / extract.js.
18
- let markdownToProseMirror
19
- try {
20
- const contentReader = await import('@uniweb/content-reader')
21
- markdownToProseMirror = contentReader.markdownToProseMirror
22
- } catch {
23
- markdownToProseMirror = null
24
- }
25
-
26
15
  /**
27
16
  * Audit a locale file against the manifest
28
17
  * @param {string} localesPath - Path to locales directory
@@ -118,11 +107,8 @@ export async function auditLocale(localesPath, locale) {
118
107
  *
119
108
  * Asks the merge rather than a regex (`translationKeepsMarkup`), since what the
120
109
  * merge makes of a value is the only honest answer to "will this keep its link".
121
- * With no converter available the check declines to fire — a missing dependency
122
- * must not invent findings.
123
110
  */
124
111
  function dropsSourceMarkup(markup, value) {
125
- if (!markdownToProseMirror) return false
126
112
  try {
127
113
  return !translationKeepsMarkup(markup, value)
128
114
  } catch {
@@ -8,19 +8,7 @@
8
8
  import { resolveDefaultLocale } from '@uniweb/core'
9
9
  import { computeHash, stripInlineTags } from './hash.js'
10
10
  import { visitDataBlockStrings } from './data-strings.js'
11
-
12
- // ProseMirror inline fragment → inline markdown, for the TRANSLATOR-FACING half of
13
- // a unit. Same lazy-import-with-fallback pattern as merge.js, so the synchronous
14
- // extraction path has the serializer ready at call time. The fallback is the plain
15
- // text we already had, so a resolution failure degrades to the old behaviour rather
16
- // than breaking extraction.
17
- let serializeInlineContent
18
- try {
19
- const contentWriter = await import('@uniweb/content-writer')
20
- serializeInlineContent = contentWriter.serializeInlineContent
21
- } catch {
22
- serializeInlineContent = null
23
- }
11
+ import { serializeInlineContent } from '@uniweb/content-writer'
24
12
 
25
13
  /**
26
14
  * Extract all translatable units from site content
@@ -417,11 +405,11 @@ export function elementText(node) {
417
405
  * no instrument reported anything (coverage counts keys, not fidelity).
418
406
  * Measured 2026-09-18 on a documentation site with 266 internal links.
419
407
  *
420
- * Returns null when the serializer is unavailable or adds nothing over the plain
421
- * text, so `markup` appears on a unit only when it actually carries more.
408
+ * Returns null when the serializer adds nothing over the plain text, so `markup`
409
+ * appears on a unit only when it actually carries more.
422
410
  */
423
411
  export function elementMarkup(node) {
424
- if (!serializeInlineContent || !node?.content) return null
412
+ if (!node?.content) return null
425
413
  try {
426
414
  const markup = serializeInlineContent(node.content)
427
415
  if (typeof markup !== 'string') return null
@@ -27,25 +27,7 @@ import { parseFrontmatter } from '../utils/frontmatter.js'
27
27
  import { computeSourceHash } from './freeform-manifest.js'
28
28
  import { layoutAreaRoute } from '../site/layout-folder.js'
29
29
  import { warnOutsideVocabulary } from '../site/vocabulary-warnings.js'
30
-
31
- // Try to import content-reader for markdown → ProseMirror conversion
32
- let markdownToProseMirror
33
- try {
34
- const contentReader = await import('@uniweb/content-reader')
35
- markdownToProseMirror = contentReader.markdownToProseMirror
36
- } catch {
37
- // Simplified fallback - just wraps content as text
38
- markdownToProseMirror = (markdown) => ({
39
- type: 'doc',
40
- content: [
41
- {
42
- type: 'paragraph',
43
- content: [{ type: 'text', text: markdown.trim() }]
44
- }
45
- ]
46
- })
47
- }
48
-
30
+ import { markdownToProseMirror } from '@uniweb/content-reader'
49
31
 
50
32
  /** Report what a translation's markdown could not honour, naming the file. */
51
33
  function warnFor(filePath) {
package/src/i18n/merge.js CHANGED
@@ -16,18 +16,7 @@ import { computeHash } from './hash.js'
16
16
  import { loadFreeformTranslation } from './freeform.js'
17
17
  import { elementText, blockElements, translationContext, SITE_META_CONTEXT } from './extract.js'
18
18
  import { visitDataBlockStrings } from './data-strings.js'
19
-
20
- // Inline markdown → ProseMirror inline nodes, for resolving a whole-element
21
- // translation VALUE (which carries marks/links/icons as inline markdown). Same
22
- // lazy-import-with-fallback pattern as freeform.js / collection-processor.js, so
23
- // the synchronous merge path has the converter ready at call time.
24
- let inlineMarkdownToProseMirror
25
- try {
26
- const contentReader = await import('@uniweb/content-reader')
27
- inlineMarkdownToProseMirror = contentReader.inlineMarkdownToProseMirror
28
- } catch {
29
- inlineMarkdownToProseMirror = (markdown) => [{ type: 'text', text: markdown.trim() }]
30
- }
19
+ import { inlineMarkdownToProseMirror } from '@uniweb/content-reader'
31
20
 
32
21
  /**
33
22
  * Merge translations into site content for a specific locale
@@ -102,18 +102,14 @@ async function resolveSchema(queryName, siteRoot) {
102
102
  }
103
103
 
104
104
  // 2. Standard schema from @uniweb/schemas (try exact name + singularized)
105
- try {
106
- const schemasModule = await import('@uniweb/schemas')
107
- const names = [queryName, singularize(queryName)]
105
+ const schemasModule = await import('@uniweb/schemas')
106
+ const names = [queryName, singularize(queryName)]
108
107
 
109
- for (const name of names) {
110
- if (schemasModule.schemas?.[name]) {
111
- schema = schemasModule.schemas[name]
112
- break
113
- }
108
+ for (const name of names) {
109
+ if (schemasModule.schemas[name]) {
110
+ schema = schemasModule.schemas[name]
111
+ break
114
112
  }
115
- } catch {
116
- // @uniweb/schemas not installed — that's fine
117
113
  }
118
114
 
119
115
  schemaCache.set(queryName, schema)
package/src/prerender.js CHANGED
@@ -34,6 +34,8 @@ import { getAdapter } from './hosts/index.js'
34
34
  import { detectCiContext } from './hosts/detect-ci-context.js'
35
35
  import { stripBasePath } from './site/extension-urls.js'
36
36
  import { isSiteRelative, resolveExtensionDist } from './site/emit-extensions.js'
37
+ import { crawlerFiles, sitemapEntry } from './site/seo-files.js'
38
+ import { absoluteUrl } from '@uniweb/core/site-address'
37
39
 
38
40
  /**
39
41
  * Resolve an extension URL to the file prerender imports.
@@ -877,6 +879,10 @@ export async function prerenderSite(siteDir, options = {}) {
877
879
 
878
880
  // Pre-render each locale
879
881
  const renderedFiles = []
882
+ // Each written page's sitemap entry, every locale's: the sitemap lists what this build serves.
883
+ const sitemapEntries = []
884
+ // A redirect stub names its target as the target's own pages do: absolutely, on the site's address.
885
+ const stubCanonical = (target) => absoluteUrl(defaultSiteContent.config?.url, target) || target
880
886
 
881
887
  for (const localeConfig of localeConfigs) {
882
888
  const { locale, contentPath: localeContentPath, htmlPath, isDefault, routePrefix } = localeConfig
@@ -982,20 +988,30 @@ export async function prerenderSite(siteDir, options = {}) {
982
988
  })
983
989
  const buildDataCache = new DataStore()
984
990
 
985
- // Build-specific: pre-populate DataStore so EntityStore can resolve data during prerender.
986
- // hydrateDataStore handles cache-key derivation + value-shape wrapping
987
- // — same helper used by the browser SPA boot and by the Cloudflare
988
- // Worker SSR isolate, so all three render paths agree on shape.
989
- // ⛔ `siteContent.fetchedData`, not the raw list: only it holds the route-bound
990
- // views, and an expanded page rendered without them paints "not found" (measured).
991
- hydrateDataStore(uniweb.activeWebsite, siteContent.fetchedData)
992
-
993
991
  // Pre-fetch icons for SSR embedding
994
992
  await prefetchIcons(siteContent, uniweb, onProgress)
995
993
 
996
994
  // Pre-render each page
997
995
  const website = uniweb.activeWebsite
998
996
 
997
+ /**
998
+ * ⭐ A PAGE RENDERS AGAINST WHAT IT CARRIES — the entries its HTML embeds, which are all the
999
+ * browser's store holds when it boots on that page (`initUniweb` hydrates from them). The
1000
+ * browser renders over the prerendered page, so a render that reads more paints content the
1001
+ * browser does not have, and the browser replaces it with a loading state at boot.
1002
+ *
1003
+ * ⛔ Until 2026-10-25 the store was hydrated once for the whole locale and then took every
1004
+ * page's answers in turn, so a page could render data that only other pages carried. A
1005
+ * section can read the store directly (kit's `useFetched`, `useWholeRecord`), beyond what its
1006
+ * page declares: a page showing records whole through `useWholeRecord` prerendered the ones
1007
+ * the record pages before it had fetched, carried none of them, and showed "loading…" at
1008
+ * boot (measured).
1009
+ */
1010
+ const holdOnly = (carried) => {
1011
+ website.dataStore.clear()
1012
+ hydrateDataStore(website, carried)
1013
+ }
1014
+
999
1015
  for (const page of website.pages) {
1000
1016
  // Skip dynamic template pages — they exist in the content for runtime
1001
1017
  // route matching but can't be pre-rendered (no concrete route)
@@ -1017,7 +1033,7 @@ export async function prerenderSite(siteDir, options = {}) {
1017
1033
  ? (website.basePath || '') + location
1018
1034
  : location
1019
1035
  onProgress(` Redirect ${outputRoute} → ${target}`)
1020
- const redirectHtml = `<!DOCTYPE html><html><head><meta charset="utf-8"><meta http-equiv="refresh" content="0;url=${target}"><link rel="canonical" href="${target}"><title>Redirecting...</title></head><body><p>Redirecting to <a href="${target}">${target}</a></p></body></html>`
1036
+ const redirectHtml = `<!DOCTYPE html><html><head><meta charset="utf-8"><meta http-equiv="refresh" content="0;url=${target}"><link rel="canonical" href="${stubCanonical(target)}"><title>Redirecting...</title></head><body><p>Redirecting to <a href="${target}">${target}</a></p></body></html>`
1021
1037
  const outputPath = getOutputPath(distDir, outputRoute)
1022
1038
  await mkdir(dirname(outputPath), { recursive: true })
1023
1039
  await writeFile(outputPath, redirectHtml)
@@ -1050,7 +1066,7 @@ export async function prerenderSite(siteDir, options = {}) {
1050
1066
  routePrefix
1051
1067
  })
1052
1068
  onProgress(` Auto-redirect ${outputRoute} → ${targetPath}`)
1053
- const redirectHtml = `<!DOCTYPE html><html><head><meta charset="utf-8"><meta http-equiv="refresh" content="0;url=${targetPath}"><link rel="canonical" href="${targetPath}"><title>Redirecting...</title></head><body><p>Redirecting to <a href="${targetPath}">${targetPath}</a></p></body></html>`
1069
+ const redirectHtml = `<!DOCTYPE html><html><head><meta charset="utf-8"><meta http-equiv="refresh" content="0;url=${targetPath}"><link rel="canonical" href="${stubCanonical(targetPath)}"><title>Redirecting...</title></head><body><p>Redirecting to <a href="${targetPath}">${targetPath}</a></p></body></html>`
1054
1070
  const outputPath = getOutputPath(distDir, outputRoute)
1055
1071
  await mkdir(dirname(outputPath), { recursive: true })
1056
1072
  await writeFile(outputPath, redirectHtml)
@@ -1079,7 +1095,6 @@ export async function prerenderSite(siteDir, options = {}) {
1079
1095
  prerender: 'author',
1080
1096
  cache: buildDataCache,
1081
1097
  })
1082
- hydrateDataStore(website, asked)
1083
1098
  // Filed under this page, the way a route-bound view always has been: `scopeFetchedData`
1084
1099
  // embeds it in this page's HTML and nowhere else, and one address embedded twice collapses.
1085
1100
  for (const entry of asked) {
@@ -1087,6 +1102,15 @@ export async function prerenderSite(siteDir, options = {}) {
1087
1102
  siteContent.fetchedData.push({ ...entry, _scope: page.route, _routeBound: true })
1088
1103
  }
1089
1104
 
1105
+ // scopeRoutes mirrors the runtime data cascade (page → page.parent → site)
1106
+ // so split-mode pages embed only the collection data their first render reads.
1107
+ // An expanded page's own fetch was read under its TEMPLATE's route — the
1108
+ // route it had when the fetches ran — so that route is in its cascade too.
1109
+ const scopeRoutes = new Set([page.route, page.parent?.route, page.dynamicContext?.templateRoute].filter(Boolean))
1110
+ // The same call `injectBuildData` makes below for this page's HTML, so the render holds what
1111
+ // the HTML embeds — the page's own data step's answers among them.
1112
+ holdOnly(scopeFetchedData(siteContent.fetchedData, splitContent ? scopeRoutes : null, page.route))
1113
+
1090
1114
  onProgress(`Rendering ${outputRoute}...`)
1091
1115
 
1092
1116
  // ⭐ `renderer.render` IS the sequence this loop used to spell out —
@@ -1127,11 +1151,6 @@ export async function prerenderSite(siteDir, options = {}) {
1127
1151
  let html = result.html
1128
1152
 
1129
1153
  // Build-specific: theme CSS, __SITE_CONTENT__, icon cache.
1130
- // scopeRoutes mirrors the runtime data cascade (page → page.parent → site)
1131
- // so split-mode pages embed only the collection data their first render reads.
1132
- // An expanded page's own fetch was read under its TEMPLATE's route — the
1133
- // route it had when the fetches ran — so that route is in its cascade too.
1134
- const scopeRoutes = new Set([page.route, page.parent?.route, page.dynamicContext?.templateRoute].filter(Boolean))
1135
1154
  html = injectBuildData(html, siteContent, {
1136
1155
  splitContent,
1137
1156
  currentRoute: page.route,
@@ -1145,6 +1164,9 @@ export async function prerenderSite(siteDir, options = {}) {
1145
1164
 
1146
1165
  renderedFiles.push(outputPath)
1147
1166
  onProgress(` → ${outputPath.replace(distDir, 'dist')}`)
1167
+ // The page whose head was written — a content-less folder renders its index child.
1168
+ const entry = sitemapEntry(result.page || page)
1169
+ if (entry) sitemapEntries.push(entry)
1148
1170
  }
1149
1171
 
1150
1172
  // Write 404.html — shared logic from @uniweb/runtime/ssr
@@ -1153,6 +1175,8 @@ export async function prerenderSite(siteDir, options = {}) {
1153
1175
  currentRoute: null, // 404 has no current page — manifest only
1154
1176
  scopeRoutes: new Set(), // SPA fallback carries site-level fetched data only
1155
1177
  })
1178
+ // The 404 page renders against what 404.html carries, as every page above does.
1179
+ holdOnly(scopeFetchedData(siteContent.fetchedData, splitContent ? new Set() : null, null))
1156
1180
  const { html: notFoundHtml, hasNotFoundPage } = generate404Html({
1157
1181
  baseHtml: fallbackBaseHtml,
1158
1182
  website,
@@ -1187,6 +1211,25 @@ export async function prerenderSite(siteDir, options = {}) {
1187
1211
  }
1188
1212
  }
1189
1213
 
1214
+ // ⭐ The crawler files, from the pages just written (`./site/seo-files.js`). A site's own
1215
+ // `public/robots.txt` or `public/sitemap.xml` is its author's, and is left as it is.
1216
+ const crawl = crawlerFiles({
1217
+ address: defaultSiteContent.config?.url || null,
1218
+ base: defaultSiteContent.config?.base || '/',
1219
+ entries: sitemapEntries,
1220
+ robots: defaultSiteContent.config?.seo?.robots,
1221
+ })
1222
+ for (const [file, body] of [['sitemap.xml', crawl.sitemap], ['robots.txt', crawl.robots]]) {
1223
+ if (!body) continue
1224
+ if (existsSync(join(siteDir, 'public', file))) {
1225
+ onProgress(` ${file}: the site's own public/${file} is served; none was generated.`)
1226
+ continue
1227
+ }
1228
+ await writeFile(join(distDir, file), body)
1229
+ onProgress(` → ${file}${file === 'sitemap.xml' ? ` (${sitemapEntries.length} pages)` : ''}`)
1230
+ }
1231
+ for (const note of crawl.notes) onProgress(` ${note}`)
1232
+
1190
1233
  // Emit host-specific helper files via the selected host adapter.
1191
1234
  //
1192
1235
  // Resolution order:
@@ -22,6 +22,7 @@
22
22
  * video posters, PDF thumbnails — is the bundle path's alone (below).
23
23
  */
24
24
 
25
+ import { recordProjections, routeOnDisk } from './record-projections.js'
25
26
  import { writeFile, readFile, mkdir, cp } from 'node:fs/promises'
26
27
  import { existsSync } from 'node:fs'
27
28
  import { join, resolve, dirname } from 'node:path'
@@ -240,7 +241,7 @@ export async function buildSiteData({
240
241
  //
241
242
  // Ungated by `features:` — projections are free, so they carry no
242
243
  // billing intent and no entitlement to check downstream.
243
- await writeProjections(finalContent, resolvedDistDir)
244
+ await writeProjections(finalContent, resolvedDistDir, join(resolvedSiteRoot, 'public'))
244
245
 
245
246
  return { siteContent: finalContent, distDir: resolvedDistDir }
246
247
  }
@@ -255,22 +256,27 @@ export async function buildSiteData({
255
256
  *
256
257
  * @param {Object} siteContent - Final site content for the default locale
257
258
  * @param {string} distDir - Resolved output directory
259
+ * @param {string} dataRoot - where the compiled `/data/…` files are, for the record pages
258
260
  * @returns {Promise<void>}
259
261
  */
260
- async function writeProjections(siteContent, distDir) {
262
+ async function writeProjections(siteContent, distDir, dataRoot) {
261
263
  const agents = resolveAgentsConfig(siteContent?.config)
262
264
  if (!agents.index && !agents.markdown) return
263
265
  if (!siteContent?.pages?.length) return
264
266
 
265
267
  const defaultLocale = resolveDefaultLocale(siteContent.config)
268
+ // ⭐ No address is assumed here: this lane builds for a host that serves the site, and states where
269
+ // (`config.url`). An author's static origin would send an agent to another copy of the site.
266
270
  const options = {
267
- baseUrl: siteContent.config?.seo?.baseUrl || '',
271
+ address: siteContent.config?.url || null,
268
272
  locale: siteContent.config?.activeLocale || defaultLocale,
269
273
  defaultLocale
270
274
  }
275
+ // Each record of a parametric page is a page, as a host lists them (`./record-projections.js`).
276
+ const records = recordProjections(siteContent, { dataRoot, locale: options.locale })
271
277
 
272
278
  if (agents.index) {
273
- const index = renderSiteIndex(siteContent, { ...options, exclude: agents.exclude })
279
+ const index = renderSiteIndex(siteContent, { ...options, exclude: agents.exclude, records: records.entries })
274
280
  await writeFile(join(distDir, INDEX_FILENAME), index)
275
281
 
276
282
  // Additive scoped indexes; the root one above stays complete. See
@@ -285,7 +291,7 @@ async function writeProjections(siteContent, distDir) {
285
291
  await mkdir(dirname(target), { recursive: true })
286
292
  await writeFile(
287
293
  target,
288
- renderSiteIndex(siteContent, { ...options, exclude: agents.exclude, branch: branch.route })
294
+ renderSiteIndex(siteContent, { ...options, exclude: agents.exclude, records: records.entries, branch: branch.route })
289
295
  )
290
296
  }
291
297
  }
@@ -300,4 +306,9 @@ async function writeProjections(siteContent, distDir) {
300
306
  await mkdir(dirname(target), { recursive: true })
301
307
  await writeFile(target, markdown)
302
308
  }
309
+ for (const { route, markdown } of selectIndexablePages(records.pages, { exclude: agents.exclude })) {
310
+ const target = join(distDir, pageMarkdownFilename(routeOnDisk(route)))
311
+ await mkdir(dirname(target), { recursive: true })
312
+ await writeFile(target, markdown)
313
+ }
303
314
  }
@@ -39,6 +39,7 @@ import { detectFoundationType } from './foundation-ref.js'
39
39
  import { readPreview, PREVIEW_ENV } from './preview.js'
40
40
  import { previewMediaPlugin } from './preview-media.js'
41
41
  import { previewDirectoryIndexPlugin } from './preview-directory-index.js'
42
+ import { devServerRecordPlugin, requestedDevPort } from './dev-server.js'
42
43
 
43
44
  /**
44
45
  * Normalize a base path for Vite compatibility
@@ -153,6 +154,8 @@ export async function defineSiteConfig(options = {}) {
153
154
 
154
155
  // Determine site root (where vite.config.js is)
155
156
  const siteRoot = process.cwd()
157
+ // The port `uniweb dev --port <n>` asked for, if it did (`dev-server.js`).
158
+ const devPort = requestedDevPort()
156
159
 
157
160
  // Read site.yml
158
161
  const siteConfig = readSiteConfig(siteRoot)
@@ -349,6 +352,9 @@ export async function defineSiteConfig(options = {}) {
349
352
  watch: true
350
353
  }),
351
354
 
355
+ // The dev server says where it listens, for `uniweb dev` to read (`dev-server.js`).
356
+ devServerRecordPlugin(siteRoot),
357
+
352
358
  // User-provided plugins
353
359
  ...extraPlugins
354
360
  ].filter(Boolean)
@@ -567,7 +573,10 @@ export async function defineSiteConfig(options = {}) {
567
573
  })()
568
574
  },
569
575
  ...(siteConfig.build?.port && { port: siteConfig.build.port }),
570
- ...serverOverrides
576
+ ...serverOverrides,
577
+ // `uniweb dev --port <n>` asks for that port and no other (`dev-server.js`). Asked on the
578
+ // command line, it is the last word over the site's own settings.
579
+ ...(devPort !== null && { port: devPort, strictPort: true })
571
580
  },
572
581
 
573
582
  build: {
@@ -15,8 +15,7 @@
15
15
  * - fetch: The section's binding — names a query (query, as, where, sort, limit, current,
16
16
  * prerender, detailPage); `query:` is its shorthand
17
17
  *
18
- * Uses @uniweb/content-reader for markdown → ProseMirror conversion
19
- * when available, otherwise uses a simplified parser.
18
+ * Uses @uniweb/content-reader for markdown → ProseMirror conversion.
20
19
  *
21
20
  * @module @uniweb/build/site
22
21
  */
@@ -43,26 +42,10 @@ import { parentRouteOf } from '@uniweb/core/route-match'
43
42
  import { parseFrontmatter } from '../utils/frontmatter.js'
44
43
  import { parseGrid } from '@uniweb/schemas/grid'
45
44
  import { runtimeServicesConfig, refuseRetiredServiceKeys, refuseUnreadableServices } from '../uwx/services-request.js'
45
+ import { refuseUnreadableAddress } from './site-address.js'
46
46
  import { warnOutsideVocabulary } from './vocabulary-warnings.js'
47
47
  import { warnPlacementsNotOffered } from './placement-warnings.js'
48
-
49
- // Try to import content-reader, fall back to simplified parser
50
- let markdownToProseMirror
51
- try {
52
- const contentReader = await import('@uniweb/content-reader')
53
- markdownToProseMirror = contentReader.markdownToProseMirror
54
- } catch {
55
- // Simplified fallback - just wraps content as text
56
- markdownToProseMirror = (markdown) => ({
57
- type: 'doc',
58
- content: [
59
- {
60
- type: 'paragraph',
61
- content: [{ type: 'text', text: markdown.trim() }]
62
- }
63
- ]
64
- })
65
- }
48
+ import { markdownToProseMirror } from '@uniweb/content-reader'
66
49
 
67
50
  /**
68
51
  * The multi-segment route folder — ONE fixed spelling, ruled 2026-09-04 [Diego].
@@ -2579,6 +2562,7 @@ export async function collectSiteContent(sitePath, options = {}) {
2579
2562
  // for a placement its type does not offer is told (`./placement-warnings.js`).
2580
2563
  sectionTypes = await foundationSections(sitePath, siteConfig)
2581
2564
  refuseRetiredServiceKeys(siteConfig, configFile)
2565
+ refuseUnreadableAddress(siteConfig, configFile)
2582
2566
  refuseUnreadableServices(siteConfig, configFile)
2583
2567
 
2584
2568
  // Queries are declared in TWO files — `site.yml::queries` and `queries.yml`,