@koehler8/cms 1.0.0-beta.6 → 1.0.0-beta.8

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/README.md CHANGED
@@ -75,15 +75,25 @@ vite-ssg build # Static site generation
75
75
 
76
76
  ## Site Configuration
77
77
 
78
- Site content lives in `site/config/`:
78
+ Site content lives in `site/content/`, organized by locale:
79
79
 
80
- - **`site.json`** -- Site-level metadata (title, description, URL, theme, Google Analytics ID)
81
- - **`shared.json`** -- Content blocks shared across pages (header, footer, socials)
82
- - **`pages/*.json`** -- Per-page content keyed by page ID
83
-
84
- ### Locale Overrides
80
+ ```
81
+ site/content/
82
+ content.config.json # { "baseLocale": "en" }
83
+ en/
84
+ site.json # Site metadata (flat dot-notation keys, sorted)
85
+ shared.json # Shared content (header, footer, socials)
86
+ pages/
87
+ home.json # Per-page content
88
+ privacy.json
89
+ de/
90
+ site.json # German overrides (only translated keys)
91
+ shared.json
92
+ pages/
93
+ home.json
94
+ ```
85
95
 
86
- Place locale-specific configs in `site/config/i18n/{locale}/` mirroring the same structure. They are deep-merged over the base config at runtime.
96
+ All files use flat dot-notation keys in alphabetical order. The base locale (specified in `content.config.json`) is loaded first; other locale directories override only the keys they specify. Missing keys fall back to the base locale value.
87
97
 
88
98
  **Supported locales:** en, fr, es, de, ja, ko, pt, ru, tr, vi, id, zh, th, hi, fil
89
99
 
@@ -91,7 +101,7 @@ Place locale-specific configs in `site/config/i18n/{locale}/` mirroring the same
91
101
 
92
102
  Themes export a manifest with design tokens (palette, typography, surfaces, CTAs, etc.) that are applied as CSS variables at runtime.
93
103
 
94
- - Set `site.theme` in `site.json` to a theme slug
104
+ - Set the `theme` key in `content/{baseLocale}/site.json` to a theme slug
95
105
  - Omit it to use the built-in `base` theme
96
106
  - External themes are npm packages registered via the `themes` plugin option
97
107
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@koehler8/cms",
3
- "version": "1.0.0-beta.6",
3
+ "version": "1.0.0-beta.8",
4
4
  "description": "Vertex CMS — lightweight, config-driven Vue 3 framework with theming, extensions, and SSG support",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -3,7 +3,7 @@
3
3
  //
4
4
  // Reads from CMS_SITE_DIR env var (e.g. /path/to/my-site/site/).
5
5
  // Assets are read from {CMS_SITE_DIR}/assets/, config from
6
- // {CMS_SITE_DIR}/config/site.json, output is written to
6
+ // {CMS_SITE_DIR}/content/{baseLocale}/site.json, output is written to
7
7
  // {dirname(CMS_SITE_DIR)}/public/.
8
8
 
9
9
  import 'dotenv/config';
