@uniweb/build 0.14.35 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniweb/build",
3
- "version": "0.14.35",
3
+ "version": "0.14.37",
4
4
  "description": "Build tooling for the Uniweb Component Web Platform",
5
5
  "type": "module",
6
6
  "exports": {
@@ -60,11 +60,11 @@
60
60
  "sharp": "^0.33.2",
61
61
  "yaml": "^2.5.0",
62
62
  "@uniweb/theming": "0.1.9",
63
- "@uniweb/content-writer": "0.2.6"
63
+ "@uniweb/content-writer": "0.2.7"
64
64
  },
65
65
  "optionalDependencies": {
66
- "@uniweb/runtime": "0.8.28",
67
- "@uniweb/content-reader": "1.1.13",
66
+ "@uniweb/content-reader": "1.1.14",
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.22"
77
+ "@uniweb/core": "0.7.23"
78
78
  },
79
79
  "peerDependenciesMeta": {
80
80
  "vite": {
@@ -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?.defaultLanguage || 'en',
59
+ defaultLocale: resolveDefaultLocale(siteContent.config),
59
60
  extracted: new Date().toISOString(),
60
61
  units
61
62
  }
@@ -241,11 +242,17 @@ function collectInlineText(node) {
241
242
  for (const child of node.content) {
242
243
  if (child.type === 'text') {
243
244
  out += child.text || ''
245
+ } else if (child.type === 'hardBreak') {
246
+ // A break must contribute a separator or the words either side fuse
247
+ // ("line oneline two"), silently changing the unit key. "\n" is the
248
+ // pinned separator — see vector I. computeHash normalizes whitespace, so
249
+ // it hashes identically to " "; only the raw structural-map key differs.
250
+ out += '\n'
244
251
  } else if (child.content) {
245
252
  // recurse through inline wrappers into their text
246
253
  out += collectInlineText(child)
247
254
  }
248
- // else: inline atom (image/icon/emoji/math/hardBreak) → contributes no text
255
+ // else: other inline atom (image/icon/emoji/math) → contributes no text
249
256
  }
250
257
  return out
251
258
  }
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?.defaultLanguage || 'en'
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?.defaultLanguage || 'en'
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 map a scope to a
22
- * directory of schema files, so '@org/name' resolves to a bare folder anywhere
23
- * on disk — no package, no install. The alias takes precedence over the
24
- * '@org/schemas' package convention; see `loadSchemaAliases`.
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'
@@ -93,7 +95,7 @@ export function parseSchemaRef(ref) {
93
95
  if (typeof ref !== 'string' || ref[0] !== '@') {
94
96
  throw new Error(
95
97
  `Invalid data-schema ref ${JSON.stringify(ref)}: must start with '@' ` +
96
- `(e.g. '@/member' for this foundation, or '@uniweb/person' for a shared standard).`
98
+ `(e.g. '@/member' for this foundation, or '@std/person' for a shared standard).`
97
99
  )
98
100
  }
99
101
  const slash = ref.indexOf('/')
@@ -155,20 +157,47 @@ export async function resolveSchemaRef(ref, { srcDir, aliases }) {
155
157
  )
156
158
  }
157
159
 
158
- // Alias routing (schemas.config.js): a scope mapped to a directory resolves to
159
- // a bare schema FILE in that directory no package, no install, no node_modules.
160
- // This lets a foundation point '@agency' at a shared schema folder anywhere on
161
- // disk. Takes precedence over the package convention below; '@/' (self) and the
162
- // reserved '@uniweb' scope are handled above and are never aliasable.
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
- throw new Error(
188
+ let msg =
169
189
  `Data schema '${ref}' not found in the directory '@${scope}' is aliased to ('${aliasDir}' ` +
170
- `via schemas.config.js). Expected one of: ${tried}.`
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
  }
@@ -324,10 +353,26 @@ export function validateAndNormalizeSchema(schema, ref) {
324
353
  }
325
354
 
326
355
  const out = {}
327
- for (const k of ['name', 'version', 'description', 'sort_date', 'sortDate']) {
356
+ for (const k of ['name', 'version', 'description']) {
328
357
  if (schema[k] !== undefined) out[k] = schema[k]
329
358
  }
330
359
 
360
+ // The model's sort axis names a DATE FIELD IN THE BRIEF section (not a boolean,
361
+ // not a field-level flag) — the lowering stamps `sort_date: true` on that field.
362
+ // Authored as `sort_date` (the authoring vocabulary is snake_case, like
363
+ // `append_only`); `sortDate` is an accepted alias. Both normalize to `sortDate`,
364
+ // the single key the lowering reads — carrying the two spellings through verbatim
365
+ // meant an authored `sort_date` was silently dropped.
366
+ const sortDate = schema.sort_date ?? schema.sortDate
367
+ if (sortDate !== undefined) {
368
+ if (typeof sortDate !== 'string') {
369
+ throw new Error(
370
+ `Data schema '${ref}': 'sort_date' must name a date field in the brief section, got ${typeof sortDate}.`
371
+ )
372
+ }
373
+ out.sortDate = sortDate
374
+ }
375
+
331
376
  const hasFields = schema.fields !== undefined
332
377
  const hasSections = schema.sections !== undefined
333
378
  if (hasFields && hasSections) {
@@ -624,20 +669,23 @@ function findSchemaFileInDir(dir, name) {
624
669
 
625
670
  /**
626
671
  * Load a foundation's optional `schemas.config.js` and return a map of
627
- * `'@scope' → absolute directory`. The file default-exports a plain object
628
- * mapping a schema-ref scope to a directory of schema files:
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):
629
675
  *
630
676
  * // <foundation>/schemas.config.js
631
677
  * export default {
632
- * '@agency': '../shared/agency-schemas', // relative to the foundation
633
- * '@brand': process.env.BRAND_SCHEMAS, // machine-specific, via env
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
634
681
  * }
635
682
  *
636
683
  * It's plain JS (consistent with main.js / vite.config.js), so paths compute
637
684
  * natively — relative, absolute, env-based, or homedir — with no expansion DSL.
638
- * Relative paths resolve against the foundation source dir. A scope whose value
639
- * is null/undefined (e.g. an unset env var) is skipped — that scope falls back
640
- * to the '@org/schemas' package convention. Returns `{}` when the file is absent.
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.
641
689
  *
642
690
  * @param {string} srcDir - Foundation source root (where main.js lives).
643
691
  * @returns {Promise<Record<string, string>>}
@@ -654,23 +702,69 @@ async function loadSchemaAliases(srcDir) {
654
702
  throw new Error(`Failed to load schemas.config.js: ${err.message}`)
655
703
  }
656
704
  if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
657
- throw new Error(`schemas.config.js must default-export a map of '@scope' → directory.`)
705
+ throw new Error(`schemas.config.js must default-export a map of '@scope' (or '@scope/name') path.`)
658
706
  }
659
707
 
660
708
  const out = {}
661
709
  for (const [key, value] of Object.entries(raw)) {
662
710
  if (value == null) continue // unset (e.g. missing env var) → not aliased
663
711
  if (typeof value !== 'string') {
664
- throw new Error(`schemas.config.js: alias '${key}' must be a directory path string, got ${typeof value}.`)
665
- }
666
- if (key[0] !== '@' || key === '@/' || key.includes('/')) {
667
- 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}.`)
668
713
  }
714
+ validateAliasKey(key)
669
715
  out[key] = isAbsolute(value) ? value : resolve(srcDir, value)
670
716
  }
671
717
  return out
672
718
  }
673
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
+
674
768
  async function loadSchemaFile(filePath) {
675
769
  if (filePath.endsWith('.js')) {
676
770
  const mod = await import(pathToFileURL(filePath).href)
@@ -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?.defaultLanguage || 'en',
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
- siteConfig.defaultLanguage ||
2209
- (Array.isArray(siteConfig.languages) ? siteConfig.languages[0] : null) ||
2210
- 'en'
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: Array.isArray(siteConfig.languages) ? siteConfig.languages : null,
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
- ...siteConfig,
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
@@ -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, locales = [] } = seoOptions
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?.defaultLanguage || 'en'
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?.defaultLanguage || 'en'
1217
+ const defaultLocale = resolveDefaultLocale(finalContent.config)
1196
1218
  const activeLocale = finalContent.config?.activeLocale || defaultLocale
1197
1219
 
1198
1220
  // Generate search index for current locale
@@ -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 (default "en").
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