@koehler8/cms 1.0.0-beta.17 → 1.0.0-beta.19
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/DraftGate.vue +212 -0
- package/src/components/Home.vue +38 -19
- package/src/composables/useDraftGate.js +89 -0
- package/src/composables/usePageConfig.js +5 -1
- package/src/composables/usePageMeta.js +88 -5
- package/src/router/index.js +62 -34
- package/src/utils/canonicalUrl.js +44 -0
- package/src/utils/draftMode.js +65 -0
- package/src/utils/loadConfig.js +3 -0
- package/src/utils/robotsGenerator.js +62 -0
- package/src/utils/sha256.js +20 -0
- package/src/utils/sitemapGenerator.js +102 -0
- package/vite-plugin.js +116 -8
- package/public/robots.txt +0 -5
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
|
@@ -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>
|
package/src/components/Home.vue
CHANGED
|
@@ -1,29 +1,39 @@
|
|
|
1
1
|
<template>
|
|
2
|
-
<
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
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
|
-
</
|
|
15
|
-
<
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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,7 +82,15 @@ const { loadedComponents } = useComponentResolver({
|
|
|
71
82
|
|
|
72
83
|
const { introGateEnabled, introGateProps } = useIntroGate({ siteData, pageContent });
|
|
73
84
|
|
|
74
|
-
|
|
85
|
+
const {
|
|
86
|
+
isDraft,
|
|
87
|
+
isUnlocked,
|
|
88
|
+
hasPassword,
|
|
89
|
+
errorMessage,
|
|
90
|
+
attemptUnlock,
|
|
91
|
+
} = useDraftGate({ siteData, currentPage });
|
|
92
|
+
|
|
93
|
+
usePageMeta({ siteData, currentPage, locale: () => props.locale });
|
|
75
94
|
|
|
76
95
|
const {
|
|
77
96
|
isComingSoonVisible,
|
|
@@ -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,8 @@
|
|
|
1
1
|
import { computed } from 'vue';
|
|
2
2
|
import { useHead } from '@unhead/vue';
|
|
3
|
+
import { isPathDraft } from '../utils/draftMode.js';
|
|
4
|
+
import { buildCanonicalUrl } from '../utils/canonicalUrl.js';
|
|
5
|
+
import { availableLocales as configAvailableLocales, baseLocale as configBaseLocale } from '../utils/loadConfig.js';
|
|
3
6
|
|
|
4
7
|
const SPECIAL_PAGE_LABELS = {
|
|
5
8
|
home: '',
|
|
@@ -35,13 +38,30 @@ function inferPageLabel(page) {
|
|
|
35
38
|
.join(' / ');
|
|
36
39
|
}
|
|
37
40
|
|
|
38
|
-
export function usePageMeta({ siteData, currentPage }) {
|
|
41
|
+
export function usePageMeta({ siteData, currentPage, locale }) {
|
|
39
42
|
const siteTitle = computed(() => siteData.value?.site?.title || '');
|
|
40
43
|
const siteDescription = computed(() => siteData.value?.site?.description || '');
|
|
44
|
+
const siteUrl = computed(() => siteData.value?.site?.url || '');
|
|
45
|
+
const localeValue = computed(() => {
|
|
46
|
+
if (typeof locale === 'function') return locale() || '';
|
|
47
|
+
if (typeof locale === 'string') return locale;
|
|
48
|
+
return '';
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
const isDraft = computed(() =>
|
|
52
|
+
isPathDraft(siteData.value, currentPage.value?.path, currentPage.value),
|
|
53
|
+
);
|
|
41
54
|
|
|
42
55
|
const pageMetaTitle = computed(() => {
|
|
43
56
|
const page = currentPage.value;
|
|
44
57
|
const site = siteTitle.value;
|
|
58
|
+
// Draft pages emit a generic title so the page slug doesn't leak via
|
|
59
|
+
// <title> in the SSG-rendered HTML on disk. The tab title remains
|
|
60
|
+
// generic even after client-side unlock — for "iterate before launch"
|
|
61
|
+
// the URL bar already identifies the page.
|
|
62
|
+
if (isDraft.value) {
|
|
63
|
+
return site ? `Draft — ${site}` : 'Draft';
|
|
64
|
+
}
|
|
45
65
|
const explicitTitle = page?.meta?.title;
|
|
46
66
|
if (explicitTitle) {
|
|
47
67
|
return explicitTitle;
|
|
@@ -54,6 +74,9 @@ export function usePageMeta({ siteData, currentPage }) {
|
|
|
54
74
|
});
|
|
55
75
|
|
|
56
76
|
const pageMetaDescription = computed(() => {
|
|
77
|
+
if (isDraft.value) {
|
|
78
|
+
return '';
|
|
79
|
+
}
|
|
57
80
|
const explicitDescription = currentPage.value?.meta?.description;
|
|
58
81
|
if (explicitDescription && explicitDescription.trim()) {
|
|
59
82
|
return explicitDescription.trim();
|
|
@@ -61,16 +84,76 @@ export function usePageMeta({ siteData, currentPage }) {
|
|
|
61
84
|
return siteDescription.value || '';
|
|
62
85
|
});
|
|
63
86
|
|
|
87
|
+
// Canonical URL — base-locale URLs have no prefix; non-base locales get
|
|
88
|
+
// /{locale}/path. Drafts skip canonical (gated content shouldn't advertise
|
|
89
|
+
// an authoritative URL). Sites without a configured site.url skip too —
|
|
90
|
+
// we can't build an absolute URL.
|
|
91
|
+
const canonicalHref = computed(() => {
|
|
92
|
+
if (isDraft.value) return '';
|
|
93
|
+
if (!siteUrl.value) return '';
|
|
94
|
+
return buildCanonicalUrl({
|
|
95
|
+
siteUrl: siteUrl.value,
|
|
96
|
+
locale: localeValue.value,
|
|
97
|
+
baseLocale: configBaseLocale,
|
|
98
|
+
path: currentPage.value?.path || '/',
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
// hreflang alternates — only emitted when a site has more than one
|
|
103
|
+
// locale's content on disk. Includes one entry per available locale plus
|
|
104
|
+
// an `x-default` pointing at the base-locale URL (Google's preferred
|
|
105
|
+
// pattern for sites without explicit geo-targeting).
|
|
106
|
+
const hreflangLinks = computed(() => {
|
|
107
|
+
if (isDraft.value) return [];
|
|
108
|
+
if (!siteUrl.value) return [];
|
|
109
|
+
if (!Array.isArray(configAvailableLocales) || configAvailableLocales.length <= 1) return [];
|
|
110
|
+
const path = currentPage.value?.path || '/';
|
|
111
|
+
const links = [];
|
|
112
|
+
for (const code of configAvailableLocales) {
|
|
113
|
+
const href = buildCanonicalUrl({
|
|
114
|
+
siteUrl: siteUrl.value,
|
|
115
|
+
locale: code,
|
|
116
|
+
baseLocale: configBaseLocale,
|
|
117
|
+
path,
|
|
118
|
+
});
|
|
119
|
+
if (href) {
|
|
120
|
+
links.push({ rel: 'alternate', hreflang: code, href, key: `hreflang-${code}` });
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
const defaultHref = buildCanonicalUrl({
|
|
124
|
+
siteUrl: siteUrl.value,
|
|
125
|
+
locale: configBaseLocale,
|
|
126
|
+
baseLocale: configBaseLocale,
|
|
127
|
+
path,
|
|
128
|
+
});
|
|
129
|
+
if (defaultHref) {
|
|
130
|
+
links.push({ rel: 'alternate', hreflang: 'x-default', href: defaultHref, key: 'hreflang-x-default' });
|
|
131
|
+
}
|
|
132
|
+
return links;
|
|
133
|
+
});
|
|
134
|
+
|
|
64
135
|
useHead(() => {
|
|
65
136
|
const description = pageMetaDescription.value;
|
|
66
|
-
const meta =
|
|
67
|
-
|
|
68
|
-
:
|
|
137
|
+
const meta = [];
|
|
138
|
+
if (description) {
|
|
139
|
+
meta.push({ name: 'description', content: description, key: 'description' });
|
|
140
|
+
}
|
|
141
|
+
if (isDraft.value) {
|
|
142
|
+
meta.push({ name: 'robots', content: 'noindex, nofollow', key: 'robots' });
|
|
143
|
+
}
|
|
144
|
+
const link = [];
|
|
145
|
+
if (canonicalHref.value) {
|
|
146
|
+
link.push({ rel: 'canonical', href: canonicalHref.value, key: 'canonical' });
|
|
147
|
+
}
|
|
148
|
+
for (const entry of hreflangLinks.value) {
|
|
149
|
+
link.push(entry);
|
|
150
|
+
}
|
|
69
151
|
return {
|
|
70
152
|
title: pageMetaTitle.value,
|
|
71
153
|
meta,
|
|
154
|
+
link,
|
|
72
155
|
};
|
|
73
156
|
});
|
|
74
157
|
|
|
75
|
-
return { pageMetaTitle, pageMetaDescription };
|
|
158
|
+
return { pageMetaTitle, pageMetaDescription, isDraft, canonicalHref, hreflangLinks };
|
|
76
159
|
}
|
package/src/router/index.js
CHANGED
|
@@ -3,7 +3,7 @@ import { h } from 'vue';
|
|
|
3
3
|
|
|
4
4
|
import Home from '../components/Home.vue';
|
|
5
5
|
|
|
6
|
-
import {
|
|
6
|
+
import { availableLocales, baseLocale } from '../utils/loadConfig.js';
|
|
7
7
|
|
|
8
8
|
const LocaleLayout = {
|
|
9
9
|
name: 'LocaleLayout',
|
|
@@ -12,57 +12,85 @@ const LocaleLayout = {
|
|
|
12
12
|
},
|
|
13
13
|
};
|
|
14
14
|
|
|
15
|
-
|
|
16
|
-
|
|
15
|
+
// Locales that should match the `/:locale/...` route — every available
|
|
16
|
+
// locale on disk EXCEPT the base. The base locale is served at the
|
|
17
|
+
// unprefixed path; emitting `/{baseLocale}/path` would be a duplicate URL.
|
|
18
|
+
//
|
|
19
|
+
// Read at module-load time. The vite-plugin's generated entry calls
|
|
20
|
+
// setConfigLoader() synchronously before importing the app, so by the
|
|
21
|
+
// time this module is evaluated (inside main.js), the singletons are
|
|
22
|
+
// populated.
|
|
23
|
+
function computeLocalePrefixes() {
|
|
24
|
+
if (!Array.isArray(availableLocales)) return [];
|
|
25
|
+
return availableLocales.filter((l) => typeof l === 'string' && l !== baseLocale);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function buildRoutes(localePrefixes = computeLocalePrefixes()) {
|
|
29
|
+
const homeRoute = {
|
|
17
30
|
path: '/',
|
|
18
31
|
name: 'Home',
|
|
19
32
|
component: Home,
|
|
20
33
|
props: { pageId: 'home', pagePath: '/' },
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
component: LocaleLayout,
|
|
25
|
-
children: [
|
|
26
|
-
{
|
|
27
|
-
path: '',
|
|
28
|
-
name: 'HomeLocale',
|
|
29
|
-
component: Home,
|
|
30
|
-
props: (route) => ({ pageId: 'home', pagePath: '/', locale: route.params.locale }),
|
|
31
|
-
},
|
|
32
|
-
{
|
|
33
|
-
path: ':pathMatch(.*)*',
|
|
34
|
-
name: 'LocaleDynamicPage',
|
|
35
|
-
component: Home,
|
|
36
|
-
props: (route) => {
|
|
37
|
-
const pathMatch = route.params.pathMatch;
|
|
38
|
-
const slugSegments = Array.isArray(pathMatch) ? pathMatch : [pathMatch || ''];
|
|
39
|
-
const rawSlug = `/${slugSegments.filter(Boolean).join('/')}`;
|
|
40
|
-
const normalizedSlug = rawSlug.replace(/\/+/g, '/').replace(/\/$/, '') || '/';
|
|
41
|
-
return { pagePath: normalizedSlug, locale: route.params.locale };
|
|
42
|
-
},
|
|
43
|
-
},
|
|
44
|
-
],
|
|
45
|
-
},
|
|
46
|
-
{
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const catchAllRoute = {
|
|
47
37
|
path: '/:pathMatch(.*)*',
|
|
48
38
|
name: 'DynamicPage',
|
|
49
39
|
component: Home,
|
|
50
40
|
props: (route) => ({ pagePath: route.path || '/' }),
|
|
51
|
-
}
|
|
52
|
-
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
if (!localePrefixes.length) {
|
|
44
|
+
// Single-locale site: don't register the locale-prefixed route at all.
|
|
45
|
+
// `/de/about` etc. fall into the catch-all → DynamicPage with a
|
|
46
|
+
// pagePath that won't match any page → home fallback.
|
|
47
|
+
return [homeRoute, catchAllRoute];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return [
|
|
51
|
+
homeRoute,
|
|
52
|
+
{
|
|
53
|
+
path: `/:locale(${localePrefixes.join('|')})`,
|
|
54
|
+
component: LocaleLayout,
|
|
55
|
+
children: [
|
|
56
|
+
{
|
|
57
|
+
path: '',
|
|
58
|
+
name: 'HomeLocale',
|
|
59
|
+
component: Home,
|
|
60
|
+
props: (route) => ({ pageId: 'home', pagePath: '/', locale: route.params.locale }),
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
path: ':pathMatch(.*)*',
|
|
64
|
+
name: 'LocaleDynamicPage',
|
|
65
|
+
component: Home,
|
|
66
|
+
props: (route) => {
|
|
67
|
+
const pathMatch = route.params.pathMatch;
|
|
68
|
+
const slugSegments = Array.isArray(pathMatch) ? pathMatch : [pathMatch || ''];
|
|
69
|
+
const rawSlug = `/${slugSegments.filter(Boolean).join('/')}`;
|
|
70
|
+
const normalizedSlug = rawSlug.replace(/\/+/g, '/').replace(/\/$/, '') || '/';
|
|
71
|
+
return { pagePath: normalizedSlug, locale: route.params.locale };
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
],
|
|
75
|
+
},
|
|
76
|
+
catchAllRoute,
|
|
77
|
+
];
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export const routes = buildRoutes();
|
|
53
81
|
|
|
54
82
|
function normalizeLocale(value = '') {
|
|
55
83
|
return value.toLowerCase().split('-')[0];
|
|
56
84
|
}
|
|
57
85
|
|
|
58
|
-
export function applyRouterGuards(router) {
|
|
86
|
+
export function applyRouterGuards(router, localePrefixes = computeLocalePrefixes()) {
|
|
59
87
|
router.beforeEach((to) => {
|
|
60
88
|
const rawLocale = (to.params.locale || '').toString().trim();
|
|
61
89
|
|
|
62
90
|
if (rawLocale) {
|
|
63
91
|
const normalized = normalizeLocale(rawLocale);
|
|
64
92
|
|
|
65
|
-
if (!
|
|
93
|
+
if (!localePrefixes.includes(normalized)) {
|
|
66
94
|
return { path: '/', replace: true };
|
|
67
95
|
}
|
|
68
96
|
|
|
@@ -87,7 +115,7 @@ export function applyRouterGuards(router) {
|
|
|
87
115
|
const navLang = (navigator.language || navigator.userLanguage || '').toString();
|
|
88
116
|
const shortLang = normalizeLocale(navLang || '');
|
|
89
117
|
|
|
90
|
-
if (to.path === '/' &&
|
|
118
|
+
if (to.path === '/' && localePrefixes.includes(shortLang)) {
|
|
91
119
|
return { path: `/${shortLang}`, replace: true };
|
|
92
120
|
}
|
|
93
121
|
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Single source of truth for the canonical URL formula.
|
|
3
|
+
*
|
|
4
|
+
* Used by:
|
|
5
|
+
* - usePageMeta.js (emits <link rel="canonical"> + <link rel="alternate" hreflang>)
|
|
6
|
+
* - sitemapGenerator.js (emits <loc> + <xhtml:link rel="alternate" hreflang>)
|
|
7
|
+
*
|
|
8
|
+
* URL space rule (matches Google / Stripe / GitHub conventions):
|
|
9
|
+
* - Base locale → ${siteUrl}${path} e.g. https://example.com/about
|
|
10
|
+
* - Non-base → ${siteUrl}/${locale}${path} e.g. https://example.com/de/about
|
|
11
|
+
*
|
|
12
|
+
* Returns '' when siteUrl is missing — sitemaps require absolute URLs and
|
|
13
|
+
* canonical links without a host are useless. Callers treat '' as "skip".
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
function normalizePath(value) {
|
|
17
|
+
if (typeof value !== 'string') return '/';
|
|
18
|
+
const trimmed = value.trim();
|
|
19
|
+
if (!trimmed) return '/';
|
|
20
|
+
let p = trimmed.startsWith('/') ? trimmed : `/${trimmed}`;
|
|
21
|
+
p = p.replace(/\/+/g, '/').replace(/\/+$/, '');
|
|
22
|
+
return p || '/';
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function normalizeLocale(value) {
|
|
26
|
+
if (typeof value !== 'string') return '';
|
|
27
|
+
const trimmed = value.trim().toLowerCase();
|
|
28
|
+
return trimmed;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function buildCanonicalUrl({ siteUrl, locale, baseLocale, path } = {}) {
|
|
32
|
+
const host = typeof siteUrl === 'string' ? siteUrl.trim().replace(/\/+$/, '') : '';
|
|
33
|
+
if (!host) return '';
|
|
34
|
+
|
|
35
|
+
const normalizedPath = normalizePath(path);
|
|
36
|
+
const loc = normalizeLocale(locale);
|
|
37
|
+
const base = normalizeLocale(baseLocale);
|
|
38
|
+
|
|
39
|
+
if (!loc || loc === base) {
|
|
40
|
+
return normalizedPath === '/' ? `${host}/` : `${host}${normalizedPath}`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return normalizedPath === '/' ? `${host}/${loc}` : `${host}/${loc}${normalizedPath}`;
|
|
44
|
+
}
|
|
@@ -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
|
+
}
|
package/src/utils/loadConfig.js
CHANGED
|
@@ -307,13 +307,16 @@ let _loadConfigData = async () => {
|
|
|
307
307
|
};
|
|
308
308
|
|
|
309
309
|
let _availableLocales = [];
|
|
310
|
+
let _baseLocale = '';
|
|
310
311
|
|
|
311
312
|
export function setConfigLoader(instance) {
|
|
312
313
|
if (!instance) return;
|
|
313
314
|
_loadConfigData = instance.loadConfigData;
|
|
314
315
|
_availableLocales = instance.availableLocales || [];
|
|
316
|
+
_baseLocale = typeof instance.baseLocale === 'string' ? instance.baseLocale : '';
|
|
315
317
|
}
|
|
316
318
|
|
|
317
319
|
export { _availableLocales as availableLocales };
|
|
320
|
+
export { _baseLocale as baseLocale };
|
|
318
321
|
|
|
319
322
|
export const loadConfigData = (...args) => _loadConfigData(...args);
|
|
@@ -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,102 @@
|
|
|
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
|
+
* Single-locale sites emit only <loc> per page. Multi-locale sites add
|
|
9
|
+
* <xhtml:link rel="alternate" hreflang="..." /> annotations per page (one
|
|
10
|
+
* per available locale plus an `x-default` pointing at the base-locale
|
|
11
|
+
* URL). The single-locale output stays byte-identical to pre-hreflang.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { isPathDraft, normalizeDraftPath } from './draftMode.js';
|
|
15
|
+
import { buildCanonicalUrl } from './canonicalUrl.js';
|
|
16
|
+
|
|
17
|
+
function escapeXml(value) {
|
|
18
|
+
return String(value).replace(/[<>&'"]/g, (c) => ({
|
|
19
|
+
'<': '<',
|
|
20
|
+
'>': '>',
|
|
21
|
+
'&': '&',
|
|
22
|
+
"'": ''',
|
|
23
|
+
'"': '"',
|
|
24
|
+
}[c]));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function buildSitemap(siteConfig, options = {}) {
|
|
28
|
+
const siteUrl = (siteConfig?.site?.url || '').trim().replace(/\/+$/, '');
|
|
29
|
+
if (!siteUrl) return '';
|
|
30
|
+
|
|
31
|
+
// Whole-site draft → no sitemap. robots.txt emits "Disallow: /" in this
|
|
32
|
+
// state, and listing URLs in the sitemap that the same robots blocks
|
|
33
|
+
// would be inconsistent (and would cause crawl-error noise in Search
|
|
34
|
+
// Console). Page-level draft:false in a fully-draft site only affects
|
|
35
|
+
// the runtime gate; it is not advertised to crawlers.
|
|
36
|
+
if (siteConfig?.site?.draft === true) return '';
|
|
37
|
+
|
|
38
|
+
const baseLocale = typeof options.baseLocale === 'string' ? options.baseLocale.toLowerCase() : '';
|
|
39
|
+
const availableLocales = Array.isArray(options.availableLocales)
|
|
40
|
+
? options.availableLocales.map((l) => (typeof l === 'string' ? l.toLowerCase() : '')).filter(Boolean)
|
|
41
|
+
: [];
|
|
42
|
+
const isMultiLocale = availableLocales.length > 1;
|
|
43
|
+
|
|
44
|
+
const pages = siteConfig?.pages || {};
|
|
45
|
+
const entries = [];
|
|
46
|
+
const seenPaths = new Set();
|
|
47
|
+
|
|
48
|
+
for (const [, pageData] of Object.entries(pages)) {
|
|
49
|
+
if (!pageData || typeof pageData !== 'object') continue;
|
|
50
|
+
const pagePath = normalizeDraftPath(pageData.path) || '/';
|
|
51
|
+
if (isPathDraft(siteConfig, pagePath, pageData)) continue;
|
|
52
|
+
if (seenPaths.has(pagePath)) continue;
|
|
53
|
+
seenPaths.add(pagePath);
|
|
54
|
+
|
|
55
|
+
const baseUrl = buildCanonicalUrl({ siteUrl, baseLocale, locale: baseLocale, path: pagePath });
|
|
56
|
+
if (!baseUrl) continue;
|
|
57
|
+
|
|
58
|
+
const alternates = isMultiLocale
|
|
59
|
+
? availableLocales.map((loc) => ({
|
|
60
|
+
hreflang: loc,
|
|
61
|
+
href: buildCanonicalUrl({ siteUrl, baseLocale, locale: loc, path: pagePath }),
|
|
62
|
+
}))
|
|
63
|
+
: [];
|
|
64
|
+
if (isMultiLocale) {
|
|
65
|
+
alternates.push({ hreflang: 'x-default', href: baseUrl });
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
entries.push({ loc: baseUrl, alternates });
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (entries.length === 0) return '';
|
|
72
|
+
|
|
73
|
+
entries.sort((a, b) => a.loc.localeCompare(b.loc));
|
|
74
|
+
|
|
75
|
+
const urlsetAttrs = isMultiLocale
|
|
76
|
+
? 'xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml"'
|
|
77
|
+
: 'xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"';
|
|
78
|
+
|
|
79
|
+
const renderUrlEntry = (entry) => {
|
|
80
|
+
if (entry.alternates.length === 0) {
|
|
81
|
+
return ` <url><loc>${escapeXml(entry.loc)}</loc></url>`;
|
|
82
|
+
}
|
|
83
|
+
const altLines = entry.alternates
|
|
84
|
+
.map((a) => ` <xhtml:link rel="alternate" hreflang="${escapeXml(a.hreflang)}" href="${escapeXml(a.href)}"/>`)
|
|
85
|
+
.join('\n');
|
|
86
|
+
return ` <url>\n <loc>${escapeXml(entry.loc)}</loc>\n${altLines}\n </url>`;
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
const urlEntries = entries.map(renderUrlEntry).join('\n');
|
|
90
|
+
|
|
91
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
92
|
+
<urlset ${urlsetAttrs}>
|
|
93
|
+
${urlEntries}
|
|
94
|
+
</urlset>
|
|
95
|
+
`;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function getSitemapUrl(siteConfig) {
|
|
99
|
+
const siteUrl = (siteConfig?.site?.url || '').trim().replace(/\/+$/, '');
|
|
100
|
+
if (!siteUrl) return '';
|
|
101
|
+
return `${siteUrl}/sitemap.xml`;
|
|
102
|
+
}
|
package/vite-plugin.js
CHANGED
|
@@ -12,8 +12,10 @@ import { createRequire } from 'node:module';
|
|
|
12
12
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
13
13
|
import vue from '@vitejs/plugin-vue';
|
|
14
14
|
|
|
15
|
-
import { SUPPORTED_LOCALES } from './src/constants/locales.js';
|
|
16
15
|
import { inflateFlatConfig } from './src/utils/inflateFlatConfig.js';
|
|
16
|
+
import { buildRobotsTxt } from './src/utils/robotsGenerator.js';
|
|
17
|
+
import { buildSitemap, getSitemapUrl } from './src/utils/sitemapGenerator.js';
|
|
18
|
+
import { sha256Hex } from './src/utils/sha256.js';
|
|
17
19
|
|
|
18
20
|
// ---- Helpers ----
|
|
19
21
|
|
|
@@ -22,6 +24,15 @@ function loadSplitConfig(contentLocaleDir) {
|
|
|
22
24
|
if (!fs.existsSync(sitePath)) return null;
|
|
23
25
|
|
|
24
26
|
const site = inflateFlatConfig(JSON.parse(fs.readFileSync(sitePath, 'utf-8')));
|
|
27
|
+
// The plaintext password is read at build time so the plugin can produce
|
|
28
|
+
// robots.txt / sitemap.xml without surfacing it; strip it out of the
|
|
29
|
+
// in-memory siteConfig so accidental future code paths can't pass it
|
|
30
|
+
// through to the bundle. The `transform` hook below replaces the
|
|
31
|
+
// plaintext with a SHA-256 hash before any JSON file is imported by the
|
|
32
|
+
// runtime.
|
|
33
|
+
if (site && typeof site === 'object') {
|
|
34
|
+
delete site.draftPassword;
|
|
35
|
+
}
|
|
25
36
|
const sharedPath = path.join(contentLocaleDir, 'shared.json');
|
|
26
37
|
const shared = fs.existsSync(sharedPath) ? inflateFlatConfig(JSON.parse(fs.readFileSync(sharedPath, 'utf-8'))) : {};
|
|
27
38
|
|
|
@@ -38,6 +49,29 @@ function loadSplitConfig(contentLocaleDir) {
|
|
|
38
49
|
return { site, shared, pages };
|
|
39
50
|
}
|
|
40
51
|
|
|
52
|
+
// Replace site.json's plaintext draftPassword with a SHA-256 hash before
|
|
53
|
+
// the JSON ever enters the bundle. Returns the original code untouched
|
|
54
|
+
// when the file doesn't carry a password (or doesn't parse as JSON), so
|
|
55
|
+
// non-CMS site.json files anywhere else in the project are unaffected.
|
|
56
|
+
async function transformSiteJson(code) {
|
|
57
|
+
let parsed;
|
|
58
|
+
try {
|
|
59
|
+
parsed = JSON.parse(code);
|
|
60
|
+
} catch {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null;
|
|
64
|
+
|
|
65
|
+
const password = typeof parsed.draftPassword === 'string' ? parsed.draftPassword.trim() : '';
|
|
66
|
+
if (!('draftPassword' in parsed) && !password) return null;
|
|
67
|
+
|
|
68
|
+
delete parsed.draftPassword;
|
|
69
|
+
if (password) {
|
|
70
|
+
parsed.draftPasswordHash = await sha256Hex(password);
|
|
71
|
+
}
|
|
72
|
+
return JSON.stringify(parsed);
|
|
73
|
+
}
|
|
74
|
+
|
|
41
75
|
function normalizeRoutePath(value = '/') {
|
|
42
76
|
if (typeof value !== 'string') return '/';
|
|
43
77
|
const trimmed = value.trim();
|
|
@@ -286,16 +320,22 @@ export default function cmsPlugin(options = {}) {
|
|
|
286
320
|
const {
|
|
287
321
|
siteDir: siteDirOption = './site',
|
|
288
322
|
frameworkRoot: frameworkRootOption = __dirname,
|
|
289
|
-
locales = SUPPORTED_LOCALES,
|
|
290
323
|
themes: themePackages = [],
|
|
291
324
|
extensions: extensionPackages = [],
|
|
292
325
|
} = options;
|
|
326
|
+
// The previous `locales` option is no longer accepted: per-site available
|
|
327
|
+
// locales are now discovered from on-disk content directories. Passing
|
|
328
|
+
// `locales` is silently ignored (kept here as a destructure to swallow it
|
|
329
|
+
// gracefully; the `SUPPORTED_LOCALES` constant remains exported only for
|
|
330
|
+
// any consumer that imports it directly).
|
|
293
331
|
|
|
294
332
|
// These are resolved relative to the project cwd at config time
|
|
295
333
|
let siteDir;
|
|
296
334
|
let siteRoot; // project root — parent of siteDir
|
|
297
335
|
let frameworkRoot;
|
|
298
336
|
let siteConfig;
|
|
337
|
+
let baseLocale = 'en';
|
|
338
|
+
let availableLocales = [];
|
|
299
339
|
let pagePaths;
|
|
300
340
|
let metadata;
|
|
301
341
|
let tempIndexPath;
|
|
@@ -356,6 +396,30 @@ export default function cmsPlugin(options = {}) {
|
|
|
356
396
|
mirrorDir(frameworkPublicDir, sitePublicDir);
|
|
357
397
|
};
|
|
358
398
|
|
|
399
|
+
// Generate robots.txt and sitemap.xml from the inflated site config and
|
|
400
|
+
// write them to siteRoot/public/. Runs after syncPublicDir so dynamic
|
|
401
|
+
// output overwrites any framework or stale static copy. Sites pick up
|
|
402
|
+
// changes by rebuilding (the existing edit-JSON-rebuild-deploy flow).
|
|
403
|
+
const writeSeoFiles = () => {
|
|
404
|
+
const sitePublicDir = path.join(siteRoot, 'public');
|
|
405
|
+
fs.mkdirSync(sitePublicDir, { recursive: true });
|
|
406
|
+
|
|
407
|
+
const sitemapXml = buildSitemap(siteConfig, { availableLocales, baseLocale });
|
|
408
|
+
const sitemapPath = path.join(sitePublicDir, 'sitemap.xml');
|
|
409
|
+
if (sitemapXml) {
|
|
410
|
+
fs.writeFileSync(sitemapPath, sitemapXml, 'utf-8');
|
|
411
|
+
} else if (fs.existsSync(sitemapPath)) {
|
|
412
|
+
// Stale sitemap from a previous build (e.g. site.url was removed, or
|
|
413
|
+
// every page is now draft). Drop it so crawlers don't get a list of
|
|
414
|
+
// URLs that no longer apply.
|
|
415
|
+
try { fs.unlinkSync(sitemapPath); } catch { /* noop */ }
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
const sitemapUrl = sitemapXml ? getSitemapUrl(siteConfig) : '';
|
|
419
|
+
const robotsBody = buildRobotsTxt(siteConfig, sitemapUrl);
|
|
420
|
+
fs.writeFileSync(path.join(sitePublicDir, 'robots.txt'), robotsBody, 'utf-8');
|
|
421
|
+
};
|
|
422
|
+
|
|
359
423
|
const cmsCore = {
|
|
360
424
|
name: '@koehler8/cms',
|
|
361
425
|
enforce: 'pre',
|
|
@@ -376,12 +440,24 @@ export default function cmsPlugin(options = {}) {
|
|
|
376
440
|
const contentConfig = fs.existsSync(contentConfigPath)
|
|
377
441
|
? JSON.parse(fs.readFileSync(contentConfigPath, 'utf-8'))
|
|
378
442
|
: {};
|
|
379
|
-
|
|
443
|
+
baseLocale = contentConfig.baseLocale || 'en';
|
|
380
444
|
const configDir = path.join(contentDir, baseLocale);
|
|
381
445
|
siteConfig = loadSplitConfig(configDir);
|
|
382
446
|
if (!siteConfig) {
|
|
383
447
|
throw new Error(`[@koehler8/cms] site config not found at ${configDir}`);
|
|
384
448
|
}
|
|
449
|
+
// Discover locales actually present on disk: directories under
|
|
450
|
+
// content/ that contain a site.json. This becomes the authoritative
|
|
451
|
+
// list for both the SSG route fanout and the runtime locale-aware
|
|
452
|
+
// canonical/hreflang emission.
|
|
453
|
+
availableLocales = fs.existsSync(contentDir)
|
|
454
|
+
? fs
|
|
455
|
+
.readdirSync(contentDir, { withFileTypes: true })
|
|
456
|
+
.filter((d) => d.isDirectory())
|
|
457
|
+
.map((d) => d.name)
|
|
458
|
+
.filter((name) => fs.existsSync(path.join(contentDir, name, 'site.json')))
|
|
459
|
+
.sort()
|
|
460
|
+
: [];
|
|
385
461
|
pagePaths = collectPagePaths(siteConfig);
|
|
386
462
|
metadata = extractSiteMetadata(siteConfig);
|
|
387
463
|
|
|
@@ -466,8 +542,14 @@ export default function cmsPlugin(options = {}) {
|
|
|
466
542
|
const staticRoutes = new Set(['/admin']);
|
|
467
543
|
pagePaths.forEach((routePath) => staticRoutes.add(routePath));
|
|
468
544
|
|
|
545
|
+
// Pre-render every page under each non-base locale prefix that
|
|
546
|
+
// actually has a content directory on disk. The base locale is
|
|
547
|
+
// served at the unprefixed path (it's already covered by
|
|
548
|
+
// staticRoutes) — emitting `/{baseLocale}/path` would create
|
|
549
|
+
// duplicate URLs that all render the same content.
|
|
550
|
+
const localePrefixes = availableLocales.filter((l) => l !== baseLocale);
|
|
469
551
|
const localizedRoutes = new Set();
|
|
470
|
-
|
|
552
|
+
localePrefixes.forEach((locale) => {
|
|
471
553
|
pagePaths.forEach((routePath) => {
|
|
472
554
|
const localized =
|
|
473
555
|
routePath === '/' ? `/${locale}` : `/${locale}${routePath}`;
|
|
@@ -561,7 +643,12 @@ const loader = createConfigLoader(allModules);
|
|
|
561
643
|
export const loadConfigData = loader.loadConfigData;
|
|
562
644
|
export const mergeConfigTrees = loader.mergeConfigTrees;
|
|
563
645
|
export const cloneConfig = loader.cloneConfig;
|
|
564
|
-
|
|
646
|
+
// availableLocales + baseLocale are emitted as build-time literals derived
|
|
647
|
+
// from the same on-disk discovery the plugin uses for SSG includedRoutes
|
|
648
|
+
// and sitemap generation. This guarantees the runtime sees the exact same
|
|
649
|
+
// locale list as the build, regardless of glob-based heuristics.
|
|
650
|
+
export const availableLocales = ${JSON.stringify(availableLocales)};
|
|
651
|
+
export const baseLocale = ${JSON.stringify(baseLocale)};
|
|
565
652
|
`;
|
|
566
653
|
}
|
|
567
654
|
|
|
@@ -612,16 +699,37 @@ export const assetUrlMap = resolver.assetUrlMap;
|
|
|
612
699
|
return null;
|
|
613
700
|
},
|
|
614
701
|
|
|
702
|
+
async transform(code, id) {
|
|
703
|
+
// Strip plaintext draftPassword from any site.json under content/
|
|
704
|
+
// before it lands in the bundle. The runtime reads the resulting
|
|
705
|
+
// draftPasswordHash via siteData and verifies user input against
|
|
706
|
+
// the hash. enforce: 'pre' on this plugin runs the transform before
|
|
707
|
+
// Vite's built-in JSON plugin converts the file to a JS module.
|
|
708
|
+
if (!id) return null;
|
|
709
|
+
const idPath = id.split('?')[0];
|
|
710
|
+
if (!idPath.endsWith('/site.json')) return null;
|
|
711
|
+
if (!idPath.includes(`${path.sep}content${path.sep}`) && !idPath.includes('/content/')) {
|
|
712
|
+
return null;
|
|
713
|
+
}
|
|
714
|
+
const transformed = await transformSiteJson(code);
|
|
715
|
+
if (transformed == null) return null;
|
|
716
|
+
return { code: transformed, map: null };
|
|
717
|
+
},
|
|
718
|
+
|
|
615
719
|
configResolved(resolvedConfig) {
|
|
616
720
|
// Write temp files as soon as config is resolved so vite-ssg's
|
|
617
721
|
// detectEntry (which runs before Vite's build lifecycle) finds them.
|
|
618
722
|
writeTempFiles();
|
|
619
723
|
|
|
620
724
|
// Mirror framework's public/ into the site's public/ so fonts, maps,
|
|
621
|
-
//
|
|
622
|
-
//
|
|
623
|
-
//
|
|
725
|
+
// etc. end up in dist. Site-specific files (favicon.ico, logo.png,
|
|
726
|
+
// og-image.jpg written by generate-public-assets) win over any
|
|
727
|
+
// accidental collisions because we skip files that already exist.
|
|
624
728
|
syncPublicDir();
|
|
729
|
+
|
|
730
|
+
// robots.txt and sitemap.xml are generated from the inflated site
|
|
731
|
+
// config (so per-site draft state regenerates on every build).
|
|
732
|
+
writeSeoFiles();
|
|
625
733
|
},
|
|
626
734
|
|
|
627
735
|
buildEnd() {
|