@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.
- 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/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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uniweb/build",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.9.0",
|
|
4
4
|
"description": "Build tooling for the Uniweb Component Web Platform",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -56,15 +56,15 @@
|
|
|
56
56
|
"js-yaml": "^4.1.0",
|
|
57
57
|
"sharp": "^0.35.3",
|
|
58
58
|
"yaml": "^2.5.0",
|
|
59
|
+
"@uniweb/projections": "~1.2.0",
|
|
60
|
+
"@uniweb/theming": "~1.2.1",
|
|
61
|
+
"@uniweb/schemas": "~1.6.5",
|
|
59
62
|
"@uniweb/content-reader": "~1.5.6",
|
|
60
63
|
"@uniweb/content-writer": "~1.3.0",
|
|
61
|
-
"@uniweb/
|
|
62
|
-
"@uniweb/semantic-parser": "~1.13.0",
|
|
63
|
-
"@uniweb/projections": "~1.1.7",
|
|
64
|
-
"@uniweb/theming": "~1.2.1"
|
|
64
|
+
"@uniweb/semantic-parser": "~1.13.0"
|
|
65
65
|
},
|
|
66
66
|
"optionalDependencies": {
|
|
67
|
-
"@uniweb/runtime": "~1.
|
|
67
|
+
"@uniweb/runtime": "~1.2.0"
|
|
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.
|
|
77
|
-
"@uniweb/runtime": "~1.
|
|
76
|
+
"@uniweb/core": "~1.4.5",
|
|
77
|
+
"@uniweb/runtime": "~1.2.0"
|
|
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
|
|
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
|
-
*
|
|
38
|
-
*
|
|
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
|
-
* -
|
|
41
|
-
*
|
|
42
|
-
* root
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
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 —
|
|
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
|
|
326
|
-
`
|
|
327
|
-
`
|
|
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
|
-
//
|
|
482
|
-
//
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
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
|
-
: `
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
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
|
-
${
|
|
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 {
|
package/src/i18n/extract.js
CHANGED
|
@@ -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
|
|
421
|
-
*
|
|
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 (!
|
|
412
|
+
if (!node?.content) return null
|
|
425
413
|
try {
|
|
426
414
|
const markup = serializeInlineContent(node.content)
|
|
427
415
|
if (typeof markup !== 'string') return null
|
package/src/i18n/freeform.js
CHANGED
|
@@ -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
|
package/src/i18n/records.js
CHANGED
|
@@ -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
|
-
|
|
106
|
-
|
|
107
|
-
const names = [queryName, singularize(queryName)]
|
|
105
|
+
const schemasModule = await import('@uniweb/schemas')
|
|
106
|
+
const names = [queryName, singularize(queryName)]
|
|
108
107
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
|
@@ -1017,7 +1023,7 @@ export async function prerenderSite(siteDir, options = {}) {
|
|
|
1017
1023
|
? (website.basePath || '') + location
|
|
1018
1024
|
: location
|
|
1019
1025
|
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>`
|
|
1026
|
+
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
1027
|
const outputPath = getOutputPath(distDir, outputRoute)
|
|
1022
1028
|
await mkdir(dirname(outputPath), { recursive: true })
|
|
1023
1029
|
await writeFile(outputPath, redirectHtml)
|
|
@@ -1050,7 +1056,7 @@ export async function prerenderSite(siteDir, options = {}) {
|
|
|
1050
1056
|
routePrefix
|
|
1051
1057
|
})
|
|
1052
1058
|
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>`
|
|
1059
|
+
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
1060
|
const outputPath = getOutputPath(distDir, outputRoute)
|
|
1055
1061
|
await mkdir(dirname(outputPath), { recursive: true })
|
|
1056
1062
|
await writeFile(outputPath, redirectHtml)
|
|
@@ -1145,6 +1151,9 @@ export async function prerenderSite(siteDir, options = {}) {
|
|
|
1145
1151
|
|
|
1146
1152
|
renderedFiles.push(outputPath)
|
|
1147
1153
|
onProgress(` → ${outputPath.replace(distDir, 'dist')}`)
|
|
1154
|
+
// The page whose head was written — a content-less folder renders its index child.
|
|
1155
|
+
const entry = sitemapEntry(result.page || page)
|
|
1156
|
+
if (entry) sitemapEntries.push(entry)
|
|
1148
1157
|
}
|
|
1149
1158
|
|
|
1150
1159
|
// Write 404.html — shared logic from @uniweb/runtime/ssr
|
|
@@ -1187,6 +1196,25 @@ export async function prerenderSite(siteDir, options = {}) {
|
|
|
1187
1196
|
}
|
|
1188
1197
|
}
|
|
1189
1198
|
|
|
1199
|
+
// ⭐ The crawler files, from the pages just written (`./site/seo-files.js`). A site's own
|
|
1200
|
+
// `public/robots.txt` or `public/sitemap.xml` is its author's, and is left as it is.
|
|
1201
|
+
const crawl = crawlerFiles({
|
|
1202
|
+
address: defaultSiteContent.config?.url || null,
|
|
1203
|
+
base: defaultSiteContent.config?.base || '/',
|
|
1204
|
+
entries: sitemapEntries,
|
|
1205
|
+
robots: defaultSiteContent.config?.seo?.robots,
|
|
1206
|
+
})
|
|
1207
|
+
for (const [file, body] of [['sitemap.xml', crawl.sitemap], ['robots.txt', crawl.robots]]) {
|
|
1208
|
+
if (!body) continue
|
|
1209
|
+
if (existsSync(join(siteDir, 'public', file))) {
|
|
1210
|
+
onProgress(` ${file}: the site's own public/${file} is served; none was generated.`)
|
|
1211
|
+
continue
|
|
1212
|
+
}
|
|
1213
|
+
await writeFile(join(distDir, file), body)
|
|
1214
|
+
onProgress(` → ${file}${file === 'sitemap.xml' ? ` (${sitemapEntries.length} pages)` : ''}`)
|
|
1215
|
+
}
|
|
1216
|
+
for (const note of crawl.notes) onProgress(` ${note}`)
|
|
1217
|
+
|
|
1190
1218
|
// Emit host-specific helper files via the selected host adapter.
|
|
1191
1219
|
//
|
|
1192
1220
|
// 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
|
-
|
|
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
|
}
|
package/src/site/config.js
CHANGED
|
@@ -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`,
|