@koehler8/cms 1.0.0-beta.19 → 1.0.0-beta.21
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 +27 -0
- package/src/composables/usePageMeta.js +66 -8
- package/src/utils/jsonLd.js +58 -0
- package/src/utils/socialMeta.js +83 -0
- package/src/utils/webAppManifest.js +82 -0
- package/templates/index.html +8 -11
- package/vite-plugin.js +45 -2
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>
|
|
@@ -50,6 +50,7 @@ export function usePageConfig({ pageId, pagePath, locale, onPageLoaded } = {}) {
|
|
|
50
50
|
content: data.content || {},
|
|
51
51
|
meta: data.meta || {},
|
|
52
52
|
draft: data.draft,
|
|
53
|
+
jsonld: data.jsonld,
|
|
53
54
|
};
|
|
54
55
|
};
|
|
55
56
|
|
|
@@ -72,9 +73,34 @@ export function usePageConfig({ pageId, pagePath, locale, onPageLoaded } = {}) {
|
|
|
72
73
|
content: data.content || {},
|
|
73
74
|
meta: data.meta || {},
|
|
74
75
|
draft: data.draft,
|
|
76
|
+
jsonld: data.jsonld,
|
|
75
77
|
};
|
|
76
78
|
}
|
|
77
79
|
}
|
|
80
|
+
|
|
81
|
+
// No page matches the requested path. If it's anything other than
|
|
82
|
+
// the home path "/" — or the explicit /404 SSG route — treat it as
|
|
83
|
+
// a not-found page (instead of silently returning home content with
|
|
84
|
+
// HTTP 200, which is what the framework used to do).
|
|
85
|
+
//
|
|
86
|
+
// Sites can override the rendered 404 by authoring pages/404.json
|
|
87
|
+
// with their own components[]; otherwise the synthesized sentinel
|
|
88
|
+
// renders the bundled NotFound component.
|
|
89
|
+
if (requestedPath !== '/') {
|
|
90
|
+
const customNotFound = resolveById('404');
|
|
91
|
+
if (customNotFound) {
|
|
92
|
+
return { ...customNotFound, isNotFound: true };
|
|
93
|
+
}
|
|
94
|
+
return {
|
|
95
|
+
id: '__not_found__',
|
|
96
|
+
path: requestedPath,
|
|
97
|
+
components: [{ name: 'NotFound', enabled: true }],
|
|
98
|
+
content: {},
|
|
99
|
+
meta: {},
|
|
100
|
+
draft: undefined,
|
|
101
|
+
isNotFound: true,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
78
104
|
}
|
|
79
105
|
|
|
80
106
|
const homeFallback = resolveById('home');
|
|
@@ -89,6 +115,7 @@ export function usePageConfig({ pageId, pagePath, locale, onPageLoaded } = {}) {
|
|
|
89
115
|
content: firstData.content || {},
|
|
90
116
|
meta: firstData.meta || {},
|
|
91
117
|
draft: firstData.draft,
|
|
118
|
+
jsonld: firstData.jsonld,
|
|
92
119
|
};
|
|
93
120
|
}
|
|
94
121
|
|
|
@@ -2,8 +2,21 @@ 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';
|
|
6
|
+
import { buildJsonLdScripts } from '../utils/jsonLd.js';
|
|
5
7
|
import { availableLocales as configAvailableLocales, baseLocale as configBaseLocale } from '../utils/loadConfig.js';
|
|
6
8
|
|
|
9
|
+
// Site-verification meta-tag names per platform. Authors set
|
|
10
|
+
// `site.siteVerification.<platform>` in site.json with the token issued by
|
|
11
|
+
// the platform's webmaster console; we emit one <meta> per platform.
|
|
12
|
+
const SITE_VERIFICATION_META = {
|
|
13
|
+
google: 'google-site-verification',
|
|
14
|
+
bing: 'msvalidate.01',
|
|
15
|
+
yandex: 'yandex-verification',
|
|
16
|
+
pinterest: 'p:domain_verify',
|
|
17
|
+
facebook: 'facebook-domain-verification',
|
|
18
|
+
};
|
|
19
|
+
|
|
7
20
|
const SPECIAL_PAGE_LABELS = {
|
|
8
21
|
home: '',
|
|
9
22
|
terms: 'Terms of Service',
|
|
@@ -51,10 +64,16 @@ export function usePageMeta({ siteData, currentPage, locale }) {
|
|
|
51
64
|
const isDraft = computed(() =>
|
|
52
65
|
isPathDraft(siteData.value, currentPage.value?.path, currentPage.value),
|
|
53
66
|
);
|
|
67
|
+
const isNotFound = computed(() => Boolean(currentPage.value?.isNotFound));
|
|
54
68
|
|
|
55
69
|
const pageMetaTitle = computed(() => {
|
|
56
70
|
const page = currentPage.value;
|
|
57
71
|
const site = siteTitle.value;
|
|
72
|
+
// 404 pages: generic "Page not found — Site". The page slug (which is
|
|
73
|
+
// some URL the visitor typed) shouldn't leak into <title>.
|
|
74
|
+
if (isNotFound.value) {
|
|
75
|
+
return site ? `Page not found — ${site}` : 'Page not found';
|
|
76
|
+
}
|
|
58
77
|
// Draft pages emit a generic title so the page slug doesn't leak via
|
|
59
78
|
// <title> in the SSG-rendered HTML on disk. The tab title remains
|
|
60
79
|
// generic even after client-side unlock — for "iterate before launch"
|
|
@@ -74,7 +93,7 @@ export function usePageMeta({ siteData, currentPage, locale }) {
|
|
|
74
93
|
});
|
|
75
94
|
|
|
76
95
|
const pageMetaDescription = computed(() => {
|
|
77
|
-
if (isDraft.value) {
|
|
96
|
+
if (isDraft.value || isNotFound.value) {
|
|
78
97
|
return '';
|
|
79
98
|
}
|
|
80
99
|
const explicitDescription = currentPage.value?.meta?.description;
|
|
@@ -85,11 +104,11 @@ export function usePageMeta({ siteData, currentPage, locale }) {
|
|
|
85
104
|
});
|
|
86
105
|
|
|
87
106
|
// 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.
|
|
107
|
+
// /{locale}/path. Drafts and 404s skip canonical (the URL itself isn't
|
|
108
|
+
// authoritative for gated/missing content). Sites without a configured
|
|
109
|
+
// site.url skip too — we can't build an absolute URL.
|
|
91
110
|
const canonicalHref = computed(() => {
|
|
92
|
-
if (isDraft.value) return '';
|
|
111
|
+
if (isDraft.value || isNotFound.value) return '';
|
|
93
112
|
if (!siteUrl.value) return '';
|
|
94
113
|
return buildCanonicalUrl({
|
|
95
114
|
siteUrl: siteUrl.value,
|
|
@@ -104,7 +123,7 @@ export function usePageMeta({ siteData, currentPage, locale }) {
|
|
|
104
123
|
// an `x-default` pointing at the base-locale URL (Google's preferred
|
|
105
124
|
// pattern for sites without explicit geo-targeting).
|
|
106
125
|
const hreflangLinks = computed(() => {
|
|
107
|
-
if (isDraft.value) return [];
|
|
126
|
+
if (isDraft.value || isNotFound.value) return [];
|
|
108
127
|
if (!siteUrl.value) return [];
|
|
109
128
|
if (!Array.isArray(configAvailableLocales) || configAvailableLocales.length <= 1) return [];
|
|
110
129
|
const path = currentPage.value?.path || '/';
|
|
@@ -138,9 +157,39 @@ export function usePageMeta({ siteData, currentPage, locale }) {
|
|
|
138
157
|
if (description) {
|
|
139
158
|
meta.push({ name: 'description', content: description, key: 'description' });
|
|
140
159
|
}
|
|
141
|
-
|
|
160
|
+
// 404 pages get noindex defensively — Amplify (or whichever host)
|
|
161
|
+
// already serves them with HTTP 404, but search engines occasionally
|
|
162
|
+
// crawl 404 URLs and the meta is a stronger signal than relying on
|
|
163
|
+
// the status header alone.
|
|
164
|
+
if (isDraft.value || isNotFound.value) {
|
|
142
165
|
meta.push({ name: 'robots', content: 'noindex, nofollow', key: 'robots' });
|
|
143
166
|
}
|
|
167
|
+
const social = buildSocialMeta({
|
|
168
|
+
siteData: siteData.value,
|
|
169
|
+
currentPage: currentPage.value,
|
|
170
|
+
pageTitle: pageMetaTitle.value,
|
|
171
|
+
pageDescription: pageMetaDescription.value,
|
|
172
|
+
canonicalHref: canonicalHref.value,
|
|
173
|
+
isDraft: isDraft.value,
|
|
174
|
+
isNotFound: isNotFound.value,
|
|
175
|
+
});
|
|
176
|
+
for (const entry of social) meta.push(entry);
|
|
177
|
+
|
|
178
|
+
// Site-verification meta tags. These are emitted on every page (not
|
|
179
|
+
// just home) — verification platforms typically only check homepage
|
|
180
|
+
// but emitting site-wide is harmless and matches what most CMSs do.
|
|
181
|
+
const verification = siteData.value?.site?.siteVerification;
|
|
182
|
+
if (verification && typeof verification === 'object') {
|
|
183
|
+
for (const [platform, token] of Object.entries(verification)) {
|
|
184
|
+
const metaName = SITE_VERIFICATION_META[platform];
|
|
185
|
+
if (!metaName) continue;
|
|
186
|
+
if (typeof token !== 'string') continue;
|
|
187
|
+
const trimmed = token.trim();
|
|
188
|
+
if (!trimmed) continue;
|
|
189
|
+
meta.push({ name: metaName, content: trimmed, key: `verify-${platform}` });
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
144
193
|
const link = [];
|
|
145
194
|
if (canonicalHref.value) {
|
|
146
195
|
link.push({ rel: 'canonical', href: canonicalHref.value, key: 'canonical' });
|
|
@@ -148,12 +197,21 @@ export function usePageMeta({ siteData, currentPage, locale }) {
|
|
|
148
197
|
for (const entry of hreflangLinks.value) {
|
|
149
198
|
link.push(entry);
|
|
150
199
|
}
|
|
200
|
+
|
|
201
|
+
const script = buildJsonLdScripts({
|
|
202
|
+
siteData: siteData.value,
|
|
203
|
+
currentPage: currentPage.value,
|
|
204
|
+
isDraft: isDraft.value,
|
|
205
|
+
isNotFound: isNotFound.value,
|
|
206
|
+
});
|
|
207
|
+
|
|
151
208
|
return {
|
|
152
209
|
title: pageMetaTitle.value,
|
|
153
210
|
meta,
|
|
154
211
|
link,
|
|
212
|
+
script,
|
|
155
213
|
};
|
|
156
214
|
});
|
|
157
215
|
|
|
158
|
-
return { pageMetaTitle, pageMetaDescription, isDraft, canonicalHref, hreflangLinks };
|
|
216
|
+
return { pageMetaTitle, pageMetaDescription, isDraft, isNotFound, canonicalHref, hreflangLinks };
|
|
159
217
|
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Normalize per-page + site-wide JSON-LD blocks into a flat list of
|
|
3
|
+
* @unhead/vue script descriptors ready for `useHead({ script: [...] })`.
|
|
4
|
+
*
|
|
5
|
+
* Authoring conventions in `pages/{id}.json` and `site.json`:
|
|
6
|
+
*
|
|
7
|
+
* "jsonld": { "@context": "https://schema.org", "@type": "Article", ... }
|
|
8
|
+
*
|
|
9
|
+
* or for multiple blocks on a page:
|
|
10
|
+
*
|
|
11
|
+
* "jsonld": [
|
|
12
|
+
* { "@type": "Article", ... },
|
|
13
|
+
* { "@type": "BreadcrumbList", ... }
|
|
14
|
+
* ]
|
|
15
|
+
*
|
|
16
|
+
* Page blocks ALWAYS append to site blocks (there's no "override" semantic
|
|
17
|
+
* — each schema.org block is an independent assertion, so listing both is
|
|
18
|
+
* the right behavior for a sitewide Organization + a page-specific Article).
|
|
19
|
+
*
|
|
20
|
+
* Skipped entirely on drafts and 404s; structured data on those would
|
|
21
|
+
* mislead crawlers (drafts shouldn't be indexed; 404s aren't authoritative).
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
function normalizeBlock(block) {
|
|
25
|
+
if (!block || typeof block !== 'object' || Array.isArray(block)) return null;
|
|
26
|
+
// Inject the schema.org @context if the author omitted it. JSON-LD blocks
|
|
27
|
+
// without @context are ignored by all major consumers, so adding it
|
|
28
|
+
// defensively is the right default.
|
|
29
|
+
if (!block['@context']) {
|
|
30
|
+
return { '@context': 'https://schema.org', ...block };
|
|
31
|
+
}
|
|
32
|
+
return block;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function collectBlocks(input) {
|
|
36
|
+
if (!input) return [];
|
|
37
|
+
if (Array.isArray(input)) {
|
|
38
|
+
return input.map(normalizeBlock).filter(Boolean);
|
|
39
|
+
}
|
|
40
|
+
const single = normalizeBlock(input);
|
|
41
|
+
return single ? [single] : [];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function buildJsonLdScripts({ siteData, currentPage, isDraft, isNotFound } = {}) {
|
|
45
|
+
if (isDraft || isNotFound) return [];
|
|
46
|
+
|
|
47
|
+
const siteBlocks = collectBlocks(siteData?.site?.jsonld);
|
|
48
|
+
const pageBlocks = collectBlocks(currentPage?.jsonld);
|
|
49
|
+
|
|
50
|
+
const all = [...siteBlocks, ...pageBlocks];
|
|
51
|
+
if (all.length === 0) return [];
|
|
52
|
+
|
|
53
|
+
return all.map((block, i) => ({
|
|
54
|
+
type: 'application/ld+json',
|
|
55
|
+
innerHTML: JSON.stringify(block),
|
|
56
|
+
key: `jsonld-${block['@type'] || 'block'}-${i}`,
|
|
57
|
+
}));
|
|
58
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Build a Web App Manifest (manifest.json) from the inflated site config.
|
|
3
|
+
*
|
|
4
|
+
* Returns the manifest object — the plugin JSON-stringifies and writes it
|
|
5
|
+
* to `siteRoot/public/manifest.json`. Browsers consume the file when the
|
|
6
|
+
* page emits `<link rel="manifest" href="/manifest.json">`.
|
|
7
|
+
*
|
|
8
|
+
* Authoring conventions in `site.json`:
|
|
9
|
+
*
|
|
10
|
+
* "manifest.name": "...", // overrides site.title
|
|
11
|
+
* "manifest.shortName": "...", // overrides truncated site.title
|
|
12
|
+
* "manifest.themeColor": "#...", // mobile chrome / splash bar color
|
|
13
|
+
* "manifest.backgroundColor": "#...", // splash background
|
|
14
|
+
* "manifest.startUrl": "/", // landing path on launch
|
|
15
|
+
* "manifest.display": "standalone",// browser, standalone, fullscreen, minimal-ui
|
|
16
|
+
* "manifest.icons[0].src": "...", // override default icon set
|
|
17
|
+
* "manifest.icons[0].sizes": "256x256",
|
|
18
|
+
* "manifest.icons[0].type": "image/png"
|
|
19
|
+
*
|
|
20
|
+
* Defaults are derived from existing site config and the icons that
|
|
21
|
+
* cms-generate-public-assets writes (favicon-256.png + favicon.ico).
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
const DEFAULT_DISPLAY = 'standalone';
|
|
25
|
+
const DEFAULT_BG = '#ffffff';
|
|
26
|
+
const DEFAULT_THEME = '#ffffff';
|
|
27
|
+
const DEFAULT_START = '/';
|
|
28
|
+
|
|
29
|
+
function trim(value, fallback = '') {
|
|
30
|
+
if (typeof value !== 'string') return fallback;
|
|
31
|
+
const t = value.trim();
|
|
32
|
+
return t || fallback;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function defaultIcons() {
|
|
36
|
+
// Mirrors what cms-generate-public-assets writes into public/.
|
|
37
|
+
// Browsers pick the best size; the 256x256 PNG is the primary, the
|
|
38
|
+
// .ico is the legacy fallback for older Android.
|
|
39
|
+
return [
|
|
40
|
+
{ src: '/favicon-256.png', sizes: '256x256', type: 'image/png', purpose: 'any maskable' },
|
|
41
|
+
{ src: '/favicon.ico', sizes: '64x64 32x32 16x16', type: 'image/x-icon' },
|
|
42
|
+
];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function buildWebAppManifest(siteConfig) {
|
|
46
|
+
const site = (siteConfig && siteConfig.site) || {};
|
|
47
|
+
const m = (site.manifest && typeof site.manifest === 'object') ? site.manifest : {};
|
|
48
|
+
|
|
49
|
+
const siteTitle = trim(site.title);
|
|
50
|
+
const name = trim(m.name, siteTitle) || 'Site';
|
|
51
|
+
const shortName = trim(m.shortName, name.length <= 12 ? name : name.slice(0, 12));
|
|
52
|
+
|
|
53
|
+
const description = trim(m.description, trim(site.description));
|
|
54
|
+
|
|
55
|
+
const themeColor = trim(m.themeColor, DEFAULT_THEME);
|
|
56
|
+
const backgroundColor = trim(m.backgroundColor, DEFAULT_BG);
|
|
57
|
+
const startUrl = trim(m.startUrl, DEFAULT_START);
|
|
58
|
+
const display = trim(m.display, DEFAULT_DISPLAY);
|
|
59
|
+
const lang = trim(m.lang);
|
|
60
|
+
|
|
61
|
+
const customIcons = Array.isArray(m.icons)
|
|
62
|
+
? m.icons
|
|
63
|
+
.filter((icon) => icon && typeof icon === 'object' && typeof icon.src === 'string')
|
|
64
|
+
.map((icon) => ({ ...icon }))
|
|
65
|
+
: null;
|
|
66
|
+
const icons = customIcons && customIcons.length ? customIcons : defaultIcons();
|
|
67
|
+
|
|
68
|
+
const manifest = {
|
|
69
|
+
name,
|
|
70
|
+
short_name: shortName,
|
|
71
|
+
start_url: startUrl,
|
|
72
|
+
display,
|
|
73
|
+
theme_color: themeColor,
|
|
74
|
+
background_color: backgroundColor,
|
|
75
|
+
icons,
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
if (description) manifest.description = description;
|
|
79
|
+
if (lang) manifest.lang = lang;
|
|
80
|
+
|
|
81
|
+
return manifest;
|
|
82
|
+
}
|
package/templates/index.html
CHANGED
|
@@ -2,19 +2,16 @@
|
|
|
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
|
-
<meta name="twitter:card" content="summary_large_image">
|
|
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
|
+
hreflang, and JSON-LD blocks are emitted by usePageMeta at SSG render
|
|
9
|
+
time. Site-wide invariants (favicon, web-app manifest, the JSON-LD
|
|
10
|
+
Organization snippet, mobile-chrome theme color) live here.
|
|
11
|
+
-->
|
|
17
12
|
<link rel="icon" href="/favicon.ico">
|
|
13
|
+
<link rel="manifest" href="/manifest.json">
|
|
14
|
+
<meta name="theme-color" content="<%= manifestThemeColor %>">
|
|
18
15
|
<script type="application/ld+json">
|
|
19
16
|
{
|
|
20
17
|
"@context": "https://schema.org",
|
package/vite-plugin.js
CHANGED
|
@@ -15,6 +15,7 @@ import vue from '@vitejs/plugin-vue';
|
|
|
15
15
|
import { inflateFlatConfig } from './src/utils/inflateFlatConfig.js';
|
|
16
16
|
import { buildRobotsTxt } from './src/utils/robotsGenerator.js';
|
|
17
17
|
import { buildSitemap, getSitemapUrl } from './src/utils/sitemapGenerator.js';
|
|
18
|
+
import { buildWebAppManifest } from './src/utils/webAppManifest.js';
|
|
18
19
|
import { sha256Hex } from './src/utils/sha256.js';
|
|
19
20
|
|
|
20
21
|
// ---- Helpers ----
|
|
@@ -117,6 +118,7 @@ function extractSiteMetadata(siteConfig) {
|
|
|
117
118
|
url: siteUrl = '',
|
|
118
119
|
googleId: siteGoogleId = '',
|
|
119
120
|
sameAs: siteSameAs = [],
|
|
121
|
+
manifest: siteManifest = {},
|
|
120
122
|
} = siteConfig.site || {};
|
|
121
123
|
|
|
122
124
|
const siteSameAsList = Array.isArray(siteSameAs)
|
|
@@ -124,7 +126,14 @@ function extractSiteMetadata(siteConfig) {
|
|
|
124
126
|
: [];
|
|
125
127
|
const siteSameAsJson = JSON.stringify(siteSameAsList);
|
|
126
128
|
|
|
127
|
-
|
|
129
|
+
// Mobile chrome bar / PWA splash color. Defaults match buildWebAppManifest's
|
|
130
|
+
// default so the rendered <meta name="theme-color"> matches whatever
|
|
131
|
+
// manifest.json advertises.
|
|
132
|
+
const manifestThemeColor = (typeof siteManifest.themeColor === 'string'
|
|
133
|
+
? siteManifest.themeColor.trim()
|
|
134
|
+
: '') || '#ffffff';
|
|
135
|
+
|
|
136
|
+
return { siteTitle, siteDescription, siteUrl, siteGoogleId, siteSameAsJson, manifestThemeColor };
|
|
128
137
|
}
|
|
129
138
|
|
|
130
139
|
const __filename = fileURLToPath(import.meta.url);
|
|
@@ -354,6 +363,7 @@ export default function cmsPlugin(options = {}) {
|
|
|
354
363
|
siteUrl: metadata.siteUrl,
|
|
355
364
|
siteGoogleId: metadata.siteGoogleId,
|
|
356
365
|
siteSameAsJson: metadata.siteSameAsJson,
|
|
366
|
+
manifestThemeColor: metadata.manifestThemeColor,
|
|
357
367
|
});
|
|
358
368
|
|
|
359
369
|
const processed = html.replace(
|
|
@@ -418,6 +428,16 @@ export default function cmsPlugin(options = {}) {
|
|
|
418
428
|
const sitemapUrl = sitemapXml ? getSitemapUrl(siteConfig) : '';
|
|
419
429
|
const robotsBody = buildRobotsTxt(siteConfig, sitemapUrl);
|
|
420
430
|
fs.writeFileSync(path.join(sitePublicDir, 'robots.txt'), robotsBody, 'utf-8');
|
|
431
|
+
|
|
432
|
+
// Web App Manifest. The template emits <link rel="manifest"> on every
|
|
433
|
+
// page; browsers use this to enable Add-to-Home-Screen + control
|
|
434
|
+
// splash colors when the site is launched from a homescreen icon.
|
|
435
|
+
const manifest = buildWebAppManifest(siteConfig);
|
|
436
|
+
fs.writeFileSync(
|
|
437
|
+
path.join(sitePublicDir, 'manifest.json'),
|
|
438
|
+
JSON.stringify(manifest, null, 2) + '\n',
|
|
439
|
+
'utf-8',
|
|
440
|
+
);
|
|
421
441
|
};
|
|
422
442
|
|
|
423
443
|
const cmsCore = {
|
|
@@ -538,8 +558,30 @@ export default function cmsPlugin(options = {}) {
|
|
|
538
558
|
},
|
|
539
559
|
ssgOptions: {
|
|
540
560
|
dirStyle: 'nested',
|
|
561
|
+
// After vite-ssg pre-renders every route, copy the rendered
|
|
562
|
+
// dist/404/index.html to dist/404.html. AWS Amplify (and most
|
|
563
|
+
// static hosts) auto-serve a top-level 404.html for any
|
|
564
|
+
// unmatched URL with HTTP status 404, so this gives sites a
|
|
565
|
+
// proper not-found response without per-site config.
|
|
566
|
+
onFinished() {
|
|
567
|
+
const distDir = path.join(siteRoot, 'dist');
|
|
568
|
+
const nestedNotFound = path.join(distDir, '404', 'index.html');
|
|
569
|
+
const flatNotFound = path.join(distDir, '404.html');
|
|
570
|
+
if (fs.existsSync(nestedNotFound)) {
|
|
571
|
+
try {
|
|
572
|
+
fs.copyFileSync(nestedNotFound, flatNotFound);
|
|
573
|
+
} catch (err) {
|
|
574
|
+
console.warn(`[@koehler8/cms] failed to write 404.html: ${err.message}`);
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
},
|
|
541
578
|
includedRoutes(paths) {
|
|
542
|
-
|
|
579
|
+
// /admin is a vestigial placeholder; /404 is the not-found
|
|
580
|
+
// landing page (rendered via the catch-all + NotFound
|
|
581
|
+
// component). onFinished above copies dist/404/index.html
|
|
582
|
+
// to dist/404.html so AWS Amplify serves it for unmatched
|
|
583
|
+
// URLs with HTTP 404.
|
|
584
|
+
const staticRoutes = new Set(['/admin', '/404']);
|
|
543
585
|
pagePaths.forEach((routePath) => staticRoutes.add(routePath));
|
|
544
586
|
|
|
545
587
|
// Pre-render every page under each non-base locale prefix that
|
|
@@ -810,6 +852,7 @@ export const assetUrlMap = resolver.assetUrlMap;
|
|
|
810
852
|
siteUrl: metadata.siteUrl,
|
|
811
853
|
siteGoogleId: metadata.siteGoogleId,
|
|
812
854
|
siteSameAsJson: metadata.siteSameAsJson,
|
|
855
|
+
manifestThemeColor: metadata.manifestThemeColor,
|
|
813
856
|
});
|
|
814
857
|
|
|
815
858
|
html = html.replace(
|