@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@koehler8/cms",
3
- "version": "1.0.0-beta.19",
3
+ "version": "1.0.0-beta.21",
4
4
  "description": "Vertex CMS — lightweight, config-driven Vue 3 framework with theming, extensions, and SSG support",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -0,0 +1,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 (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.
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
- if (isDraft.value) {
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
+ }
@@ -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
- <meta name="description" content="<%= siteDescription %>">
8
- <meta property="og:title" content="<%= site %>">
9
- <meta property="og:description" content="<%= siteDescription %>">
10
- <meta property="og:url" content="<%= siteUrl %>">
11
- <meta property="og:type" content="website">
12
- <meta property="og:image" content="<%= ABSOLUTE_OG_IMAGE %>">
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
- return { siteTitle, siteDescription, siteUrl, siteGoogleId, siteSameAsJson };
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
- const staticRoutes = new Set(['/admin']);
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(