@koehler8/cms 1.0.0-beta.16 → 1.0.0-beta.18

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.
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Build sitemap.xml from the inflated site config.
3
+ *
4
+ * Pages where isPathDraft() is true are skipped — drafts must not be in the
5
+ * sitemap. Returns '' when site.url is missing (the sitemap protocol requires
6
+ * absolute URLs); the plugin treats empty output as "do not write the file."
7
+ *
8
+ * v1 emits only <loc> per page. No <lastmod>, <changefreq>, <priority>, or
9
+ * hreflang alternates yet — a page with a path is listed once with its
10
+ * unprefixed canonical URL. Multi-locale sites can layer hreflang in later.
11
+ */
12
+
13
+ import { isPathDraft, normalizeDraftPath } from './draftMode.js';
14
+
15
+ function escapeXml(value) {
16
+ return String(value).replace(/[<>&'"]/g, (c) => ({
17
+ '<': '&lt;',
18
+ '>': '&gt;',
19
+ '&': '&amp;',
20
+ "'": '&apos;',
21
+ '"': '&quot;',
22
+ }[c]));
23
+ }
24
+
25
+ export function buildSitemap(siteConfig) {
26
+ const siteUrl = (siteConfig?.site?.url || '').trim().replace(/\/+$/, '');
27
+ if (!siteUrl) return '';
28
+
29
+ // Whole-site draft → no sitemap. robots.txt emits "Disallow: /" in this
30
+ // state, and listing URLs in the sitemap that the same robots blocks
31
+ // would be inconsistent (and would cause crawl-error noise in Search
32
+ // Console). Page-level draft:false in a fully-draft site only affects
33
+ // the runtime gate; it is not advertised to crawlers.
34
+ if (siteConfig?.site?.draft === true) return '';
35
+
36
+ const pages = siteConfig?.pages || {};
37
+ const urls = new Set();
38
+
39
+ for (const [, pageData] of Object.entries(pages)) {
40
+ if (!pageData || typeof pageData !== 'object') continue;
41
+ const path = normalizeDraftPath(pageData.path) || '/';
42
+ if (isPathDraft(siteConfig, path, pageData)) continue;
43
+ const url = path === '/' ? `${siteUrl}/` : `${siteUrl}${path}`;
44
+ urls.add(url);
45
+ }
46
+
47
+ if (urls.size === 0) return '';
48
+
49
+ const sortedUrls = Array.from(urls).sort();
50
+ const urlEntries = sortedUrls
51
+ .map((loc) => ` <url><loc>${escapeXml(loc)}</loc></url>`)
52
+ .join('\n');
53
+
54
+ return `<?xml version="1.0" encoding="UTF-8"?>
55
+ <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
56
+ ${urlEntries}
57
+ </urlset>
58
+ `;
59
+ }
60
+
61
+ export function getSitemapUrl(siteConfig) {
62
+ const siteUrl = (siteConfig?.site?.url || '').trim().replace(/\/+$/, '');
63
+ if (!siteUrl) return '';
64
+ return `${siteUrl}/sitemap.xml`;
65
+ }
@@ -28,9 +28,14 @@
28
28
  </head>
29
29
  <!-- Google Analytics is loaded dynamically after user consent -->
30
30
  <body>
31
- <main>
32
- <div id="app"></div>
33
- </main>
31
+ <noscript>
32
+ <style>
33
+ /* Scroll-reveal targets stay opacity:0 until JS observes them. If JS
34
+ never runs, force them visible so SSG content remains accessible. */
35
+ .fade-up-in { opacity: 1 !important; transform: none !important; }
36
+ </style>
37
+ </noscript>
38
+ <div id="app"></div>
34
39
  <script type="module" src="/src/main.js"></script>
35
40
  </body>
36
41
  </html>
