@koehler8/cms 1.0.0-beta.10

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.
Files changed (86) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +212 -0
  3. package/bin/cms-generate-public-assets.js +27 -0
  4. package/bin/cms-validate-extensions.js +18 -0
  5. package/bin/cms-validate-themes.js +7 -0
  6. package/extensions/manifest.schema.json +125 -0
  7. package/package.json +88 -0
  8. package/public/img/preloaders/preloader-black.svg +1 -0
  9. package/public/img/preloaders/preloader-white.svg +1 -0
  10. package/public/robots.txt +5 -0
  11. package/scripts/check-overflow.mjs +33 -0
  12. package/scripts/generate-public-assets.js +412 -0
  13. package/scripts/migrate-to-content-dirs.mjs +223 -0
  14. package/scripts/patch-lru-cache-tla.js +164 -0
  15. package/scripts/validate-extensions.mjs +392 -0
  16. package/scripts/validate-themes.mjs +64 -0
  17. package/src/App.vue +3 -0
  18. package/src/components/About.vue +481 -0
  19. package/src/components/AboutValue.vue +361 -0
  20. package/src/components/BackToTop.vue +42 -0
  21. package/src/components/ComingSoon.vue +411 -0
  22. package/src/components/ComingSoonModal.vue +230 -0
  23. package/src/components/Contact.vue +518 -0
  24. package/src/components/Footer.vue +65 -0
  25. package/src/components/FooterMinimal.vue +153 -0
  26. package/src/components/Header.vue +583 -0
  27. package/src/components/Hero.vue +327 -0
  28. package/src/components/Home.vue +161 -0
  29. package/src/components/Intro.vue +130 -0
  30. package/src/components/IntroGate.vue +444 -0
  31. package/src/components/Plan.vue +116 -0
  32. package/src/components/Portfolio.vue +459 -0
  33. package/src/components/Preloader.vue +20 -0
  34. package/src/components/Principles.vue +67 -0
  35. package/src/components/Spacer15.vue +9 -0
  36. package/src/components/Spacer30.vue +9 -0
  37. package/src/components/Spacer40.vue +9 -0
  38. package/src/components/Spacer60.vue +9 -0
  39. package/src/components/StickyCTA.vue +263 -0
  40. package/src/components/Team.vue +432 -0
  41. package/src/components/icons/IconLinkedIn.vue +22 -0
  42. package/src/components/icons/IconX.vue +22 -0
  43. package/src/components/ui/SbCard.vue +52 -0
  44. package/src/components/ui/SkeletonPulse.vue +117 -0
  45. package/src/components/ui/UnitChip.vue +69 -0
  46. package/src/composables/useComingSoonConfig.js +120 -0
  47. package/src/composables/useComingSoonInterstitial.js +27 -0
  48. package/src/composables/useComponentResolver.js +235 -0
  49. package/src/composables/useEngagementTracking.js +187 -0
  50. package/src/composables/useIntroGate.js +46 -0
  51. package/src/composables/useLazyImage.js +77 -0
  52. package/src/composables/usePageConfig.js +184 -0
  53. package/src/composables/usePageMeta.js +76 -0
  54. package/src/composables/usePromoBackgroundStyles.js +67 -0
  55. package/src/constants/locales.js +20 -0
  56. package/src/extensions/extensionLoader.js +512 -0
  57. package/src/main.js +187 -0
  58. package/src/router/index.js +112 -0
  59. package/src/styles/base.css +896 -0
  60. package/src/styles/layout.css +342 -0
  61. package/src/styles/theme-base.css +84 -0
  62. package/src/themes/buildCssVarMap.js +246 -0
  63. package/src/themes/themeLoader.js +156 -0
  64. package/src/themes/themeManager.js +34 -0
  65. package/src/themes/themeValidator.js +380 -0
  66. package/src/utils/analytics.js +100 -0
  67. package/src/utils/appInfo.js +9 -0
  68. package/src/utils/assetResolver.js +162 -0
  69. package/src/utils/componentRegistry.js +146 -0
  70. package/src/utils/contentRequirements.js +67 -0
  71. package/src/utils/cookieConsent.js +281 -0
  72. package/src/utils/ctaCopy.js +58 -0
  73. package/src/utils/formatNumber.js +115 -0
  74. package/src/utils/imageSources.js +179 -0
  75. package/src/utils/inflateFlatConfig.js +30 -0
  76. package/src/utils/loadConfig.js +319 -0
  77. package/src/utils/semver.js +49 -0
  78. package/src/utils/siteStyles.js +40 -0
  79. package/src/utils/themeColors.js +65 -0
  80. package/src/utils/trackingContext.js +142 -0
  81. package/src/utils/unwrapDefault.js +14 -0
  82. package/src/utils/useScrollReveal.js +48 -0
  83. package/templates/index.html +36 -0
  84. package/themes/base/README.md +23 -0
  85. package/themes/base/theme.config.js +214 -0
  86. package/vite-plugin.js +726 -0
