@uniweb/build 0.14.36 → 0.14.37
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 +5 -5
- package/src/i18n/extract.js +2 -1
- package/src/prerender.js +3 -2
- package/src/resolve-data-schema.js +103 -25
- package/src/search/generate.js +2 -1
- package/src/site/build-site-data.js +2 -2
- package/src/site/content-collector.js +45 -6
- package/src/site/plugin.js +27 -5
- package/src/uwx/site-project.js +3 -0
- package/src/uwx/site.js +25 -5
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uniweb/build",
|
|
3
|
-
"version": "0.14.
|
|
3
|
+
"version": "0.14.37",
|
|
4
4
|
"description": "Build tooling for the Uniweb Component Web Platform",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -59,12 +59,12 @@
|
|
|
59
59
|
"js-yaml": "^4.1.0",
|
|
60
60
|
"sharp": "^0.33.2",
|
|
61
61
|
"yaml": "^2.5.0",
|
|
62
|
-
"@uniweb/
|
|
63
|
-
"@uniweb/
|
|
62
|
+
"@uniweb/theming": "0.1.9",
|
|
63
|
+
"@uniweb/content-writer": "0.2.7"
|
|
64
64
|
},
|
|
65
65
|
"optionalDependencies": {
|
|
66
66
|
"@uniweb/content-reader": "1.1.14",
|
|
67
|
-
"@uniweb/runtime": "0.8.
|
|
67
|
+
"@uniweb/runtime": "0.8.29",
|
|
68
68
|
"@uniweb/schemas": "0.2.4"
|
|
69
69
|
},
|
|
70
70
|
"peerDependencies": {
|
|
@@ -74,7 +74,7 @@
|
|
|
74
74
|
"@tailwindcss/vite": "^4.0.0",
|
|
75
75
|
"@vitejs/plugin-react": "^4.0.0 || ^5.0.0",
|
|
76
76
|
"vite-plugin-svgr": "^4.0.0",
|
|
77
|
-
"@uniweb/core": "0.7.
|
|
77
|
+
"@uniweb/core": "0.7.23"
|
|
78
78
|
},
|
|
79
79
|
"peerDependenciesMeta": {
|
|
80
80
|
"vite": {
|
package/src/i18n/extract.js
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* strings and building a manifest of translation units.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
+
import { resolveDefaultLocale } from '@uniweb/core'
|
|
8
9
|
import { computeHash, stripInlineTags } from './hash.js'
|
|
9
10
|
|
|
10
11
|
/**
|
|
@@ -55,7 +56,7 @@ export function extractTranslatableContent(siteContent) {
|
|
|
55
56
|
|
|
56
57
|
return {
|
|
57
58
|
version: '1.0',
|
|
58
|
-
defaultLocale: siteContent.config
|
|
59
|
+
defaultLocale: resolveDefaultLocale(siteContent.config),
|
|
59
60
|
extracted: new Date().toISOString(),
|
|
60
61
|
units
|
|
61
62
|
}
|
package/src/prerender.js
CHANGED
|
@@ -11,6 +11,7 @@ import { readFile, writeFile, mkdir } from 'node:fs/promises'
|
|
|
11
11
|
import { existsSync, readdirSync, statSync } from 'node:fs'
|
|
12
12
|
import { join, dirname, resolve } from 'node:path'
|
|
13
13
|
import { pathToFileURL } from 'node:url'
|
|
14
|
+
import { resolveDefaultLocale } from '@uniweb/core'
|
|
14
15
|
import { executeFetch, mergeDataIntoContent } from './site/data-fetcher.js'
|
|
15
16
|
import { shouldSplitContent } from './site/split-content.js'
|
|
16
17
|
import { FONT_LINKS_MARKER } from './site/head-markers.js'
|
|
@@ -284,7 +285,7 @@ async function processSectionFetches(sections, fetchOptions, onProgress) {
|
|
|
284
285
|
*/
|
|
285
286
|
async function discoverLocaleContents(distDir, defaultContent) {
|
|
286
287
|
const locales = []
|
|
287
|
-
const defaultLocale = defaultContent.config
|
|
288
|
+
const defaultLocale = resolveDefaultLocale(defaultContent.config)
|
|
288
289
|
|
|
289
290
|
// Add the default locale (root level)
|
|
290
291
|
locales.push({
|
|
@@ -582,7 +583,7 @@ export async function prerenderSite(siteDir, options = {}) {
|
|
|
582
583
|
// Execute data fetches (site, page, section levels)
|
|
583
584
|
// For non-default locales, collection data is read from dist/{locale}/data/
|
|
584
585
|
onProgress('Executing data fetches...')
|
|
585
|
-
const defaultLocale = defaultSiteContent.config
|
|
586
|
+
const defaultLocale = resolveDefaultLocale(defaultSiteContent.config)
|
|
586
587
|
const { pageFetchedData, fetchedData } = await executeAllFetches(
|
|
587
588
|
siteContent, siteDir, onProgress,
|
|
588
589
|
{ locale, defaultLocale, distDir }
|
|
@@ -18,10 +18,12 @@
|
|
|
18
18
|
* '@uniweb/name' → reserved: the platform system namespace, not a data
|
|
19
19
|
* schema source (rejected, with a pointer to '@std').
|
|
20
20
|
*
|
|
21
|
-
* Alias routing (`schemas.config.js`): a foundation may
|
|
22
|
-
* directory of schema files, so '@org/name' resolves to a
|
|
23
|
-
* on disk — no package, no install.
|
|
24
|
-
* '@org/
|
|
21
|
+
* Alias routing (`schemas.config.js`): a foundation may route a scope to a
|
|
22
|
+
* directory of schema files ('@org' → a folder), so '@org/name' resolves to a
|
|
23
|
+
* bare folder anywhere on disk — no package, no install. It may also override a
|
|
24
|
+
* SINGLE schema to an exact file ('@org/name' → a file), which wins over the
|
|
25
|
+
* scope directory for that one name. Both take precedence over the '@org/schemas'
|
|
26
|
+
* package convention (file over directory over package); see `loadSchemaAliases`.
|
|
25
27
|
*
|
|
26
28
|
* The authoring format and its canonical type vocabulary are documented in
|
|
27
29
|
* `data-schema-format.md`. This module validates that format and normalizes the
|
|
@@ -31,7 +33,7 @@
|
|
|
31
33
|
*/
|
|
32
34
|
|
|
33
35
|
import { readFile } from 'node:fs/promises'
|
|
34
|
-
import { existsSync, readdirSync } from 'node:fs'
|
|
36
|
+
import { existsSync, readdirSync, statSync } from 'node:fs'
|
|
35
37
|
import { join, resolve, isAbsolute, extname, basename } from 'node:path'
|
|
36
38
|
import { pathToFileURL } from 'node:url'
|
|
37
39
|
import { createRequire } from 'node:module'
|
|
@@ -155,20 +157,47 @@ export async function resolveSchemaRef(ref, { srcDir, aliases }) {
|
|
|
155
157
|
)
|
|
156
158
|
}
|
|
157
159
|
|
|
158
|
-
// Alias routing (schemas.config.js)
|
|
159
|
-
//
|
|
160
|
-
//
|
|
161
|
-
//
|
|
162
|
-
//
|
|
160
|
+
// Alias routing (schemas.config.js), most-specific first — no package, no
|
|
161
|
+
// install, no node_modules; '@/' (self) and the reserved '@uniweb' scope are
|
|
162
|
+
// handled above and are never aliasable.
|
|
163
|
+
//
|
|
164
|
+
// A per-schema alias ('@org/name' → a FILE) overrides one schema to an exact
|
|
165
|
+
// file. It's the explicit way to keep a shared scope routed to a catalog while
|
|
166
|
+
// swapping in one local definition — no symlink, no forked scope. It wins over
|
|
167
|
+
// the scope directory (checked next), which wins over the package (below).
|
|
168
|
+
const aliasedFile = aliases?.[`@${scope}/${name}`]
|
|
169
|
+
if (aliasedFile) {
|
|
170
|
+
const file = resolveAliasedSchemaFile(aliasedFile)
|
|
171
|
+
if (!file) {
|
|
172
|
+
throw new Error(
|
|
173
|
+
`Data schema '${ref}' is aliased to '${aliasedFile}' (via schemas.config.js), but no schema file ` +
|
|
174
|
+
`exists there. Point it at a ${SCHEMA_EXTENSIONS.join(' / ')} file.`
|
|
175
|
+
)
|
|
176
|
+
}
|
|
177
|
+
return validateAndNormalizeSchema(await loadSchemaFile(file), ref)
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// A scope alias ('@org' → a DIR) resolves 'name' to a bare schema FILE in that
|
|
181
|
+
// directory. This lets a foundation point '@agency' at a shared schema folder
|
|
182
|
+
// anywhere on disk.
|
|
163
183
|
const aliasDir = aliases?.[`@${scope}`]
|
|
164
184
|
if (aliasDir) {
|
|
165
185
|
const file = findSchemaFileInDir(aliasDir, name)
|
|
166
186
|
if (!file) {
|
|
167
187
|
const tried = SCHEMA_EXTENSIONS.map((e) => `${name}${e}`).join(', ')
|
|
168
|
-
|
|
188
|
+
let msg =
|
|
169
189
|
`Data schema '${ref}' not found in the directory '@${scope}' is aliased to ('${aliasDir}' ` +
|
|
170
|
-
|
|
171
|
-
|
|
190
|
+
`via schemas.config.js). Expected one of: ${tried}.`
|
|
191
|
+
// The confusing case: the '@org/schemas' package is ALSO installed. A routed
|
|
192
|
+
// scope never falls back to it (fail-loud beats silently loading a different
|
|
193
|
+
// definition), so say so rather than leave the developer wondering.
|
|
194
|
+
if (isScopePackageInstalled(scope, srcDir)) {
|
|
195
|
+
msg +=
|
|
196
|
+
` Note: '${packageForScope(scope)}' is installed, but a routed scope takes precedence over the ` +
|
|
197
|
+
`package and does not fall back to it. Override this one schema with a '@${scope}/${name}' file alias, ` +
|
|
198
|
+
`or add '${name}' to the routed directory.`
|
|
199
|
+
}
|
|
200
|
+
throw new Error(msg)
|
|
172
201
|
}
|
|
173
202
|
return validateAndNormalizeSchema(await loadSchemaFile(file), ref)
|
|
174
203
|
}
|
|
@@ -640,20 +669,23 @@ function findSchemaFileInDir(dir, name) {
|
|
|
640
669
|
|
|
641
670
|
/**
|
|
642
671
|
* Load a foundation's optional `schemas.config.js` and return a map of
|
|
643
|
-
* `'@
|
|
644
|
-
*
|
|
672
|
+
* `'@key' → absolute path`. The file default-exports a plain object; each key is
|
|
673
|
+
* either a SCOPE (routing the whole scope to a directory of schema files) or a
|
|
674
|
+
* single SCHEMA (`@scope/name`, overriding that one schema to an exact file):
|
|
645
675
|
*
|
|
646
676
|
* // <foundation>/schemas.config.js
|
|
647
677
|
* export default {
|
|
648
|
-
* '@agency':
|
|
649
|
-
* '@
|
|
678
|
+
* '@agency': '../shared/agency-schemas', // scope → directory
|
|
679
|
+
* '@agency/person': './schemas/agency-person.yml', // one schema → a file
|
|
680
|
+
* '@brand': process.env.BRAND_SCHEMAS, // machine-specific, via env
|
|
650
681
|
* }
|
|
651
682
|
*
|
|
652
683
|
* It's plain JS (consistent with main.js / vite.config.js), so paths compute
|
|
653
684
|
* natively — relative, absolute, env-based, or homedir — with no expansion DSL.
|
|
654
|
-
* Relative paths resolve against the foundation source dir. A
|
|
655
|
-
*
|
|
656
|
-
* to the '@org/schemas' package
|
|
685
|
+
* Relative paths resolve against the foundation source dir. A key whose value is
|
|
686
|
+
* null/undefined (e.g. an unset env var) is skipped — that scope/schema falls
|
|
687
|
+
* back to the next source (scope directory, then the '@org/schemas' package).
|
|
688
|
+
* Returns `{}` when the file is absent.
|
|
657
689
|
*
|
|
658
690
|
* @param {string} srcDir - Foundation source root (where main.js lives).
|
|
659
691
|
* @returns {Promise<Record<string, string>>}
|
|
@@ -670,23 +702,69 @@ async function loadSchemaAliases(srcDir) {
|
|
|
670
702
|
throw new Error(`Failed to load schemas.config.js: ${err.message}`)
|
|
671
703
|
}
|
|
672
704
|
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
|
|
673
|
-
throw new Error(`schemas.config.js must default-export a map of '@scope' →
|
|
705
|
+
throw new Error(`schemas.config.js must default-export a map of '@scope' (or '@scope/name') → path.`)
|
|
674
706
|
}
|
|
675
707
|
|
|
676
708
|
const out = {}
|
|
677
709
|
for (const [key, value] of Object.entries(raw)) {
|
|
678
710
|
if (value == null) continue // unset (e.g. missing env var) → not aliased
|
|
679
711
|
if (typeof value !== 'string') {
|
|
680
|
-
throw new Error(`schemas.config.js: alias '${key}' must be a
|
|
681
|
-
}
|
|
682
|
-
if (key[0] !== '@' || key === '@/' || key.includes('/')) {
|
|
683
|
-
throw new Error(`schemas.config.js: alias key '${key}' must be a scope like '@agency' (no slash, not '@/').`)
|
|
712
|
+
throw new Error(`schemas.config.js: alias '${key}' must be a path string, got ${typeof value}.`)
|
|
684
713
|
}
|
|
714
|
+
validateAliasKey(key)
|
|
685
715
|
out[key] = isAbsolute(value) ? value : resolve(srcDir, value)
|
|
686
716
|
}
|
|
687
717
|
return out
|
|
688
718
|
}
|
|
689
719
|
|
|
720
|
+
// A schemas.config.js key is either a scope ('@agency' → a directory of schema
|
|
721
|
+
// files) or a single schema ('@agency/person' → one file). Reject '@/' (self —
|
|
722
|
+
// put the file in the foundation's schemas/), the reserved '@uniweb' scope, and
|
|
723
|
+
// anything malformed. The message keeps the "must be a scope like '@agency'"
|
|
724
|
+
// phrasing so the guidance reads the same for the common no-'@' slip.
|
|
725
|
+
function validateAliasKey(key) {
|
|
726
|
+
if (typeof key !== 'string' || key[0] !== '@') {
|
|
727
|
+
throw new Error(
|
|
728
|
+
`schemas.config.js: alias key '${key}' must be a scope like '@agency' (or a single schema like '@agency/person').`
|
|
729
|
+
)
|
|
730
|
+
}
|
|
731
|
+
const slash = key.indexOf('/')
|
|
732
|
+
const scope = slash === -1 ? key.slice(1) : key.slice(1, slash)
|
|
733
|
+
const name = slash === -1 ? '' : key.slice(slash + 1)
|
|
734
|
+
if (!scope) {
|
|
735
|
+
throw new Error(`schemas.config.js: alias key '${key}' must be a scope like '@agency' (not '@/…' — that's the foundation's own schemas/).`)
|
|
736
|
+
}
|
|
737
|
+
if (scope === RESERVED_SYSTEM_SCOPE) {
|
|
738
|
+
throw new Error(`schemas.config.js: '@${scope}' is reserved and cannot be aliased. Use '@std' for the standard schemas.`)
|
|
739
|
+
}
|
|
740
|
+
if (slash !== -1 && (!name || name.includes('/'))) {
|
|
741
|
+
throw new Error(`schemas.config.js: alias key '${key}' must be '@scope' or '@scope/name' (a single schema name).`)
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
// Resolve a per-schema alias VALUE (already an absolute path) to a loadable
|
|
746
|
+
// schema file: the exact path if it names a file, else the path + each known
|
|
747
|
+
// extension (so a '@acme/person' → '.../person' value finds 'person.yml'). A
|
|
748
|
+
// directory is not a file — returns null so the caller errors clearly.
|
|
749
|
+
function resolveAliasedSchemaFile(absPath) {
|
|
750
|
+
if (existsSync(absPath) && !statSync(absPath).isDirectory()) return absPath
|
|
751
|
+
for (const ext of SCHEMA_EXTENSIONS) {
|
|
752
|
+
if (existsSync(absPath + ext)) return absPath + ext
|
|
753
|
+
}
|
|
754
|
+
return null
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
// Is the scope's '@org/schemas' package resolvable from the foundation? Used only
|
|
758
|
+
// to enrich a routed-scope "not found" error — never throws, never loads.
|
|
759
|
+
function isScopePackageInstalled(scope, srcDir) {
|
|
760
|
+
try {
|
|
761
|
+
createRequire(join(srcDir, 'package.json')).resolve(packageForScope(scope))
|
|
762
|
+
return true
|
|
763
|
+
} catch {
|
|
764
|
+
return false
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
|
|
690
768
|
async function loadSchemaFile(filePath) {
|
|
691
769
|
if (filePath.endsWith('.js')) {
|
|
692
770
|
const mod = await import(pathToFileURL(filePath).href)
|
package/src/search/generate.js
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* for client-side search functionality.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
+
import { resolveDefaultLocale } from '@uniweb/core'
|
|
8
9
|
import { extractSearchContent } from './extract.js'
|
|
9
10
|
|
|
10
11
|
/**
|
|
@@ -18,7 +19,7 @@ import { extractSearchContent } from './extract.js'
|
|
|
18
19
|
*/
|
|
19
20
|
export function generateSearchIndex(siteContent, options = {}) {
|
|
20
21
|
const {
|
|
21
|
-
locale = siteContent.config?.activeLocale || siteContent.config
|
|
22
|
+
locale = siteContent.config?.activeLocale || resolveDefaultLocale(siteContent.config),
|
|
22
23
|
extract: extractOptions = {},
|
|
23
24
|
search: searchConfig = {}
|
|
24
25
|
} = options
|
|
@@ -21,6 +21,7 @@ import { writeFile, readFile, mkdir, cp } from 'node:fs/promises'
|
|
|
21
21
|
import { existsSync } from 'node:fs'
|
|
22
22
|
import { join, resolve } from 'node:path'
|
|
23
23
|
|
|
24
|
+
import { resolveDefaultLocale } from '@uniweb/core'
|
|
24
25
|
import { collectSiteContent } from './content-collector.js'
|
|
25
26
|
import { processCollections, writeCollectionFiles } from './collection-processor.js'
|
|
26
27
|
import { processAssets, rewriteSiteContentPaths } from './asset-processor.js'
|
|
@@ -197,8 +198,7 @@ export async function buildSiteData({
|
|
|
197
198
|
// declared the feature at all.
|
|
198
199
|
const siteFeatures = finalContent.config?.features || []
|
|
199
200
|
if (Array.isArray(siteFeatures) && siteFeatures.includes('search')) {
|
|
200
|
-
const defaultLocale =
|
|
201
|
-
finalContent.config?.defaultLanguage || finalContent.config?.activeLocale || 'en'
|
|
201
|
+
const defaultLocale = resolveDefaultLocale(finalContent.config)
|
|
202
202
|
const searchDir = join(resolvedDistDir, '_search', defaultLocale)
|
|
203
203
|
await mkdir(searchDir, { recursive: true })
|
|
204
204
|
|
|
@@ -32,6 +32,7 @@ import { collectSectionIcons, mergeIconCollections, buildIconManifest } from './
|
|
|
32
32
|
import { normalizeHideIn, dropUnpublishedPages } from './nav-visibility.js'
|
|
33
33
|
import { parseFetchConfig, singularize } from './data-fetcher.js'
|
|
34
34
|
import { buildTheme, extractFoundationVars } from '../theme/index.js'
|
|
35
|
+
import { resolveDefaultLocale, resolvePublishableLocales, validateLanguageConfig } from '@uniweb/core'
|
|
35
36
|
|
|
36
37
|
// Try to import content-reader, fall back to simplified parser
|
|
37
38
|
let markdownToProseMirror
|
|
@@ -2204,21 +2205,59 @@ export async function collectSiteContent(sitePath, options = {}) {
|
|
|
2204
2205
|
// keeps the canonical (folder-based) route. Strip the build-time `slug` from
|
|
2205
2206
|
// pages afterward — the runtime hydrates the precomputed map, not per-page
|
|
2206
2207
|
// slugs (see "Minimize Runtime Payload").
|
|
2207
|
-
const defaultLocale =
|
|
2208
|
-
|
|
2209
|
-
|
|
2210
|
-
|
|
2208
|
+
const defaultLocale = resolveDefaultLocale(siteConfig)
|
|
2209
|
+
const langValidation = validateLanguageConfig(siteConfig)
|
|
2210
|
+
for (const { message } of langValidation.warnings) {
|
|
2211
|
+
console.warn(`[content-collector] ${message}`)
|
|
2212
|
+
}
|
|
2213
|
+
// Publish filter (kb/framework/build/uwx-format.md → "Per-locale publish
|
|
2214
|
+
// readiness"): on published build paths, only the publishable intersection
|
|
2215
|
+
// (publishLanguages ∩ languages; absent field = all declared) ships — in
|
|
2216
|
+
// the embedded languages list and in route translations. Dev keeps the
|
|
2217
|
+
// full declared set so draft locales stay previewable, mirroring how
|
|
2218
|
+
// `hidden` pages behave. Invalid publish configs (nothing publishable /
|
|
2219
|
+
// default not publishable) hard-error the publish build, warn-only in dev.
|
|
2220
|
+
const { publishable, explicit: hasPublishList } = resolvePublishableLocales(siteConfig)
|
|
2221
|
+
if (dropUnpublished && langValidation.errors.length > 0) {
|
|
2222
|
+
throw new Error(
|
|
2223
|
+
`[content-collector] invalid language configuration:\n` +
|
|
2224
|
+
langValidation.errors.map((e) => ` - ${e.message}`).join('\n')
|
|
2225
|
+
)
|
|
2226
|
+
}
|
|
2227
|
+
const publishFilterActive = dropUnpublished && hasPublishList
|
|
2228
|
+
const effectiveLanguages = Array.isArray(siteConfig.languages)
|
|
2229
|
+
? publishFilterActive
|
|
2230
|
+
? publishable
|
|
2231
|
+
: siteConfig.languages
|
|
2232
|
+
: null
|
|
2211
2233
|
const routeTranslations = buildRouteTranslations(pages, {
|
|
2212
2234
|
defaultLocale,
|
|
2213
|
-
languages:
|
|
2235
|
+
languages: effectiveLanguages,
|
|
2214
2236
|
})
|
|
2215
2237
|
for (const page of pages) {
|
|
2216
2238
|
if (page.slug) delete page.slug
|
|
2217
2239
|
}
|
|
2218
2240
|
|
|
2241
|
+
// `publishLanguages` is authoring/publish intent — it has no runtime
|
|
2242
|
+
// consumer and never ships in a payload (the visitor runtime is
|
|
2243
|
+
// list-unaware; the sync lane reads site.yml directly, not this output).
|
|
2244
|
+
const { publishLanguages: _publishLanguages, ...runtimeSiteConfig } = siteConfig
|
|
2245
|
+
|
|
2219
2246
|
return {
|
|
2220
2247
|
config: {
|
|
2221
|
-
...
|
|
2248
|
+
...runtimeSiteConfig,
|
|
2249
|
+
// Embed the RESOLVED effective default (defaultLanguage || languages[0]
|
|
2250
|
+
// || 'en') so every downstream reader — prerender, search, runtime,
|
|
2251
|
+
// shell-mode consumers — sees an explicit value instead of re-deriving
|
|
2252
|
+
// it (historically with inconsistent fallbacks). The backend projection
|
|
2253
|
+
// normalizes identically on its lane (default_language || languages[0]).
|
|
2254
|
+
defaultLanguage: defaultLocale,
|
|
2255
|
+
// On publish builds the embedded languages list is the publishable
|
|
2256
|
+
// intersection — a draft locale is indistinguishable from an
|
|
2257
|
+
// undeclared one to visitors (no locale switcher entry).
|
|
2258
|
+
...(publishFilterActive && Array.isArray(siteConfig.languages)
|
|
2259
|
+
? { languages: publishable }
|
|
2260
|
+
: {}),
|
|
2222
2261
|
fetch: parseFetchConfig(siteConfig.fetch),
|
|
2223
2262
|
...(hasIntelligence && { intelligence: intelligenceConfig }),
|
|
2224
2263
|
...(routeTranslations
|
package/src/site/plugin.js
CHANGED
|
@@ -33,6 +33,7 @@
|
|
|
33
33
|
import { resolve, join } from 'node:path'
|
|
34
34
|
import { watch, existsSync } from 'node:fs'
|
|
35
35
|
import { readFile, readdir } from 'node:fs/promises'
|
|
36
|
+
import { resolveDefaultLocale } from '@uniweb/core'
|
|
36
37
|
import { collectSiteContent } from './content-collector.js'
|
|
37
38
|
import { processAssets, rewriteSiteContentPaths } from './asset-processor.js'
|
|
38
39
|
import { processAdvancedAssets } from './advanced-processors.js'
|
|
@@ -204,6 +205,21 @@ function applyRouteTranslation(route, locale, routeTranslations) {
|
|
|
204
205
|
return route
|
|
205
206
|
}
|
|
206
207
|
|
|
208
|
+
/**
|
|
209
|
+
* Restrict authored `seo.locales` hreflang entries to locales the payload
|
|
210
|
+
* actually serves. `config.languages` is the publishable intersection on
|
|
211
|
+
* production builds (the collector filters by publishLanguages), so draft
|
|
212
|
+
* locales never get advertised in sitemap/meta hreflang. The default-locale
|
|
213
|
+
* entry always survives (it's served at the root regardless).
|
|
214
|
+
*/
|
|
215
|
+
function filterSeoLocales(seoLocales, config) {
|
|
216
|
+
if (!Array.isArray(seoLocales) || seoLocales.length === 0) return seoLocales || []
|
|
217
|
+
const langs = Array.isArray(config?.languages) ? config.languages : null
|
|
218
|
+
if (!langs) return seoLocales
|
|
219
|
+
const served = new Set(langs)
|
|
220
|
+
return seoLocales.filter((l) => l.default || served.has(l.code))
|
|
221
|
+
}
|
|
222
|
+
|
|
207
223
|
/**
|
|
208
224
|
* Generate sitemap.xml content
|
|
209
225
|
*/
|
|
@@ -299,7 +315,8 @@ function generateRobotsTxt(baseUrl, options = {}) {
|
|
|
299
315
|
* Generate meta tags for SEO
|
|
300
316
|
*/
|
|
301
317
|
function generateMetaTags(siteContent, seoOptions) {
|
|
302
|
-
const { baseUrl, defaultImage, twitterHandle
|
|
318
|
+
const { baseUrl, defaultImage, twitterHandle } = seoOptions
|
|
319
|
+
const locales = filterSeoLocales(seoOptions.locales, siteContent.config)
|
|
303
320
|
const siteConfig = siteContent.config || {}
|
|
304
321
|
const tags = []
|
|
305
322
|
|
|
@@ -651,6 +668,11 @@ export function siteContentPlugin(options = {}) {
|
|
|
651
668
|
collectionTranslations = {}
|
|
652
669
|
} catch (err) {
|
|
653
670
|
console.error('[site-content] Failed to collect content:', err.message)
|
|
671
|
+
// Production: a failed collect must fail the build — falling through
|
|
672
|
+
// would publish an empty site (and would swallow deliberate hard
|
|
673
|
+
// errors like an invalid publishLanguages config). Dev: keep the
|
|
674
|
+
// server up with empty content so the author can fix the file.
|
|
675
|
+
if (isProduction) throw err
|
|
654
676
|
siteContent = { config: {}, theme: {}, pages: [] }
|
|
655
677
|
}
|
|
656
678
|
},
|
|
@@ -871,7 +893,7 @@ export function siteContentPlugin(options = {}) {
|
|
|
871
893
|
// Serve sitemap.xml in dev mode
|
|
872
894
|
if (req.url === '/sitemap.xml' && seoEnabled && siteContent?.pages) {
|
|
873
895
|
res.setHeader('Content-Type', 'application/xml')
|
|
874
|
-
res.end(generateSitemap(siteContent.pages, seoOptions.baseUrl, seoOptions.locales, siteContent.config?.i18n?.routeTranslations))
|
|
896
|
+
res.end(generateSitemap(siteContent.pages, seoOptions.baseUrl, filterSeoLocales(seoOptions.locales, siteContent.config), siteContent.config?.i18n?.routeTranslations))
|
|
875
897
|
return
|
|
876
898
|
}
|
|
877
899
|
|
|
@@ -888,7 +910,7 @@ export function siteContentPlugin(options = {}) {
|
|
|
888
910
|
const searchEnabled = searchPluginConfig.enabled !== false && isSearchEnabled(siteContent)
|
|
889
911
|
if (searchEnabled) {
|
|
890
912
|
const searchConfig = siteContent.config?.search || {}
|
|
891
|
-
const defaultLocale = siteContent.config
|
|
913
|
+
const defaultLocale = resolveDefaultLocale(siteContent.config)
|
|
892
914
|
// Use requested locale from URL, fall back to active or default
|
|
893
915
|
const requestedLocale = searchIndexMatch[1]
|
|
894
916
|
const activeLocale = requestedLocale || siteContent.config?.activeLocale || defaultLocale
|
|
@@ -1170,7 +1192,7 @@ export function siteContentPlugin(options = {}) {
|
|
|
1170
1192
|
// Generate SEO files if enabled
|
|
1171
1193
|
if (seoEnabled && finalContent?.pages) {
|
|
1172
1194
|
// Generate sitemap.xml
|
|
1173
|
-
const sitemap = generateSitemap(finalContent.pages, seoOptions.baseUrl, seoOptions.locales, finalContent.config?.i18n?.routeTranslations)
|
|
1195
|
+
const sitemap = generateSitemap(finalContent.pages, seoOptions.baseUrl, filterSeoLocales(seoOptions.locales, finalContent.config), finalContent.config?.i18n?.routeTranslations)
|
|
1174
1196
|
this.emitFile({
|
|
1175
1197
|
type: 'asset',
|
|
1176
1198
|
fileName: 'sitemap.xml',
|
|
@@ -1192,7 +1214,7 @@ export function siteContentPlugin(options = {}) {
|
|
|
1192
1214
|
const searchEnabled = searchPluginConfig.enabled !== false && isSearchEnabled(finalContent)
|
|
1193
1215
|
if (searchEnabled) {
|
|
1194
1216
|
const searchConfig = finalContent.config?.search || {}
|
|
1195
|
-
const defaultLocale = finalContent.config
|
|
1217
|
+
const defaultLocale = resolveDefaultLocale(finalContent.config)
|
|
1196
1218
|
const activeLocale = finalContent.config?.activeLocale || defaultLocale
|
|
1197
1219
|
|
|
1198
1220
|
// Generate search index for current locale
|
package/src/uwx/site-project.js
CHANGED
|
@@ -92,6 +92,9 @@ const INFO_TO_SITE_YML = {
|
|
|
92
92
|
foundation: 'foundation',
|
|
93
93
|
languages: 'languages',
|
|
94
94
|
default_language: 'defaultLanguage',
|
|
95
|
+
// Publish intent — verbatim both ways, dangling codes included (they carry
|
|
96
|
+
// the preserved publish intent of a temporarily-undeclared language).
|
|
97
|
+
publish_languages: 'publishLanguages',
|
|
95
98
|
base: 'base',
|
|
96
99
|
favicon: 'favicon',
|
|
97
100
|
fetcher: 'fetcher',
|
package/src/uwx/site.js
CHANGED
|
@@ -46,8 +46,8 @@ import {
|
|
|
46
46
|
processMarkdownFile,
|
|
47
47
|
} from '../site/content-collector.js'
|
|
48
48
|
import { normalizeHideIn } from '../site/nav-visibility.js'
|
|
49
|
+
import { resolveDefaultLocale, validateLanguageConfig } from '@uniweb/core'
|
|
49
50
|
import { emitEntitySyncPackage } from './entity-document.js'
|
|
50
|
-
import { LOCALIZED_FIELD_ASSUMPTION } from './localize.js'
|
|
51
51
|
import { loadLocaleTranslations, localizeScalar, localizeScalarList, localizeContentDoc, localesDir, isLocalizedContent } from './locale-sync.js'
|
|
52
52
|
import { unwrapLocalized } from './backfill.js'
|
|
53
53
|
import { loadFreeformTranslation } from '../i18n/freeform.js'
|
|
@@ -503,15 +503,15 @@ function collectionsNested(declarations) {
|
|
|
503
503
|
* @param {object} [opts]
|
|
504
504
|
* @param {string} [opts.entityUuid] - override the entity `$uuid` (tests); default
|
|
505
505
|
* is `site.yml::$uuid` (absent on first sync — `$id`-only document).
|
|
506
|
-
* @param {string} [opts.sourceLocale] - localized-field wrap locale
|
|
506
|
+
* @param {string} [opts.sourceLocale] - localized-field wrap locale. Defaults to
|
|
507
|
+
* the site's effective default locale (`defaultLanguage || languages[0] ||
|
|
508
|
+
* 'en'` — the shared `resolveDefaultLocale` rule), NOT a bare 'en'.
|
|
507
509
|
* @returns {Promise<object>} the section-keyed `$`-document:
|
|
508
510
|
* `{ $uuid?, $id, $model, info, pages, layout_sections, extensions, collections }`
|
|
509
511
|
*/
|
|
510
512
|
export async function siteProjectToDocument(siteRoot, opts = {}) {
|
|
511
|
-
const sourceLocale =
|
|
512
|
-
opts.sourceLocale || LOCALIZED_FIELD_ASSUMPTION.defaultSourceLocale
|
|
513
|
-
|
|
514
513
|
const siteYml = await readYamlFile(join(siteRoot, 'site.yml'))
|
|
514
|
+
const sourceLocale = opts.sourceLocale || resolveDefaultLocale(siteYml)
|
|
515
515
|
if (!siteYml.name) {
|
|
516
516
|
throw new Error('uwx/site: site.yml::name is required')
|
|
517
517
|
}
|
|
@@ -521,6 +521,20 @@ export async function siteProjectToDocument(siteRoot, opts = {}) {
|
|
|
521
521
|
'it maps to the required @uniweb/site-content info.foundation'
|
|
522
522
|
)
|
|
523
523
|
}
|
|
524
|
+
// Language-config contract: warnings surface (dangling publish codes,
|
|
525
|
+
// legacy entry shapes); errors — nothing publishable / default not
|
|
526
|
+
// publishable — hard-error at push, same as the static build does at
|
|
527
|
+
// build/deploy (uwx-format.md → "Per-locale publish readiness").
|
|
528
|
+
const langValidation = validateLanguageConfig(siteYml)
|
|
529
|
+
for (const { message } of langValidation.warnings) {
|
|
530
|
+
console.warn(`uwx/site: ${message}`)
|
|
531
|
+
}
|
|
532
|
+
if (langValidation.errors.length > 0) {
|
|
533
|
+
throw new Error(
|
|
534
|
+
'uwx/site: invalid language configuration:\n' +
|
|
535
|
+
langValidation.errors.map((e) => ` - ${e.message}`).join('\n')
|
|
536
|
+
)
|
|
537
|
+
}
|
|
524
538
|
|
|
525
539
|
const themeYml = await readYamlFile(join(siteRoot, 'theme.yml'))
|
|
526
540
|
let headHtml
|
|
@@ -550,6 +564,12 @@ export async function siteProjectToDocument(siteRoot, opts = {}) {
|
|
|
550
564
|
if (themeYml && Object.keys(themeYml).length > 0) info.theme = themeYml
|
|
551
565
|
setIf(info, 'languages', siteYml.languages)
|
|
552
566
|
setIf(info, 'default_language', siteYml.defaultLanguage)
|
|
567
|
+
// Publish intent (site.yml `publishLanguages:`) rides VERBATIM — dangling
|
|
568
|
+
// codes included. Sync carries the full working set; only *publish* filters
|
|
569
|
+
// (backend projection / static-build filter). The verbatim carry is what
|
|
570
|
+
// preserves a locale's publish intent across a remove + re-add in
|
|
571
|
+
// `languages:` (uwx-format.md → "Per-locale publish readiness").
|
|
572
|
+
setIf(info, 'publish_languages', siteYml.publishLanguages)
|
|
553
573
|
// `foundation` (required) — the verbatim `site.yml::foundation` string
|
|
554
574
|
// (registry ref / URL / local path), the round-trip source of truth.
|
|
555
575
|
info.foundation = siteYml.foundation
|