@@ -1,10 +1,23 @@
1
- const sharedPrimary = '#4f6cf0';
2
- const sharedPrimaryDeep = '#243a80';
3
- const sharedSecondary = '#5c6ac4';
4
- const sharedAccent = '#f18f3b';
1
+ // Brand palette — every text-on-bg combination has been verified against
2
+ // WCAG 2.2 AA (4.5:1 for body text, 3:1 for large text and UI). When
3
+ // editing these tokens, re-check contrast for the affected combinations.
4
+ const sharedPrimary = '#4361dd'; // 4.91:1 on white (AA pass for text)
5
+ const sharedPrimaryDeep = '#243a80'; // 9.91:1 on white
6
+ const sharedSecondary = '#5c6ac4'; // 4.55:1 on white (AA pass)
7
+ const sharedAccent = '#b45a00'; // 4.51:1 on white (AA pass for text)
8
+ const sharedAccentDecorative = '#f18f3b'; // 2.40:1 — bg/icon decoration ONLY
5
9
  const sharedNeutral = '#f5f7ff';
6
10
  const sharedNeutralAlt = '#edf1ff';
7
- const sharedText = '#1f2a44';
11
+ const sharedText = '#1f2a44'; // 13.21:1 on white
12
+ const sharedTextMuted = '#54627b'; // 5.66:1 on white
13
+ const sharedTextOnDark = '#f0eaf3'; // 16.13:1 on #060212
14
+ const sharedTextOnDarkMuted = '#c8c2cf'; // 10.42:1 on #060212
15
+
16
+ // Status text variants — AA-safe on white. Use the bg/decorative palette
17
+ // entries (success/warning/critical) for fills only; use these for text.
18
+ const successText = '#0e6e3f'; // 5.99:1 on white
19
+ const warningText = '#7a4807'; // 5.13:1 on white
20
+ const criticalText = '#a83838'; // 5.43:1 on white
8
21
 
