@uniweb/core 0.7.22 → 0.7.24

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.24",
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,7 +31,7 @@
30
31
  "vitest": "^4.1.7"
31
32
  },
32
33
  "dependencies": {
33
- "@uniweb/theming": "0.1.9",
34
+ "@uniweb/theming": "0.1.10",
34
35
  "@uniweb/semantic-parser": "1.1.18"
35
36
  },
36
37
  "scripts": {
package/src/index.js CHANGED
@@ -12,7 +12,7 @@ export { Uniweb }
12
12
  export { default as Website } from './website.js'
13
13
  export { default as Page } from './page.js'
14
14
  export { default as Block } from './block.js'
15
- export { default as Theme } from './theme.js'
15
+ export { default as Theme, hasDarkScheme } from './theme.js'
16
16
  export { default as DataStore, deriveCacheKey } from './datastore.js'
17
17
  export { default as EntityStore } from './entity-store.js'
18
18
  export { default as FetcherDispatcher } from './fetcher-dispatcher.js'
@@ -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/theme.js CHANGED
@@ -404,3 +404,33 @@ export default class Theme {
404
404
  }
405
405
  }
406
406
  }
407
+
408
+ /**
409
+ * Does a site's appearance config make the dark scheme reachable at all?
410
+ *
411
+ * This is the behavioral "can this site ever show dark" predicate — distinct
412
+ * from Theme.supportsScheme(), which literally answers "is this scheme listed
413
+ * in `schemes:`". A site reaches dark if it offers a toggle, defaults to dark
414
+ * or system, or explicitly lists dark in `schemes`.
415
+ *
416
+ * CANONICAL SHARED PREDICATE. It MUST stay in lockstep with the dark-CSS
417
+ * emission guard in @uniweb/theming's css-generator.js (`generateThemeCSS`,
418
+ * "Dark scheme support" block): that guard decides whether the `.scheme-dark`
419
+ * rules physically exist, and this decides whether the runtime may boot into
420
+ * or switch to dark. If the two disagree, you get a scheme with no matching
421
+ * CSS (page claims dark, renders light) — the exact desync class this unifies
422
+ * away. @uniweb/theming cannot import @uniweb/core, so its copy is annotated to
423
+ * point here; everything that consumes the model (runtime boot, kit's
424
+ * useAppearance) imports THIS one.
425
+ *
426
+ * @param {Object} appearance - the resolved theme.yml `appearance:` block
427
+ * @returns {boolean}
428
+ */
429
+ export function hasDarkScheme(appearance = {}) {
430
+ return Boolean(
431
+ appearance.allowToggle ||
432
+ appearance.default === 'dark' ||
433
+ appearance.default === 'system' ||
434
+ appearance.schemes?.includes('dark')
435
+ )
436
+ }
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