@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 +18 -8
- package/package.json +1 -1
- package/scripts/generate-public-assets.js +14 -3
- package/scripts/migrate-to-content-dirs.mjs +223 -0
- package/src/components/Header.vue +2 -2
- package/src/main.js +21 -9
- package/src/themes/buildCssVarMap.js +246 -0
- package/src/themes/themeLoader.js +33 -1
- package/src/themes/themeManager.js +4 -227
- package/src/utils/loadConfig.js +80 -32
- package/vite-plugin.js +83 -12
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/
|
|
78
|
+
Site content lives in `site/content/`, organized by locale:
|
|
79
79
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
|
|
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 `
|
|
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
|
@@ -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}/
|
|
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
|
|
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
|
-
|
|
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
|
|
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(() =>
|
|
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
|
-
|
|
47
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
4
|
-
|
|
5
|
-
|
|
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);
|
package/src/utils/loadConfig.js
CHANGED
|
@@ -133,44 +133,84 @@ export function mergeConfigTrees(target, source, options = {}) {
|
|
|
133
133
|
export function createConfigLoader(allModules) {
|
|
134
134
|
const toPlain = unwrapDefault;
|
|
135
135
|
|
|
136
|
-
//
|
|
137
|
-
//
|
|
138
|
-
function
|
|
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
|
|
141
|
-
if (
|
|
142
|
-
|
|
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
|
|
149
|
-
const
|
|
150
|
-
if (!
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
213
|
-
const
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
const
|
|
219
|
-
|
|
220
|
-
|
|
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.
|
|
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 {
|
|
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(
|
|
19
|
-
const sitePath = path.join(
|
|
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(
|
|
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(
|
|
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
|
|
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/
|
|
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
|
|