@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.
- package/README.md +6 -0
- package/package.json +1 -1
- package/src/components/ComingSoonModal.vue +37 -5
- package/src/components/Contact.vue +91 -9
- package/src/components/DraftGate.vue +212 -0
- package/src/components/Footer.vue +15 -2
- package/src/components/FooterMinimal.vue +13 -12
- package/src/components/Header.vue +36 -3
- package/src/components/Hero.vue +7 -6
- package/src/components/Home.vue +77 -17
- package/src/components/Plan.vue +6 -3
- package/src/components/Portfolio.vue +7 -3
- package/src/components/StickyCTA.vue +5 -1
- package/src/components/Team.vue +3 -1
- package/src/composables/useDraftGate.js +89 -0
- package/src/composables/usePageConfig.js +5 -1
- package/src/composables/usePageMeta.js +23 -4
- package/src/styles/base.css +11 -2
- package/src/themes/buildCssVarMap.js +25 -0
- package/src/utils/draftMode.js +65 -0
- package/src/utils/robotsGenerator.js +62 -0
- package/src/utils/sha256.js +20 -0
- package/src/utils/sitemapGenerator.js +65 -0
- package/templates/index.html +8 -3
- package/themes/base/theme.config.js +100 -57
- package/vite-plugin.js +83 -3
- package/public/robots.txt +0 -5
|
@@ -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
|
+
'<': '<',
|
|
18
|
+
'>': '>',
|
|
19
|
+
'&': '&',
|
|
20
|
+
"'": ''',
|
|
21
|
+
'"': '"',
|
|
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
|
+
}
|
package/templates/index.html
CHANGED
|
@@ -28,9 +28,14 @@
|
|
|
28
28
|
</head>
|
|
29
29
|
<!-- Google Analytics is loaded dynamically after user consent -->
|
|
30
30
|
<body>
|
|
31
|
-
<
|
|
32
|
-
<
|
|
33
|
-
|
|
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
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
const
|
|
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:
|
|
37
|
-
mutedStrong: 'rgba(31, 42, 68, 0.
|
|
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(
|
|
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(
|
|
53
|
-
divider: 'rgba(
|
|
71
|
+
border: 'rgba(67, 97, 221, 0.22)',
|
|
72
|
+
divider: 'rgba(67, 97, 221, 0.14)',
|
|
54
73
|
input: '#ffffff',
|
|
55
|
-
chip: 'rgba(
|
|
56
|
-
chipAccent: 'rgba(
|
|
74
|
+
chip: 'rgba(67, 97, 221, 0.1)',
|
|
75
|
+
chipAccent: 'rgba(67, 97, 221, 0.16)',
|
|
57
76
|
helper: {
|
|
58
|
-
background: 'rgba(
|
|
59
|
-
hover: 'rgba(
|
|
77
|
+
background: 'rgba(67, 97, 221, 0.08)',
|
|
78
|
+
hover: 'rgba(67, 97, 221, 0.12)',
|
|
60
79
|
text: sharedText,
|
|
61
|
-
border: 'rgba(
|
|
80
|
+
border: 'rgba(67, 97, 221, 0.22)',
|
|
62
81
|
heading: sharedText,
|
|
63
|
-
body: 'rgba(31, 42, 68, 0.
|
|
82
|
+
body: 'rgba(31, 42, 68, 0.85)',
|
|
64
83
|
},
|
|
65
84
|
tabs: {
|
|
66
85
|
background: '#ffffff',
|
|
67
|
-
border: 'rgba(
|
|
86
|
+
border: 'rgba(67, 97, 221, 0.22)',
|
|
68
87
|
shadow: '0 16px 32px rgba(15, 23, 42, 0.08)',
|
|
69
|
-
tabColor:
|
|
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(
|
|
74
|
-
stepBorder: 'rgba(
|
|
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(
|
|
101
|
+
border: 'rgba(67, 97, 221, 0.3)',
|
|
83
102
|
shadow: '0 12px 24px rgba(10, 15, 30, 0.08)',
|
|
84
|
-
addonBackground: 'rgba(
|
|
85
|
-
addonBorder: 'rgba(
|
|
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
|
-
|
|
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(
|
|
113
|
+
border: 'rgba(67, 97, 221, 0.22)',
|
|
93
114
|
text: sharedText,
|
|
94
115
|
},
|
|
95
116
|
chrome: {
|
|
96
117
|
background: '#05060b',
|
|
97
|
-
text:
|
|
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, #
|
|
103
|
-
before: 'rgba(
|
|
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%, #
|
|
128
|
+
background: 'linear-gradient(135deg, #5c6ac4 0%, #4361dd 100%)',
|
|
108
129
|
before: 'rgba(92, 106, 196, 0.2)',
|
|
109
|
-
after: 'rgba(
|
|
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, #
|
|
148
|
+
bg: 'linear-gradient(135deg, #4361dd 0%, #243a80 100%)',
|
|
128
149
|
text: '#ffffff',
|
|
129
|
-
border: '1px solid rgba(255, 255, 255, 0.
|
|
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.
|
|
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(
|
|
138
|
-
hoverBg: 'rgba(
|
|
139
|
-
hoverBorder: '1px solid rgba(
|
|
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(
|
|
146
|
-
hoverBg: 'rgba(
|
|
147
|
-
hoverBorder: '1px solid rgba(
|
|
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(
|
|
174
|
+
underline: 'rgba(67, 97, 221, 0.45)',
|
|
154
175
|
},
|
|
155
176
|
},
|
|
156
177
|
chips: {
|
|
157
178
|
neutral: {
|
|
158
|
-
bg: 'rgba(
|
|
179
|
+
bg: 'rgba(67, 97, 221, 0.1)',
|
|
159
180
|
text: sharedPrimaryDeep,
|
|
160
|
-
border: 'rgba(
|
|
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.
|
|
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(
|
|
191
|
+
border: '1px solid rgba(67, 97, 221, 0.45)',
|
|
171
192
|
},
|
|
172
193
|
},
|
|
173
194
|
focus: {
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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(
|
|
193
|
-
inputBorder: 'rgba(
|
|
235
|
+
divider: 'rgba(67, 97, 221, 0.14)',
|
|
236
|
+
inputBorder: 'rgba(67, 97, 221, 0.3)',
|
|
194
237
|
inputText: sharedText,
|
|
195
|
-
inputPlaceholder:
|
|
196
|
-
selectionBg: 'rgba(
|
|
238
|
+
inputPlaceholder: sharedTextMuted,
|
|
239
|
+
selectionBg: 'rgba(67, 97, 221, 0.3)',
|
|
197
240
|
selectionText: '#ffffff',
|
|
198
|
-
gradientHero: 'linear-gradient(135deg, #
|
|
199
|
-
gradientPromo: 'linear-gradient(135deg, #5c6ac4 0%, #
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
//
|
|
622
|
-
//
|
|
623
|
-
//
|
|
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() {
|