@@ -260,12 +260,23 @@ function renderOgImageBuffer(text, { width = 1200, height = 630, bg = '#000000',
260
260
  }
261
261
 
262
262
  function loadSiteTitle() {
263
- const configPath = path.join(CMS_SITE_DIR, 'config', 'site.json');
263
+ const contentDir = path.join(CMS_SITE_DIR, 'content');
264
+ const contentConfigPath = path.join(contentDir, 'content.config.json');
265
+ let baseLocale = 'en';
266
+ try {
267
+ if (fs.existsSync(contentConfigPath)) {
268
+ const cc = JSON.parse(fs.readFileSync(contentConfigPath, 'utf-8'));
269
+ baseLocale = cc.baseLocale || 'en';
270
+ }
271
+ } catch { /* use default */ }
272
+
273
+ const configPath = path.join(contentDir, baseLocale, 'site.json');
264
274
  if (!fs.existsSync(configPath)) return '';
265
275
  try {
266
276
  const raw = fs.readFileSync(configPath, 'utf-8');
267
277
  const parsed = JSON.parse(raw);
268
- const title = parsed?.title || parsed?.site?.title;
278
+ // Flat format: key is "title"; nested format: key is "site.title" or nested
279
+ const title = parsed?.title || parsed?.['site.title'] || parsed?.site?.title;
269
280
  if (typeof title === 'string' && title.trim()) {
270
281
  return title.trim();
271
282
  }
@@ -0,0 +1,223 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Migration script: converts site/config/ structure to site/content/ structure.
4
+ *
5
+ * Old layout:
6
+ * site/config/site.json (nested)
7
+ * site/config/shared.json (nested)
8
+ * site/config/pages/*.json (nested)
9
+ * site/config/i18n/{locale}.json (flat dot-notation)
10
+ *
11
+ * New layout:
12
+ * site/content/content.config.json ({ "baseLocale": "en" })
13
+ * site/content/en/site.json (flat dot-notation, sorted)
14
+ * site/content/en/shared.json (flat dot-notation, sorted)
15
+ * site/content/en/pages/*.json (flat dot-notation, sorted)
16
+ * site/content/{locale}/site.json (flat dot-notation, sorted, overrides only)
17
+ * site/content/{locale}/shared.json
18
+ * site/content/{locale}/pages/*.json
19
+ *
20
+ * Usage:
21
+ * node scripts/migrate-to-content-dirs.mjs /path/to/site-repo
22
+ * node scripts/migrate-to-content-dirs.mjs /path/to/site-repo --commit
23
+ *
24
+ * Without --commit, runs in dry-run mode (prints what it would do).
25
+ */
26
+
27
+ import fs from 'node:fs';
28
+ import path from 'node:path';
29
+
30
+ // ---- Flatten utility ----
31
+
32
+ const ARRAY_INDEX_RE = /\[(\d+)]/g;
33
+
34
+ function flattenObject(obj, prefix = '') {
35
+ const result = {};
36
+ if (!obj || typeof obj !== 'object') return result;
37
+
38
+ for (const [key, value] of Object.entries(obj)) {
39
+ if (key.startsWith('$')) continue;
40
+ const fullKey = prefix ? `${prefix}.${key}` : key;
41
+
42
+ if (Array.isArray(value)) {
43
+ value.forEach((item, idx) => {
44
+ if (item && typeof item === 'object' && !Array.isArray(item)) {
45
+ Object.assign(result, flattenObject(item, `${fullKey}[${idx}]`));
46
+ } else {
47
+ result[`${fullKey}[${idx}]`] = item;
48
+ }
49
+ });
50
+ } else if (value && typeof value === 'object') {
51
+ Object.assign(result, flattenObject(value, fullKey));
52
+ } else {
53
+ result[fullKey] = value;
54
+ }
55
+ }
56
+ return result;
57
+ }
58
+
59
+ function sortedJson(obj) {
60
+ const sorted = {};
61
+ for (const key of Object.keys(obj).sort()) {
62
+ sorted[key] = obj[key];
63
+ }
64
+ return JSON.stringify(sorted, null, 2) + '\n';
65
+ }
66
+
67
+ // ---- Main ----
68
+
69
+ const siteRepoDir = process.argv[2];
70
+ const commit = process.argv.includes('--commit');
71
+
72
+ if (!siteRepoDir) {
73
+ console.error('Usage: node scripts/migrate-to-content-dirs.mjs /path/to/site-repo [--commit]');
74
+ process.exit(1);
75
+ }
76
+
77
+ const siteDir = path.join(siteRepoDir, 'site');
78
+ const configDir = path.join(siteDir, 'config');
79
+ const contentDir = path.join(siteDir, 'content');
80
+
81
+ if (!fs.existsSync(configDir)) {
82
+ console.error(`Config directory not found: ${configDir}`);
83
+ process.exit(1);
84
+ }
85
+
86
+ const actions = [];
87
+
88
+ function plan(action, detail) {
89
+ actions.push({ action, detail });
90
+ if (!commit) console.log(` [dry-run] ${action}: ${detail}`);
91
+ }
92
+
93
+ // 1. Flatten and move English base files
94
+ const enDir = path.join(contentDir, 'en');
95
+ const enPagesDir = path.join(enDir, 'pages');
96
+
97
+ // site.json
98
+ const siteJsonPath = path.join(configDir, 'site.json');
99
+ if (fs.existsSync(siteJsonPath)) {
100
+ const nested = JSON.parse(fs.readFileSync(siteJsonPath, 'utf-8'));
101
+ const flat = flattenObject(nested);
102
+ plan('write', `content/en/site.json (${Object.keys(flat).length} keys)`);
103
+ if (commit) {
104
+ fs.mkdirSync(enDir, { recursive: true });
105
+ fs.writeFileSync(path.join(enDir, 'site.json'), sortedJson(flat));
106
+ }
107
+ }
108
+
109
+ // shared.json
110
+ const sharedJsonPath = path.join(configDir, 'shared.json');
111
+ if (fs.existsSync(sharedJsonPath)) {
112
+ const nested = JSON.parse(fs.readFileSync(sharedJsonPath, 'utf-8'));
113
+ const flat = flattenObject(nested);
114
+ plan('write', `content/en/shared.json (${Object.keys(flat).length} keys)`);
115
+ if (commit) {
116
+ fs.mkdirSync(enDir, { recursive: true });
117
+ fs.writeFileSync(path.join(enDir, 'shared.json'), sortedJson(flat));
118
+ }
119
+ }
120
+
121
+ // pages/*.json
122
+ const pagesDir = path.join(configDir, 'pages');
123
+ if (fs.existsSync(pagesDir)) {
124
+ for (const file of fs.readdirSync(pagesDir)) {
125
+ if (!file.endsWith('.json')) continue;
126
+ const nested = JSON.parse(fs.readFileSync(path.join(pagesDir, file), 'utf-8'));
127
+ const flat = flattenObject(nested);
128
+ plan('write', `content/en/pages/${file} (${Object.keys(flat).length} keys)`);
129
+ if (commit) {
130
+ fs.mkdirSync(enPagesDir, { recursive: true });
131
+ fs.writeFileSync(path.join(enPagesDir, file), sortedJson(flat));
132
+ }
133
+ }
134
+ }
135
+
136
+ // 2. Split i18n flat files into locale directories
137
+ const i18nDir = path.join(configDir, 'i18n');
138
+ if (fs.existsSync(i18nDir)) {
139
+ // Read page filenames from config/pages/ to know valid page scopes
140
+ const pageFiles = fs.existsSync(pagesDir)
141
+ ? fs.readdirSync(pagesDir).filter((f) => f.endsWith('.json')).map((f) => f.replace('.json', ''))
142
+ : [];
143
+
144
+ for (const file of fs.readdirSync(i18nDir)) {
145
+ if (!file.endsWith('.json')) continue;
146
+ const locale = file.replace('.json', '');
147
+ const localeDir = path.join(contentDir, locale);
148
+ const localePagesDir = path.join(localeDir, 'pages');
149
+
150
+ const raw = JSON.parse(fs.readFileSync(path.join(i18nDir, file), 'utf-8'));
151
+
152
+ // Buckets: site.*, shared.*, pages.{pageId}.*
153
+ const siteBucket = {};
154
+ const sharedBucket = {};
155
+ const pageBuckets = {};
156
+
157
+ for (const [key, value] of Object.entries(raw)) {
158
+ if (key.startsWith('$')) continue;
159
+
160
+ if (key.startsWith('site.')) {
161
+ siteBucket[key.slice('site.'.length)] = value;
162
+ } else if (key.startsWith('shared.')) {
163
+ sharedBucket[key.slice('shared.'.length)] = value;
164
+ } else if (key.startsWith('pages.')) {
165
+ const rest = key.slice('pages.'.length);
166
+ const dotIdx = rest.indexOf('.');
167
+ if (dotIdx === -1) continue;
168
+ const pageId = rest.slice(0, dotIdx);
169
+ const pageKey = rest.slice(dotIdx + 1);
170
+ if (!pageBuckets[pageId]) pageBuckets[pageId] = {};
171
+ pageBuckets[pageId][pageKey] = value;
172
+ }
173
+ }
174
+
175
+ if (Object.keys(siteBucket).length > 0) {
176
+ plan('write', `content/${locale}/site.json (${Object.keys(siteBucket).length} keys)`);
177
+ if (commit) {
178
+ fs.mkdirSync(localeDir, { recursive: true });
179
+ fs.writeFileSync(path.join(localeDir, 'site.json'), sortedJson(siteBucket));
180
+ }
181
+ }
182
+
183
+ if (Object.keys(sharedBucket).length > 0) {
184
+ plan('write', `content/${locale}/shared.json (${Object.keys(sharedBucket).length} keys)`);
185
+ if (commit) {
186
+ fs.mkdirSync(localeDir, { recursive: true });
187
+ fs.writeFileSync(path.join(localeDir, 'shared.json'), sortedJson(sharedBucket));
188
+ }
189
+ }
190
+
191
+ for (const [pageId, pageData] of Object.entries(pageBuckets)) {
192
+ if (Object.keys(pageData).length === 0) continue;
193
+ plan('write', `content/${locale}/pages/${pageId}.json (${Object.keys(pageData).length} keys)`);
194
+ if (commit) {
195
+ fs.mkdirSync(localePagesDir, { recursive: true });
196
+ fs.writeFileSync(path.join(localePagesDir, `${pageId}.json`), sortedJson(pageData));
197
+ }
198
+ }
199
+ }
200
+ }
201
+
202
+ // 3. Create content.config.json
203
+ plan('write', 'content/content.config.json');
204
+ if (commit) {
205
+ fs.mkdirSync(contentDir, { recursive: true });
206
+ fs.writeFileSync(
207
+ path.join(contentDir, 'content.config.json'),
208
+ JSON.stringify({ baseLocale: 'en' }, null, 2) + '\n'
209
+ );
210
+ }
211
+
212
+ // 4. Remove old config directory
213
+ if (commit) {
214
+ fs.rmSync(configDir, { recursive: true, force: true });
215
+ plan('remove', 'config/ directory');
216
+ } else {
217
+ plan('remove', 'config/ directory (would be removed)');
218
+ }
219
+
220
+ console.log(`\n${commit ? 'Migration complete' : 'Dry run complete'}. ${actions.length} actions.`);
221
+ if (!commit) {
222
+ console.log('Run with --commit to apply changes.');
223
+ }
@@ -85,14 +85,14 @@ import { computed, inject, nextTick, onBeforeUnmount, onMounted, ref, watch } fr
85
85
  import { resolveAsset } from '../utils/assetResolver.js';
86
86
  import { trackEvent } from '../utils/analytics.js';
87
87
  import { getExtensionComponent } from '../extensions/extensionLoader.js';
88
- import SUPPORTED_LOCALES from '../constants/locales.js';
88
+ import { availableLocales as siteLocales } from '../utils/loadConfig.js';
89
89
 
90
90
  const logoSrc = computed(() => resolveAsset('img/logo.png'));
91
91
 
92
92
  const isLangOpen = ref(false);
93
93
  const isHeaderCompact = ref(false);
94
94
  const currentLocale = ref('');
95
- const availableLocales = computed(() => SUPPORTED_LOCALES.map((code) => code.trim()).filter(Boolean));
95
+ const availableLocales = computed(() => siteLocales.map((code) => code.trim()).filter(Boolean));
96
96
  const localeLabels = {
97
97
  en: 'English',
98
98
  fr: 'Français',
package/src/main.js CHANGED
@@ -39,12 +39,26 @@ const extractThemeKey = (siteData) => {
39
39
  return '';
40
40
  };
41
41
 
42
- const applySiteTheme = (themeKey) => {
43
- if (typeof document === 'undefined') return;
42
+ const applySiteTheme = (themeKey, head) => {
44
43
  const normalized = normalizeThemeKey(themeKey);
45
44
  const resolved = normalized || 'base';
46
- document.documentElement.dataset.siteTheme = resolved;
47
- applyThemeVariables(resolved);
45
+
46
+ // Push the html attribute via @unhead so `data-site-theme="X"` lands
47
+ // in the SSR-rendered HTML *before* Vue hydrates. Theme CSS selectors
48
+ // (`:root[data-site-theme="X"]`) then apply during the very first
49
+ // paint, eliminating the need for site-side critical-CSS overrides.
50
+ if (head && typeof head.push === 'function') {
51
+ head.push({ htmlAttrs: { 'data-site-theme': resolved } });
52
+ }
53
+
54
+ // Client only: also set the attribute imperatively (so runtime theme
55
+ // switching is reflected immediately) and inline the CSS variable map
56
+ // for themes that ship JS-only design tokens (e.g. the bundled `base`
57
+ // theme has no theme.css).
58
+ if (typeof document !== 'undefined') {
59
+ document.documentElement.dataset.siteTheme = resolved;
60
+ applyThemeVariables(resolved);
61
+ }
48
62
  };
49
63
 
50
64
  export function createCmsApp() {
@@ -74,9 +88,7 @@ export function createCmsApp() {
74
88
 
75
89
  if (initialState.siteTheme) {
76
90
  setActiveThemeKey(initialState.siteTheme);
77
- if (typeof document !== 'undefined') {
78
- applySiteTheme(initialState.siteTheme);
79
- }
91
+ applySiteTheme(initialState.siteTheme, ctx.head);
80
92
  }
81
93
 
82
94
  applyRouterGuards(router);
@@ -130,7 +142,7 @@ export function createCmsApp() {
130
142
  initialState.siteTheme ?? normalizeThemeKey(extractThemeKey(initialState.siteConfig));
131
143
  initialState.siteTheme = existingTheme;
132
144
  setActiveThemeKey(existingTheme);
133
- applySiteTheme(existingTheme);
145
+ applySiteTheme(existingTheme, ctx.head);
134
146
  return initialState.siteConfig;
135
147
  }
136
148
 
@@ -140,7 +152,7 @@ export function createCmsApp() {
140
152
  initialState.siteConfigLocale = localeKey;
141
153
  initialState.siteTheme = themeKey;
142
154
  setActiveThemeKey(themeKey);
143
- applySiteTheme(themeKey);
155
+ applySiteTheme(themeKey, ctx.head);
144
156
  return siteData;
145
157
  };
146
158
 
@@ -0,0 +1,246 @@
1
+ // Pure CSS-variable derivation from a theme manifest. Lives in its own
2
+ // file (with no Vite-specific imports) so it can be called from both
3
+ // the runtime themeManager AND the build-time vite plugin, which uses
4
+ // it to emit `virtual:cms-theme-vars.css` — a synchronously-linked
5
+ // stylesheet containing every registered theme's tokens. That, combined
6
+ // with the SSR-rendered `data-site-theme` attribute (see main.js
7
+ // applySiteTheme), eliminates the FOUC dark-themed sites used to suffer.
8
+
9
+ const toKebab = (value = '') =>
10
+ value
11
+ .replace(/([a-z0-9])([A-Z])/g, '$1-$2')
12
+ .replace(/[\s_]+/g, '-')
13
+ .toLowerCase();
14
+
15
+ const withPx = (value) => {
16
+ if (value === undefined || value === null) return undefined;
17
+ if (typeof value === 'number') return `${value}px`;
18
+ return value;
19
+ };
20
+
21
+ const setVar = (vars, name, value) => {
22
+ if (value === undefined || value === null || value === '') return;
23
+ vars[name] = String(value);
24
+ };
25
+
26
+ const assignPrefixed = (vars, entries = {}, prefix = '') => {
27
+ Object.entries(entries).forEach(([key, value]) => {
28
+ setVar(vars, `${prefix}${key}`, value);
29
+ });
30
+ };
31
+
32
+ function setSurfaceVars(vars, prefix, surface = {}) {
33
+ if (!surface) return;
34
+ setVar(vars, `${prefix}-bg`, surface.background);
35
+ setVar(vars, `${prefix}-before`, surface.before);
36
+ setVar(vars, `${prefix}-after`, surface.after);
37
+ }
38
+
39
+ function coerceSurface(value, defaults = {}) {
40
+ if (value && typeof value === 'object') return value;
41
+ if (!value) return defaults;
42
+ return { ...defaults, background: value };
43
+ }
44
+
45
+ function coerceArray(value) {
46
+ if (!value) return undefined;
47
+ return Array.isArray(value) ? value.filter(Boolean).join(', ') : value;
48
+ }
49
+
50
+ export function buildCssVarMap(manifest) {
51
+ if (!manifest) return {};
52
+ const vars = {};
53
+ const tokens = manifest.tokens || {};
54
+ const palette = tokens.palette || {};
55
+ const text = tokens.text || {};
56
+ const surfaces = tokens.surfaces || {};
57
+ const helper = surfaces.helper || {};
58
+ const tabs = surfaces.tabs || {};
59
+ const field = surfaces.field || {};
60
+ const strip = surfaces.strip || {};
61
+ const chrome = surfaces.chrome || {};
62
+ const heroSurface = surfaces.backdropPrimary || {};
63
+ const promoSurface = surfaces.backdropSecondary || {};
64
+ const typography = tokens.typography || {};
65
+ const ctas = tokens.ctas || {};
66
+ const chips = tokens.chips || {};
67
+ const focus = tokens.focus || {};
68
+ const radii = tokens.radii || {};
69
+ const elevation = tokens.elevation || {};
70
+ const utility = tokens.utility || {};
71
+
72
+ assignPrefixed(
73
+ vars,
74
+ Object.fromEntries(
75
+ Object.entries(palette).map(([key, value]) => [`--brand-${toKebab(key)}`, value])
76
+ )
77
+ );
78
+
79
+ setVar(vars, '--brand-accent-electric', palette.primary || palette.accent);
80
+ setVar(vars, '--brand-accent-electric-soft', palette.accentSoft);
81
+ setVar(vars, '--brand-accent-warm', palette.accent || palette.warning);
82
+
83
+ setVar(vars, '--brand-fg-100', text.primary);
84
+ setVar(vars, '--brand-fg-200', text.muted);
85
+ setVar(vars, '--brand-fg-300', text.mutedStrong);
86
+ setVar(vars, '--brand-fg-050', text.inverse);
87
+ setVar(vars, '--brand-bg-900', surfaces.base);
88
+ setVar(vars, '--brand-bg-800', surfaces.baseAlt);
89
+ setVar(vars, '--brand-bg-700', surfaces.raised);
90
+ setVar(vars, '--brand-bg-600', surfaces.sunken);
91
+
92
+ Object.entries(radii).forEach(([key, value]) => {
93
+ setVar(vars, `--brand-radius-${toKebab(key)}`, withPx(value));
94
+ });
95
+ setVar(vars, '--brand-card-radius', withPx(radii.lg || radii.md));
96
+ setVar(vars, '--brand-button-radius', withPx(radii.md || radii.sm));
97
+
98
+ setVar(vars, '--brand-shadow-glow', elevation.raised);
99
+ setVar(vars, '--brand-shadow-glow-strong', elevation.overlay);
100
+ setVar(vars, '--brand-card-shadow', elevation.raised);
101
+ setVar(vars, '--brand-surface-card-shadow', elevation.raised);
102
+
103
+ const cardTextColor = text.card || text.primary;
104
+ setVar(vars, '--brand-card-soft', surfaces.card);
105
+ setVar(vars, '--brand-surface-card-bg', surfaces.card);
106
+ setVar(vars, '--brand-card-border', surfaces.border);
107
+ setVar(vars, '--brand-surface-card-border', surfaces.border);
108
+ setVar(vars, '--brand-card-text', cardTextColor);
109
+ setVar(vars, '--brand-surface-helper-bg', helper.background || surfaces.callout);
110
+ setVar(vars, '--brand-surface-helper-hover-bg', helper.hover || helper.background);
111
+ setVar(vars, '--brand-icon-badge-bg', surfaces.chipAccent || surfaces.chip);
112
+ setVar(vars, '--brand-icon-badge-color', palette.primary || text.primary);
113
+
114
+ setVar(vars, '--brand-border-highlight', surfaces.border);
115
+ setVar(vars, '--brand-border-glow', surfaces.border);
116
+
117
+ setVar(vars, '--brand-status-success', palette.success);
118
+ setVar(vars, '--brand-status-error', palette.critical);
119
+ setVar(vars, '--brand-status-error-soft', palette.criticalSoft);
120
+
121
+ setVar(vars, '--brand-input-bg', field.background || surfaces.input);
122
+ setVar(vars, '--brand-input-border', field.border || utility.inputBorder);
123
+ setVar(vars, '--brand-input-border-active', focus.ring);
124
+ setVar(vars, '--brand-input-text', field.inputColor || utility.inputText || text.primary);
125
+ setVar(
126
+ vars,
127
+ '--brand-input-placeholder',
128
+ field.inputPlaceholder || utility.inputPlaceholder || text.muted
129
+ );
130
+ setVar(vars, '--brand-focus-ring', focus.ring);
131
+ setVar(vars, '--brand-focus-glow', focus.shadowInset);
132
+
133
+ setVar(vars, '--brand-modal-backdrop', surfaces.backdrop);
134
+ setVar(vars, '--brand-modal-surface', utility.modalSurface);
135
+ setVar(vars, '--brand-modal-border', utility.modalBorder);
136
+ setVar(vars, '--brand-modal-shadow', utility.modalShadow);
137
+ setVar(vars, '--brand-modal-radius', withPx(utility.modalRadius));
138
+
139
+ setVar(vars, '--brand-chart-track', utility.chartTrack);
140
+ setVar(vars, '--brand-chart-center-text', utility.chartCenterText);
141
+
142
+ setVar(vars, '--brand-header-bg', chrome.background);
143
+ setVar(vars, '--brand-header-text', chrome.text);
144
+ setVar(vars, '--site-header-shadow', chrome.shadow);
145
+ setVar(vars, '--site-header-shadow-compact', chrome.compactShadow);
146
+
147
+ const primaryCta = ctas.primary || {};
148
+ setVar(vars, '--brand-primary-cta-gradient', primaryCta.bg);
149
+ setVar(vars, '--brand-primary-cta-text', primaryCta.text);
150
+ setVar(vars, '--brand-primary-cta-border', primaryCta.border);
151
+ setVar(vars, '--brand-primary-cta-shadow', primaryCta.shadow);
152
+ setVar(vars, '--brand-primary-cta-hover-shadow', primaryCta.shadow);
153
+ setVar(vars, '--brand-primary-cta-hover-translate', 'translateY(-1px)');
154
+ setVar(vars, '--brand-cta-text', primaryCta.text);
155
+
156
+ setVar(vars, '--helper-strip-bg', helper.background || surfaces.callout);
157
+ setVar(vars, '--helper-strip-border', helper.border || surfaces.border);
158
+ setVar(vars, '--helper-strip-color', helper.text || text.primary);
159
+ setVar(vars, '--helper-strip-hover-bg', helper.hover || helper.background);
160
+ setVar(vars, '--helper-strip-hover-color', helper.hoverColor || helper.text || text.primary);
161
+ setVar(vars, '--helper-strip-link-hover', helper.linkHover || palette.primary);
162
+ setVar(vars, '--helper-strip-heading-color', helper.heading || text.primary);
163
+ setVar(vars, '--helper-strip-body-color', helper.body || text.mutedStrong || text.muted);
164
+
165
+ setVar(vars, '--tabs-bg', tabs.background);
166
+ setVar(vars, '--tabs-border', tabs.border || surfaces.border);
167
+ setVar(vars, '--tabs-shadow', tabs.shadow || elevation.flat);
168
+ setVar(vars, '--tab-color', tabs.tabColor || text.muted);
169
+ setVar(vars, '--tab-active-bg', tabs.activeBackground || palette.primary);
170
+ setVar(vars, '--tab-active-color', tabs.activeColor || palette.inverse || '#fff');
171
+ setVar(vars, '--tab-active-shadow', tabs.activeShadow || elevation.raised);
172
+ setVar(vars, '--tab-step-bg', tabs.stepBackground || helper.background);
173
+ setVar(vars, '--tab-step-border', tabs.stepBorder || helper.border);
174
+ setVar(vars, '--tab-step-color', tabs.stepColor || palette.primary);
175
+ setVar(vars, '--tab-active-step-bg', tabs.activeStepBackground || palette.primary);
176
+ setVar(vars, '--tab-active-step-border', tabs.activeStepBorder || tabs.stepBorder);
177
+ setVar(vars, '--tab-active-step-color', tabs.activeStepColor || palette.inverse || '#fff');
178
+
179
+ setVar(vars, '--field-bg', field.background);
180
+ setVar(vars, '--field-border', field.border || utility.inputBorder);
181
+ setVar(vars, '--field-shadow', field.shadow || elevation.flat);
182
+ setVar(vars, '--field-addon-bg', field.addonBackground || helper.background);
183
+ setVar(vars, '--field-addon-border', field.addonBorder || helper.border);
184
+ setVar(vars, '--field-addon-color', field.addonColor || helper.text || text.primary);
185
+ setVar(vars, '--field-input-color', field.inputColor || text.primary);
186
+ setVar(vars, '--field-input-placeholder', field.inputPlaceholder || text.muted);
187
+
188
+ setVar(vars, '--community-strip-bg', strip.background || surfaces.card);
189
+ setVar(vars, '--community-strip-border', strip.border || surfaces.border);
190
+ setVar(vars, '--community-strip-color', strip.text || text.primary);
191
+
192
+ setVar(vars, '--ui-text-primary', text.primary);
193
+ setVar(vars, '--ui-text-muted', text.muted);
194
+ setVar(vars, '--ui-field-label', text.muted);
195
+ setVar(vars, '--ui-field-value', text.primary);
196
+ setVar(vars, '--ui-status-heading-color', text.accent || palette.primary);
197
+
198
+ setVar(vars, '--brand-countdown-digit', palette.primary);
199
+ setVar(vars, '--brand-countdown-label', text.muted);
200
+
201
+ const secondaryCta = ctas.secondary || {};
202
+ setVar(vars, '--brand-pill-gradient', primaryCta.bg);
203
+ setVar(vars, '--brand-pill-alt-gradient', secondaryCta.bg || primaryCta.bg);
204
+ setVar(vars, '--brand-pill-contrast', primaryCta.text);
205
+
206
+ setVar(vars, '--brand-chip-neutral-bg', chips.neutral?.bg);
207
+ setVar(vars, '--brand-chip-neutral-color', chips.neutral?.text);
208
+ setVar(vars, '--brand-chip-neutral-border', chips.neutral?.border);
209
+
210
+ const heroBg = coerceArray(utility.gradientHero || heroSurface.background);
211
+ const promoBg = coerceArray(utility.gradientPromo || promoSurface.background);
212
+ if (heroBg) setVar(vars, '--brand-gradient-hero', heroBg);
213
+ if (promoBg) setVar(vars, '--brand-gradient-promo', promoBg);
214
+
215
+ setSurfaceVars(vars, '--hero-surface', heroSurface);
216
+ setSurfaceVars(vars, '--promo-surface', promoSurface);
217
+ const bodyBackground = coerceArray(utility.bodyBackground);
218
+ if (bodyBackground) {
219
+ setVar(vars, '--theme-body-background', bodyBackground);
220
+ }
221
+
222
+ const statusHeadline = coerceSurface(utility.statusHeadline, {
223
+ color: palette.primary || text.primary,
224
+ shadow: elevation.raised,
225
+ });
226
+
227
+ setVar(vars, '--status-headline-bg', statusHeadline.background);
228
+ setVar(vars, '--status-headline-color', statusHeadline.color);
229
+ setVar(vars, '--status-headline-shadow', statusHeadline.shadow);
230
+
231
+ setVar(vars, '--status-price-value-color', palette.accent || palette.primary || text.primary);
232
+ setVar(vars, '--status-price-value-shadow', elevation.raised);
233
+
234
+ return vars;
235
+ }
236
+
237
+ /**
238
+ * Render the var map as a CSS rule body.
239
+ * @param {object} vars Map of CSS custom property name → value
240
+ */
241
+ export function renderCssVarRule(selector, vars) {
242
+ const declarations = Object.entries(vars)
243
+ .map(([name, value]) => ` ${name}: ${value};`)
244
+ .join('\n');
245
+ return `${selector} {\n${declarations}\n}`;
246
+ }
@@ -71,6 +71,36 @@ if (!catalog.base) {
71
71
  * Called by the Vite plugin for each theme passed via the `themes` option.
72
72
  * Must be called before the first render (during app initialisation).
73
73
  *
74
+ * Two patterns for shipping theme CSS:
75
+ *
76
+ * 1. **Side-effect import (preferred).** The theme's `index.js` imports
77
+ * its `theme.css` for side effects:
78
+ *
79
+ * import manifest from './theme.config.js';
80
+ * import './theme.css';
81
+ * export default { manifest };
82
+ *
83
+ * Vite bundles `theme.css` into the main entry chunk and emits a
84
+ * synchronous `<link rel="stylesheet">` in `<head>`, so theme rules
85
+ * apply during the initial paint. Combined with the SSR-rendered
86
+ * `data-site-theme` attribute (set in `applySiteTheme`), this
87
+ * eliminates the FOUC that JS-injected theme CSS used to cause.
88
+ *
89
+ * 2. **Inline string (legacy).** The theme's `index.js` imports the CSS
90
+ * with `?inline` and exports it as a `css` string:
91
+ *
92
+ * import manifest from './theme.config.js';
93
+ * import css from './theme.css?inline';
94
+ * export default { manifest, css };
95
+ *
96
+ * The CSS is injected via a runtime `<style>` tag (handled below).
97
+ * This works but applies after Vue hydrates, so dark themes will
98
+ * flash a light first paint unless the site provides critical CSS.
99
+ *
100
+ * Existing themes (cms-theme-neon, -frog, -aurora, -southpark, -swamp)
101
+ * still use pattern 2 and are supported indefinitely; new themes should
102
+ * prefer pattern 1.
103
+ *
74
104
  * @param {object} themeModule - Default export from a @koehler8/cms-theme-* package
75
105
  * Expected shape: { manifest, css? } or just the manifest object directly.
76
106
  */
@@ -85,7 +115,9 @@ export function registerTheme(themeModule) {
85
115
 
86
116
  addToCatalog(manifest, sourceId, undefined);
87
117
 
88
- // If the theme package shipped inline CSS, inject it into the document head.
118
+ // Legacy pattern 2: theme shipped inline CSS, inject it into the document head.
119
+ // For pattern 1 themes, `cssContent` is undefined and this block is skipped —
120
+ // the CSS is already loaded via Vite's bundling.
89
121
  if (cssContent && typeof cssContent === 'string' && typeof document !== 'undefined') {
90
122
  const slug = normalizeSlug(manifest.slug);
91
123
  const existingStyle = document.getElementById(`theme-css-${slug}`);
@@ -1,232 +1,9 @@
1
1
  import { resolveThemeManifest } from './themeLoader.js';
2
+ import { buildCssVarMap } from './buildCssVarMap.js';
2
3
 
3
- const toKebab = (value = '') =>
4
- value
5
- .replace(/([a-z0-9])([A-Z])/g, '$1-$2')
6
- .replace(/[\s_]+/g, '-')
7
- .toLowerCase();
8
-
9
- const withPx = (value) => {
10
- if (value === undefined || value === null) return undefined;
11
- if (typeof value === 'number') return `${value}px`;
12
- return value;
13
- };
14
-
15
- const setVar = (vars, name, value) => {
16
- if (value === undefined || value === null || value === '') return;
17
- vars[name] = String(value);
18
- };
19
-
20
- const assignPrefixed = (vars, entries = {}, prefix) => {
21
- Object.entries(entries).forEach(([key, value]) => {
22
- setVar(vars, `${prefix}${key}`, value);
23
- });
24
- };
25
-
26
- function setSurfaceVars(vars, prefix, surface = {}) {
27
- if (!surface) return;
28
- setVar(vars, `${prefix}-bg`, surface.background);
29
- setVar(vars, `${prefix}-before`, surface.before);
30
- setVar(vars, `${prefix}-after`, surface.after);
31
- }
32
-
33
- function coerceSurface(value, defaults = {}) {
34
- if (value && typeof value === 'object') return value;
35
- if (!value) return defaults;
36
- return { ...defaults, background: value };
37
- }
38
-
39
- function coerceArray(value) {
40
- if (!value) return undefined;
41
- return Array.isArray(value) ? value.filter(Boolean).join(', ') : value;
42
- }
43
-
44
- function buildCssVarMap(manifest) {
45
- if (!manifest) return {};
46
- const vars = {};
47
- const tokens = manifest.tokens || {};
48
- const palette = tokens.palette || {};
49
- const text = tokens.text || {};
50
- const surfaces = tokens.surfaces || {};
51
- const helper = surfaces.helper || {};
52
- const tabs = surfaces.tabs || {};
53
- const field = surfaces.field || {};
54
- const strip = surfaces.strip || {};
55
- const chrome = surfaces.chrome || {};
56
- const heroSurface = surfaces.backdropPrimary || {};
57
- const promoSurface = surfaces.backdropSecondary || {};
58
- const typography = tokens.typography || {};
59
- const ctas = tokens.ctas || {};
60
- const chips = tokens.chips || {};
61
- const focus = tokens.focus || {};
62
- const radii = tokens.radii || {};
63
- const elevation = tokens.elevation || {};
64
- const utility = tokens.utility || {};
65
-
66
- assignPrefixed(
67
- vars,
68
- Object.fromEntries(
69
- Object.entries(palette).map(([key, value]) => [`--brand-${toKebab(key)}`, value])
70
- )
71
- );
72
-
73
- setVar(vars, '--brand-accent-electric', palette.primary || palette.accent);
74
- setVar(vars, '--brand-accent-electric-soft', palette.accentSoft);
75
- setVar(vars, '--brand-accent-warm', palette.accent || palette.warning);
76
-
77
- setVar(vars, '--brand-fg-100', text.primary);
78
- setVar(vars, '--brand-fg-200', text.muted);
79
- setVar(vars, '--brand-fg-300', text.mutedStrong);
80
- setVar(vars, '--brand-fg-050', text.inverse);
81
- setVar(vars, '--brand-bg-900', surfaces.base);
82
- setVar(vars, '--brand-bg-800', surfaces.baseAlt);
83
- setVar(vars, '--brand-bg-700', surfaces.raised);
84
- setVar(vars, '--brand-bg-600', surfaces.sunken);
85
-
86
- Object.entries(radii).forEach(([key, value]) => {
87
- setVar(vars, `--brand-radius-${toKebab(key)}`, withPx(value));
88
- });
89
- setVar(vars, '--brand-card-radius', withPx(radii.lg || radii.md));
90
- setVar(vars, '--brand-button-radius', withPx(radii.md || radii.sm));
91
-
92
- setVar(vars, '--brand-shadow-glow', elevation.raised);
93
- setVar(vars, '--brand-shadow-glow-strong', elevation.overlay);
94
- setVar(vars, '--brand-card-shadow', elevation.raised);
95
- setVar(vars, '--brand-surface-card-shadow', elevation.raised);
96
-
97
- const cardTextColor = text.card || text.primary;
98
- setVar(vars, '--brand-card-soft', surfaces.card);
99
- setVar(vars, '--brand-surface-card-bg', surfaces.card);
100
- setVar(vars, '--brand-card-border', surfaces.border);
101
- setVar(vars, '--brand-surface-card-border', surfaces.border);
102
- setVar(vars, '--brand-card-text', cardTextColor);
103
- setVar(vars, '--brand-surface-helper-bg', helper.background || surfaces.callout);
104
- setVar(vars, '--brand-surface-helper-hover-bg', helper.hover || helper.background);
105
- setVar(vars, '--brand-icon-badge-bg', surfaces.chipAccent || surfaces.chip);
106
- setVar(vars, '--brand-icon-badge-color', palette.primary || text.primary);
107
-
108
- setVar(vars, '--brand-border-highlight', surfaces.border);
109
- setVar(vars, '--brand-border-glow', surfaces.border);
110
-
111
- setVar(vars, '--brand-status-success', palette.success);
112
- setVar(vars, '--brand-status-error', palette.critical);
113
- setVar(vars, '--brand-status-error-soft', palette.criticalSoft);
114
-
115
- setVar(vars, '--brand-input-bg', field.background || surfaces.input);
116
- setVar(vars, '--brand-input-border', field.border || utility.inputBorder);
117
- setVar(vars, '--brand-input-border-active', focus.ring);
118
- setVar(vars, '--brand-input-text', field.inputColor || utility.inputText || text.primary);
119
- setVar(
120
- vars,
121
- '--brand-input-placeholder',
122
- field.inputPlaceholder || utility.inputPlaceholder || text.muted
123
- );
124
- setVar(vars, '--brand-focus-ring', focus.ring);
125
- setVar(vars, '--brand-focus-glow', focus.shadowInset);
126
-
127
- setVar(vars, '--brand-modal-backdrop', surfaces.backdrop);
128
- setVar(vars, '--brand-modal-surface', utility.modalSurface);
129
- setVar(vars, '--brand-modal-border', utility.modalBorder);
130
- setVar(vars, '--brand-modal-shadow', utility.modalShadow);
131
- setVar(vars, '--brand-modal-radius', withPx(utility.modalRadius));
132
-
133
- setVar(vars, '--brand-chart-track', utility.chartTrack);
134
- setVar(vars, '--brand-chart-center-text', utility.chartCenterText);
135
-
136
- setVar(vars, '--brand-header-bg', chrome.background);
137
- setVar(vars, '--brand-header-text', chrome.text);
138
- setVar(vars, '--site-header-shadow', chrome.shadow);
139
- setVar(vars, '--site-header-shadow-compact', chrome.compactShadow);
140
-
141
- const primaryCta = ctas.primary || {};
142
- setVar(vars, '--brand-primary-cta-gradient', primaryCta.bg);
143
- setVar(vars, '--brand-primary-cta-text', primaryCta.text);
144
- setVar(vars, '--brand-primary-cta-border', primaryCta.border);
145
- setVar(vars, '--brand-primary-cta-shadow', primaryCta.shadow);
146
- setVar(vars, '--brand-primary-cta-hover-shadow', primaryCta.shadow);
147
- setVar(vars, '--brand-primary-cta-hover-translate', 'translateY(-1px)');
148
- setVar(vars, '--brand-cta-text', primaryCta.text);
149
-
150
- setVar(vars, '--helper-strip-bg', helper.background || surfaces.callout);
151
- setVar(vars, '--helper-strip-border', helper.border || surfaces.border);
152
- setVar(vars, '--helper-strip-color', helper.text || text.primary);
153
- setVar(vars, '--helper-strip-hover-bg', helper.hover || helper.background);
154
- setVar(vars, '--helper-strip-hover-color', helper.hoverColor || helper.text || text.primary);
155
- setVar(vars, '--helper-strip-link-hover', helper.linkHover || palette.primary);
156
- setVar(vars, '--helper-strip-heading-color', helper.heading || text.primary);
157
- setVar(vars, '--helper-strip-body-color', helper.body || text.mutedStrong || text.muted);
158
-
159
- setVar(vars, '--tabs-bg', tabs.background);
160
- setVar(vars, '--tabs-border', tabs.border || surfaces.border);
161
- setVar(vars, '--tabs-shadow', tabs.shadow || elevation.flat);
162
- setVar(vars, '--tab-color', tabs.tabColor || text.muted);
163
- setVar(vars, '--tab-active-bg', tabs.activeBackground || palette.primary);
164
- setVar(vars, '--tab-active-color', tabs.activeColor || palette.inverse || '#fff');
165
- setVar(vars, '--tab-active-shadow', tabs.activeShadow || elevation.raised);
166
- setVar(vars, '--tab-step-bg', tabs.stepBackground || helper.background);
167
- setVar(vars, '--tab-step-border', tabs.stepBorder || helper.border);
168
- setVar(vars, '--tab-step-color', tabs.stepColor || palette.primary);
169
- setVar(vars, '--tab-active-step-bg', tabs.activeStepBackground || palette.primary);
170
- setVar(vars, '--tab-active-step-border', tabs.activeStepBorder || tabs.stepBorder);
171
- setVar(vars, '--tab-active-step-color', tabs.activeStepColor || palette.inverse || '#fff');
172
-
173
- setVar(vars, '--field-bg', field.background);
174
- setVar(vars, '--field-border', field.border || utility.inputBorder);
175
- setVar(vars, '--field-shadow', field.shadow || elevation.flat);
176
- setVar(vars, '--field-addon-bg', field.addonBackground || helper.background);
177
- setVar(vars, '--field-addon-border', field.addonBorder || helper.border);
178
- setVar(vars, '--field-addon-color', field.addonColor || helper.text || text.primary);
179
- setVar(vars, '--field-input-color', field.inputColor || text.primary);
180
- setVar(vars, '--field-input-placeholder', field.inputPlaceholder || text.muted);
181
-
182
- setVar(vars, '--community-strip-bg', strip.background || surfaces.card);
183
- setVar(vars, '--community-strip-border', strip.border || surfaces.border);
184
- setVar(vars, '--community-strip-color', strip.text || text.primary);
185
-
186
- setVar(vars, '--ui-text-primary', text.primary);
187
- setVar(vars, '--ui-text-muted', text.muted);
188
- setVar(vars, '--ui-field-label', text.muted);
189
- setVar(vars, '--ui-field-value', text.primary);
190
- setVar(vars, '--ui-status-heading-color', text.accent || palette.primary);
191
-
192
- setVar(vars, '--brand-countdown-digit', palette.primary);
193
- setVar(vars, '--brand-countdown-label', text.muted);
194
-
195
- const secondaryCta = ctas.secondary || {};
196
- setVar(vars, '--brand-pill-gradient', primaryCta.bg);
197
- setVar(vars, '--brand-pill-alt-gradient', secondaryCta.bg || primaryCta.bg);
198
- setVar(vars, '--brand-pill-contrast', primaryCta.text);
199
-
200
- setVar(vars, '--brand-chip-neutral-bg', chips.neutral?.bg);
201
- setVar(vars, '--brand-chip-neutral-color', chips.neutral?.text);
202
- setVar(vars, '--brand-chip-neutral-border', chips.neutral?.border);
203
-
204
- const heroBg = coerceArray(utility.gradientHero || heroSurface.background);
205
- const promoBg = coerceArray(utility.gradientPromo || promoSurface.background);
206
- if (heroBg) setVar(vars, '--brand-gradient-hero', heroBg);
207
- if (promoBg) setVar(vars, '--brand-gradient-promo', promoBg);
208
-
209
- setSurfaceVars(vars, '--hero-surface', heroSurface);
210
- setSurfaceVars(vars, '--promo-surface', promoSurface);
211
- const bodyBackground = coerceArray(utility.bodyBackground);
212
- if (bodyBackground) {
213
- setVar(vars, '--theme-body-background', bodyBackground);
214
- }
215
-
216
- const statusHeadline = coerceSurface(utility.statusHeadline, {
217
- color: palette.primary || text.primary,
218
- shadow: elevation.raised,
219
- });
220
-
221
- setVar(vars, '--status-headline-bg', statusHeadline.background);
222
- setVar(vars, '--status-headline-color', statusHeadline.color);
223
- setVar(vars, '--status-headline-shadow', statusHeadline.shadow);
224
-
225
- setVar(vars, '--status-price-value-color', palette.accent || palette.primary || text.primary);
226
- setVar(vars, '--status-price-value-shadow', elevation.raised);
227
-
228
- return vars;
229
- }
4
+ // buildCssVarMap was extracted to its own pure module so the build-time
5
+ // vite plugin can call it (to emit `virtual:cms-theme-vars.css`) without
6
+ // pulling in this file's runtime-only imports.
230
7
 
231
8
  export function applyThemeVariables(themeKey) {
232
9
  const manifest = resolveThemeManifest(themeKey);
@@ -133,44 +133,84 @@ export function mergeConfigTrees(target, source, options = {}) {
133
133
  export function createConfigLoader(allModules) {
134
134
  const toPlain = unwrapDefault;
135
135
 
136
- // Find the site.json path and derive the config prefix (everything up to
137
- // and including `/config/`) so subsequent lookups are scoped correctly.
138
- function findSiteConfigPrefix() {
136
+ // Discover which locale directories exist in the content folder by
137
+ // extracting unique locale codes from the allModules glob keys.
138
+ function discoverAvailableLocales() {
139
+ const locales = new Set();
140
+ const re = /\/content\/([a-z]{2,3}(?:-[a-zA-Z]{2,4})?)\//;
141
+ for (const key of Object.keys(allModules)) {
142
+ const match = key.match(re);
143
+ if (match) locales.add(match[1].toLowerCase());
144
+ }
145
+ return Array.from(locales).sort();
146
+ }
147
+
148
+ const availableLocales = discoverAvailableLocales();
149
+
150
+ // Find the content root (path ending in `/content/`) and resolve the base
151
+ // locale from content.config.json.
152
+ function findContentRoot() {
139
153
  const keys = Object.keys(allModules);
140
- const siteJsonKeys = keys.filter((key) => key.endsWith('/config/site.json'));
141
- if (siteJsonKeys.length === 1) {
142
- const key = siteJsonKeys[0];
143
- return key.slice(0, key.length - 'site.json'.length);
154
+ const configKey = keys.find((key) => key.endsWith('/content/content.config.json'));
155
+ if (configKey) {
156
+ return configKey.slice(0, configKey.length - 'content.config.json'.length);
144
157
  }
145
158
  return null;
146
159
  }
147
160
 
148
- async function assembleBaseConfig() {
149
- const prefix = findSiteConfigPrefix();
150
- if (!prefix) return null;
151
-
152
- const siteLoader = allModules[`${prefix}site.json`];
153
- if (!siteLoader) return null;
161
+ async function resolveBaseLocale(contentRoot) {
162
+ const configLoader = allModules[`${contentRoot}content.config.json`];
163
+ if (!configLoader) return 'en';
164
+ const config = toPlain(await configLoader());
165
+ return (config && config.baseLocale) || 'en';
166
+ }
154
167
 
155
- const sharedLoader = allModules[`${prefix}shared.json`];
156
- const [siteData, sharedData] = await Promise.all([
157
- siteLoader().then(toPlain),
168
+ // Assemble a config from a locale directory: inflates flat-format files
169
+ // (site.json, shared.json, pages/*.json) and returns { site, shared, pages }.
170
+ // When require is true, returns null if site.json is missing (used for
171
+ // base config). When false, assembles whatever files exist (used for
172
+ // locale overrides that may only contain partial translations).
173
+ async function assembleSplitConfig(localePrefix, { require: requireSite = false } = {}) {
174
+ const siteLoader = allModules[`${localePrefix}site.json`];
175
+ if (!siteLoader && requireSite) return null;
176
+
177
+ const sharedLoader = allModules[`${localePrefix}shared.json`];
178
+ const [rawSite, rawShared] = await Promise.all([
179
+ siteLoader ? siteLoader().then(toPlain) : Promise.resolve({}),
158
180
  sharedLoader ? sharedLoader().then(toPlain) : Promise.resolve({}),
159
181
  ]);
182
+ const siteData = inflateFlatConfig(rawSite);
183
+ const sharedData = inflateFlatConfig(rawShared);
160
184
 
161
- // Collect only the pages that belong to this site's config directory
162
- const pagePrefix = `${prefix}pages/`;
185
+ const pagePrefix = `${localePrefix}pages/`;
163
186
  const pages = {};
164
187
  const pageLoads = [];
165
188
  for (const [path, loader] of Object.entries(allModules)) {
166
189
  if (path.startsWith(pagePrefix) && path.endsWith('.json')) {
167
190
  const pageId = path.slice(pagePrefix.length, -5);
168
- pageLoads.push(loader().then((mod) => { pages[pageId] = toPlain(mod); }));
191
+ pageLoads.push(
192
+ loader().then((mod) => { pages[pageId] = inflateFlatConfig(toPlain(mod)); })
193
+ );
169
194
  }
170
195
  }
171
196
  await Promise.all(pageLoads);
172
197
 
173
- return { site: siteData, shared: sharedData, pages, _configPrefix: prefix };
198
+ // Return null if no files were found at all for this locale
199
+ if (!siteLoader && !sharedLoader && Object.keys(pages).length === 0) return null;
200
+
201
+ return { site: siteData, shared: sharedData, pages };
202
+ }
203
+
204
+ async function assembleBaseConfig() {
205
+ const contentRoot = findContentRoot();
206
+ if (!contentRoot) return null;
207
+
208
+ const baseLocale = await resolveBaseLocale(contentRoot);
209
+ const basePrefix = `${contentRoot}${baseLocale}/`;
210
+ const config = await assembleSplitConfig(basePrefix, { require: true });
211
+ if (!config) return null;
212
+
213
+ return { ...config, _contentRoot: contentRoot, _baseLocale: baseLocale };
174
214
  }
175
215
 
176
216
  async function loadConfigData(options) {
@@ -202,22 +242,24 @@ export function createConfigLoader(allModules) {
202
242
 
203
243
  if (normalizedLocale === undefined) {
204
244
  const config = await getBaseConfig();
205
- delete config._configPrefix;
245
+ delete config._contentRoot;
246
+ delete config._baseLocale;
206
247
  return config;
207
248
  }
208
249
 
209
250
  const baseConfig = await getBaseConfig();
210
251
 
211
252
  let mergedConfig = baseConfig;
212
- // Find locale override scoped to the same config directory as the site
213
- const configPrefix = baseConfig._configPrefix;
214
- const localizedLoader = configPrefix
215
- ? allModules[`${configPrefix}i18n/${normalizedLocale}.json`]
216
- : undefined;
217
- if (localizedLoader) {
218
- const rawLocalized = toPlain(await localizedLoader());
219
- const localizedConfig = inflateFlatConfig(rawLocalized);
220
- mergedConfig = mergeConfigTrees(baseConfig, localizedConfig, { cloneTarget: true, skipEmpty: true });
253
+ const contentRoot = baseConfig._contentRoot;
254
+ const baseLocale = baseConfig._baseLocale;
255
+
256
+ // Only load locale overrides if the requested locale differs from the base
257
+ if (contentRoot && normalizedLocale !== baseLocale) {
258
+ const localePrefix = `${contentRoot}${normalizedLocale}/`;
259
+ const localeConfig = await assembleSplitConfig(localePrefix);
260
+ if (localeConfig) {
261
+ mergedConfig = mergeConfigTrees(baseConfig, localeConfig, { cloneTarget: true, skipEmpty: true });
262
+ }
221
263
  }
222
264
  if (mergedConfig && mergedConfig.pages && typeof mergedConfig.pages === 'object') {
223
265
  for (const page of Object.values(mergedConfig.pages)) {
@@ -247,11 +289,12 @@ export function createConfigLoader(allModules) {
247
289
  });
248
290
  }
249
291
  }
250
- delete mergedConfig._configPrefix;
292
+ delete mergedConfig._contentRoot;
293
+ delete mergedConfig._baseLocale;
251
294
  return mergedConfig;
252
295
  }
253
296
 
254
- return { loadConfigData, mergeConfigTrees, cloneConfig };
297
+ return { loadConfigData, mergeConfigTrees, cloneConfig, availableLocales };
255
298
  }
256
299
 
257
300
  // ---- Runtime singleton ----
@@ -263,9 +306,14 @@ let _loadConfigData = async () => {
263
306
  throw new Error('[@koehler8/cms] loadConfigData() called before config loader was initialized');
264
307
  };
265
308
 
309
+ let _availableLocales = [];
310
+
266
311
  export function setConfigLoader(instance) {
267
312
  if (!instance) return;
268
313
  _loadConfigData = instance.loadConfigData;
314
+ _availableLocales = instance.availableLocales || [];
269
315
  }
270
316
 
317
+ export { _availableLocales as availableLocales };
318
+
271
319
  export const loadConfigData = (...args) => _loadConfigData(...args);
package/vite-plugin.js CHANGED
@@ -8,28 +8,30 @@
8
8
 
9
9
  import fs from 'node:fs';
10
10
  import path from 'node:path';
11
- import { fileURLToPath } from 'node:url';
11
+ import { createRequire } from 'node:module';
12
+ import { fileURLToPath, pathToFileURL } from 'node:url';
12
13
  import vue from '@vitejs/plugin-vue';
13
14
 
14
15
  import { SUPPORTED_LOCALES } from './src/constants/locales.js';
16
+ import { inflateFlatConfig } from './src/utils/inflateFlatConfig.js';
15
17
 
16
18
  // ---- Helpers ----
17
19
 
18
- function loadSplitConfig(configDir) {
19
- const sitePath = path.join(configDir, 'site.json');
20
+ function loadSplitConfig(contentLocaleDir) {
21
+ const sitePath = path.join(contentLocaleDir, 'site.json');
20
22
  if (!fs.existsSync(sitePath)) return null;
21
23
 
22
- const site = JSON.parse(fs.readFileSync(sitePath, 'utf-8'));
23
- const sharedPath = path.join(configDir, 'shared.json');
24
- const shared = fs.existsSync(sharedPath) ? JSON.parse(fs.readFileSync(sharedPath, 'utf-8')) : {};
24
+ const site = inflateFlatConfig(JSON.parse(fs.readFileSync(sitePath, 'utf-8')));
25
+ const sharedPath = path.join(contentLocaleDir, 'shared.json');
26
+ const shared = fs.existsSync(sharedPath) ? inflateFlatConfig(JSON.parse(fs.readFileSync(sharedPath, 'utf-8'))) : {};
25
27
 
26
- const pagesDir = path.join(configDir, 'pages');
28
+ const pagesDir = path.join(contentLocaleDir, 'pages');
27
29
  const pages = {};
28
30
  if (fs.existsSync(pagesDir)) {
29
31
  for (const file of fs.readdirSync(pagesDir)) {
30
32
  if (!file.endsWith('.json')) continue;
31
33
  const pageId = file.replace('.json', '');
32
- pages[pageId] = JSON.parse(fs.readFileSync(path.join(pagesDir, file), 'utf-8'));
34
+ pages[pageId] = inflateFlatConfig(JSON.parse(fs.readFileSync(path.join(pagesDir, file), 'utf-8')));
33
35
  }
34
36
  }
35
37
 
@@ -98,8 +100,13 @@ const __dirname = path.dirname(__filename);
98
100
  const VIRTUAL_CONFIG = 'virtual:cms-config-loader';
99
101
  const VIRTUAL_STYLES = 'virtual:cms-site-styles';
100
102
  const VIRTUAL_ASSETS = 'virtual:cms-asset-resolver';
103
+ // Synthetic stylesheet built from each registered theme's manifest at build
104
+ // time. Importing this from the generated entry causes Vite to emit a real
105
+ // <link rel="stylesheet"> in <head>, so theme CSS variables apply during the
106
+ // initial paint instead of after Vue hydrates.
107
+ const VIRTUAL_THEME_VARS = 'virtual:cms-theme-vars.css';
101
108
 
102
- const VIRTUAL_IDS = new Set([VIRTUAL_CONFIG, VIRTUAL_STYLES, VIRTUAL_ASSETS]);
109
+ const VIRTUAL_IDS = new Set([VIRTUAL_CONFIG, VIRTUAL_STYLES, VIRTUAL_ASSETS, VIRTUAL_THEME_VARS]);
103
110
 
104
111
  // Entry file name (written to site repo root, gitignored, cleaned up in buildEnd)
105
112
  const ENTRY_FILENAME = '.cms-entry.js';
@@ -147,6 +154,7 @@ ${extImports}
147
154
  import * as __cmsConfig from '${VIRTUAL_CONFIG}';
148
155
  import * as __cmsStyles from '${VIRTUAL_STYLES}';
149
156
  import * as __cmsAssets from '${VIRTUAL_ASSETS}';
157
+ import '${VIRTUAL_THEME_VARS}';
150
158
 
151
159
  setConfigLoader(__cmsConfig);
152
160
  setSiteStyleLoader(__cmsStyles);
@@ -356,7 +364,13 @@ export default function cmsPlugin(options = {}) {
356
364
  tempEntryPath = path.join(siteRoot, ENTRY_FILENAME);
357
365
 
358
366
  // Load site config at config-resolution time (needed for SSG routes + HTML injection)
359
- const configDir = path.join(siteDir, 'config');
367
+ const contentDir = path.join(siteDir, 'content');
368
+ const contentConfigPath = path.join(contentDir, 'content.config.json');
369
+ const contentConfig = fs.existsSync(contentConfigPath)
370
+ ? JSON.parse(fs.readFileSync(contentConfigPath, 'utf-8'))
371
+ : {};
372
+ const baseLocale = contentConfig.baseLocale || 'en';
373
+ const configDir = path.join(contentDir, baseLocale);
360
374
  siteConfig = loadSplitConfig(configDir);
361
375
  if (!siteConfig) {
362
376
  throw new Error(`[@koehler8/cms] site config not found at ${configDir}`);
@@ -472,18 +486,75 @@ export default function cmsPlugin(options = {}) {
472
486
  return null;
473
487
  },
474
488
 
475
- load(id) {
489
+ async load(id) {
476
490
  if (!isResolvedVirtual(id)) return null;
477
491
  const virtualId = id.slice(1); // strip leading \0
478
492
 
493
+ if (virtualId === VIRTUAL_THEME_VARS) {
494
+ // Build a stylesheet by deriving CSS variables from each registered
495
+ // theme's manifest at build time. We resolve the theme package via
496
+ // the site's node_modules (createRequire from siteRoot) and import
497
+ // its pure manifest through the package's `./config` exports field
498
+ // (which points at theme.config.js — sidestepping any Vite-only
499
+ // imports the theme's index.js may have, e.g. `?inline` CSS or a
500
+ // side-effect `import './theme.css'`). The bundled `base` theme is
501
+ // always included so sites without a `themes` option still get a
502
+ // sync theme.
503
+ const { buildCssVarMap, renderCssVarRule } = await import(
504
+ './src/themes/buildCssVarMap.js'
505
+ );
506
+ const baseManifestModule = await import('./themes/base/theme.config.js');
507
+ const baseManifest = baseManifestModule.default || baseManifestModule;
508
+
509
+ const requireFromSite = createRequire(path.join(siteRoot, 'package.json'));
510
+
511
+ const blocks = [];
512
+ const seen = new Set();
513
+
514
+ const addManifest = (manifest, sourceLabel) => {
515
+ if (!manifest || typeof manifest !== 'object') return;
516
+ const slug = typeof manifest.slug === 'string' ? manifest.slug.trim().toLowerCase() : '';
517
+ if (!slug || seen.has(slug)) return;
518
+ seen.add(slug);
519
+ const vars = buildCssVarMap(manifest);
520
+ if (Object.keys(vars).length === 0) return;
521
+ const selector = `:root[data-site-theme="${slug}"]`;
522
+ blocks.push(`/* ${sourceLabel} */\n${renderCssVarRule(selector, vars)}`);
523
+ };
524
+
525
+ addManifest(baseManifest, '@koehler8/cms (base)');
526
+
527
+ for (const pkg of themePackages) {
528
+ let manifestPath;
529
+ try {
530
+ manifestPath = requireFromSite.resolve(`${pkg}/config`);
531
+ } catch (err) {
532
+ this.warn(`[cms-vite-plugin] Theme "${pkg}" does not expose a "./config" export pointing at theme.config.js; skipping. (${err.message})`);
533
+ continue;
534
+ }
535
+ let manifest;
536
+ try {
537
+ const mod = await import(pathToFileURL(manifestPath).href);
538
+ manifest = mod.default || mod.manifest || mod;
539
+ } catch (err) {
540
+ this.warn(`[cms-vite-plugin] Failed to load theme manifest from "${pkg}": ${err.message}`);
541
+ continue;
542
+ }
543
+ addManifest(manifest, pkg);
544
+ }
545
+
546
+ return blocks.join('\n\n') + '\n';
547
+ }
548
+
479
549
  if (virtualId === VIRTUAL_CONFIG) {
480
550
  return `
481
551
  import { createConfigLoader } from '@koehler8/cms/utils/loadConfig';
482
- const allModules = import.meta.glob('@cms-site/config/**/*.json');
552
+ const allModules = import.meta.glob('@cms-site/content/**/*.json');
483
553
  const loader = createConfigLoader(allModules);
484
554
  export const loadConfigData = loader.loadConfigData;
485
555
  export const mergeConfigTrees = loader.mergeConfigTrees;
486
556
  export const cloneConfig = loader.cloneConfig;
557
+ export const availableLocales = loader.availableLocales;
487
558
  `;
488
559
  }
489
560