@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
package/src/components/Hero.vue
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
>
|
|
8
8
|
<div class="container text-center hero-content hero-inner">
|
|
9
9
|
<div class="hero-eyebrow fade-up-in js-scroll-fade" style="--fade-up-delay: 0s">
|
|
10
|
-
<
|
|
10
|
+
<p
|
|
11
11
|
:class="[
|
|
12
12
|
'h4',
|
|
13
13
|
'text-uppercase',
|
|
@@ -16,9 +16,9 @@
|
|
|
16
16
|
]"
|
|
17
17
|
>
|
|
18
18
|
{{ tagLine }}
|
|
19
|
-
</
|
|
19
|
+
</p>
|
|
20
20
|
</div>
|
|
21
|
-
<
|
|
21
|
+
<h1
|
|
22
22
|
:class="[
|
|
23
23
|
'display-heading',
|
|
24
24
|
'text-uppercase',
|
|
@@ -38,7 +38,7 @@
|
|
|
38
38
|
>
|
|
39
39
|
{{ part }}
|
|
40
40
|
</span>
|
|
41
|
-
</
|
|
41
|
+
</h1>
|
|
42
42
|
<div
|
|
43
43
|
class="hero-cta-stack fade-up-in js-scroll-fade"
|
|
44
44
|
style="--fade-up-delay: 0.2s"
|
|
@@ -256,8 +256,9 @@ function getHeaderOffset() {
|
|
|
256
256
|
letter-spacing: 0.12em;
|
|
257
257
|
font-size: clamp(1.1rem, 1.5vw, 1.55rem);
|
|
258
258
|
line-height: 1.35;
|
|
259
|
-
color: var(--hero-
|
|
259
|
+
color: var(--hero-eyebrow-color, var(--brand-hero-text-on-dark, var(--brand-hero-text, #f0eaf3)));
|
|
260
260
|
border-bottom: 1px solid currentColor;
|
|
261
|
+
margin: 0;
|
|
261
262
|
}
|
|
262
263
|
|
|
263
264
|
.hero-headline {
|
|
@@ -266,7 +267,7 @@ function getHeaderOffset() {
|
|
|
266
267
|
line-height: 1.08;
|
|
267
268
|
margin-bottom: clamp(18px, 2.5vw, 28px);
|
|
268
269
|
letter-spacing: 0.04em;
|
|
269
|
-
color: var(--hero-text-color, var(--
|
|
270
|
+
color: var(--hero-text-color, var(--brand-hero-text-on-dark, var(--brand-hero-text, #f0eaf3)));
|
|
270
271
|
}
|
|
271
272
|
|
|
272
273
|
.promo-surface {
|
package/src/components/Home.vue
CHANGED
|
@@ -1,28 +1,39 @@
|
|
|
1
1
|
<template>
|
|
2
|
-
<
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
<component
|
|
8
|
-
v-for="entry in loadedComponents"
|
|
9
|
-
:is="entry.component"
|
|
10
|
-
:key="entry.key"
|
|
11
|
-
v-bind="entry.props"
|
|
2
|
+
<template v-if="isDraft && !isUnlocked">
|
|
3
|
+
<DraftGate
|
|
4
|
+
:has-password="hasPassword"
|
|
5
|
+
:error-message="errorMessage"
|
|
6
|
+
@submit="attemptUnlock"
|
|
12
7
|
/>
|
|
13
|
-
</
|
|
14
|
-
<
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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>
|
|
20
30
|
</template>
|
|
21
31
|
|
|
22
32
|
<script setup>
|
|
23
33
|
import { computed, nextTick, provide, watch } from 'vue';
|
|
24
34
|
|
|
25
35
|
import ComingSoonModal from '../components/ComingSoonModal.vue';
|
|
36
|
+
import DraftGate from '../components/DraftGate.vue';
|
|
26
37
|
import IntroGate from '../components/IntroGate.vue';
|
|
27
38
|
|
|
28
39
|
import { registry } from '../utils/componentRegistry.js';
|
|
@@ -30,6 +41,7 @@ import { usePageConfig } from '../composables/usePageConfig.js';
|
|
|
30
41
|
import { useComponentResolver } from '../composables/useComponentResolver.js';
|
|
31
42
|
import { usePageMeta } from '../composables/usePageMeta.js';
|
|
32
43
|
import { useIntroGate } from '../composables/useIntroGate.js';
|
|
44
|
+
import { useDraftGate } from '../composables/useDraftGate.js';
|
|
33
45
|
import { useEngagementTracking } from '../composables/useEngagementTracking.js';
|
|
34
46
|
import { useComingSoonInterstitial } from '../composables/useComingSoonInterstitial.js';
|
|
35
47
|
|
|
@@ -70,6 +82,14 @@ const { loadedComponents } = useComponentResolver({
|
|
|
70
82
|
|
|
71
83
|
const { introGateEnabled, introGateProps } = useIntroGate({ siteData, pageContent });
|
|
72
84
|
|
|
85
|
+
const {
|
|
86
|
+
isDraft,
|
|
87
|
+
isUnlocked,
|
|
88
|
+
hasPassword,
|
|
89
|
+
errorMessage,
|
|
90
|
+
attemptUnlock,
|
|
91
|
+
} = useDraftGate({ siteData, currentPage });
|
|
92
|
+
|
|
73
93
|
usePageMeta({ siteData, currentPage });
|
|
74
94
|
|
|
75
95
|
const {
|
|
@@ -115,3 +135,43 @@ watch(
|
|
|
115
135
|
);
|
|
116
136
|
</script>
|
|
117
137
|
|
|
138
|
+
<style>
|
|
139
|
+
/* Visually-hidden until focused. The skip link is the first focusable
|
|
140
|
+
element on the page; pressing Tab once reveals it and Enter jumps
|
|
141
|
+
focus past the sticky header into <main>. WCAG 2.4.1 (Bypass Blocks). */
|
|
142
|
+
.cms-skip-link {
|
|
143
|
+
position: absolute;
|
|
144
|
+
top: 0;
|
|
145
|
+
left: 0;
|
|
146
|
+
padding: 12px 20px;
|
|
147
|
+
background: #ffffff;
|
|
148
|
+
color: #1f2a44;
|
|
149
|
+
border: 2px solid #1f2a44;
|
|
150
|
+
border-radius: 0 0 8px 0;
|
|
151
|
+
font-family: var(--ui-font-body, system-ui, -apple-system, sans-serif);
|
|
152
|
+
font-weight: 600;
|
|
153
|
+
text-decoration: none;
|
|
154
|
+
z-index: 9999;
|
|
155
|
+
transform: translateY(-100%);
|
|
156
|
+
transition: transform 0.15s ease;
|
|
157
|
+
}
|
|
158
|
+
.cms-skip-link:focus {
|
|
159
|
+
transform: translateY(0);
|
|
160
|
+
outline: 2px solid #1f2a44;
|
|
161
|
+
outline-offset: 2px;
|
|
162
|
+
}
|
|
163
|
+
@media (prefers-reduced-motion: reduce) {
|
|
164
|
+
.cms-skip-link { transition: none; }
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/* When the skip link sends focus to <main>, browsers don't render an
|
|
168
|
+
outline on the [tabindex="-1"] element by default — strip it so the
|
|
169
|
+
page doesn't get an unexpected ring around its main region. */
|
|
170
|
+
#main-content:focus { outline: none; }
|
|
171
|
+
|
|
172
|
+
/* Sticky header offset for in-page anchor jumps and skip-link landing.
|
|
173
|
+
Without this, the focused element is hidden under the sticky header
|
|
174
|
+
(fails 2.4.11 Focus Not Obscured). */
|
|
175
|
+
html { scroll-padding-top: 88px; }
|
|
176
|
+
</style>
|
|
177
|
+
|
package/src/components/Plan.vue
CHANGED
|
@@ -94,7 +94,8 @@ const planItems = computed(() =>
|
|
|
94
94
|
width: 70px;
|
|
95
95
|
height: 70px;
|
|
96
96
|
border-radius: 50%;
|
|
97
|
-
|
|
97
|
+
/* 3:1+ against the dark plan-card surface (WCAG 1.4.11). */
|
|
98
|
+
border: 3px solid var(--plan-step-border, var(--brand-plan-step-ring, rgba(255, 255, 255, 0.5)));
|
|
98
99
|
color: var(--plan-step-color, #ffffff);
|
|
99
100
|
display: inline-flex;
|
|
100
101
|
align-items: center;
|
|
@@ -107,10 +108,12 @@ const planItems = computed(() =>
|
|
|
107
108
|
font-size: 1.15rem;
|
|
108
109
|
font-weight: 600;
|
|
109
110
|
margin: 0;
|
|
110
|
-
|
|
111
|
+
/* Dark plan-card surface needs light text. Don't fall through to
|
|
112
|
+
--ui-text-primary (dark) — that's the FOUC trap that bit FooterMinimal. */
|
|
113
|
+
color: var(--plan-card-title, var(--brand-plan-card-text, #ffffff));
|
|
111
114
|
}
|
|
112
115
|
|
|
113
116
|
.plan-card__description {
|
|
114
|
-
color: var(--
|
|
117
|
+
color: var(--plan-card-description, var(--brand-plan-card-muted-text, #c8c2cf));
|
|
115
118
|
}
|
|
116
119
|
</style>
|
|
@@ -72,7 +72,7 @@
|
|
|
72
72
|
</div>
|
|
73
73
|
<span class="portfolio-tag ui-label-sm">{{ project.displayTags || project.tags }}</span>
|
|
74
74
|
<h3 class="portfolio-title ui-title-md">{{ project.title }}</h3>
|
|
75
|
-
<a class="portfolio-card__link" :href="project.href" aria-label="
|
|
75
|
+
<a class="portfolio-card__link" :href="project.href" :aria-label="`View ${project.title}`"></a>
|
|
76
76
|
</div>
|
|
77
77
|
</div>
|
|
78
78
|
</transition-group>
|
|
@@ -340,10 +340,14 @@ function handleAfterLeave() {
|
|
|
340
340
|
|
|
341
341
|
.portfolio-filter:hover,
|
|
342
342
|
.portfolio-filter:focus-visible {
|
|
343
|
-
color: var(--portfolio-filter-hover-color, var(--brand-accent-electric, #
|
|
344
|
-
border-color: var(--portfolio-filter-hover-border, rgba(
|
|
343
|
+
color: var(--portfolio-filter-hover-color, var(--brand-accent-electric, #4361dd));
|
|
344
|
+
border-color: var(--portfolio-filter-hover-border, rgba(67, 97, 221, 0.55));
|
|
345
345
|
transform: translateY(-1px);
|
|
346
346
|
}
|
|
347
|
+
.portfolio-filter:focus-visible {
|
|
348
|
+
outline: 2px solid var(--portfolio-filter-focus-outline, var(--brand-accent-electric, #4361dd));
|
|
349
|
+
outline-offset: 2px;
|
|
350
|
+
}
|
|
347
351
|
|
|
348
352
|
.portfolio-filter--active {
|
|
349
353
|
color: var(--portfolio-filter-active-color, #fff);
|
|
@@ -220,9 +220,13 @@ function scrollTargetWithOffset(target) {
|
|
|
220
220
|
padding: 14px 24px;
|
|
221
221
|
border: none;
|
|
222
222
|
border-radius: var(--brand-button-radius, 14px);
|
|
223
|
+
/* The default 3-stop gradient previously failed contrast at the mid stop
|
|
224
|
+
(#0a0a0d on #9a2eff = 3.96:1). Mid stop darkened to #7e23d5 → 5.34:1
|
|
225
|
+
with the dark text. Sites overriding --brand-primary-cta-gradient
|
|
226
|
+
are responsible for verifying contrast against --brand-primary-cta-text. */
|
|
223
227
|
background: var(
|
|
224
228
|
--brand-primary-cta-gradient,
|
|
225
|
-
linear-gradient(135deg, #ff2d86 0%, #
|
|
229
|
+
linear-gradient(135deg, #ff2d86 0%, #7e23d5 55%, #2bb8d9 100%)
|
|
226
230
|
);
|
|
227
231
|
color: var(--brand-primary-cta-text, #0a0a0d);
|
|
228
232
|
font-size: clamp(0.95rem, 2.5vw, 1.05rem);
|
package/src/components/Team.vue
CHANGED
|
@@ -337,7 +337,9 @@ const buildSocialAriaLabel = (memberName, social) => {
|
|
|
337
337
|
font-size: 0.72rem;
|
|
338
338
|
letter-spacing: 0.18em;
|
|
339
339
|
text-transform: uppercase;
|
|
340
|
-
|
|
340
|
+
/* AA-safe muted text on the white card. Don't fall through to a brand
|
|
341
|
+
color rgba — it composites below 4.5:1. */
|
|
342
|
+
color: var(--team-role-color, var(--ui-text-muted, #54627b));
|
|
341
343
|
}
|
|
342
344
|
|
|
343
345
|
.team-card__bio {
|
|
@@ -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 =
|
|
67
|
-
|
|
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
|
}
|
package/src/styles/base.css
CHANGED
|
@@ -643,7 +643,9 @@ a:focus-visible {
|
|
|
643
643
|
}
|
|
644
644
|
|
|
645
645
|
.ui-form-control::placeholder {
|
|
646
|
-
|
|
646
|
+
/* Solid fallback (#54627b = 5.66:1 on white). Avoid alpha — composited
|
|
647
|
+
placeholder colors slip below 4.5:1 quickly. */
|
|
648
|
+
color: var(--ui-form-placeholder, var(--brand-input-placeholder, #54627b));
|
|
647
649
|
}
|
|
648
650
|
|
|
649
651
|
.ui-form-control:focus {
|
|
@@ -692,7 +694,9 @@ a:focus-visible {
|
|
|
692
694
|
}
|
|
693
695
|
|
|
694
696
|
.ui-input-group__input::placeholder {
|
|
695
|
-
|
|
697
|
+
/* Solid fallback (#54627b = 5.66:1 on white). Avoid alpha — composited
|
|
698
|
+
placeholder colors slip below 4.5:1 quickly. */
|
|
699
|
+
color: var(--ui-form-placeholder, var(--brand-input-placeholder, #54627b));
|
|
696
700
|
}
|
|
697
701
|
|
|
698
702
|
.ui-input-group__input:focus {
|
|
@@ -852,6 +856,11 @@ a:focus-visible {
|
|
|
852
856
|
transform: translate3d(0, 0, 0);
|
|
853
857
|
}
|
|
854
858
|
|
|
859
|
+
.ui-backtotop:focus-visible {
|
|
860
|
+
outline: 2px solid #ffffff;
|
|
861
|
+
outline-offset: 3px;
|
|
862
|
+
}
|
|
863
|
+
|
|
855
864
|
.ui-backtotop__icon {
|
|
856
865
|
font-size: 1rem;
|
|
857
866
|
line-height: 1;
|
|
@@ -68,6 +68,12 @@ export function buildCssVarMap(manifest) {
|
|
|
68
68
|
const radii = tokens.radii || {};
|
|
69
69
|
const elevation = tokens.elevation || {};
|
|
70
70
|
const utility = tokens.utility || {};
|
|
71
|
+
// Section-chrome blocks for surfaces that pair text with non-default
|
|
72
|
+
// backgrounds. Components read these to avoid the fallback-chain trap
|
|
73
|
+
// (e.g. dark hero surface + light --ui-text-primary fallback = invisible).
|
|
74
|
+
const hero = tokens.hero || {};
|
|
75
|
+
const footer = tokens.footer || {};
|
|
76
|
+
const plan = tokens.plan || {};
|
|
71
77
|
|
|
72
78
|
assignPrefixed(
|
|
73
79
|
vars,
|
|
@@ -231,6 +237,25 @@ export function buildCssVarMap(manifest) {
|
|
|
231
237
|
setVar(vars, '--status-price-value-color', palette.accent || palette.primary || text.primary);
|
|
232
238
|
setVar(vars, '--status-price-value-shadow', elevation.raised);
|
|
233
239
|
|
|
240
|
+
// Hero text — paired with the hero surface to keep contrast intact when
|
|
241
|
+
// a consumer overrides the hero bg. textOnDark is the fallback when no
|
|
242
|
+
// hero.text is set.
|
|
243
|
+
setVar(vars, '--brand-hero-text', hero.text || text.primary);
|
|
244
|
+
setVar(vars, '--brand-hero-text-on-dark', hero.textOnDark || text.onDark || text.inverse);
|
|
245
|
+
setVar(vars, '--brand-hero-eyebrow', hero.eyebrow || palette.primary);
|
|
246
|
+
|
|
247
|
+
// Footer chrome — FooterMinimal's default bg is dark, so it reads
|
|
248
|
+
// these instead of falling through --ui-text-primary (a light-on-light trap).
|
|
249
|
+
setVar(vars, '--brand-footer-text', footer.text || text.onDark || text.inverse);
|
|
250
|
+
setVar(vars, '--brand-footer-text-muted', footer.textMuted || text.onDarkMuted);
|
|
251
|
+
setVar(vars, '--brand-footer-link-color', footer.linkColor || footer.text || text.onDark || text.inverse);
|
|
252
|
+
setVar(vars, '--brand-footer-link-hover', footer.linkHover || '#ffffff');
|
|
253
|
+
|
|
254
|
+
// Plan card chrome — same dark-surface pairing.
|
|
255
|
+
setVar(vars, '--brand-plan-card-text', plan.cardText || text.inverse);
|
|
256
|
+
setVar(vars, '--brand-plan-card-muted-text', plan.cardMutedText || text.onDarkMuted);
|
|
257
|
+
setVar(vars, '--brand-plan-step-ring', plan.stepRing);
|
|
258
|
+
|
|
234
259
|
return vars;
|
|
235
260
|
}
|
|
236
261
|
|
|
@@ -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
|
+
}
|