@uniweb/core 0.7.22 → 0.7.23

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,10 +1,11 @@
1
1
  {
2
2
  "name": "@uniweb/core",
3
- "version": "0.7.22",
3
+ "version": "0.7.23",
4
4
  "description": "Core classes for the Uniweb platform - Uniweb, Website, Page, Block",
5
5
  "type": "module",
6
6
  "exports": {
7
- ".": "./src/index.js"
7
+ ".": "./src/index.js",
8
+ "./locale-config": "./src/locale-config.js"
8
9
  },
9
10
  "files": [
10
11
  "src"
@@ -30,8 +31,8 @@
30
31
  "vitest": "^4.1.7"
31
32
  },
32
33
  "dependencies": {
33
- "@uniweb/theming": "0.1.9",
34
- "@uniweb/semantic-parser": "1.1.18"
34
+ "@uniweb/semantic-parser": "1.1.18",
35
+ "@uniweb/theming": "0.1.9"
35
36
  },
36
37
  "scripts": {
37
38
  "test": "vitest run"
package/src/index.js CHANGED
@@ -20,6 +20,13 @@ export { default as ObservableState } from './observable-state.js'
20
20
 
21
21
  // Utilities
22
22
  export { substitutePlaceholders } from './substitute-placeholders.js'
23
+ export {
24
+ normalizeLanguageList,
25
+ isWildcardLanguages,
26
+ resolveDefaultLocale,
27
+ resolvePublishableLocales,
28
+ validateLanguageConfig
29
+ } from './locale-config.js'
23
30
  export { evaluate as evaluateWhere, match as matchWhere } from './where.js'
24
31
  export { isRichSchema } from './schemas.js'
25
32
  export { resolveStyle as resolveRequestStyle, listStyleNames as listRequestStyleNames } from './request-styles/index.js'
@@ -0,0 +1,202 @@
1
+ /**
2
+ * Shared locale-config helpers — the ONE home for the language rules that
3
+ * build, sync, runtime, and the CLI all apply to a site's config.
4
+ *
5
+ * Contract (kb/framework/build/uwx-format.md → "Per-locale publish readiness"):
6
+ * - `languages` (site.yml) / `info.languages` (wire) — the DECLARED working
7
+ * set. A plain, strongly-validated string list.
8
+ * - `publishLanguages` (site.yml) / `info.publish_languages` (wire) — publish
9
+ * intent. Publishable = intersection with declared; absent field = all
10
+ * declared publishable; present-but-empty = nothing publishable; dangling
11
+ * codes (listed but not declared) are benign — warn, ignore in the
12
+ * intersection, round-trip verbatim.
13
+ * - Effective default locale = `defaultLanguage || languages[0] || 'en'` —
14
+ * one rule everywhere. (Historically half the call sites skipped the
15
+ * `languages[0]` step; this module exists so that can't drift again.)
16
+ */
17
+
18
+ /**
19
+ * Extract a locale code from a declared-language entry. The contract is
20
+ * strings-only; legacy `{ code, label }` objects are tolerated on read
21
+ * (they appeared in older configs and the runtime's buildLocalesList
22
+ * accepted them) but are never produced. The `'*'` wildcard marker
23
+ * (auto-discover from `locales/`) is not a locale code.
24
+ *
25
+ * @param {*} entry - Declared-language entry.
26
+ * @returns {string|null} The locale code, or null when unusable.
27
+ */
28
+ function codeOf(entry) {
29
+ if (typeof entry === 'string' && entry.trim() && entry.trim() !== '*') return entry.trim()
30
+ if (entry && typeof entry === 'object' && typeof entry.code === 'string' && entry.code.trim()) {
31
+ return entry.code.trim()
32
+ }
33
+ return null
34
+ }
35
+
36
+ /**
37
+ * Whether `languages` uses the auto-discover wildcard (`'*'`, or an array
38
+ * containing it). The declared set is then filesystem-derived and unknown
39
+ * to these pure helpers.
40
+ *
41
+ * @param {*} value - The authored `languages` value.
42
+ * @returns {boolean}
43
+ */
44
+ export function isWildcardLanguages(value) {
45
+ return value === '*' || (Array.isArray(value) && value.includes('*'))
46
+ }
47
+
48
+ /**
49
+ * Normalize a language list to validated string codes: invalid entries
50
+ * dropped, duplicates deduped, order preserved.
51
+ *
52
+ * @param {*} value - The authored list (anything; non-arrays yield []).
53
+ * @returns {string[]} Clean locale codes.
54
+ */
55
+ export function normalizeLanguageList(value) {
56
+ if (!Array.isArray(value)) return []
57
+ const seen = new Set()
58
+ const codes = []
59
+ for (const entry of value) {
60
+ const code = codeOf(entry)
61
+ if (!code || seen.has(code)) continue
62
+ seen.add(code)
63
+ codes.push(code)
64
+ }
65
+ return codes
66
+ }
67
+
68
+ /**
69
+ * The effective default locale: `defaultLanguage || languages[0] || 'en'`.
70
+ * Works on authored site.yml, built site-content config, and served payload
71
+ * config alike (all carry the same camelCase keys).
72
+ *
73
+ * @param {Object} [config] - Site config (`{ defaultLanguage?, languages? }`).
74
+ * @returns {string} The effective default locale code.
75
+ */
76
+ export function resolveDefaultLocale(config = {}) {
77
+ if (typeof config?.defaultLanguage === 'string' && config.defaultLanguage.trim()) {
78
+ return config.defaultLanguage.trim()
79
+ }
80
+ return normalizeLanguageList(config?.languages)[0] || 'en'
81
+ }
82
+
83
+ /**
84
+ * The publishable set: `publishLanguages ∩ languages`, in declared order.
85
+ *
86
+ * - Absent `publishLanguages` → all declared publishable (`explicit: false`).
87
+ * - Present (even empty) → the intersection (`explicit: true`); an authored
88
+ * empty list means nothing publishable — NOT treated as absent.
89
+ * - Dangling codes are returned for the caller to warn about; they are never
90
+ * silently pruned from the authored/stored list (the verbatim round-trip is
91
+ * what preserves publish intent across a remove + re-add in `languages`).
92
+ *
93
+ * With wildcard `languages` (`'*'`), the declared set is unknown here
94
+ * (filesystem-derived), so the intersection with "all" is the publish list
95
+ * itself and dangling codes cannot exist.
96
+ *
97
+ * @param {Object} [config] - Site config (`{ languages?, publishLanguages? }`).
98
+ * @returns {{ publishable: string[], dangling: string[], explicit: boolean }}
99
+ */
100
+ export function resolvePublishableLocales(config = {}) {
101
+ const declared = normalizeLanguageList(config?.languages)
102
+ const raw = config?.publishLanguages
103
+ if (raw == null) return { publishable: declared, dangling: [], explicit: false }
104
+ const listed = normalizeLanguageList(raw)
105
+ if (isWildcardLanguages(config?.languages)) {
106
+ return { publishable: listed, dangling: [], explicit: true }
107
+ }
108
+ const declaredSet = new Set(declared)
109
+ const listedSet = new Set(listed)
110
+ return {
111
+ publishable: declared.filter((code) => listedSet.has(code)),
112
+ dangling: listed.filter((code) => !declaredSet.has(code)),
113
+ explicit: true
114
+ }
115
+ }
116
+
117
+ /**
118
+ * Validate a site's language configuration against the contract. Pure — the
119
+ * caller decides how to surface results (build warnings, publish hard error).
120
+ *
121
+ * Errors (producers hard-error at build/deploy/push):
122
+ * - `nothing-publishable` — an explicit publish list intersects declared to ∅.
123
+ * - `default-not-publishable` — the effective default is excluded from the
124
+ * publishable set.
125
+ *
126
+ * Warnings:
127
+ * - `invalid-language-entry` / `invalid-publish-language-entry` — non-string
128
+ * entries (dropped by normalization).
129
+ * - `duplicate-language` — repeated codes (deduped).
130
+ * - `dangling-publish-language` — listed but not declared (ignored at
131
+ * publish, preserved in the file/wire).
132
+ *
133
+ * @param {Object} [config] - Site config.
134
+ * @returns {{ errors: {code: string, message: string}[],
135
+ * warnings: {code: string, message: string}[] }}
136
+ */
137
+ export function validateLanguageConfig(config = {}) {
138
+ const errors = []
139
+ const warnings = []
140
+
141
+ const inspectList = (value, field, entryCode) => {
142
+ if (value == null) return
143
+ if (value === '*') return // auto-discover wildcard (languages only)
144
+ if (!Array.isArray(value)) {
145
+ warnings.push({
146
+ code: entryCode,
147
+ message: `${field} must be a list of locale codes (got ${typeof value}) — ignored`
148
+ })
149
+ return
150
+ }
151
+ const seen = new Set()
152
+ for (const entry of value) {
153
+ if (entry === '*') continue // auto-discover wildcard marker, not a code
154
+ const code = codeOf(entry)
155
+ if (!code) {
156
+ warnings.push({
157
+ code: entryCode,
158
+ message: `${field} entry ${JSON.stringify(entry)} is not a locale code string — dropped`
159
+ })
160
+ continue
161
+ }
162
+ if (typeof entry !== 'string') {
163
+ warnings.push({
164
+ code: entryCode,
165
+ message: `${field} entry for '${code}' uses the legacy object form — use the plain string '${code}'`
166
+ })
167
+ }
168
+ if (seen.has(code)) {
169
+ warnings.push({ code: 'duplicate-language', message: `${field} lists '${code}' more than once — deduped` })
170
+ }
171
+ seen.add(code)
172
+ }
173
+ }
174
+
175
+ inspectList(config?.languages, 'languages', 'invalid-language-entry')
176
+ inspectList(config?.publishLanguages, 'publishLanguages', 'invalid-publish-language-entry')
177
+
178
+ const { publishable, dangling, explicit } = resolvePublishableLocales(config)
179
+ for (const code of dangling) {
180
+ warnings.push({
181
+ code: 'dangling-publish-language',
182
+ message: `publishLanguages lists '${code}' but languages does not declare it — ignored at publish (kept in the file so a re-declared language keeps its publish intent)`
183
+ })
184
+ }
185
+
186
+ if (explicit && publishable.length === 0) {
187
+ errors.push({
188
+ code: 'nothing-publishable',
189
+ message: 'publishLanguages leaves no publishable language (empty list, or nothing it lists is declared) — a publishable default language is required'
190
+ })
191
+ } else if (explicit) {
192
+ const defaultLocale = resolveDefaultLocale(config)
193
+ if (!publishable.includes(defaultLocale)) {
194
+ errors.push({
195
+ code: 'default-not-publishable',
196
+ message: `the default language '${defaultLocale}' is not in publishLanguages — the effective default must be publishable`
197
+ })
198
+ }
199
+ }
200
+
201
+ return { errors, warnings }
202
+ }
package/src/website.js CHANGED
@@ -10,6 +10,7 @@ import EntityStore from './entity-store.js'
10
10
  import FetcherDispatcher from './fetcher-dispatcher.js'
11
11
  import ObservableState from './observable-state.js'
12
12
  import { normalizeSeo } from './seo.js'
13
+ import { resolveDefaultLocale } from './locale-config.js'
13
14
 
14
15
  /**
15
16
  * Website — orchestration root for a single site instance.
@@ -162,7 +163,7 @@ export default class Website {
162
163
  this.seo = normalizeSeo(config.seo)
163
164
  this.keywords = config.keywords || null
164
165
 
165
- this.siteDefaultLocale = config.defaultLanguage || 'en'
166
+ this.siteDefaultLocale = resolveDefaultLocale(config)
166
167
  this.defaultLocale = config.domainLocale || this.siteDefaultLocale
167
168
  this.activeLocale = config.activeLocale || this.defaultLocale
168
169
 
@@ -229,7 +230,7 @@ export default class Website {
229
230
  * @private
230
231
  */
231
232
  buildLocalesList(config) {
232
- const defaultLocale = config.defaultLanguage || 'en'
233
+ const defaultLocale = resolveDefaultLocale(config)
233
234
  const languages = config.languages || []
234
235
 
235
236
  // Normalize input: convert strings to objects, keep objects as-is