@koehler8/cms 1.0.0-beta.19 → 1.0.0-beta.20
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/package.json +1 -1
- package/src/components/NotFound.vue +100 -0
- package/src/composables/usePageConfig.js +24 -0
- package/src/composables/usePageMeta.js +29 -8
- package/src/utils/socialMeta.js +83 -0
- package/templates/index.html +7 -11
- package/vite-plugin.js +23 -1
package/package.json
CHANGED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<section class="not-found" aria-labelledby="not-found-heading">
|
|
3
|
+
<p class="not-found__eyebrow" aria-hidden="true">404</p>
|
|
4
|
+
<h1 id="not-found-heading" class="not-found__title">{{ heading }}</h1>
|
|
5
|
+
<p class="not-found__body">{{ body }}</p>
|
|
6
|
+
<a class="not-found__home" :href="homeHref">{{ homeLabel }}</a>
|
|
7
|
+
</section>
|
|
8
|
+
</template>
|
|
9
|
+
|
|
10
|
+
<script setup>
|
|
11
|
+
import { computed, inject } from 'vue';
|
|
12
|
+
|
|
13
|
+
const pageContent = inject('pageContent', null);
|
|
14
|
+
const siteData = inject('siteData', null);
|
|
15
|
+
|
|
16
|
+
const config = computed(() => {
|
|
17
|
+
const fromShared = siteData?.value?.shared?.content?.notFound;
|
|
18
|
+
const fromPage = pageContent?.value?.notFound;
|
|
19
|
+
return { ...(fromShared || {}), ...(fromPage || {}) };
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
const heading = computed(() => config.value.heading || 'Page not found');
|
|
23
|
+
const body = computed(
|
|
24
|
+
() => config.value.body || "We couldn't find the page you're looking for.",
|
|
25
|
+
);
|
|
26
|
+
const homeLabel = computed(() => config.value.homeLabel || 'Return home');
|
|
27
|
+
const homeHref = computed(() => config.value.homeHref || '/');
|
|
28
|
+
</script>
|
|
29
|
+
|
|
30
|
+
<style scoped>
|
|
31
|
+
.not-found {
|
|
32
|
+
display: flex;
|
|
33
|
+
flex-direction: column;
|
|
34
|
+
align-items: center;
|
|
35
|
+
justify-content: center;
|
|
36
|
+
min-height: 60vh;
|
|
37
|
+
padding: clamp(2rem, 6vw, 4rem) clamp(1rem, 4vw, 2rem);
|
|
38
|
+
text-align: center;
|
|
39
|
+
color: var(--brand-text, #1f2a44);
|
|
40
|
+
background: var(--brand-surface, transparent);
|
|
41
|
+
font-family: var(--ui-font-body, system-ui, -apple-system, sans-serif);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
.not-found__eyebrow {
|
|
45
|
+
margin: 0 0 0.25rem;
|
|
46
|
+
font-size: clamp(2.5rem, 8vw, 4rem);
|
|
47
|
+
font-weight: 700;
|
|
48
|
+
line-height: 1;
|
|
49
|
+
letter-spacing: 0.02em;
|
|
50
|
+
color: var(--brand-text-muted, rgba(31, 42, 68, 0.55));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
.not-found__title {
|
|
54
|
+
margin: 0 0 0.75rem;
|
|
55
|
+
font-size: clamp(1.5rem, 4vw, 2.25rem);
|
|
56
|
+
font-weight: 700;
|
|
57
|
+
line-height: 1.2;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
.not-found__body {
|
|
61
|
+
margin: 0 0 1.5rem;
|
|
62
|
+
max-width: 32rem;
|
|
63
|
+
font-size: 1rem;
|
|
64
|
+
line-height: 1.6;
|
|
65
|
+
color: var(--brand-text-muted, rgba(31, 42, 68, 0.7));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
.not-found__home {
|
|
69
|
+
display: inline-flex;
|
|
70
|
+
align-items: center;
|
|
71
|
+
justify-content: center;
|
|
72
|
+
min-height: 2.75rem;
|
|
73
|
+
padding: 0.7rem 1.5rem;
|
|
74
|
+
font-size: 1rem;
|
|
75
|
+
font-weight: 600;
|
|
76
|
+
text-decoration: none;
|
|
77
|
+
color: var(--brand-primary-cta-text, #ffffff);
|
|
78
|
+
background: var(--brand-primary-cta, var(--brand-primary, #1f2a44));
|
|
79
|
+
border-radius: var(--brand-button-radius, 10px);
|
|
80
|
+
transition: transform 0.15s ease, box-shadow 0.15s ease;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
.not-found__home:hover {
|
|
84
|
+
transform: translateY(-1px);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
.not-found__home:focus-visible {
|
|
88
|
+
outline: 2px solid var(--brand-primary, #1f2a44);
|
|
89
|
+
outline-offset: 3px;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
@media (prefers-reduced-motion: reduce) {
|
|
93
|
+
.not-found__home {
|
|
94
|
+
transition: none;
|
|
95
|
+
}
|
|
96
|
+
.not-found__home:hover {
|
|
97
|
+
transform: none;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
</style>
|
|
@@ -75,6 +75,30 @@ export function usePageConfig({ pageId, pagePath, locale, onPageLoaded } = {}) {
|
|
|
75
75
|
};
|
|
76
76
|
}
|
|
77
77
|
}
|
|
78
|
+
|
|
79
|
+
// No page matches the requested path. If it's anything other than
|
|
80
|
+
// the home path "/" — or the explicit /404 SSG route — treat it as
|
|
81
|
+
// a not-found page (instead of silently returning home content with
|
|
82
|
+
// HTTP 200, which is what the framework used to do).
|
|
83
|
+
//
|
|
84
|
+
// Sites can override the rendered 404 by authoring pages/404.json
|
|
85
|
+
// with their own components[]; otherwise the synthesized sentinel
|
|
86
|
+
// renders the bundled NotFound component.
|
|
87
|
+
if (requestedPath !== '/') {
|
|
88
|
+
const customNotFound = resolveById('404');
|
|
89
|
+
if (customNotFound) {
|
|
90
|
+
return { ...customNotFound, isNotFound: true };
|
|
91
|
+
}
|
|
92
|
+
return {
|
|
93
|
+
id: '__not_found__',
|
|
94
|
+
path: requestedPath,
|
|
95
|
+
components: [{ name: 'NotFound', enabled: true }],
|
|
96
|
+
content: {},
|
|
97
|
+
meta: {},
|
|
98
|
+
draft: undefined,
|
|
99
|
+
isNotFound: true,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
78
102
|
}
|
|
79
103
|
|
|
80
104
|
const homeFallback = resolveById('home');
|
|
@@ -2,6 +2,7 @@ import { computed } from 'vue';
|
|
|
2
2
|
import { useHead } from '@unhead/vue';
|
|
3
3
|
import { isPathDraft } from '../utils/draftMode.js';
|
|
4
4
|
import { buildCanonicalUrl } from '../utils/canonicalUrl.js';
|
|
5
|
+
import { buildSocialMeta } from '../utils/socialMeta.js';
|
|
5
6
|
import { availableLocales as configAvailableLocales, baseLocale as configBaseLocale } from '../utils/loadConfig.js';
|
|
6
7
|
|
|
7
8
|
const SPECIAL_PAGE_LABELS = {
|
|
@@ -51,10 +52,16 @@ export function usePageMeta({ siteData, currentPage, locale }) {
|
|
|
51
52
|
const isDraft = computed(() =>
|
|
52
53
|
isPathDraft(siteData.value, currentPage.value?.path, currentPage.value),
|
|
53
54
|
);
|
|
55
|
+
const isNotFound = computed(() => Boolean(currentPage.value?.isNotFound));
|
|
54
56
|
|
|
55
57
|
const pageMetaTitle = computed(() => {
|
|
56
58
|
const page = currentPage.value;
|
|
57
59
|
const site = siteTitle.value;
|
|
60
|
+
// 404 pages: generic "Page not found — Site". The page slug (which is
|
|
61
|
+
// some URL the visitor typed) shouldn't leak into <title>.
|
|
62
|
+
if (isNotFound.value) {
|
|
63
|
+
return site ? `Page not found — ${site}` : 'Page not found';
|
|
64
|
+
}
|
|
58
65
|
// Draft pages emit a generic title so the page slug doesn't leak via
|
|
59
66
|
// <title> in the SSG-rendered HTML on disk. The tab title remains
|
|
60
67
|
// generic even after client-side unlock — for "iterate before launch"
|
|
@@ -74,7 +81,7 @@ export function usePageMeta({ siteData, currentPage, locale }) {
|
|
|
74
81
|
});
|
|
75
82
|
|
|
76
83
|
const pageMetaDescription = computed(() => {
|
|
77
|
-
if (isDraft.value) {
|
|
84
|
+
if (isDraft.value || isNotFound.value) {
|
|
78
85
|
return '';
|
|
79
86
|
}
|
|
80
87
|
const explicitDescription = currentPage.value?.meta?.description;
|
|
@@ -85,11 +92,11 @@ export function usePageMeta({ siteData, currentPage, locale }) {
|
|
|
85
92
|
});
|
|
86
93
|
|
|
87
94
|
// Canonical URL — base-locale URLs have no prefix; non-base locales get
|
|
88
|
-
// /{locale}/path. Drafts skip canonical (
|
|
89
|
-
//
|
|
90
|
-
// we can't build an absolute URL.
|
|
95
|
+
// /{locale}/path. Drafts and 404s skip canonical (the URL itself isn't
|
|
96
|
+
// authoritative for gated/missing content). Sites without a configured
|
|
97
|
+
// site.url skip too — we can't build an absolute URL.
|
|
91
98
|
const canonicalHref = computed(() => {
|
|
92
|
-
if (isDraft.value) return '';
|
|
99
|
+
if (isDraft.value || isNotFound.value) return '';
|
|
93
100
|
if (!siteUrl.value) return '';
|
|
94
101
|
return buildCanonicalUrl({
|
|
95
102
|
siteUrl: siteUrl.value,
|
|
@@ -104,7 +111,7 @@ export function usePageMeta({ siteData, currentPage, locale }) {
|
|
|
104
111
|
// an `x-default` pointing at the base-locale URL (Google's preferred
|
|
105
112
|
// pattern for sites without explicit geo-targeting).
|
|
106
113
|
const hreflangLinks = computed(() => {
|
|
107
|
-
if (isDraft.value) return [];
|
|
114
|
+
if (isDraft.value || isNotFound.value) return [];
|
|
108
115
|
if (!siteUrl.value) return [];
|
|
109
116
|
if (!Array.isArray(configAvailableLocales) || configAvailableLocales.length <= 1) return [];
|
|
110
117
|
const path = currentPage.value?.path || '/';
|
|
@@ -138,9 +145,23 @@ export function usePageMeta({ siteData, currentPage, locale }) {
|
|
|
138
145
|
if (description) {
|
|
139
146
|
meta.push({ name: 'description', content: description, key: 'description' });
|
|
140
147
|
}
|
|
141
|
-
|
|
148
|
+
// 404 pages get noindex defensively — Amplify (or whichever host)
|
|
149
|
+
// already serves them with HTTP 404, but search engines occasionally
|
|
150
|
+
// crawl 404 URLs and the meta is a stronger signal than relying on
|
|
151
|
+
// the status header alone.
|
|
152
|
+
if (isDraft.value || isNotFound.value) {
|
|
142
153
|
meta.push({ name: 'robots', content: 'noindex, nofollow', key: 'robots' });
|
|
143
154
|
}
|
|
155
|
+
const social = buildSocialMeta({
|
|
156
|
+
siteData: siteData.value,
|
|
157
|
+
currentPage: currentPage.value,
|
|
158
|
+
pageTitle: pageMetaTitle.value,
|
|
159
|
+
pageDescription: pageMetaDescription.value,
|
|
160
|
+
canonicalHref: canonicalHref.value,
|
|
161
|
+
isDraft: isDraft.value,
|
|
162
|
+
isNotFound: isNotFound.value,
|
|
163
|
+
});
|
|
164
|
+
for (const entry of social) meta.push(entry);
|
|
144
165
|
const link = [];
|
|
145
166
|
if (canonicalHref.value) {
|
|
146
167
|
link.push({ rel: 'canonical', href: canonicalHref.value, key: 'canonical' });
|
|
@@ -155,5 +176,5 @@ export function usePageMeta({ siteData, currentPage, locale }) {
|
|
|
155
176
|
};
|
|
156
177
|
});
|
|
157
178
|
|
|
158
|
-
return { pageMetaTitle, pageMetaDescription, isDraft, canonicalHref, hreflangLinks };
|
|
179
|
+
return { pageMetaTitle, pageMetaDescription, isDraft, isNotFound, canonicalHref, hreflangLinks };
|
|
159
180
|
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Build per-page Open Graph + Twitter Card meta entries.
|
|
3
|
+
*
|
|
4
|
+
* Returns an array of @unhead/vue meta descriptors ready to be spread into
|
|
5
|
+
* useHead's `meta:` array. Returns [] for drafts and 404 pages — drafts
|
|
6
|
+
* shouldn't be advertised socially, and 404 cards would mislead.
|
|
7
|
+
*
|
|
8
|
+
* Title and description are passed in pre-resolved (the caller — usually
|
|
9
|
+
* usePageMeta — already computes them with the site-wide fallback chain).
|
|
10
|
+
*
|
|
11
|
+
* og:image priority:
|
|
12
|
+
* 1. page.meta.image (per-page override; e.g. an article hero)
|
|
13
|
+
* 2. site.image / site.ogImage (site-wide override authored in site.json)
|
|
14
|
+
* 3. /og-image.jpg (the static asset cms-generate-public-assets writes)
|
|
15
|
+
*
|
|
16
|
+
* Relative image paths get an absolute URL built from site.url. If
|
|
17
|
+
* site.url is missing, the relative path is emitted as-is — most social
|
|
18
|
+
* scrapers won't follow it, but that's better than emitting nothing.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
const STATIC_OG_IMAGE = '/og-image.jpg';
|
|
22
|
+
|
|
23
|
+
function absolutize(siteUrl, pathOrUrl) {
|
|
24
|
+
if (!pathOrUrl) return '';
|
|
25
|
+
if (/^https?:\/\//i.test(pathOrUrl)) return pathOrUrl;
|
|
26
|
+
if (!siteUrl) return pathOrUrl;
|
|
27
|
+
const path = pathOrUrl.startsWith('/') ? pathOrUrl : `/${pathOrUrl}`;
|
|
28
|
+
return `${siteUrl}${path}`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function buildSocialMeta({
|
|
32
|
+
siteData,
|
|
33
|
+
currentPage,
|
|
34
|
+
pageTitle,
|
|
35
|
+
pageDescription,
|
|
36
|
+
canonicalHref,
|
|
37
|
+
isDraft,
|
|
38
|
+
isNotFound,
|
|
39
|
+
} = {}) {
|
|
40
|
+
if (isDraft || isNotFound) return [];
|
|
41
|
+
|
|
42
|
+
const site = (siteData && siteData.site) || {};
|
|
43
|
+
const page = currentPage || {};
|
|
44
|
+
const pageMeta = page.meta || {};
|
|
45
|
+
|
|
46
|
+
const siteUrl = (typeof site.url === 'string' ? site.url : '').trim().replace(/\/+$/, '');
|
|
47
|
+
const siteTitle = typeof site.title === 'string' ? site.title.trim() : '';
|
|
48
|
+
|
|
49
|
+
const rawImage =
|
|
50
|
+
pageMeta.image ||
|
|
51
|
+
site.image ||
|
|
52
|
+
site.ogImage ||
|
|
53
|
+
STATIC_OG_IMAGE;
|
|
54
|
+
const ogImage = absolutize(siteUrl, rawImage);
|
|
55
|
+
|
|
56
|
+
const ogType = pageMeta.ogType || 'website';
|
|
57
|
+
const twitterCard = pageMeta.twitterCard || 'summary_large_image';
|
|
58
|
+
|
|
59
|
+
const meta = [];
|
|
60
|
+
|
|
61
|
+
if (pageTitle) {
|
|
62
|
+
meta.push({ property: 'og:title', content: pageTitle, key: 'og:title' });
|
|
63
|
+
meta.push({ name: 'twitter:title', content: pageTitle, key: 'twitter:title' });
|
|
64
|
+
}
|
|
65
|
+
if (pageDescription) {
|
|
66
|
+
meta.push({ property: 'og:description', content: pageDescription, key: 'og:description' });
|
|
67
|
+
meta.push({ name: 'twitter:description', content: pageDescription, key: 'twitter:description' });
|
|
68
|
+
}
|
|
69
|
+
if (canonicalHref) {
|
|
70
|
+
meta.push({ property: 'og:url', content: canonicalHref, key: 'og:url' });
|
|
71
|
+
}
|
|
72
|
+
meta.push({ property: 'og:type', content: ogType, key: 'og:type' });
|
|
73
|
+
if (siteTitle) {
|
|
74
|
+
meta.push({ property: 'og:site_name', content: siteTitle, key: 'og:site_name' });
|
|
75
|
+
}
|
|
76
|
+
if (ogImage) {
|
|
77
|
+
meta.push({ property: 'og:image', content: ogImage, key: 'og:image' });
|
|
78
|
+
meta.push({ name: 'twitter:image', content: ogImage, key: 'twitter:image' });
|
|
79
|
+
}
|
|
80
|
+
meta.push({ name: 'twitter:card', content: twitterCard, key: 'twitter:card' });
|
|
81
|
+
|
|
82
|
+
return meta;
|
|
83
|
+
}
|
package/templates/index.html
CHANGED
|
@@ -2,18 +2,14 @@
|
|
|
2
2
|
<html lang="">
|
|
3
3
|
<head>
|
|
4
4
|
<meta charset="UTF-8">
|
|
5
|
-
<title><%= site %></title>
|
|
6
5
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
<meta name="twitter:title" content="<%= site %>">
|
|
15
|
-
<meta name="twitter:description" content="<%= siteDescription %>">
|
|
16
|
-
<meta name="twitter:image" content="<%= ABSOLUTE_OG_IMAGE %>">
|
|
6
|
+
<!--
|
|
7
|
+
Per-page <title>, <meta name="description">, og:*, twitter:*, canonical,
|
|
8
|
+
and hreflang are emitted by usePageMeta at SSG render time. Site-wide
|
|
9
|
+
structured-data (the JSON-LD Organization snippet below) and the
|
|
10
|
+
static <link rel="icon"> remain in this template since they don't
|
|
11
|
+
vary per page.
|
|
12
|
+
-->
|
|
17
13
|
<link rel="icon" href="/favicon.ico">
|
|
18
14
|
<script type="application/ld+json">
|
|
19
15
|
{
|
package/vite-plugin.js
CHANGED
|
@@ -538,8 +538,30 @@ export default function cmsPlugin(options = {}) {
|
|
|
538
538
|
},
|
|
539
539
|
ssgOptions: {
|
|
540
540
|
dirStyle: 'nested',
|
|
541
|
+
// After vite-ssg pre-renders every route, copy the rendered
|
|
542
|
+
// dist/404/index.html to dist/404.html. AWS Amplify (and most
|
|
543
|
+
// static hosts) auto-serve a top-level 404.html for any
|
|
544
|
+
// unmatched URL with HTTP status 404, so this gives sites a
|
|
545
|
+
// proper not-found response without per-site config.
|
|
546
|
+
onFinished() {
|
|
547
|
+
const distDir = path.join(siteRoot, 'dist');
|
|
548
|
+
const nestedNotFound = path.join(distDir, '404', 'index.html');
|
|
549
|
+
const flatNotFound = path.join(distDir, '404.html');
|
|
550
|
+
if (fs.existsSync(nestedNotFound)) {
|
|
551
|
+
try {
|
|
552
|
+
fs.copyFileSync(nestedNotFound, flatNotFound);
|
|
553
|
+
} catch (err) {
|
|
554
|
+
console.warn(`[@koehler8/cms] failed to write 404.html: ${err.message}`);
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
},
|
|
541
558
|
includedRoutes(paths) {
|
|
542
|
-
|
|
559
|
+
// /admin is a vestigial placeholder; /404 is the not-found
|
|
560
|
+
// landing page (rendered via the catch-all + NotFound
|
|
561
|
+
// component). onFinished above copies dist/404/index.html
|
|
562
|
+
// to dist/404.html so AWS Amplify serves it for unmatched
|
|
563
|
+
// URLs with HTTP 404.
|
|
564
|
+
const staticRoutes = new Set(['/admin', '/404']);
|
|
543
565
|
pagePaths.forEach((routePath) => staticRoutes.add(routePath));
|
|
544
566
|
|
|
545
567
|
// Pre-render every page under each non-base locale prefix that
|