@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.
@@ -1,17 +1,18 @@
1
1
  /**
2
- * Collection Processor
2
+ * Query Processor
3
3
  *
4
4
  * Materializes each named QUERY over the site's records into JSON data.
5
- * Collections are defined in site.yml and processed at build time.
5
+ * Queries are declared in `queries.yml` (or `site.yml::queries`) and compiled
6
+ * at build time.
6
7
  *
7
- * ⛔ A COLLECTION `.md` IS NOT A PAGE-SECTION `.md`. Same extension, unrelated
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/collection-source.js`) because getting it backwards produces
10
+ * (`build/src/uwx/entity-source.js`) because getting it backwards produces
10
11
  * confident nonsense:
11
12
  *
12
- * - collection record — frontmatter is structured DATA whose shape is the
13
- * collection's data schema; the body is the value of ONE declared field
14
- * (the Model's content body field). Not "metadata".
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 in collection folders
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/collection-processor
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,81 @@
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
+ * ⚠️ For the default language only. Another language's search index is still made from its pages
13
+ * alone, before its records are translated (`i18n/index.js::buildLocalizedContent`), and the build
14
+ * writes that language no llms.txt or `.md` (measured 2026-10-25 on a site in three languages).
15
+ */
16
+
17
+ import { readFileSync } from 'node:fs'
18
+ import { join } from 'node:path'
19
+ import { fillRoutePattern, recordHandle } from '@uniweb/core/route-match'
20
+ import { recordRoutes, generateRecordSearchIndex, renderPageMarkdown } from '@uniweb/projections'
21
+
22
+ /**
23
+ * A route as a file is named for it: each segment decoded, since a static host decodes a request's
24
+ * path before it looks for the file (`fillRoutePattern` encodes a record's route for a URL).
25
+ */
26
+ export function routeOnDisk(route) {
27
+ return route.split('/').map((segment) => {
28
+ try {
29
+ return decodeURIComponent(segment)
30
+ } catch {
31
+ return segment
32
+ }
33
+ }).join('/')
34
+ }
35
+
36
+ function readJson(file) {
37
+ try {
38
+ return JSON.parse(readFileSync(file, 'utf8'))
39
+ } catch {
40
+ return null
41
+ }
42
+ }
43
+
44
+ /**
45
+ * The record pages of a site: each query whose records have a page of their own, with its search
46
+ * index and each record's page — its route, and its markdown (the parametric page's, then the
47
+ * record).
48
+ *
49
+ * @param {Object} content - one locale's site content
50
+ * @param {Object} options
51
+ * @param {string} options.dataRoot - the directory a query's compiled `path` (`/data/talks.json`) is under
52
+ * @param {string} [options.locale]
53
+ * @returns {{ indexes: Object[], entries: Object[], pages: Array<{ route: string, markdown: string }> }}
54
+ */
55
+ export function recordProjections(content, { dataRoot, locale } = {}) {
56
+ const queries = content?.config?.queries || {}
57
+ const indexes = []
58
+ const pages = []
59
+ for (const { name, pattern, path, field } of recordRoutes(content)) {
60
+ // No compiled file: a query answered by a records service, or an external one — its records
61
+ // are not this build's to list.
62
+ if (!path) continue
63
+ const records = readJson(join(dataRoot, path))
64
+ if (!Array.isArray(records)) continue
65
+ indexes.push(generateRecordSearchIndex(name, { ...(queries[name] || {}), pattern, field }, records, locale))
66
+
67
+ const template = (content.pages || []).find((page) => page.route === pattern)
68
+ if (!template) continue
69
+ // A record's own file holds it whole; the list holds its brief.
70
+ const wholeDir = join(dataRoot, path.replace(/\.json$/, ''))
71
+ for (const brief of records) {
72
+ const route = fillRoutePattern(pattern, brief, { field: field ?? null })
73
+ if (!route) continue
74
+ const handle = recordHandle(brief)
75
+ const record = (handle && readJson(join(wholeDir, `${handle}.json`))) || brief
76
+ const markdown = renderPageMarkdown(template, { record })
77
+ if (markdown) pages.push({ route, markdown })
78
+ }
79
+ }
80
+ return { indexes, entries: indexes.flatMap((index) => index.entries), pages }
81
+ }
@@ -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, '&amp;')
126
+ .replace(/</g, '&lt;')
127
+ .replace(/>/g, '&gt;')
128
+ .replace(/"/g, '&quot;')
129
+ .replace(/'/g, '&apos;')
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) {