@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
|
@@ -1,17 +1,18 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Query Processor
|
|
3
3
|
*
|
|
4
4
|
* Materializes each named QUERY over the site's records into JSON data.
|
|
5
|
-
*
|
|
5
|
+
* Queries are declared in `queries.yml` (or `site.yml::queries`) and compiled
|
|
6
|
+
* at build time.
|
|
6
7
|
*
|
|
7
|
-
* ⛔ A
|
|
8
|
+
* ⛔ A RECORD `.md` IS NOT A PAGE-SECTION `.md`. Same extension, unrelated
|
|
8
9
|
* meanings, and the sync-side reader states this too
|
|
9
|
-
* (`build/src/uwx/
|
|
10
|
+
* (`build/src/uwx/entity-source.js`) because getting it backwards produces
|
|
10
11
|
* confident nonsense:
|
|
11
12
|
*
|
|
12
|
-
* -
|
|
13
|
-
*
|
|
14
|
-
*
|
|
13
|
+
* - record — frontmatter is structured DATA whose shape is the record's
|
|
14
|
+
* data schema; the body is the value of ONE declared field (the Model's
|
|
15
|
+
* content body field). Not "metadata".
|
|
15
16
|
* - page section — frontmatter is foundation/runtime CONFIG (`type:`, params,
|
|
16
17
|
* `theme:`); the body is authored content with no schema behind it.
|
|
17
18
|
*
|
|
@@ -24,7 +25,7 @@
|
|
|
24
25
|
*
|
|
25
26
|
* Features:
|
|
26
27
|
* - Discovers markdown (.md), data (.yml/.yaml), JSON (.json), and BibTeX (.bib)
|
|
27
|
-
* files
|
|
28
|
+
* record files
|
|
28
29
|
* - Parses frontmatter for record data (markdown), full YAML or JSON (data items),
|
|
29
30
|
* or BibTeX → CSL-JSON (bibliography items)
|
|
30
31
|
* - Pure-data formats (YAML, JSON, BibTeX) accept either one record per file
|
|
@@ -38,7 +39,7 @@
|
|
|
38
39
|
* fixed `where` selects compiles
|
|
39
40
|
* - Auto-generates excerpts and extracts first images (markdown items only)
|
|
40
41
|
*
|
|
41
|
-
* @module @uniweb/build/site/
|
|
42
|
+
* @module @uniweb/build/site/query-processor
|
|
42
43
|
*
|
|
43
44
|
* @example
|
|
44
45
|
* // queries.yml
|
|
@@ -73,24 +74,7 @@ import { collectNestedRefs } from '@uniweb/schemas/format'
|
|
|
73
74
|
import { labelIn } from './entry-label.js'
|
|
74
75
|
import { fileRecordValue, makeFilePreview } from './file-records.js'
|
|
75
76
|
import { warnOutsideVocabulary } from './vocabulary-warnings.js'
|
|
76
|
-
|
|
77
|
-
// Try to import content-reader for markdown parsing
|
|
78
|
-
let markdownToProseMirror
|
|
79
|
-
try {
|
|
80
|
-
const contentReader = await import('@uniweb/content-reader')
|
|
81
|
-
markdownToProseMirror = contentReader.markdownToProseMirror
|
|
82
|
-
} catch {
|
|
83
|
-
// Simplified fallback
|
|
84
|
-
markdownToProseMirror = (markdown) => ({
|
|
85
|
-
type: 'doc',
|
|
86
|
-
content: [
|
|
87
|
-
{
|
|
88
|
-
type: 'paragraph',
|
|
89
|
-
content: [{ type: 'text', text: markdown.trim() }]
|
|
90
|
-
}
|
|
91
|
-
]
|
|
92
|
-
})
|
|
93
|
-
}
|
|
77
|
+
import { markdownToProseMirror } from '@uniweb/content-reader'
|
|
94
78
|
|
|
95
79
|
/**
|
|
96
80
|
* Parse collection config from site.yml
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A STATICALLY BUILT SITE'S RECORD PAGES IN ITS PROJECTIONS — the agent index, each record page's
|
|
3
|
+
* markdown, the search index — made by the rule a host makes them with (`@uniweb/projections`:
|
|
4
|
+
* `recordRoutes`, `generateRecordSearchIndex`, `renderPageMarkdown`), over the files this build
|
|
5
|
+
* compiled. A parametric page is one template in the site's content, and each of its records is a
|
|
6
|
+
* page a visitor opens.
|
|
7
|
+
*
|
|
8
|
+
* ⛔ Until 2026-10-25 the static build made its projections from the site's pages alone: llms.txt
|
|
9
|
+
* and the search index held none of a site's books, talks or posts, while a host's held all of them
|
|
10
|
+
* (measured on a site with 16 record pages: 0 in llms.txt, 0 in the search index).
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { readFileSync } from 'node:fs'
|
|
14
|
+
import { join } from 'node:path'
|
|
15
|
+
import { fillRoutePattern, recordHandle } from '@uniweb/core/route-match'
|
|
16
|
+
import { recordRoutes, generateRecordSearchIndex, renderPageMarkdown } from '@uniweb/projections'
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* A route as a file is named for it: each segment decoded, since a static host decodes a request's
|
|
20
|
+
* path before it looks for the file (`fillRoutePattern` encodes a record's route for a URL).
|
|
21
|
+
*/
|
|
22
|
+
export function routeOnDisk(route) {
|
|
23
|
+
return route.split('/').map((segment) => {
|
|
24
|
+
try {
|
|
25
|
+
return decodeURIComponent(segment)
|
|
26
|
+
} catch {
|
|
27
|
+
return segment
|
|
28
|
+
}
|
|
29
|
+
}).join('/')
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function readJson(file) {
|
|
33
|
+
try {
|
|
34
|
+
return JSON.parse(readFileSync(file, 'utf8'))
|
|
35
|
+
} catch {
|
|
36
|
+
return null
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The record pages of a site: each query whose records have a page of their own, with its search
|
|
42
|
+
* index and each record's page — its route, and its markdown (the parametric page's, then the
|
|
43
|
+
* record).
|
|
44
|
+
*
|
|
45
|
+
* @param {Object} content - one locale's site content
|
|
46
|
+
* @param {Object} options
|
|
47
|
+
* @param {string} options.dataRoot - the directory a query's compiled `path` (`/data/talks.json`) is under
|
|
48
|
+
* @param {string} [options.locale]
|
|
49
|
+
* @returns {{ indexes: Object[], entries: Object[], pages: Array<{ route: string, markdown: string }> }}
|
|
50
|
+
*/
|
|
51
|
+
export function recordProjections(content, { dataRoot, locale } = {}) {
|
|
52
|
+
const queries = content?.config?.queries || {}
|
|
53
|
+
const indexes = []
|
|
54
|
+
const pages = []
|
|
55
|
+
for (const { name, pattern, path, field } of recordRoutes(content)) {
|
|
56
|
+
// No compiled file: a query answered by a records service, or an external one — its records
|
|
57
|
+
// are not this build's to list.
|
|
58
|
+
if (!path) continue
|
|
59
|
+
const records = readJson(join(dataRoot, path))
|
|
60
|
+
if (!Array.isArray(records)) continue
|
|
61
|
+
indexes.push(generateRecordSearchIndex(name, { ...(queries[name] || {}), pattern, field }, records, locale))
|
|
62
|
+
|
|
63
|
+
const template = (content.pages || []).find((page) => page.route === pattern)
|
|
64
|
+
if (!template) continue
|
|
65
|
+
// A record's own file holds it whole; the list holds its brief.
|
|
66
|
+
const wholeDir = join(dataRoot, path.replace(/\.json$/, ''))
|
|
67
|
+
for (const brief of records) {
|
|
68
|
+
const route = fillRoutePattern(pattern, brief, { field: field ?? null })
|
|
69
|
+
if (!route) continue
|
|
70
|
+
const handle = recordHandle(brief)
|
|
71
|
+
const record = (handle && readJson(join(wholeDir, `${handle}.json`))) || brief
|
|
72
|
+
const markdown = renderPageMarkdown(template, { record })
|
|
73
|
+
if (markdown) pages.push({ route, markdown })
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return { indexes, entries: indexes.flatMap((index) => index.entries), pages }
|
|
77
|
+
}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `sitemap.xml` and `robots.txt` — the two files a crawler reads about a statically served site,
|
|
3
|
+
* written by the prerender from the pages it wrote.
|
|
4
|
+
*
|
|
5
|
+
* ⭐ THE SITEMAP LISTS THE PAGES A STATIC HOST WILL ANSWER WITH, BY THE URLS THEY STATE. An entry is
|
|
6
|
+
* a page the prerender wrote — the static ones and each record's own — at its canonical URL with its
|
|
7
|
+
* alternates, read off `Page#getHeadMeta`, the source its `<head>` was written from, so the sitemap
|
|
8
|
+
* and the page cannot disagree. ⛔ Until 2026-10-25 the plugin wrote it at bundle time, before
|
|
9
|
+
* parametric pages were expanded: it listed `/books/:slug` and no book, at URLs that left the base
|
|
10
|
+
* path out.
|
|
11
|
+
*
|
|
12
|
+
* ⭐ `robots.txt` IS READ ONLY AT A HOST'S ROOT. A site served under a base path — a GitHub project
|
|
13
|
+
* site — cannot have one; it is not written there, and the build says so, rather than shipping a file
|
|
14
|
+
* no crawler reads that points at a sitemap where none is.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/** The three defined Content Signals, in the order they are emitted. */
|
|
18
|
+
const CONTENT_SIGNAL_KEYS = ['search', 'ai-input', 'ai-train']
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Format the `Content-Signal:` directive from `seo.robots.contentSignals`.
|
|
22
|
+
*
|
|
23
|
+
* Content Signals express what a site permits its content to be *used for*,
|
|
24
|
+
* which is a different axis from `Disallow:` — that governs fetching, this
|
|
25
|
+
* governs use after fetching. The three defined signals:
|
|
26
|
+
*
|
|
27
|
+
* - `search` — appear in search results
|
|
28
|
+
* - `ai-input` — be retrieved at inference time (RAG, grounding)
|
|
29
|
+
* - `ai-train` — be used to train a model
|
|
30
|
+
*
|
|
31
|
+
* **Emitted only when declared.** There is no default: a preference the site
|
|
32
|
+
* owner did not state is not ours to assert, in either direction. An absent
|
|
33
|
+
* signal means "unstated", which is not the same as `no`.
|
|
34
|
+
*
|
|
35
|
+
* Unknown keys are ignored rather than passed through — the vocabulary is a
|
|
36
|
+
* closed set, and forwarding an invented signal would produce a directive no
|
|
37
|
+
* crawler honors while reading as though it were doing something.
|
|
38
|
+
*
|
|
39
|
+
* @param {Object|null} signals - e.g. `{ search: true, 'ai-input': true, 'ai-train': false }`
|
|
40
|
+
* @returns {string} The directive line, or `''` when nothing is declared
|
|
41
|
+
*/
|
|
42
|
+
export function formatContentSignals(signals) {
|
|
43
|
+
if (!signals || typeof signals !== 'object') return ''
|
|
44
|
+
|
|
45
|
+
const parts = []
|
|
46
|
+
for (const key of CONTENT_SIGNAL_KEYS) {
|
|
47
|
+
if (!(key in signals)) continue
|
|
48
|
+
const value = signals[key]
|
|
49
|
+
const yes = value === true || value === 'yes'
|
|
50
|
+
const no = value === false || value === 'no'
|
|
51
|
+
if (!yes && !no) continue
|
|
52
|
+
parts.push(`${key}=${yes ? 'yes' : 'no'}`)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return parts.length ? `Content-Signal: ${parts.join(', ')}` : ''
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* `robots.txt` — the site's rules, and where its sitemap is.
|
|
60
|
+
*
|
|
61
|
+
* @param {Object} [robots] - `seo.robots`: `allow`, `disallow`, `crawlDelay`, `additionalSitemaps`,
|
|
62
|
+
* `contentSignals`
|
|
63
|
+
* @param {Object} [options]
|
|
64
|
+
* @param {string|null} [options.sitemap] - the sitemap's absolute URL, when there is one
|
|
65
|
+
* @returns {string}
|
|
66
|
+
*/
|
|
67
|
+
export function generateRobotsTxt(robots = {}, { sitemap = null } = {}) {
|
|
68
|
+
const { disallow = [], allow = [], crawlDelay = null, additionalSitemaps = [], contentSignals = null } = robots || {}
|
|
69
|
+
|
|
70
|
+
let content = 'User-agent: *\n'
|
|
71
|
+
|
|
72
|
+
const signals = formatContentSignals(contentSignals)
|
|
73
|
+
if (signals) {
|
|
74
|
+
// Inside the User-agent group, before the rules — the directive applies to
|
|
75
|
+
// the group it sits in.
|
|
76
|
+
content += `${signals}\n`
|
|
77
|
+
}
|
|
78
|
+
for (const path of allow) content += `Allow: ${path}\n`
|
|
79
|
+
for (const path of disallow) content += `Disallow: ${path}\n`
|
|
80
|
+
if (crawlDelay) content += `Crawl-delay: ${crawlDelay}\n`
|
|
81
|
+
|
|
82
|
+
const sitemaps = [sitemap, ...additionalSitemaps].filter(Boolean)
|
|
83
|
+
if (sitemaps.length) content += `\n${sitemaps.map((url) => `Sitemap: ${url}`).join('\n')}\n`
|
|
84
|
+
return content
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Whether `seo.robots` says anything a crawler would act on. */
|
|
88
|
+
function hasRules(robots) {
|
|
89
|
+
if (!robots || typeof robots !== 'object') return false
|
|
90
|
+
return Boolean(
|
|
91
|
+
robots.allow?.length ||
|
|
92
|
+
robots.disallow?.length ||
|
|
93
|
+
robots.crawlDelay ||
|
|
94
|
+
robots.additionalSitemaps?.length ||
|
|
95
|
+
formatContentSignals(robots.contentSignals)
|
|
96
|
+
)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* A rendered page's sitemap entry — its canonical URL, with its alternates — or null when it is not
|
|
101
|
+
* one to list: kept out of search (`noindex`), naming another URL as its canonical, or on a site
|
|
102
|
+
* whose address is unknown.
|
|
103
|
+
*
|
|
104
|
+
* @param {Object} page - a Page of the Website the prerender rendered it from
|
|
105
|
+
* @returns {{ loc: string, alternates: Array, lastmod: string|null, changefreq: string|null, priority: (string|number)|null }|null}
|
|
106
|
+
*/
|
|
107
|
+
export function sitemapEntry(page) {
|
|
108
|
+
const meta = page?.getHeadMeta?.()
|
|
109
|
+
if (!meta?.canonical || meta.robots?.includes('noindex')) return null
|
|
110
|
+
const own = page.website?.getPageUrl?.(page)
|
|
111
|
+
if (!own || meta.canonical !== own) return null
|
|
112
|
+
return {
|
|
113
|
+
loc: own,
|
|
114
|
+
alternates: meta.alternates || [],
|
|
115
|
+
// A record's page has no file of its own, so no date of its own: none is better than its template's.
|
|
116
|
+
lastmod: !page.dynamicContext && page.lastModified ? String(page.lastModified).split('T')[0] : null,
|
|
117
|
+
changefreq: page.seo?.changefreq || null,
|
|
118
|
+
priority: page.seo?.priority ?? null,
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Escape a value for XML text or a double-quoted attribute. */
|
|
123
|
+
function escapeXml(str) {
|
|
124
|
+
return String(str)
|
|
125
|
+
.replace(/&/g, '&')
|
|
126
|
+
.replace(/</g, '<')
|
|
127
|
+
.replace(/>/g, '>')
|
|
128
|
+
.replace(/"/g, '"')
|
|
129
|
+
.replace(/'/g, ''')
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* `sitemap.xml` for entries (`sitemapEntry`), each with its alternates when the site has several
|
|
134
|
+
* locales.
|
|
135
|
+
*
|
|
136
|
+
* @param {Array} entries
|
|
137
|
+
* @returns {string}
|
|
138
|
+
*/
|
|
139
|
+
export function renderSitemap(entries) {
|
|
140
|
+
const alternated = entries.some((entry) => entry.alternates?.length)
|
|
141
|
+
const urls = entries.map((entry) => {
|
|
142
|
+
const lines = [` <loc>${escapeXml(entry.loc)}</loc>`]
|
|
143
|
+
if (entry.lastmod) lines.push(` <lastmod>${escapeXml(entry.lastmod)}</lastmod>`)
|
|
144
|
+
if (entry.changefreq) lines.push(` <changefreq>${escapeXml(entry.changefreq)}</changefreq>`)
|
|
145
|
+
if (entry.priority !== null && entry.priority !== undefined) lines.push(` <priority>${escapeXml(entry.priority)}</priority>`)
|
|
146
|
+
for (const { hreflang, url } of entry.alternates || []) {
|
|
147
|
+
lines.push(` <xhtml:link rel="alternate" hreflang="${escapeXml(hreflang)}" href="${escapeXml(url)}" />`)
|
|
148
|
+
}
|
|
149
|
+
return ` <url>\n${lines.join('\n')}\n </url>`
|
|
150
|
+
})
|
|
151
|
+
const xmlns = alternated ? ' xmlns:xhtml="http://www.w3.org/1999/xhtml"' : ''
|
|
152
|
+
return `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"${xmlns}>\n${urls.join('\n')}\n</urlset>\n`
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* The site's crawler files, for what this build serves: the sitemap when the site's address is
|
|
157
|
+
* known and it has pages to list, `robots.txt` when the site is at its host's root and has rules or a
|
|
158
|
+
* sitemap to name.
|
|
159
|
+
*
|
|
160
|
+
* @param {Object} options
|
|
161
|
+
* @param {string|null} options.address - the site's address (`config.url`)
|
|
162
|
+
* @param {string} [options.base] - the base the site is built for
|
|
163
|
+
* @param {Array} options.entries - `sitemapEntry` results
|
|
164
|
+
* @param {Object} [options.robots] - `seo.robots`
|
|
165
|
+
* @returns {{ sitemap: string|null, robots: string|null, notes: string[] }} what to write, and why
|
|
166
|
+
* anything was not
|
|
167
|
+
*/
|
|
168
|
+
export function crawlerFiles({ address, base = '/', entries = [], robots = {} }) {
|
|
169
|
+
const notes = []
|
|
170
|
+
let sitemap = null
|
|
171
|
+
if (!address) {
|
|
172
|
+
notes.push('No sitemap.xml: the site has no address to list its pages at — set `seo.origin` in site.yml, or let its deploy target state one.')
|
|
173
|
+
} else if (!entries.length) {
|
|
174
|
+
notes.push('No sitemap.xml: no page was written to list.')
|
|
175
|
+
} else {
|
|
176
|
+
sitemap = renderSitemap(entries)
|
|
177
|
+
}
|
|
178
|
+
const atRoot = !base || base === '/'
|
|
179
|
+
const sitemapUrl = sitemap ? new URL('sitemap.xml', address).href : null
|
|
180
|
+
let robotsTxt = null
|
|
181
|
+
if (atRoot) {
|
|
182
|
+
if (sitemapUrl || hasRules(robots)) robotsTxt = generateRobotsTxt(robots, { sitemap: sitemapUrl })
|
|
183
|
+
} else {
|
|
184
|
+
notes.push(
|
|
185
|
+
`No robots.txt: crawlers read it only at a host's root, and this site is served under ${base}.` +
|
|
186
|
+
(sitemapUrl ? ` Submit ${sitemapUrl} to search engines directly.` : '')
|
|
187
|
+
)
|
|
188
|
+
}
|
|
189
|
+
return { sitemap, robots: robotsTxt, notes }
|
|
190
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WHERE A STATICALLY BUILT SITE LIVES — the address a build states for it, `config.url`, which the
|
|
3
|
+
* runtime reads (`@uniweb/core/site-address`) to state every absolute URL: a page's canonical,
|
|
4
|
+
* `og:url` and hreflang, the sitemap, `robots.txt`, the agent index.
|
|
5
|
+
*
|
|
6
|
+
* A static host cannot tell a site where it is served, so the build states it: the origin its deploy
|
|
7
|
+
* target gives — `ORIGIN_ENV` (the GitHub Pages workflow passes the one GitHub states), or the
|
|
8
|
+
* production origin a host building the site says it serves it at (Netlify, Vercel:
|
|
9
|
+
* `detectCiContext`) — else the author's (`site.yml::seo.origin`), joined to the base the site is
|
|
10
|
+
* built for. A host that serves the site itself states its own, and none of this is consulted.
|
|
11
|
+
*
|
|
12
|
+
* ⭐ AN ORIGIN IS A SCHEME AND A HOST; THE PATH IS THE BASE'S. An origin written with a path is
|
|
13
|
+
* refused: the path would be stated twice, and could disagree with the base the files are built for.
|
|
14
|
+
* ⛔ Until 2026-10-25 the author wrote `seo.baseUrl`, which the docs called an origin while the
|
|
15
|
+
* sitemap and the page tags read it as the whole address, so under a base path no value was right.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { siteAddress } from '@uniweb/core/site-address'
|
|
19
|
+
import { detectCiContext } from '../hosts/detect-ci-context.js'
|
|
20
|
+
|
|
21
|
+
/** The origin a deploy target states for the site it serves, e.g. `https://user.github.io`. */
|
|
22
|
+
export const ORIGIN_ENV = 'UNIWEB_ORIGIN'
|
|
23
|
+
|
|
24
|
+
/** Keys of `seo` that are retired, and what replaced each. */
|
|
25
|
+
const RETIRED_SEO_KEYS = {
|
|
26
|
+
baseUrl:
|
|
27
|
+
"`seo.baseUrl` is now `seo.origin`: the origin the site is served from, `https://example.com`. A path is the site's base — `base:` in site.yml, or UNIWEB_BASE from its deploy.",
|
|
28
|
+
defaultImage: '`seo.defaultImage` is `seo.image`: the social card every page inherits.',
|
|
29
|
+
locales: "`seo.locales` is retired: a page's hreflang follows the site's `languages:`.",
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Keys of a site's config that whoever serves the site states, never its author. */
|
|
33
|
+
const STATED_KEYS = {
|
|
34
|
+
url: "`url:` in site.yml is not read: a site's address is its origin (`seo.origin`) and its base, or what its deploy target states.",
|
|
35
|
+
trailingSlash: "`trailingSlash:` in site.yml is not read: how a page's URL ends follows how the build lays out its files.",
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Stop on what `site.yml` says about where the site lives that nothing reads — a retired `seo` key,
|
|
40
|
+
* an address or URL form only whoever serves the site states, an origin that is not one. Run
|
|
41
|
+
* wherever `site.yml` is read for a build or a push, as `refuseRetiredServiceKeys` is.
|
|
42
|
+
*
|
|
43
|
+
* @param {Object} siteYml
|
|
44
|
+
* @param {string} [where]
|
|
45
|
+
* @throws {Error}
|
|
46
|
+
*/
|
|
47
|
+
export function refuseUnreadableAddress(siteYml, where = 'site.yml') {
|
|
48
|
+
if (!siteYml || typeof siteYml !== 'object') return
|
|
49
|
+
for (const [key, message] of Object.entries(STATED_KEYS)) {
|
|
50
|
+
if (siteYml[key] !== undefined) throw new Error(`[uniweb] ${where}: ${message}`)
|
|
51
|
+
}
|
|
52
|
+
const seo = siteYml.seo
|
|
53
|
+
if (!seo || typeof seo !== 'object') return
|
|
54
|
+
for (const [key, message] of Object.entries(RETIRED_SEO_KEYS)) {
|
|
55
|
+
if (seo[key] !== undefined) throw new Error(`[uniweb] ${where}: ${message}`)
|
|
56
|
+
}
|
|
57
|
+
readOrigin(seo.origin, `\`seo.origin\` in ${where}`)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* A site's effective `seo` block: `site.yml::seo`, with a caller's overrides over it key by key
|
|
62
|
+
* (`defineSiteConfig({ seo })`). Refused when it holds a retired key, wherever that came from.
|
|
63
|
+
*
|
|
64
|
+
* @param {Object} [seo] - `site.yml::seo`
|
|
65
|
+
* @param {Object} [overrides]
|
|
66
|
+
* @returns {Object}
|
|
67
|
+
*/
|
|
68
|
+
export function effectiveSeo(seo, overrides = {}) {
|
|
69
|
+
const merged = { ...(seo || {}), ...(overrides || {}) }
|
|
70
|
+
for (const [key, message] of Object.entries(RETIRED_SEO_KEYS)) {
|
|
71
|
+
if (merged[key] !== undefined) throw new Error(message)
|
|
72
|
+
}
|
|
73
|
+
return merged
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* An origin as written, normalized — `https://example.com` — or null when none is written. Throws,
|
|
78
|
+
* naming `where`, for a value that is not one.
|
|
79
|
+
*
|
|
80
|
+
* @param {*} value
|
|
81
|
+
* @param {string} where - what to call it in the message (`seo.origin`, `UNIWEB_ORIGIN`)
|
|
82
|
+
* @returns {string|null}
|
|
83
|
+
*/
|
|
84
|
+
export function readOrigin(value, where) {
|
|
85
|
+
if (value === undefined || value === null || value === '') return null
|
|
86
|
+
const text = String(value).trim()
|
|
87
|
+
let url = null
|
|
88
|
+
try {
|
|
89
|
+
url = new URL(text)
|
|
90
|
+
} catch {
|
|
91
|
+
// reported below
|
|
92
|
+
}
|
|
93
|
+
// A string that parses is not yet an origin: `example.com:443` parses, with `example.com:` as its scheme.
|
|
94
|
+
if (!url || (url.protocol !== 'http:' && url.protocol !== 'https:') || !url.host) {
|
|
95
|
+
throw new Error(`${where} is "${text}", which is not an origin. Write it with its scheme: https://example.com`)
|
|
96
|
+
}
|
|
97
|
+
if (url.pathname !== '/') {
|
|
98
|
+
throw new Error(
|
|
99
|
+
`${where} is "${text}", but an origin is a scheme and a host: ${url.origin}. ` +
|
|
100
|
+
`The path ${url.pathname.replace(/\/?$/, '/')} is the site's base — \`base:\` in site.yml, or UNIWEB_BASE from its deploy.`
|
|
101
|
+
)
|
|
102
|
+
}
|
|
103
|
+
if (url.search || url.hash) {
|
|
104
|
+
throw new Error(`${where} is "${text}", but an origin is a scheme and a host: ${url.origin}.`)
|
|
105
|
+
}
|
|
106
|
+
return url.origin
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* The address a static build states for its site — the origin from its deploy target or its
|
|
111
|
+
* author, under the base it is built for — or null when neither states an origin.
|
|
112
|
+
*
|
|
113
|
+
* @param {Object} options
|
|
114
|
+
* @param {Object} [options.config] - the site's collected config, its `seo` effective (`effectiveSeo`)
|
|
115
|
+
* @param {string} [options.base] - the base the site is built for, `/handbook/`
|
|
116
|
+
* @param {Object} [options.env]
|
|
117
|
+
* @returns {string|null}
|
|
118
|
+
*/
|
|
119
|
+
export function resolveSiteAddress({ config = {}, base = '/', env = process.env } = {}) {
|
|
120
|
+
const ci = detectCiContext(env)
|
|
121
|
+
const origin =
|
|
122
|
+
readOrigin(env[ORIGIN_ENV], ORIGIN_ENV) ??
|
|
123
|
+
readOrigin(ci?.origin, `the production URL ${ci?.host} states`) ??
|
|
124
|
+
readOrigin(config.seo?.origin, '`seo.origin`')
|
|
125
|
+
if (!origin) return null
|
|
126
|
+
return siteAddress(`${origin}${base || '/'}`)
|
|
127
|
+
}
|
package/src/uwx/site.js
CHANGED
|
@@ -92,6 +92,7 @@ import {
|
|
|
92
92
|
refuseRetiredServiceKeys,
|
|
93
93
|
refuseUnreadableServices
|
|
94
94
|
} from './services-request.js'
|
|
95
|
+
import { refuseUnreadableAddress } from '../site/site-address.js'
|
|
95
96
|
import { resolveQueriesConfig } from './queries-config.js'
|
|
96
97
|
import { qualifyRef } from '@uniweb/schemas/refs'
|
|
97
98
|
import { siteSelfScope, refuseOrgOption } from './self-scope.js'
|
|
@@ -1418,6 +1419,7 @@ export async function siteProjectToDocument(siteRoot, opts = {}) {
|
|
|
1418
1419
|
refuseOrgOption(opts, 'uwx/site')
|
|
1419
1420
|
const siteYml = await readYamlFile(join(siteRoot, 'site.yml'))
|
|
1420
1421
|
refuseRetiredServiceKeys(siteYml)
|
|
1422
|
+
refuseUnreadableAddress(siteYml)
|
|
1421
1423
|
refuseUnreadableServices(siteYml)
|
|
1422
1424
|
const sourceLocale = opts.sourceLocale || resolveDefaultLocale(siteYml)
|
|
1423
1425
|
if (!siteYml.name) {
|