@koehler8/cms 1.0.0-beta.17 → 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 CHANGED
@@ -5,6 +5,12 @@
5
5
 
6
6
  Vertex CMS is a lightweight, config-driven Vue 3 framework with theming, extensions, and SSG support.
7
7
 
8
+ ## Accessibility
9
+
10
+ Vertex CMS targets **[WCAG 2.2 Level AA](https://www.w3.org/WAI/WCAG22/quickref/?currentsidebar=%23col_overview&versions=2.2&levels=aaa)** as a hard requirement for the bundled components, the `base` theme, and the page wrapper. Sites that ship on this framework with the bundled pieces inherit a skip link, single `<main>` landmark, real form labels with `aria-live` errors, visible focus rings, modal focus trap+restore, AA-verified palette contrast, and `prefers-reduced-motion` support out of the box.
11
+
12
+ When extending or theming, **don't regress AA**. See the "Accessibility" section of [CLAUDE.md](CLAUDE.md#accessibility-wcag-22-level-aa--hard-requirement) for the requirements, the token naming conventions for safe colors, and the audit checklist to run when changing palette/markup/CSS.
13
+
8
14
  ## Quick Start
9
15
 
10
16
  ### 1. Create a site repo
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@koehler8/cms",
3
- "version": "1.0.0-beta.17",
3
+ "version": "1.0.0-beta.18",
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",
@@ -0,0 +1,212 @@
1
+ <template>
2
+ <section
3
+ class="draft-gate"
4
+ role="dialog"
5
+ aria-modal="true"
6
+ aria-labelledby="draft-gate-title"
7
+ aria-describedby="draft-gate-description"
8
+ >
9
+ <div class="draft-gate-card" ref="cardRef" tabindex="-1">
10
+ <p class="draft-gate-eyebrow">Draft</p>
11
+ <h1 id="draft-gate-title" class="draft-gate-title">
12
+ This page is not yet public
13
+ </h1>
14
+ <p id="draft-gate-description" class="draft-gate-body">
15
+ {{ bodyText }}
16
+ </p>
17
+ <form class="draft-gate-form" @submit.prevent="handleSubmit" novalidate>
18
+ <label class="draft-gate-label" for="draft-gate-password">
19
+ Password
20
+ </label>
21
+ <input
22
+ id="draft-gate-password"
23
+ ref="inputRef"
24
+ v-model="passwordInput"
25
+ type="password"
26
+ autocomplete="current-password"
27
+ autocapitalize="off"
28
+ autocorrect="off"
29
+ spellcheck="false"
30
+ :aria-invalid="hasError ? 'true' : 'false'"
31
+ aria-describedby="draft-gate-error"
32
+ class="draft-gate-input"
33
+ />
34
+ <p
35
+ id="draft-gate-error"
36
+ class="draft-gate-error"
37
+ aria-live="polite"
38
+ role="status"
39
+ >
40
+ {{ errorMessage }}
41
+ </p>
42
+ <button type="submit" class="draft-gate-submit">
43
+ {{ submitLabel }}
44
+ </button>
45
+ </form>
46
+ </div>
47
+ </section>
48
+ </template>
49
+
50
+ <script setup>
51
+ import { computed, nextTick, onMounted, ref } from 'vue';
52
+
53
+ const props = defineProps({
54
+ hasPassword: {
55
+ type: Boolean,
56
+ default: false,
57
+ },
58
+ errorMessage: {
59
+ type: String,
60
+ default: '',
61
+ },
62
+ });
63
+
64
+ const emit = defineEmits(['submit']);
65
+
66
+ const passwordInput = ref('');
67
+ const inputRef = ref(null);
68
+ const cardRef = ref(null);
69
+ const hasError = computed(() => Boolean(props.errorMessage));
70
+
71
+ const bodyText = computed(() =>
72
+ props.hasPassword
73
+ ? 'Enter the password to preview this content.'
74
+ : 'No password is required — submit to continue.',
75
+ );
76
+
77
+ const submitLabel = computed(() => (props.hasPassword ? 'Unlock' : 'Continue'));
78
+
79
+ function handleSubmit() {
80
+ emit('submit', passwordInput.value);
81
+ }
82
+
83
+ onMounted(() => {
84
+ if (import.meta.env.SSR) return;
85
+ nextTick(() => {
86
+ inputRef.value?.focus();
87
+ });
88
+ });
89
+ </script>
90
+
91
+ <style scoped>
92
+ .draft-gate {
93
+ display: flex;
94
+ align-items: center;
95
+ justify-content: center;
96
+ min-height: 100dvh;
97
+ padding: clamp(1.5rem, 5vw, 3rem);
98
+ background: var(--brand-surface, #f7f7f8);
99
+ color: var(--brand-text, #1f2a44);
100
+ font-family: var(--ui-font-body, system-ui, -apple-system, sans-serif);
101
+ }
102
+
103
+ .draft-gate-card {
104
+ width: 100%;
105
+ max-width: 28rem;
106
+ padding: clamp(1.75rem, 4vw, 2.5rem);
107
+ background: var(--brand-surface-elevated, #ffffff);
108
+ border: 1px solid var(--brand-border, rgba(31, 42, 68, 0.12));
109
+ border-radius: clamp(12px, 2vw, 18px);
110
+ box-shadow: 0 18px 38px rgba(15, 22, 42, 0.08);
111
+ outline: none;
112
+ }
113
+
114
+ .draft-gate-eyebrow {
115
+ margin: 0 0 0.5rem;
116
+ letter-spacing: 0.16em;
117
+ text-transform: uppercase;
118
+ font-size: 0.7rem;
119
+ font-weight: 600;
120
+ color: var(--brand-text-muted, rgba(31, 42, 68, 0.6));
121
+ }
122
+
123
+ .draft-gate-title {
124
+ margin: 0 0 0.75rem;
125
+ font-size: clamp(1.4rem, 2.6vw, 1.75rem);
126
+ line-height: 1.2;
127
+ font-weight: 700;
128
+ }
129
+
130
+ .draft-gate-body {
131
+ margin: 0 0 1.5rem;
132
+ font-size: 1rem;
133
+ line-height: 1.55;
134
+ color: var(--brand-text-muted, rgba(31, 42, 68, 0.75));
135
+ }
136
+
137
+ .draft-gate-form {
138
+ display: flex;
139
+ flex-direction: column;
140
+ gap: 0.5rem;
141
+ }
142
+
143
+ .draft-gate-label {
144
+ font-size: 0.85rem;
145
+ font-weight: 600;
146
+ color: var(--brand-text, #1f2a44);
147
+ }
148
+
149
+ .draft-gate-input {
150
+ width: 100%;
151
+ min-height: 2.75rem;
152
+ padding: 0.65rem 0.85rem;
153
+ font-size: 1rem;
154
+ font-family: inherit;
155
+ background: var(--brand-surface, #ffffff);
156
+ color: inherit;
157
+ border: 1px solid var(--brand-border, rgba(31, 42, 68, 0.25));
158
+ border-radius: var(--brand-input-radius, 8px);
159
+ transition: border-color 0.15s ease, box-shadow 0.15s ease;
160
+ }
161
+
162
+ .draft-gate-input:focus-visible {
163
+ outline: 2px solid var(--brand-primary, #1f2a44);
164
+ outline-offset: 2px;
165
+ border-color: var(--brand-primary, #1f2a44);
166
+ }
167
+
168
+ .draft-gate-input[aria-invalid='true'] {
169
+ border-color: var(--brand-criticalText, #b3261e);
170
+ }
171
+
172
+ .draft-gate-error {
173
+ min-height: 1.25rem;
174
+ margin: 0;
175
+ font-size: 0.85rem;
176
+ color: var(--brand-criticalText, #b3261e);
177
+ }
178
+
179
+ .draft-gate-submit {
180
+ margin-top: 0.5rem;
181
+ min-height: 2.75rem;
182
+ padding: 0.7rem 1.4rem;
183
+ font-size: 1rem;
184
+ font-family: inherit;
185
+ font-weight: 600;
186
+ color: var(--brand-primary-cta-text, #ffffff);
187
+ background: var(--brand-primary-cta, var(--brand-primary, #1f2a44));
188
+ border: none;
189
+ border-radius: var(--brand-button-radius, 10px);
190
+ cursor: pointer;
191
+ transition: transform 0.15s ease, box-shadow 0.15s ease, background 0.15s ease;
192
+ }
193
+
194
+ .draft-gate-submit:hover {
195
+ transform: translateY(-1px);
196
+ }
197
+
198
+ .draft-gate-submit:focus-visible {
199
+ outline: 2px solid var(--brand-primary, #1f2a44);
200
+ outline-offset: 3px;
201
+ }
202
+
203
+ @media (prefers-reduced-motion: reduce) {
204
+ .draft-gate-submit,
205
+ .draft-gate-input {
206
+ transition: none;
207
+ }
208
+ .draft-gate-submit:hover {
209
+ transform: none;
210
+ }
211
+ }
212
+ </style>
@@ -1,29 +1,39 @@
1
1
  <template>
2
- <a class="cms-skip-link" href="#main-content">Skip to main content</a>
3
- <IntroGate
4
- :enabled="introGateEnabled"
5
- v-bind="introGateProps"
6
- />
7
- <main id="main-content" tabindex="-1">
8
- <component
9
- v-for="entry in loadedComponents"
10
- :is="entry.component"
11
- :key="entry.key"
12
- v-bind="entry.props"
2
+ <template v-if="isDraft && !isUnlocked">
3
+ <DraftGate
4
+ :has-password="hasPassword"
5
+ :error-message="errorMessage"
6
+ @submit="attemptUnlock"
13
7
  />
14
- </main>
15
- <ComingSoonModal
16
- :open="isComingSoonVisible"
17
- :title="comingSoonTitle"
18
- :message="comingSoonMessage"
19
- @close="closeComingSoon"
20
- />
8
+ </template>
9
+ <template v-else>
10
+ <a class="cms-skip-link" href="#main-content">Skip to main content</a>
11
+ <IntroGate
12
+ :enabled="introGateEnabled"
13
+ v-bind="introGateProps"
14
+ />
15
+ <main id="main-content" tabindex="-1">
16
+ <component
17
+ v-for="entry in loadedComponents"
18
+ :is="entry.component"
19
+ :key="entry.key"
20
+ v-bind="entry.props"
21
+ />
22
+ </main>
23
+ <ComingSoonModal
24
+ :open="isComingSoonVisible"
25
+ :title="comingSoonTitle"
26
+ :message="comingSoonMessage"
27
+ @close="closeComingSoon"
28
+ />
29
+ </template>
21
30
  </template>
22
31
 
23
32
  <script setup>
24
33
  import { computed, nextTick, provide, watch } from 'vue';
25
34
 
26
35
  import ComingSoonModal from '../components/ComingSoonModal.vue';
36
+ import DraftGate from '../components/DraftGate.vue';
27
37
  import IntroGate from '../components/IntroGate.vue';
28
38
 
29
39
  import { registry } from '../utils/componentRegistry.js';
@@ -31,6 +41,7 @@ import { usePageConfig } from '../composables/usePageConfig.js';
31
41
  import { useComponentResolver } from '../composables/useComponentResolver.js';
32
42
  import { usePageMeta } from '../composables/usePageMeta.js';
33
43
  import { useIntroGate } from '../composables/useIntroGate.js';
44
+ import { useDraftGate } from '../composables/useDraftGate.js';
34
45
  import { useEngagementTracking } from '../composables/useEngagementTracking.js';
35
46
  import { useComingSoonInterstitial } from '../composables/useComingSoonInterstitial.js';
36
47
 
@@ -71,6 +82,14 @@ const { loadedComponents } = useComponentResolver({
71
82
 
72
83
  const { introGateEnabled, introGateProps } = useIntroGate({ siteData, pageContent });
73
84
 
85
+ const {
86
+ isDraft,
87
+ isUnlocked,
88
+ hasPassword,
89
+ errorMessage,
90
+ attemptUnlock,
91
+ } = useDraftGate({ siteData, currentPage });
92
+
74
93
  usePageMeta({ siteData, currentPage });
75
94
 
76
95
  const {
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Reactive gate state for draft pages.
3
+ *
4
+ * - During SSR (vite-ssg pre-render), isUnlocked is always false so the
5
+ * pre-rendered HTML on disk contains only the gate component, not the
6
+ * page body. Crawlers and `curl` see a noindex page with no content.
7
+ * - In the browser, isUnlocked hydrates from sessionStorage so the gate
8
+ * doesn't re-prompt while the user iterates between draft pages within
9
+ * one tab. Closing the tab re-engages the gate (sessionStorage scope).
10
+ * - Password verification is hash-based: the Vite plugin replaces
11
+ * site.draftPassword with site.draftPasswordHash (SHA-256 hex) at build
12
+ * time so the plaintext never lands in the bundle. attemptUnlock hashes
13
+ * the user's input with the same algorithm and compares.
14
+ * - Empty/missing hash is a deliberate fail-safe state: the gate still
15
+ * appears, but accepts any input including empty. This makes the gate
16
+ * visible even when the author hasn't picked a password yet — see
17
+ * hasPassword in the return value.
18
+ */
19
+
20
+ import { computed, ref } from 'vue';
21
+ import { isPathDraft, getDraftPasswordHash } from '../utils/draftMode.js';
22
+ import { sha256Hex } from '../utils/sha256.js';
23
+
24
+ const SESSION_KEY = 'cms_draft_unlocked';
25
+
26
+ function readUnlockedFromSession() {
27
+ if (typeof window === 'undefined') return false;
28
+ try {
29
+ return window.sessionStorage.getItem(SESSION_KEY) === '1';
30
+ } catch {
31
+ return false;
32
+ }
33
+ }
34
+
35
+ function persistUnlocked() {
36
+ if (typeof window === 'undefined') return;
37
+ try {
38
+ window.sessionStorage.setItem(SESSION_KEY, '1');
39
+ } catch {
40
+ /* sessionStorage may be blocked in private mode; gate falls back to
41
+ in-memory unlock for the lifetime of the page. */
42
+ }
43
+ }
44
+
45
+ export function useDraftGate({ siteData, currentPage }) {
46
+ const isDraft = computed(() => {
47
+ const page = currentPage.value || {};
48
+ return isPathDraft(siteData.value, page.path, page);
49
+ });
50
+
51
+ const expectedHash = computed(() => getDraftPasswordHash(siteData.value));
52
+ const hasPassword = computed(() => expectedHash.value.length > 0);
53
+
54
+ const isUnlocked = ref(import.meta.env.SSR ? false : readUnlockedFromSession());
55
+ const errorMessage = ref('');
56
+
57
+ async function attemptUnlock(input) {
58
+ const hash = expectedHash.value;
59
+ if (!hash) {
60
+ isUnlocked.value = true;
61
+ errorMessage.value = '';
62
+ persistUnlocked();
63
+ return true;
64
+ }
65
+ const provided = typeof input === 'string' ? input : '';
66
+ let providedHash = '';
67
+ try {
68
+ providedHash = await sha256Hex(provided);
69
+ } catch {
70
+ providedHash = '';
71
+ }
72
+ if (providedHash && providedHash === hash) {
73
+ isUnlocked.value = true;
74
+ errorMessage.value = '';
75
+ persistUnlocked();
76
+ return true;
77
+ }
78
+ errorMessage.value = 'Incorrect password.';
79
+ return false;
80
+ }
81
+
82
+ return {
83
+ isDraft,
84
+ isUnlocked,
85
+ hasPassword,
86
+ errorMessage,
87
+ attemptUnlock,
88
+ };
89
+ }
@@ -26,6 +26,7 @@ export function usePageConfig({ pageId, pagePath, locale, onPageLoaded } = {}) {
26
26
  components: [],
27
27
  content: {},
28
28
  meta: {},
29
+ draft: undefined,
29
30
  });
30
31
  const componentKeys = ref([]);
31
32
  const isLoading = ref(false);
@@ -48,6 +49,7 @@ export function usePageConfig({ pageId, pagePath, locale, onPageLoaded } = {}) {
48
49
  components: Array.isArray(data.components) ? [...data.components] : [],
49
50
  content: data.content || {},
50
51
  meta: data.meta || {},
52
+ draft: data.draft,
51
53
  };
52
54
  };
53
55
 
@@ -69,6 +71,7 @@ export function usePageConfig({ pageId, pagePath, locale, onPageLoaded } = {}) {
69
71
  components: Array.isArray(data.components) ? [...data.components] : [],
70
72
  content: data.content || {},
71
73
  meta: data.meta || {},
74
+ draft: data.draft,
72
75
  };
73
76
  }
74
77
  }
@@ -85,6 +88,7 @@ export function usePageConfig({ pageId, pagePath, locale, onPageLoaded } = {}) {
85
88
  components: Array.isArray(firstData.components) ? [...firstData.components] : [],
86
89
  content: firstData.content || {},
87
90
  meta: firstData.meta || {},
91
+ draft: firstData.draft,
88
92
  };
89
93
  }
90
94
 
@@ -141,7 +145,7 @@ export function usePageConfig({ pageId, pagePath, locale, onPageLoaded } = {}) {
141
145
  if (import.meta.env.DEV) {
142
146
  console.error('[PageRenderer] Failed to load page configuration', error);
143
147
  }
144
- currentPage.value = { id: '', path: '/', components: [], content: {}, meta: {} };
148
+ currentPage.value = { id: '', path: '/', components: [], content: {}, meta: {}, draft: undefined };
145
149
  pageContent.value = {};
146
150
  if (typeof onPageLoaded === 'function') {
147
151
  onPageLoaded({ config: null, page: null, content: {} });
@@ -1,5 +1,6 @@
1
1
  import { computed } from 'vue';
2
2
  import { useHead } from '@unhead/vue';
3
+ import { isPathDraft } from '../utils/draftMode.js';
3
4
 
4
5
  const SPECIAL_PAGE_LABELS = {
5
6
  home: '',
@@ -39,9 +40,20 @@ export function usePageMeta({ siteData, currentPage }) {
39
40
  const siteTitle = computed(() => siteData.value?.site?.title || '');
40
41
  const siteDescription = computed(() => siteData.value?.site?.description || '');
41
42
 
43
+ const isDraft = computed(() =>
44
+ isPathDraft(siteData.value, currentPage.value?.path, currentPage.value),
45
+ );
46
+
42
47
  const pageMetaTitle = computed(() => {
43
48
  const page = currentPage.value;
44
49
  const site = siteTitle.value;
50
+ // Draft pages emit a generic title so the page slug doesn't leak via
51
+ // <title> in the SSG-rendered HTML on disk. The tab title remains
52
+ // generic even after client-side unlock — for "iterate before launch"
53
+ // the URL bar already identifies the page.
54
+ if (isDraft.value) {
55
+ return site ? `Draft — ${site}` : 'Draft';
56
+ }
45
57
  const explicitTitle = page?.meta?.title;
46
58
  if (explicitTitle) {
47
59
  return explicitTitle;
@@ -54,6 +66,9 @@ export function usePageMeta({ siteData, currentPage }) {
54
66
  });
55
67
 
56
68
  const pageMetaDescription = computed(() => {
69
+ if (isDraft.value) {
70
+ return '';
71
+ }
57
72
  const explicitDescription = currentPage.value?.meta?.description;
58
73
  if (explicitDescription && explicitDescription.trim()) {
59
74
  return explicitDescription.trim();
@@ -63,14 +78,18 @@ export function usePageMeta({ siteData, currentPage }) {
63
78
 
64
79
  useHead(() => {
65
80
  const description = pageMetaDescription.value;
66
- const meta = description
67
- ? [{ name: 'description', content: description, key: 'description' }]
68
- : [];
81
+ const meta = [];
82
+ if (description) {
83
+ meta.push({ name: 'description', content: description, key: 'description' });
84
+ }
85
+ if (isDraft.value) {
86
+ meta.push({ name: 'robots', content: 'noindex, nofollow', key: 'robots' });
87
+ }
69
88
  return {
70
89
  title: pageMetaTitle.value,
71
90
  meta,
72
91
  };
73
92
  });
74
93
 
75
- return { pageMetaTitle, pageMetaDescription };
94
+ return { pageMetaTitle, pageMetaDescription, isDraft };
76
95
  }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Pure helpers for "draft mode" — letting authors mark a whole site, a URL
3
+ * prefix, or a single page as not-yet-public so search engines and casual
4
+ * visitors stay out while content is being iterated on.
5
+ *
6
+ * Used by:
7
+ * - vite-plugin.js: emit Disallow lines in robots.txt, skip URLs in sitemap.xml
8
+ * - usePageMeta.js: inject <meta name="robots" content="noindex,nofollow">
9
+ * - useDraftGate.js: decide whether to render the password gate instead of
10
+ * the page contents
11
+ *
12
+ * Resolution rule (page wins both ways, then site, then prefixes):
13
+ * 1. pageData.draft === true → draft
14
+ * 2. pageData.draft === false → published (overrides site/prefix)
15
+ * 3. site.draft === true → draft
16
+ * 4. any draftPaths prefix matches → draft
17
+ * 5. otherwise → published
18
+ *
19
+ * Prefix match is path-segment-aware: "/blog" matches "/blog" and
20
+ * "/blog/2026/post" but NOT "/blog-archive". The literal prefix "/" matches
21
+ * only "/" itself; for whole-site draft, set site.draft = true instead.
22
+ */
23
+
24
+ export function normalizeDraftPath(value) {
25
+ if (typeof value !== 'string') return null;
26
+ const trimmed = value.trim();
27
+ if (!trimmed) return null;
28
+ let p = trimmed.startsWith('/') ? trimmed : `/${trimmed}`;
29
+ p = p.replace(/\/+/g, '/').replace(/\/+$/, '');
30
+ return p || '/';
31
+ }
32
+
33
+ export function pathMatchesPrefix(prefix, path) {
34
+ const np = normalizeDraftPath(prefix);
35
+ const target = normalizeDraftPath(path);
36
+ if (!np || !target) return false;
37
+ if (np === target) return true;
38
+ if (np === '/') return false;
39
+ return target.startsWith(`${np}/`);
40
+ }
41
+
42
+ export function isPathDraft(siteData, pagePath, pageData) {
43
+ if (pageData && typeof pageData === 'object') {
44
+ if (pageData.draft === true) return true;
45
+ if (pageData.draft === false) return false;
46
+ }
47
+
48
+ const site = siteData?.site;
49
+ if (!site || typeof site !== 'object') return false;
50
+
51
+ if (site.draft === true) return true;
52
+
53
+ const draftPaths = Array.isArray(site.draftPaths) ? site.draftPaths : [];
54
+ for (const prefix of draftPaths) {
55
+ if (pathMatchesPrefix(prefix, pagePath)) return true;
56
+ }
57
+
58
+ return false;
59
+ }
60
+
61
+ export function getDraftPasswordHash(siteData) {
62
+ const raw = siteData?.site?.draftPasswordHash;
63
+ if (typeof raw !== 'string') return '';
64
+ return raw.trim().toLowerCase();
65
+ }
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Build robots.txt body from the inflated site config.
3
+ *
4
+ * Replaces the static cms/public/robots.txt — the plugin writes this string
5
+ * to siteRoot/public/robots.txt at config time so per-site draft state
6
+ * (site.draft, site.draftPaths, page.draft) regenerates the disallow list on
7
+ * every build.
8
+ *
9
+ * Always includes the framework-default disallows (/admin, /privacy, /terms,
10
+ * /cookies — these routes exist on every CMS site). Adds:
11
+ * - "Disallow: /" when site.draft === true
12
+ * - "Disallow: <prefix>" for each entry in site.draftPaths
13
+ * - "Disallow: <path>" for each page with draft === true
14
+ *
15
+ * If sitemapUrl is provided, appends a "Sitemap: <url>" line.
16
+ */
17
+
18
+ import { normalizeDraftPath } from './draftMode.js';
19
+
20
+ const FRAMEWORK_DEFAULTS = ['/admin', '/privacy', '/terms', '/cookies'];
21
+
22
+ export function buildRobotsTxt(siteConfig, sitemapUrl = '') {
23
+ const lines = ['User-agent: *'];
24
+
25
+ for (const p of FRAMEWORK_DEFAULTS) {
26
+ lines.push(`Disallow: ${p}`);
27
+ }
28
+
29
+ const site = siteConfig?.site || {};
30
+ const pages = siteConfig?.pages || {};
31
+
32
+ if (site.draft === true) {
33
+ lines.push('Disallow: /');
34
+ } else {
35
+ const additional = new Set();
36
+
37
+ const draftPaths = Array.isArray(site.draftPaths) ? site.draftPaths : [];
38
+ for (const p of draftPaths) {
39
+ const np = normalizeDraftPath(p);
40
+ if (np && !FRAMEWORK_DEFAULTS.includes(np)) additional.add(np);
41
+ }
42
+
43
+ for (const [, pageData] of Object.entries(pages)) {
44
+ if (!pageData || typeof pageData !== 'object') continue;
45
+ if (pageData.draft !== true) continue;
46
+ const np = normalizeDraftPath(pageData.path);
47
+ if (np && !FRAMEWORK_DEFAULTS.includes(np)) additional.add(np);
48
+ }
49
+
50
+ for (const p of Array.from(additional).sort()) {
51
+ lines.push(`Disallow: ${p}`);
52
+ }
53
+ }
54
+
55
+ const trimmedSitemap = (sitemapUrl || '').trim();
56
+ if (trimmedSitemap) {
57
+ lines.push('');
58
+ lines.push(`Sitemap: ${trimmedSitemap}`);
59
+ }
60
+
61
+ return `${lines.join('\n')}\n`;
62
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * SHA-256 hex digest via Web Crypto API.
3
+ *
4
+ * Available in Node 19+ (globalThis.crypto.subtle), modern browsers, and
5
+ * happy-dom test environment (which inherits Node's globalThis.crypto). The
6
+ * CMS targets Node 20.19, so a single Web Crypto path is sufficient.
7
+ *
8
+ * Used by the draft gate: site.draftPassword is hashed at build time (in
9
+ * the Vite plugin) so the plaintext never reaches the bundle. At runtime,
10
+ * the user's input is hashed and compared against the stored hash.
11
+ */
12
+
13
+ export async function sha256Hex(input) {
14
+ const text = typeof input === 'string' ? input : String(input ?? '');
15
+ const bytes = new TextEncoder().encode(text);
16
+ const hashBuffer = await globalThis.crypto.subtle.digest('SHA-256', bytes);
17
+ return Array.from(new Uint8Array(hashBuffer))
18
+ .map((b) => b.toString(16).padStart(2, '0'))
19
+ .join('');
20
+ }
@@ -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
+ }
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