@@ -0,0 +1,412 @@
1
+ // scripts/generate-public-assets.js (run at build time)
2
+ // Syncs favicon.ico, logo.png, and og-image.jpg for the site.
3
+ //
4
+ // Reads from CMS_SITE_DIR env var (e.g. /path/to/my-site/site/).
5
+ // Assets are read from {CMS_SITE_DIR}/assets/, config from
6
+ // {CMS_SITE_DIR}/content/{baseLocale}/site.json, output is written to
7
+ // {dirname(CMS_SITE_DIR)}/public/.
8
+
9
+ import 'dotenv/config';
10
+
11
+ import fs from 'fs';
12
+ import path from 'path';
13
+ import { fileURLToPath } from 'url';
14
+ import { createCanvas, registerFont } from 'canvas';
15
+ import pngToIco from 'png-to-ico';
16
+
17
+ const __filename = fileURLToPath(import.meta.url);
18
+ const __dirname = path.dirname(__filename);
19
+
20
+ // ---- Layout resolution ----
21
+
22
+ const CMS_SITE_DIR = process.env.CMS_SITE_DIR
23
+ ? path.resolve(process.env.CMS_SITE_DIR)
24
+ : null;
25
+
26
+ if (!CMS_SITE_DIR || !fs.existsSync(CMS_SITE_DIR)) {
27
+ console.error('❌ CMS_SITE_DIR environment variable is required and must point to an existing directory.');
28
+ console.error(' Example: CMS_SITE_DIR=./site npx cms-generate-public-assets');
29
+ process.exit(1);
30
+ }
31
+
32
+ const PUBLIC_DIR = path.join(path.dirname(CMS_SITE_DIR), 'public');
33
+
34
+ function resolveSiteAssetFile(relativePaths = []) {
35
+ for (const rel of relativePaths) {
36
+ if (!rel) continue;
37
+ const candidate = path.join(CMS_SITE_DIR, rel);
38
+ if (fs.existsSync(candidate)) {
39
+ return candidate;
40
+ }
41
+ }
42
+ return null;
43
+ }
44
+
45
+ function resolveSiteAssetsDir() {
46
+ const assetsDir = path.join(CMS_SITE_DIR, 'assets');
47
+ return fs.existsSync(assetsDir) ? assetsDir : null;
48
+ }
49
+
50
+ function resolveSiteImageDir() {
51
+ const assetsDir = resolveSiteAssetsDir();
52
+ if (!assetsDir) return null;
53
+ const imgDir = path.join(assetsDir, 'img');
54
+ return fs.existsSync(imgDir) ? imgDir : null;
55
+ }
56
+
57
+ function resolveFirstExistingFile(paths = []) {
58
+ for (const candidate of paths) {
59
+ if (!candidate) continue;
60
+ if (fs.existsSync(candidate)) {
61
+ return candidate;
62
+ }
63
+ }
64
+ return null;
65
+ }
66
+
67
+ function hexToRgb(h) {
68
+ const m = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(h);
69
+ return m ? { r: parseInt(m[1], 16), g: parseInt(m[2], 16), b: parseInt(m[3], 16) } : { r: 0, g: 0, b: 0 };
70
+ }
71
+
72
+ function rgbToHex({ r, g, b }) {
73
+ const toHex = (v) => v.toString(16).padStart(2, '0');
74
+ return `#${toHex(Math.max(0, Math.min(255, r)))}${toHex(Math.max(0, Math.min(255, g)))}${toHex(Math.max(0, Math.min(255, b)))}`;
75
+ }
76
+
77
+ function adjustLum(hex, amt) {
78
+ const { r, g, b } = hexToRgb(hex);
79
+ return rgbToHex({ r: r + amt, g: g + amt, b: b + amt });
80
+ }
81
+
82
+ function luminance({ r, g, b }) {
83
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b;
84
+ }
85
+
86
+ // Try to register a professional-looking font if available
87
+ function tryRegisterFonts() {
88
+ const candidates = [
89
+ path.join(process.cwd(), 'public', 'fonts', 'Inter-Bold.ttf'),
90
+ path.join(process.cwd(), 'public', 'fonts', 'Nunito-Black.ttf'),
91
+ path.join(process.cwd(), 'assets', 'fonts', 'Inter-Bold.ttf'),
92
+ path.join(__dirname, 'fonts', 'Inter-Bold.ttf'),
93
+ ];
94
+ for (const p of candidates) {
95
+ if (fs.existsSync(p)) {
96
+ try {
97
+ registerFont(p, { family: 'BrandBold' });
98
+ console.log(`ℹ️ Using custom font: ${p}`);
99
+ return 'BrandBold';
100
+ } catch (e) {
101
+ // continue trying others
102
+ }
103
+ }
104
+ }
105
+ return 'Sans';
106
+ }
107
+ const FONT_FAMILY = tryRegisterFonts();
108
+
109
+ function renderLetterPng(letter, size, { bg = '#ffffff', fg = '#000000' } = {}) {
110
+ const canvas = createCanvas(size, size);
111
+ const ctx = canvas.getContext('2d');
112
+
113
+ // 3D coin background (radial gradient + rim + inner shadow + highlight)
114
+ const cx = size / 2;
115
+ const cy = size / 2;
116
+ const rimOuter = Math.max(1, Math.floor(size * 0.06));
117
+ const rimInner = Math.max(1, Math.floor(size * 0.02));
118
+ const margin = Math.ceil(size * 0.01);
119
+ const r = size / 2 - rimOuter / 2 - margin;
120
+
121
+ const grad = ctx.createRadialGradient(cx - r * 0.35, cy - r * 0.35, r * 0.05, cx, cy, r);
122
+ grad.addColorStop(0, adjustLum(bg, +40));
123
+ grad.addColorStop(0.55, bg);
124
+ grad.addColorStop(1, adjustLum(bg, -40));
125
+ ctx.fillStyle = grad;
126
+ ctx.beginPath();
127
+ ctx.arc(cx, cy, r, 0, Math.PI * 2);
128
+ ctx.closePath();
129
+ ctx.fill();
130
+
131
+ ctx.lineWidth = rimOuter;
132
+ ctx.strokeStyle = adjustLum(bg, -60);
133
+ ctx.stroke();
134
+ ctx.lineWidth = rimInner;
135
+ ctx.strokeStyle = adjustLum(bg, +60);
136
+ ctx.stroke();
137
+
138
+ const inner = ctx.createRadialGradient(cx, cy, r * 0.4, cx, cy, r);
139
+ inner.addColorStop(0, 'rgba(0,0,0,0)');
140
+ inner.addColorStop(1, 'rgba(0,0,0,0.22)');
141
+ ctx.fillStyle = inner;
142
+ ctx.beginPath();
143
+ ctx.arc(cx, cy, r, 0, Math.PI * 2);
144
+ ctx.closePath();
145
+ ctx.fill();
146
+
147
+ const hlr = r * 0.75;
148
+ const hlGrad = ctx.createRadialGradient(cx - r * 0.35, cy - r * 0.40, r * 0.05, cx - r * 0.35, cy - r * 0.40, hlr);
149
+ hlGrad.addColorStop(0, 'rgba(255,255,255,0.55)');
150
+ hlGrad.addColorStop(0.35, 'rgba(255,255,255,0.20)');
151
+ hlGrad.addColorStop(1, 'rgba(255,255,255,0)');
152
+ ctx.fillStyle = hlGrad;
153
+ ctx.beginPath();
154
+ ctx.arc(cx, cy, r, 0, Math.PI * 2);
155
+ ctx.closePath();
156
+ ctx.fill();
157
+
158
+ ctx.textAlign = 'center';
159
+ ctx.textBaseline = 'alphabetic';
160
+
161
+ const pad = Math.max(2, Math.floor(size * 0.14));
162
+ const target = 2 * (r - pad);
163
+
164
+ let fontSize = Math.floor(size * 0.9);
165
+ ctx.font = `bold ${fontSize}px ${FONT_FAMILY}`;
166
+ let m = ctx.measureText(letter);
167
+ let height = (m.actualBoundingBoxAscent || fontSize * 0.8) + (m.actualBoundingBoxDescent || fontSize * 0.2);
168
+ let width = Math.max(1, m.width);
169
+ let scale = Math.min(target / height, target / width);
170
+ fontSize = Math.floor(fontSize * scale * 0.96);
171
+ ctx.font = `bold ${fontSize}px ${FONT_FAMILY}`;
172
+
173
+ m = ctx.measureText(letter);
174
+ const ascent = m.actualBoundingBoxAscent || fontSize * 0.8;
175
+ const descent = m.actualBoundingBoxDescent || fontSize * 0.2;
176
+ let y = cy + (ascent - (ascent + descent) / 2);
177
+ y += size * 0.06;
178
+
179
+ ctx.shadowColor = 'rgba(0,0,0,0.22)';
180
+ ctx.shadowBlur = Math.max(1, Math.floor(size * 0.05));
181
+ ctx.shadowOffsetY = Math.floor(size * 0.015);
182
+
183
+ const fgRgb = hexToRgb(fg);
184
+ const strokeIsDark = luminance(fgRgb) > 128;
185
+ ctx.lineWidth = Math.max(1, Math.floor(size * 0.045));
186
+ ctx.lineJoin = 'round';
187
+ ctx.strokeStyle = strokeIsDark ? 'rgba(0,0,0,0.5)' : 'rgba(255,255,255,0.65)';
188
+
189
+ ctx.strokeText(letter, cx, y);
190
+ ctx.fillStyle = fg;
191
+ ctx.fillText(letter, cx, y);
192
+
193
+ ctx.shadowColor = 'transparent';
194
+ ctx.shadowBlur = 0;
195
+ ctx.shadowOffsetY = 0;
196
+
197
+ return canvas.toBuffer('image/png');
198
+ }
199
+
200
+ function renderOgImageBuffer(text, { width = 1200, height = 630, bg = '#000000', fg = '#ffffff' } = {}) {
201
+ const canvas = createCanvas(width, height);
202
+ const ctx = canvas.getContext('2d');
203
+
204
+ const background = ctx.createLinearGradient(0, 0, width, height);
205
+ background.addColorStop(0, adjustLum(bg, +20));
206
+ background.addColorStop(0.5, bg);
207
+ background.addColorStop(1, adjustLum(bg, -40));
208
+ ctx.fillStyle = background;
209
+ ctx.fillRect(0, 0, width, height);
210
+
211
+ const highlight = ctx.createLinearGradient(0, 0, 0, height);
212
+ highlight.addColorStop(0, 'rgba(255,255,255,0.12)');
213
+ highlight.addColorStop(0.4, 'rgba(255,255,255,0.05)');
214
+ highlight.addColorStop(1, 'rgba(255,255,255,0)');
215
+ ctx.fillStyle = highlight;
216
+ ctx.fillRect(0, 0, width, height);
217
+
218
+ ctx.textAlign = 'center';
219
+ ctx.textBaseline = 'middle';
220
+
221
+ const maxWidth = width * 0.8;
222
+ let fontSize = Math.floor(height * 0.22);
223
+
224
+ const safeText = (text && text.toString().trim()) || '';
225
+ const displayText = safeText || 'Coming Soon';
226
+
227
+ const setFont = (size) => {
228
+ ctx.font = `bold ${size}px ${FONT_FAMILY}`;
229
+ };
230
+
231
+ setFont(fontSize);
232
+ let metrics = ctx.measureText(displayText);
233
+ while (metrics.width > maxWidth && fontSize > 32) {
234
+ fontSize -= 2;
235
+ setFont(fontSize);
236
+ metrics = ctx.measureText(displayText);
237
+ }
238
+
239
+ const textX = width / 2;
240
+ const textY = height / 2;
241
+
242
+ ctx.shadowColor = 'rgba(0,0,0,0.45)';
243
+ ctx.shadowBlur = Math.max(4, Math.floor(height * 0.04));
244
+ ctx.shadowOffsetY = Math.floor(height * 0.015);
245
+
246
+ const fgRgb = hexToRgb(fg);
247
+ const strokeIsDark = luminance(fgRgb) > 150;
248
+ ctx.lineJoin = 'round';
249
+ ctx.lineWidth = Math.max(4, Math.floor(height * 0.02));
250
+ ctx.strokeStyle = strokeIsDark ? 'rgba(0,0,0,0.5)' : 'rgba(255,255,255,0.6)';
251
+ ctx.strokeText(displayText, textX, textY);
252
+ ctx.fillStyle = fg;
253
+ ctx.fillText(displayText, textX, textY);
254
+
255
+ ctx.shadowColor = 'transparent';
256
+ ctx.shadowBlur = 0;
257
+ ctx.shadowOffsetY = 0;
258
+
259
+ return canvas.toBuffer('image/jpeg', { quality: 0.9 });
260
+ }
261
+
262
+ function loadSiteTitle() {
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');
274
+ if (!fs.existsSync(configPath)) return '';
275
+ try {
276
+ const raw = fs.readFileSync(configPath, 'utf-8');
277
+ const parsed = JSON.parse(raw);
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;
280
+ if (typeof title === 'string' && title.trim()) {
281
+ return title.trim();
282
+ }
283
+ } catch (err) {
284
+ console.warn(`⚠️ Could not read site config: ${configPath}`, err);
285
+ }
286
+ return '';
287
+ }
288
+
289
+ async function generateFavicon() {
290
+ const siteTitle = loadSiteTitle() || 'X';
291
+ const letter = siteTitle.charAt(0).toUpperCase();
292
+
293
+ const publicDir = PUBLIC_DIR;
294
+ fs.mkdirSync(publicDir, { recursive: true });
295
+ const icoPath = path.join(publicDir, 'favicon.ico');
296
+
297
+ console.log(`📁 Reading site from ${CMS_SITE_DIR}`);
298
+ console.log(` Writing to ${publicDir}`);
299
+
300
+ const assetSearchDir = resolveSiteImageDir();
301
+ if (!assetSearchDir) {
302
+ const assetsDir = resolveSiteAssetsDir();
303
+ const expectedImageDir = assetsDir ? path.join(assetsDir, 'img') : path.join(CMS_SITE_DIR, 'assets', 'img');
304
+ console.warn(`⚠️ Expected asset folder not found at ${expectedImageDir}; will fall back to generated assets.`);
305
+ }
306
+
307
+ const logoSource = assetSearchDir
308
+ ? resolveFirstExistingFile([
309
+ path.join(assetSearchDir, 'logo.png'),
310
+ path.join(assetSearchDir, 'logo.jpg'),
311
+ path.join(assetSearchDir, 'logo.jpeg'),
312
+ ])
313
+ : null;
314
+ const logoDest = path.join(publicDir, 'logo.png');
315
+ if (logoSource) {
316
+ try {
317
+ fs.copyFileSync(logoSource, logoDest);
318
+ console.log(`🔁 Copied brand logo from ${logoSource} to ${logoDest}`);
319
+ } catch (err) {
320
+ console.warn(`⚠️ Failed to copy brand logo from ${logoSource}`, err);
321
+ }
322
+ }
323
+
324
+ const ogSource = resolveSiteAssetFile(['og-image.jpg', 'og-image.jpeg']);
325
+ const ogDest = path.join(publicDir, 'og-image.jpg');
326
+ let shouldGenerateOg = false;
327
+ if (ogSource) {
328
+ try {
329
+ fs.copyFileSync(ogSource, ogDest);
330
+ console.log(`📸 Copied OpenGraph image from ${ogSource} to ${ogDest}`);
331
+ } catch (err) {
332
+ console.warn(`⚠️ Failed to copy OpenGraph image from ${ogSource}`, err);
333
+ shouldGenerateOg = true;
334
+ }
335
+ } else {
336
+ shouldGenerateOg = true;
337
+ }
338
+
339
+ const providedFavicon = resolveSiteAssetFile(['favicon.ico']);
340
+ if (providedFavicon) {
341
+ fs.copyFileSync(providedFavicon, icoPath);
342
+ console.log(`✅ Found existing favicon; copied from ${providedFavicon}`);
343
+ return;
344
+ }
345
+
346
+ console.log(`Generating favicon.ico using letter "${letter}" from site "${siteTitle}"`);
347
+
348
+ function getRandomColorPair() {
349
+ const colors = [
350
+ ['#ffffff', '#000000'],
351
+ ['#000000', '#ffffff'],
352
+ ['#ff0000', '#ffffff'],
353
+ ['#0000ff', '#ffffff'],
354
+ ['#008000', '#ffffff'],
355
+ ['#ffff00', '#000000'],
356
+ ['#ff00ff', '#000000'],
357
+ ['#00ffff', '#000000'],
358
+ ['#1e90ff', '#ffffff'],
359
+ ['#ff8c00', '#000000'],
360
+ ['#4b0082', '#ffffff'],
361
+ ['#32cd32', '#000000'],
362
+ ['#8b0000', '#ffffff'],
363
+ ['#ffd700', '#000000'],
364
+ ['#ff69b4', '#000000'],
365
+ ['#2f4f4f', '#ffffff']
366
+ ];
367
+ return colors[Math.floor(Math.random() * colors.length)];
368
+ }
369
+
370
+ let bg = process.env.FAVICON_BG;
371
+ let fg = process.env.FAVICON_FG;
372
+
373
+ if (!bg || !fg) {
374
+ [bg, fg] = getRandomColorPair();
375
+ }
376
+
377
+ if (shouldGenerateOg) {
378
+ try {
379
+ const ogBuffer = renderOgImageBuffer(siteTitle, { width: 1200, height: 630, bg, fg });
380
+ fs.writeFileSync(ogDest, ogBuffer);
381
+ console.log(`🖼️ Generated fallback OpenGraph image at ${ogDest}`);
382
+ } catch (err) {
383
+ console.error(`❌ Failed to generate fallback OpenGraph image`, err);
384
+ }
385
+ }
386
+
387
+ const sizes = [256, 128, 64, 48, 32, 16];
388
+ const pngBuffers = sizes.map((s) => renderLetterPng(letter, s, { bg, fg }));
389
+
390
+ const pngPath = path.join(publicDir, 'favicon-256.png');
391
+ fs.writeFileSync(pngPath, pngBuffers[0]);
392
+
393
+ const ico = await pngToIco(pngBuffers);
394
+ fs.writeFileSync(icoPath, ico);
395
+
396
+ try {
397
+ fs.unlinkSync(pngPath);
398
+ console.log(`🗑️ Removed temporary file ${pngPath}`);
399
+ } catch (err) {
400
+ console.warn(`⚠️ Could not remove temporary PNG: ${pngPath}`, err);
401
+ }
402
+
403
+ console.log(`✅ Created favicon.ico using letter "${letter}" from site "${siteTitle}"`);
404
+ console.log(` Colors bg=${bg} fg=${fg}`);
405
+ console.log(` Wrote: ${icoPath}`);
406
+ }
407
+
408
+ generateFavicon().catch((err) => {
409
+ console.error('❌ Failed to generate favicon.ico');
410
+ console.error(err);
411
+ process.exit(1);
412
+ });
@@ -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
+ }