@koehler8/cms 1.0.0-beta.5 → 1.0.0-beta.7

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.5",
3
+ "version": "1.0.0-beta.7",
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',
@@ -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
@@ -12,24 +12,25 @@ import { fileURLToPath } from 'node:url';
12
12
  import vue from '@vitejs/plugin-vue';
13
13
 
14
14
  import { SUPPORTED_LOCALES } from './src/constants/locales.js';
15
+ import { inflateFlatConfig } from './src/utils/inflateFlatConfig.js';
15
16
 
16
17
  // ---- Helpers ----
17
18
 
18
- function loadSplitConfig(configDir) {
19
- const sitePath = path.join(configDir, 'site.json');
19
+ function loadSplitConfig(contentLocaleDir) {
20
+ const sitePath = path.join(contentLocaleDir, 'site.json');
20
21
  if (!fs.existsSync(sitePath)) return null;
21
22
 
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')) : {};
23
+ const site = inflateFlatConfig(JSON.parse(fs.readFileSync(sitePath, 'utf-8')));
24
+ const sharedPath = path.join(contentLocaleDir, 'shared.json');
25
+ const shared = fs.existsSync(sharedPath) ? inflateFlatConfig(JSON.parse(fs.readFileSync(sharedPath, 'utf-8'))) : {};
25
26
 
26
- const pagesDir = path.join(configDir, 'pages');
27
+ const pagesDir = path.join(contentLocaleDir, 'pages');
27
28
  const pages = {};
28
29
  if (fs.existsSync(pagesDir)) {
29
30
  for (const file of fs.readdirSync(pagesDir)) {
30
31
  if (!file.endsWith('.json')) continue;
31
32
  const pageId = file.replace('.json', '');
32
- pages[pageId] = JSON.parse(fs.readFileSync(path.join(pagesDir, file), 'utf-8'));
33
+ pages[pageId] = inflateFlatConfig(JSON.parse(fs.readFileSync(path.join(pagesDir, file), 'utf-8')));
33
34
  }
34
35
  }
35
36
 
@@ -356,7 +357,13 @@ export default function cmsPlugin(options = {}) {
356
357
  tempEntryPath = path.join(siteRoot, ENTRY_FILENAME);
357
358
 
358
359
  // Load site config at config-resolution time (needed for SSG routes + HTML injection)
359
- const configDir = path.join(siteDir, 'config');
360
+ const contentDir = path.join(siteDir, 'content');
361
+ const contentConfigPath = path.join(contentDir, 'content.config.json');
362
+ const contentConfig = fs.existsSync(contentConfigPath)
363
+ ? JSON.parse(fs.readFileSync(contentConfigPath, 'utf-8'))
364
+ : {};
365
+ const baseLocale = contentConfig.baseLocale || 'en';
366
+ const configDir = path.join(contentDir, baseLocale);
360
367
  siteConfig = loadSplitConfig(configDir);
361
368
  if (!siteConfig) {
362
369
  throw new Error(`[@koehler8/cms] site config not found at ${configDir}`);
@@ -431,7 +438,7 @@ export default function cmsPlugin(options = {}) {
431
438
  // native ESM resolver cannot handle. Marking them as noExternal
432
439
  // tells Vite to bundle them during SSR so its Vue/JSON plugins
433
440
  // process the files instead of Node.
434
- noExternal: ['@koehler8/cms', ...extensionPackages],
441
+ noExternal: ['@koehler8/cms', ...themePackages, ...extensionPackages],
435
442
  },
436
443
  test: {
437
444
  environment: 'happy-dom',
@@ -479,11 +486,12 @@ export default function cmsPlugin(options = {}) {
479
486
  if (virtualId === VIRTUAL_CONFIG) {
480
487
  return `
481
488
  import { createConfigLoader } from '@koehler8/cms/utils/loadConfig';
482
- const allModules = import.meta.glob('@cms-site/config/**/*.json');
489
+ const allModules = import.meta.glob('@cms-site/content/**/*.json');
483
490
  const loader = createConfigLoader(allModules);
484
491
  export const loadConfigData = loader.loadConfigData;
485
492
  export const mergeConfigTrees = loader.mergeConfigTrees;
486
493
  export const cloneConfig = loader.cloneConfig;
494
+ export const availableLocales = loader.availableLocales;
487
495
  `;
488
496
  }
489
497