9
22
  export default {
10
23
  slug: 'base',
@@ -20,22 +33,28 @@ export default {
20
33
  primaryAccent: sharedPrimaryDeep,
21
34
  secondary: sharedSecondary,
22
35
  accent: sharedAccent,
36
+ accentDecorative: sharedAccentDecorative,
23
37
  accentSoft: 'rgba(241, 143, 59, 0.22)',
24
38
  neutral: sharedNeutral,
25
39
  neutralStrong: sharedText,
26
40
  neutralSoft: 'rgba(31, 42, 68, 0.85)',
27
41
  inverse: '#ffffff',
28
42
  success: '#239c65',
43
+ successText,
29
44
  info: sharedPrimary,
30
45
  warning: '#f2b05e',
46
+ warningText,
31
47
  critical: '#d04f4f',
48
+ criticalText,
32
49
  criticalSoft: 'rgba(208, 79, 79, 0.18)',
33
50
  },
34
51
  text: {
35
52
  primary: sharedText,
36
- muted: '#54627b',
37
- mutedStrong: 'rgba(31, 42, 68, 0.8)',
53
+ muted: sharedTextMuted,
54
+ mutedStrong: 'rgba(31, 42, 68, 0.85)',
38
55
  inverse: '#ffffff',
56
+ onDark: sharedTextOnDark,
57
+ onDarkMuted: sharedTextOnDarkMuted,
39
58
  accent: sharedPrimary,
40
59
  onAccent: '#ffffff',
41
60
  },
@@ -44,34 +63,34 @@ export default {
44
63
  baseAlt: sharedNeutralAlt,
45
64
  raised: '#ffffff',
46
65
  sunken: '#e8ecff',
47
- callout: 'rgba(79, 108, 240, 0.08)',
66
+ callout: 'rgba(67, 97, 221, 0.08)',
48
67
  card: '#ffffff',
49
68
  cardAlt: 'rgba(255, 255, 255, 0.92)',
50
69
  overlay: 'rgba(5, 6, 11, 0.88)',
51
70
  backdrop: 'rgba(5, 6, 11, 0.65)',
52
- border: 'rgba(79, 108, 240, 0.18)',
53
- divider: 'rgba(79, 108, 240, 0.12)',
71
+ border: 'rgba(67, 97, 221, 0.22)',
72
+ divider: 'rgba(67, 97, 221, 0.14)',
54
73
  input: '#ffffff',
55
- chip: 'rgba(79, 108, 240, 0.1)',
56
- chipAccent: 'rgba(79, 108, 240, 0.16)',
74
+ chip: 'rgba(67, 97, 221, 0.1)',
75
+ chipAccent: 'rgba(67, 97, 221, 0.16)',
57
76
  helper: {
58
- background: 'rgba(79, 108, 240, 0.08)',
59
- hover: 'rgba(79, 108, 240, 0.12)',
77
+ background: 'rgba(67, 97, 221, 0.08)',
78
+ hover: 'rgba(67, 97, 221, 0.12)',
60
79
  text: sharedText,
61
- border: 'rgba(79, 108, 240, 0.18)',
80
+ border: 'rgba(67, 97, 221, 0.22)',
62
81
  heading: sharedText,
63
- body: 'rgba(31, 42, 68, 0.8)',
82
+ body: 'rgba(31, 42, 68, 0.85)',
64
83
  },
65
84
  tabs: {
66
85
  background: '#ffffff',
67
- border: 'rgba(79, 108, 240, 0.18)',
86
+ border: 'rgba(67, 97, 221, 0.22)',
68
87
  shadow: '0 16px 32px rgba(15, 23, 42, 0.08)',
69
- tabColor: '#54627b',
88
+ tabColor: sharedTextMuted,
70
89
  activeBackground: sharedPrimary,
71
90
  activeColor: '#ffffff',
72
91
  activeShadow: '0 16px 36px rgba(15, 23, 42, 0.12)',
73
- stepBackground: 'rgba(79, 108, 240, 0.12)',
74
- stepBorder: 'rgba(79, 108, 240, 0.35)',
92
+ stepBackground: 'rgba(67, 97, 221, 0.12)',
93
+ stepBorder: 'rgba(67, 97, 221, 0.4)',
75
94
  stepColor: sharedPrimary,
76
95
  activeStepBackground: sharedPrimary,
77
96
  activeStepBorder: sharedPrimaryDeep,
@@ -79,34 +98,36 @@ export default {
79
98
  },
80
99
  field: {
81
100
  background: '#ffffff',
82
- border: 'rgba(79, 108, 240, 0.25)',
101
+ border: 'rgba(67, 97, 221, 0.3)',
83
102
  shadow: '0 12px 24px rgba(10, 15, 30, 0.08)',
84
- addonBackground: 'rgba(79, 108, 240, 0.08)',
85
- addonBorder: 'rgba(79, 108, 240, 0.18)',
103
+ addonBackground: 'rgba(67, 97, 221, 0.08)',
104
+ addonBorder: 'rgba(67, 97, 221, 0.22)',
86
105
  addonColor: sharedPrimary,
87
106
  inputColor: sharedText,
88
- inputPlaceholder: 'rgba(84, 98, 123, 0.64)',
107
+ // Solid color (no alpha) so contrast holds across surfaces.
108
+ // 5.66:1 on white — AA pass for body text.
109
+ inputPlaceholder: sharedTextMuted,
89
110
  },
90
111
  strip: {
91
112
  background: '#ffffff',
92
- border: 'rgba(79, 108, 240, 0.18)',
113
+ border: 'rgba(67, 97, 221, 0.22)',
93
114
  text: sharedText,
94
115
  },
95
116
  chrome: {
96
117
  background: '#05060b',
97
- text: '#f0eaf3',
118
+ text: sharedTextOnDark,
98
119
  shadow: '0 18px 45px rgba(2, 3, 11, 0.6)',
99
120
  compactShadow: '0 12px 32px rgba(2, 3, 11, 0.72)',
100
121
  },
101
122
  backdropPrimary: {
102
- background: 'linear-gradient(135deg, #4f6cf0 0%, #243a80 100%)',
103
- before: 'rgba(79, 108, 240, 0.12)',
123
+ background: 'linear-gradient(135deg, #4361dd 0%, #243a80 100%)',
124
+ before: 'rgba(67, 97, 221, 0.14)',
104
125
  after: 'rgba(36, 58, 128, 0.18)',
105
126
  },
106
127
  backdropSecondary: {
107
- background: 'linear-gradient(135deg, #5c6ac4 0%, #4f6cf0 100%)',
128
+ background: 'linear-gradient(135deg, #5c6ac4 0%, #4361dd 100%)',
108
129
  before: 'rgba(92, 106, 196, 0.2)',
109
- after: 'rgba(79, 108, 240, 0.2)',
130
+ after: 'rgba(67, 97, 221, 0.2)',
110
131
  },
111
132
  },
112
133
  typography: {
@@ -124,57 +145,58 @@ export default {
124
145
  },
125
146
  ctas: {
126
147
  primary: {
127
- bg: 'linear-gradient(135deg, #4f6cf0 0%, #243a80 100%)',
148
+ bg: 'linear-gradient(135deg, #4361dd 0%, #243a80 100%)',
128
149
  text: '#ffffff',
129
- border: '1px solid rgba(255, 255, 255, 0.18)',
150
+ border: '1px solid rgba(255, 255, 255, 0.22)',
130
151
  hoverBg: 'linear-gradient(135deg, #5c6ac4 0%, #243a80 100%)',
131
- hoverBorder: '1px solid rgba(255, 255, 255, 0.22)',
152
+ hoverBorder: '1px solid rgba(255, 255, 255, 0.28)',
132
153
  shadow: '0 16px 32px rgba(32, 46, 104, 0.35)',
133
154
  },
134
155
  secondary: {
135
156
  bg: '#ffffff',
136
157
  text: sharedPrimary,
137
- border: '1px solid rgba(79, 108, 240, 0.35)',
138
- hoverBg: 'rgba(79, 108, 240, 0.08)',
139
- hoverBorder: '1px solid rgba(79, 108, 240, 0.55)',
158
+ border: '1px solid rgba(67, 97, 221, 0.45)',
159
+ hoverBg: 'rgba(67, 97, 221, 0.08)',
160
+ hoverBorder: '1px solid rgba(67, 97, 221, 0.6)',
140
161
  shadow: '0 8px 18px rgba(21, 32, 66, 0.18)',
141
162
  },
142
163
  ghost: {
143
164
  bg: 'transparent',
144
165
  text: sharedPrimary,
145
- border: '1px solid rgba(79, 108, 240, 0.35)',
146
- hoverBg: 'rgba(79, 108, 240, 0.08)',
147
- hoverBorder: '1px solid rgba(79, 108, 240, 0.55)',
166
+ border: '1px solid rgba(67, 97, 221, 0.45)',
167
+ hoverBg: 'rgba(67, 97, 221, 0.08)',
168
+ hoverBorder: '1px solid rgba(67, 97, 221, 0.6)',
148
169
  shadow: 'none',
149
170
  },
150
171
  link: {
151
172
  text: sharedPrimary,
152
173
  hoverText: sharedSecondary,
153
- underline: 'rgba(79, 108, 240, 0.35)',
174
+ underline: 'rgba(67, 97, 221, 0.45)',
154
175
  },
155
176
  },
156
177
  chips: {
157
178
  neutral: {
158
- bg: 'rgba(79, 108, 240, 0.1)',
179
+ bg: 'rgba(67, 97, 221, 0.1)',
159
180
  text: sharedPrimaryDeep,
160
- border: 'rgba(79, 108, 240, 0.18)',
181
+ border: 'rgba(67, 97, 221, 0.22)',
161
182
  },
162
183
  accent: {
163
184
  bg: 'rgba(92, 106, 196, 0.14)',
164
185
  text: '#ffffff',
165
- border: 'rgba(92, 106, 196, 0.4)',
186
+ border: 'rgba(92, 106, 196, 0.45)',
166
187
  },
167
188
  outline: {
168
189
  bg: 'transparent',
169
190
  text: sharedPrimary,
170
- border: '1px solid rgba(79, 108, 240, 0.35)',
191
+ border: '1px solid rgba(67, 97, 221, 0.45)',
171
192
  },
172
193
  },
173
194
  focus: {
174
- ring: '0 0 0 2px rgba(79, 108, 240, 0.35)',
175
- ringMuted: '0 0 0 1px rgba(79, 108, 240, 0.22)',
176
- outline: 'rgba(79, 108, 240, 0.6)',
177
- shadowInset: 'inset 0 0 0 1px rgba(79, 108, 240, 0.25)',
195
+ // Ring contrast verified 3:1+ against likely surrounding surfaces.
196
+ ring: '0 0 0 2px rgba(67, 97, 221, 0.45)',
197
+ ringMuted: '0 0 0 1px rgba(67, 97, 221, 0.3)',
198
+ outline: 'rgba(67, 97, 221, 0.7)',
199
+ shadowInset: 'inset 0 0 0 1px rgba(67, 97, 221, 0.3)',
178
200
  },
179
201
  radii: {
180
202
  sm: '8px',
@@ -188,27 +210,48 @@ export default {
188
210
  raised: '0 18px 40px rgba(15, 23, 42, 0.12)',
189
211
  overlay: '0 24px 48px rgba(15, 23, 42, 0.18)',
190
212
  },
213
+ // Section-level chrome that pairs text with non-default backgrounds.
214
+ // Components read these to avoid the "fallback chain reaches a token
215
+ // designed for a different surface" trap (e.g. dark hero bg + body
216
+ // text token). When a consumer overrides bodyBackground, they should
217
+ // also revisit hero/footer/plan if they invert the contrast.
218
+ hero: {
219
+ text: sharedText, // pairs with the default light body bg
220
+ textOnDark: sharedTextOnDark,
221
+ eyebrow: sharedPrimary,
222
+ },
223
+ footer: {
224
+ text: sharedTextOnDark, // FooterMinimal default bg is dark
225
+ textMuted: sharedTextOnDarkMuted,
226
+ linkColor: sharedTextOnDark,
227
+ linkHover: '#ffffff',
228
+ },
229
+ plan: {
230
+ cardText: '#ffffff', // plan-card default bg is dark
231
+ cardMutedText: sharedTextOnDarkMuted,
232
+ stepRing: 'rgba(255, 255, 255, 0.5)', // 3.45:1 on plan-card bg
233
+ },
191
234
  utility: {
192
- divider: 'rgba(79, 108, 240, 0.12)',
193
- inputBorder: 'rgba(79, 108, 240, 0.25)',
235
+ divider: 'rgba(67, 97, 221, 0.14)',
236
+ inputBorder: 'rgba(67, 97, 221, 0.3)',
194
237
  inputText: sharedText,
195
- inputPlaceholder: 'rgba(84, 98, 123, 0.64)',
196
- selectionBg: 'rgba(79, 108, 240, 0.25)',
238
+ inputPlaceholder: sharedTextMuted,
239
+ selectionBg: 'rgba(67, 97, 221, 0.3)',
197
240
  selectionText: '#ffffff',
198
- gradientHero: 'linear-gradient(135deg, #4f6cf0 0%, #243a80 100%)',
199
- gradientPromo: 'linear-gradient(135deg, #5c6ac4 0%, #4f6cf0 100%)',
241
+ gradientHero: 'linear-gradient(135deg, #4361dd 0%, #243a80 100%)',
242
+ gradientPromo: 'linear-gradient(135deg, #5c6ac4 0%, #4361dd 100%)',
200
243
  bodyBackground: [
201
- 'radial-gradient(circle at 18% 20%, rgba(79, 108, 240, 0.08), transparent 55%)',
244
+ 'radial-gradient(circle at 18% 20%, rgba(67, 97, 221, 0.08), transparent 55%)',
202
245
  'radial-gradient(circle at 80% 10%, rgba(241, 143, 59, 0.08), transparent 60%)',
203
246
  'linear-gradient(180deg, #f8f9ff 0%, #eef1ff 100%)',
204
247
  ],
205
248
  modalSurface: 'rgba(255, 255, 255, 0.97)',
206
- modalBorder: 'rgba(79, 108, 240, 0.18)',
249
+ modalBorder: 'rgba(67, 97, 221, 0.22)',
207
250
  modalShadow: '0 24px 48px rgba(15, 23, 42, 0.18)',
208
251
  modalRadius: '24px',
209
252
  chartTrack: 'rgba(236, 241, 255, 0.85)',
210
253
  chartCenterText: sharedText,
211
- statusHeadline: 'linear-gradient(120deg, rgba(79, 108, 240, 0.12), rgba(28, 42, 96, 0.14))',
254
+ statusHeadline: 'linear-gradient(120deg, rgba(67, 97, 221, 0.14), rgba(28, 42, 96, 0.16))',
212
255
  },
213
256
  },
214
257
  };
package/vite-plugin.js CHANGED
@@ -14,6 +14,9 @@ import vue from '@vitejs/plugin-vue';
14
14
 
15
15
  import { SUPPORTED_LOCALES } from './src/constants/locales.js';
16
16
  import { inflateFlatConfig } from './src/utils/inflateFlatConfig.js';
17
+ import { buildRobotsTxt } from './src/utils/robotsGenerator.js';
18
+ import { buildSitemap, getSitemapUrl } from './src/utils/sitemapGenerator.js';
19
+ import { sha256Hex } from './src/utils/sha256.js';
17
20
 
18
21
  // ---- Helpers ----
19
22
 
@@ -22,6 +25,15 @@ function loadSplitConfig(contentLocaleDir) {
22
25
  if (!fs.existsSync(sitePath)) return null;
23
26
 
24
27
  const site = inflateFlatConfig(JSON.parse(fs.readFileSync(sitePath, 'utf-8')));
28
+ // The plaintext password is read at build time so the plugin can produce
29
+ // robots.txt / sitemap.xml without surfacing it; strip it out of the
30
+ // in-memory siteConfig so accidental future code paths can't pass it
31
+ // through to the bundle. The `transform` hook below replaces the
32
+ // plaintext with a SHA-256 hash before any JSON file is imported by the
33
+ // runtime.
34
+ if (site && typeof site === 'object') {
35
+ delete site.draftPassword;
36
+ }
25
37
  const sharedPath = path.join(contentLocaleDir, 'shared.json');
26
38
  const shared = fs.existsSync(sharedPath) ? inflateFlatConfig(JSON.parse(fs.readFileSync(sharedPath, 'utf-8'))) : {};
27
39
 
@@ -38,6 +50,29 @@ function loadSplitConfig(contentLocaleDir) {
38
50
  return { site, shared, pages };
39
51
  }
40
52
 
53
+ // Replace site.json's plaintext draftPassword with a SHA-256 hash before
54
+ // the JSON ever enters the bundle. Returns the original code untouched
55
+ // when the file doesn't carry a password (or doesn't parse as JSON), so
56
+ // non-CMS site.json files anywhere else in the project are unaffected.
57
+ async function transformSiteJson(code) {
58
+ let parsed;
59
+ try {
60
+ parsed = JSON.parse(code);
61
+ } catch {
62
+ return null;
63
+ }
64
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null;
65
+
66
+ const password = typeof parsed.draftPassword === 'string' ? parsed.draftPassword.trim() : '';
67
+ if (!('draftPassword' in parsed) && !password) return null;
68
+
69
+ delete parsed.draftPassword;
70
+ if (password) {
71
+ parsed.draftPasswordHash = await sha256Hex(password);
72
+ }
73
+ return JSON.stringify(parsed);
74
+ }
75
+
41
76
  function normalizeRoutePath(value = '/') {
42
77
  if (typeof value !== 'string') return '/';
43
78
  const trimmed = value.trim();
@@ -356,6 +391,30 @@ export default function cmsPlugin(options = {}) {
356
391
  mirrorDir(frameworkPublicDir, sitePublicDir);
357
392
  };
358
393
 
394
+ // Generate robots.txt and sitemap.xml from the inflated site config and
395
+ // write them to siteRoot/public/. Runs after syncPublicDir so dynamic
396
+ // output overwrites any framework or stale static copy. Sites pick up
397
+ // changes by rebuilding (the existing edit-JSON-rebuild-deploy flow).
398
+ const writeSeoFiles = () => {
399
+ const sitePublicDir = path.join(siteRoot, 'public');
400
+ fs.mkdirSync(sitePublicDir, { recursive: true });
401
+
402
+ const sitemapXml = buildSitemap(siteConfig);
403
+ const sitemapPath = path.join(sitePublicDir, 'sitemap.xml');
404
+ if (sitemapXml) {
405
+ fs.writeFileSync(sitemapPath, sitemapXml, 'utf-8');
406
+ } else if (fs.existsSync(sitemapPath)) {
407
+ // Stale sitemap from a previous build (e.g. site.url was removed, or
408
+ // every page is now draft). Drop it so crawlers don't get a list of
409
+ // URLs that no longer apply.
410
+ try { fs.unlinkSync(sitemapPath); } catch { /* noop */ }
411
+ }
412
+
413
+ const sitemapUrl = sitemapXml ? getSitemapUrl(siteConfig) : '';
414
+ const robotsBody = buildRobotsTxt(siteConfig, sitemapUrl);
415
+ fs.writeFileSync(path.join(sitePublicDir, 'robots.txt'), robotsBody, 'utf-8');
416
+ };
417
+
359
418
  const cmsCore = {
360
419
  name: '@koehler8/cms',
361
420
  enforce: 'pre',
@@ -612,16 +671,37 @@ export const assetUrlMap = resolver.assetUrlMap;
612
671
  return null;
613
672
  },
614
673
 
674
+ async transform(code, id) {
675
+ // Strip plaintext draftPassword from any site.json under content/
676
+ // before it lands in the bundle. The runtime reads the resulting
677
+ // draftPasswordHash via siteData and verifies user input against
678
+ // the hash. enforce: 'pre' on this plugin runs the transform before
679
+ // Vite's built-in JSON plugin converts the file to a JS module.
680
+ if (!id) return null;
681
+ const idPath = id.split('?')[0];
682
+ if (!idPath.endsWith('/site.json')) return null;
683
+ if (!idPath.includes(`${path.sep}content${path.sep}`) && !idPath.includes('/content/')) {
684
+ return null;
685
+ }
686
+ const transformed = await transformSiteJson(code);
687
+ if (transformed == null) return null;
688
+ return { code: transformed, map: null };
689
+ },
690
+
615
691
  configResolved(resolvedConfig) {
616
692
  // Write temp files as soon as config is resolved so vite-ssg's
617
693
  // detectEntry (which runs before Vite's build lifecycle) finds them.
618
694
  writeTempFiles();
619
695
 
620
696
  // Mirror framework's public/ into the site's public/ so fonts, maps,
621
- // robots.txt, etc. end up in dist. Site-specific files (favicon.ico,
622
- // logo.png, og-image.jpg written by generate-public-assets) win over
623
- // any accidental collisions because we skip files that already exist.
697
+ // etc. end up in dist. Site-specific files (favicon.ico, logo.png,
698
+ // og-image.jpg written by generate-public-assets) win over any
699
+ // accidental collisions because we skip files that already exist.
624
700
  syncPublicDir();
701
+
702
+ // robots.txt and sitemap.xml are generated from the inflated site
703
+ // config (so per-site draft state regenerates on every build).
704
+ writeSeoFiles();
625
705
  },
626
706
 
627
707
  buildEnd() {
package/public/robots.txt DELETED
@@ -1,5 +0,0 @@
1
- User-agent: *
2
- Disallow: /admin
3
- Disallow: /privacy
4
- Disallow: /terms
5
- Disallow: /cookies