@koehler8/cms 1.0.0-beta.8 → 1.0.0
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/CHANGELOG.md +1491 -0
- package/README.md +108 -28
- package/bin/cms-create-extension.js +7 -0
- package/bin/cms-create-site.js +7 -0
- package/bin/cms-create-theme.js +7 -0
- package/bin/cms-ssg-build.js +13 -0
- package/bin/cms-validate-extensions.js +4 -0
- package/bin/cms-validate-themes.js +11 -0
- package/extensions/manifest.schema.json +1 -1
- package/package.json +36 -16
- package/scripts/create-extension.mjs +11 -0
- package/scripts/create-site.mjs +11 -0
- package/scripts/create-theme.mjs +11 -0
- package/scripts/generate-public-assets.js +70 -36
- package/scripts/image-variants/cache.js +40 -0
- package/scripts/image-variants/classifier.js +53 -0
- package/scripts/image-variants/config.js +54 -0
- package/scripts/image-variants/index.js +18 -0
- package/scripts/image-variants/manifest.js +76 -0
- package/scripts/image-variants/planner.js +95 -0
- package/scripts/image-variants/reconcile.js +194 -0
- package/scripts/image-variants/renderer.js +51 -0
- package/scripts/lib/extension-manifests.mjs +70 -0
- package/scripts/scaffold.mjs +242 -0
- package/scripts/ssg-build/findEmptyRenders.mjs +42 -0
- package/scripts/ssg-build/index.mjs +253 -0
- package/scripts/validate-extensions.mjs +50 -382
- package/scripts/validate-themes.mjs +37 -5
- package/src/components/About.vue +4 -2
- package/src/components/AboutValue.vue +5 -4
- package/src/components/ComingSoon.vue +12 -1
- package/src/components/ComingSoonModal.vue +33 -61
- package/src/components/Contact.vue +216 -120
- package/src/components/DraftGate.vue +211 -0
- package/src/components/Footer.vue +15 -2
- package/src/components/FooterMinimal.vue +13 -12
- package/src/components/Header.vue +184 -10
- package/src/components/Hero.vue +17 -8
- package/src/components/Home.vue +81 -48
- package/src/components/Intro.vue +4 -2
- package/src/components/IntroGate.vue +30 -73
- package/src/components/NotFound.vue +100 -0
- package/src/components/Plan.vue +10 -4
- package/src/components/Portfolio.vue +29 -3
- package/src/components/Principles.vue +3 -1
- package/src/components/Spacer.vue +23 -0
- package/src/components/Spacer15.vue +3 -4
- package/src/components/Spacer30.vue +3 -4
- package/src/components/Spacer40.vue +3 -4
- package/src/components/Spacer60.vue +3 -4
- package/src/components/StickyCTA.vue +23 -1
- package/src/components/Team.vue +3 -1
- package/src/composables/useComponentResolver.js +49 -1
- package/src/composables/useDraftGate.js +93 -0
- package/src/composables/useFocusTrap.js +85 -0
- package/src/composables/usePageConfig.js +126 -28
- package/src/composables/usePageMeta.js +178 -5
- package/src/composables/usePromoBackgroundStyles.js +30 -14
- package/src/extensions/extensionLoader.js +34 -24
- package/src/main.js +69 -6
- package/src/router/index.js +98 -37
- package/src/styles/base.css +11 -2
- package/src/themes/buildCssVarMap.js +34 -0
- package/src/utils/appInfo.js +8 -5
- package/src/utils/breadcrumbs.js +121 -0
- package/src/utils/canonicalUrl.js +53 -0
- package/src/utils/componentRegistry.js +98 -16
- package/src/utils/cookieConsent.js +38 -24
- package/src/utils/draftMode.js +65 -0
- package/src/utils/imageSources.js +9 -1
- package/src/utils/jsonLd.js +66 -0
- package/src/utils/loadConfig.js +106 -6
- package/src/utils/robotsGenerator.js +95 -0
- package/src/utils/sha256.js +20 -0
- package/src/utils/sitemapGenerator.js +112 -0
- package/src/utils/socialMeta.js +100 -0
- package/src/utils/ssgRoutes.js +40 -0
- package/src/utils/trackingContext.js +16 -22
- package/src/utils/webAppManifest.js +85 -0
- package/src/utils/webStorage.js +82 -0
- package/templates/index.html +17 -24
- package/templates/scaffolds/extension/README.md +53 -0
- package/templates/scaffolds/extension/extension.config.json +10 -0
- package/templates/scaffolds/extension/index.js +37 -0
- package/templates/scaffolds/extension/package.json +22 -0
- package/templates/scaffolds/site/.env.example +7 -0
- package/templates/scaffolds/site/.nvmrc +1 -0
- package/templates/scaffolds/site/CLAUDE.md +121 -0
- package/templates/scaffolds/site/README.md +25 -0
- package/templates/scaffolds/site/amplify.yml +22 -0
- package/templates/scaffolds/site/package.json +26 -0
- package/templates/scaffolds/site/site/assets/img/.gitkeep +0 -0
- package/templates/scaffolds/site/site/components/.gitkeep +0 -0
- package/templates/scaffolds/site/site/content/content.config.json +3 -0
- package/templates/scaffolds/site/site/content/en/pages/home.json +8 -0
- package/templates/scaffolds/site/site/content/en/shared.json +5 -0
- package/templates/scaffolds/site/site/content/en/site.json +10 -0
- package/templates/scaffolds/site/site/style.css +4 -0
- package/templates/scaffolds/site/vite.config.js +15 -0
- package/templates/scaffolds/theme/README.md +50 -0
- package/templates/scaffolds/theme/index.js +8 -0
- package/templates/scaffolds/theme/package.json +21 -0
- package/templates/scaffolds/theme/theme.config.js +228 -0
- package/templates/scaffolds/theme/theme.css +14 -0
- package/themes/base/theme.config.js +100 -57
- package/vite-plugin.js +545 -32
- package/public/robots.txt +0 -5
- package/scripts/check-overflow.mjs +0 -33
- package/scripts/migrate-to-content-dirs.mjs +0 -223
- package/scripts/patch-lru-cache-tla.js +0 -164
|
@@ -0,0 +1,100 @@
|
|
|
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
|
+
locale,
|
|
40
|
+
availableLocales,
|
|
41
|
+
} = {}) {
|
|
42
|
+
if (isDraft || isNotFound) return [];
|
|
43
|
+
|
|
44
|
+
const site = (siteData && siteData.site) || {};
|
|
45
|
+
const page = currentPage || {};
|
|
46
|
+
const pageMeta = page.meta || {};
|
|
47
|
+
|
|
48
|
+
const siteUrl = (typeof site.url === 'string' ? site.url : '').trim().replace(/\/+$/, '');
|
|
49
|
+
const siteTitle = typeof site.title === 'string' ? site.title.trim() : '';
|
|
50
|
+
|
|
51
|
+
const rawImage =
|
|
52
|
+
pageMeta.image ||
|
|
53
|
+
site.image ||
|
|
54
|
+
site.ogImage ||
|
|
55
|
+
STATIC_OG_IMAGE;
|
|
56
|
+
const ogImage = absolutize(siteUrl, rawImage);
|
|
57
|
+
|
|
58
|
+
const ogType = pageMeta.ogType || 'website';
|
|
59
|
+
const twitterCard = pageMeta.twitterCard || 'summary_large_image';
|
|
60
|
+
|
|
61
|
+
const meta = [];
|
|
62
|
+
|
|
63
|
+
if (pageTitle) {
|
|
64
|
+
meta.push({ property: 'og:title', content: pageTitle, key: 'og:title' });
|
|
65
|
+
meta.push({ name: 'twitter:title', content: pageTitle, key: 'twitter:title' });
|
|
66
|
+
}
|
|
67
|
+
if (pageDescription) {
|
|
68
|
+
meta.push({ property: 'og:description', content: pageDescription, key: 'og:description' });
|
|
69
|
+
meta.push({ name: 'twitter:description', content: pageDescription, key: 'twitter:description' });
|
|
70
|
+
}
|
|
71
|
+
if (canonicalHref) {
|
|
72
|
+
meta.push({ property: 'og:url', content: canonicalHref, key: 'og:url' });
|
|
73
|
+
}
|
|
74
|
+
meta.push({ property: 'og:type', content: ogType, key: 'og:type' });
|
|
75
|
+
if (siteTitle) {
|
|
76
|
+
meta.push({ property: 'og:site_name', content: siteTitle, key: 'og:site_name' });
|
|
77
|
+
}
|
|
78
|
+
if (ogImage) {
|
|
79
|
+
meta.push({ property: 'og:image', content: ogImage, key: 'og:image' });
|
|
80
|
+
meta.push({ name: 'twitter:image', content: ogImage, key: 'twitter:image' });
|
|
81
|
+
}
|
|
82
|
+
meta.push({ name: 'twitter:card', content: twitterCard, key: 'twitter:card' });
|
|
83
|
+
|
|
84
|
+
// og:locale mirrors the page's rendered language; alternates list the other
|
|
85
|
+
// locales the framework actually pre-renders (same set as hreflang). Bare
|
|
86
|
+
// language codes are emitted as-is — the framework's locale ids don't carry
|
|
87
|
+
// territories.
|
|
88
|
+
const currentLocale = typeof locale === 'string' ? locale.trim() : '';
|
|
89
|
+
if (currentLocale) {
|
|
90
|
+
meta.push({ property: 'og:locale', content: currentLocale, key: 'og:locale' });
|
|
91
|
+
const others = (Array.isArray(availableLocales) ? availableLocales : [])
|
|
92
|
+
.map((l) => (typeof l === 'string' ? l.trim() : ''))
|
|
93
|
+
.filter((l) => l && l !== currentLocale);
|
|
94
|
+
others.forEach((l) => {
|
|
95
|
+
meta.push({ property: 'og:locale:alternate', content: l, key: `og:locale:alternate:${l}` });
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return meta;
|
|
100
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// Route sharding for `cms-ssg-build` — the opt-in durable memory bound for the
|
|
2
|
+
// SSG pre-render. vite-ssg renders every route in one Node process and leaves a
|
|
3
|
+
// fixed retained cost per route, so peak heap scales with total routes;
|
|
4
|
+
// `cms-ssg-build` runs the render in N separate processes, each handling a slice
|
|
5
|
+
// of the routes, so peak heap is bounded by slice size instead. This module is
|
|
6
|
+
// the pure partition function so the slicing rules are unit-testable without a
|
|
7
|
+
// build. See SSG-MEMORY-PLAN.md.
|
|
8
|
+
|
|
9
|
+
// Routes every shard renders regardless of its slice. `/404` must be present in
|
|
10
|
+
// each shard's output dir so that shard's `onFinished` can copy 404/index.html
|
|
11
|
+
// → 404.html.
|
|
12
|
+
export const ALWAYS_SHARDED_ROUTES = ['/404'];
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Return shard `k` of `N` from the full SSG route list.
|
|
16
|
+
*
|
|
17
|
+
* `shardSpec` is a `"k/N"` string (e.g. `"2/5"`). Shard k = the always-present
|
|
18
|
+
* routes + every Nth remaining route offset by k, so the N shards partition the
|
|
19
|
+
* non-always routes exactly once each (no route rendered twice, none dropped).
|
|
20
|
+
*
|
|
21
|
+
* Returns the full list unchanged when `shardSpec` is absent or malformed —
|
|
22
|
+
* this is the normal `vite-ssg build` path and must stay byte-identical.
|
|
23
|
+
*/
|
|
24
|
+
export function applyRouteShard(allRoutes, shardSpec) {
|
|
25
|
+
if (!Array.isArray(allRoutes)) return allRoutes;
|
|
26
|
+
const match = typeof shardSpec === 'string' ? shardSpec.trim().match(/^(\d+)\/(\d+)$/) : null;
|
|
27
|
+
if (!match) return allRoutes;
|
|
28
|
+
|
|
29
|
+
const k = Number(match[1]);
|
|
30
|
+
const n = Number(match[2]);
|
|
31
|
+
if (!(n > 0) || k < 0 || k >= n) return allRoutes;
|
|
32
|
+
|
|
33
|
+
const always = new Set(ALWAYS_SHARDED_ROUTES);
|
|
34
|
+
const alwaysRoutes = allRoutes.filter((route) => always.has(route));
|
|
35
|
+
const shardRoutes = allRoutes
|
|
36
|
+
.filter((route) => !always.has(route))
|
|
37
|
+
.filter((_, i) => i % n === k);
|
|
38
|
+
|
|
39
|
+
return Array.from(new Set([...alwaysRoutes, ...shardRoutes]));
|
|
40
|
+
}
|
|
@@ -1,5 +1,7 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
1
|
+
import { STORAGE_KEYS, readStorage, writeStorage } from './webStorage.js';
|
|
2
|
+
|
|
3
|
+
const ATTRIBUTION = STORAGE_KEYS.attribution;
|
|
4
|
+
const SESSION_ID = STORAGE_KEYS.sessionId;
|
|
3
5
|
|
|
4
6
|
function generateSessionId() {
|
|
5
7
|
if (typeof crypto !== 'undefined' && crypto.randomUUID) {
|
|
@@ -8,28 +10,22 @@ function generateSessionId() {
|
|
|
8
10
|
return `sess_${Math.random().toString(36).slice(2)}${Date.now().toString(36)}`;
|
|
9
11
|
}
|
|
10
12
|
|
|
11
|
-
function readStoredJson(
|
|
12
|
-
if (typeof window === 'undefined') return { ...fallback };
|
|
13
|
+
function readStoredJson(entry, fallback = {}) {
|
|
13
14
|
try {
|
|
14
|
-
const raw =
|
|
15
|
+
const raw = readStorage(entry.kind, entry.key, entry.legacy);
|
|
15
16
|
if (!raw) return { ...fallback };
|
|
16
17
|
const parsed = JSON.parse(raw);
|
|
17
18
|
if (parsed && typeof parsed === 'object') {
|
|
18
19
|
return { ...fallback, ...parsed };
|
|
19
20
|
}
|
|
20
21
|
} catch (error) {
|
|
21
|
-
console.warn('[trackingContext] Failed to read storage key', key, error);
|
|
22
|
+
console.warn('[trackingContext] Failed to read storage key', entry.key, error);
|
|
22
23
|
}
|
|
23
24
|
return { ...fallback };
|
|
24
25
|
}
|
|
25
26
|
|
|
26
|
-
function writeStoredJson(
|
|
27
|
-
|
|
28
|
-
try {
|
|
29
|
-
window.sessionStorage.setItem(key, JSON.stringify(value));
|
|
30
|
-
} catch (error) {
|
|
31
|
-
console.warn('[trackingContext] Failed to write storage key', key, error);
|
|
32
|
-
}
|
|
27
|
+
function writeStoredJson(entry, value) {
|
|
28
|
+
writeStorage(entry.kind, entry.key, JSON.stringify(value));
|
|
33
29
|
}
|
|
34
30
|
|
|
35
31
|
function detectDeviceType(userAgent = '') {
|
|
@@ -65,7 +61,7 @@ export function persistAttributionFromLocation() {
|
|
|
65
61
|
if (typeof window === 'undefined') return;
|
|
66
62
|
|
|
67
63
|
const params = new URLSearchParams(window.location.search || '');
|
|
68
|
-
const stored = readStoredJson(
|
|
64
|
+
const stored = readStoredJson(ATTRIBUTION, {});
|
|
69
65
|
|
|
70
66
|
const next = { ...stored };
|
|
71
67
|
|
|
@@ -93,15 +89,15 @@ export function persistAttributionFromLocation() {
|
|
|
93
89
|
next.referrer = document.referrer;
|
|
94
90
|
}
|
|
95
91
|
|
|
96
|
-
writeStoredJson(
|
|
92
|
+
writeStoredJson(ATTRIBUTION, next);
|
|
97
93
|
|
|
98
|
-
if (!
|
|
99
|
-
|
|
94
|
+
if (!readStorage(SESSION_ID.kind, SESSION_ID.key, SESSION_ID.legacy)) {
|
|
95
|
+
writeStorage(SESSION_ID.kind, SESSION_ID.key, generateSessionId());
|
|
100
96
|
}
|
|
101
97
|
}
|
|
102
98
|
|
|
103
99
|
export function getAnalyticsContext(overrides = {}) {
|
|
104
|
-
const attribution = readStoredJson(
|
|
100
|
+
const attribution = readStoredJson(ATTRIBUTION, {
|
|
105
101
|
utm_source: 'direct',
|
|
106
102
|
utm_medium: 'none',
|
|
107
103
|
utm_campaign: '',
|
|
@@ -113,12 +109,10 @@ export function getAnalyticsContext(overrides = {}) {
|
|
|
113
109
|
let sessionId = '';
|
|
114
110
|
if (typeof window !== 'undefined') {
|
|
115
111
|
sessionId =
|
|
116
|
-
|
|
112
|
+
readStorage(SESSION_ID.kind, SESSION_ID.key, SESSION_ID.legacy) ||
|
|
117
113
|
(() => {
|
|
118
114
|
const fresh = generateSessionId();
|
|
119
|
-
|
|
120
|
-
window.sessionStorage.setItem(SESSION_STORAGE_KEY, fresh);
|
|
121
|
-
} catch {}
|
|
115
|
+
writeStorage(SESSION_ID.kind, SESSION_ID.key, fresh);
|
|
122
116
|
return fresh;
|
|
123
117
|
})();
|
|
124
118
|
}
|
|
@@ -0,0 +1,85 @@
|
|
|
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
|
+
// purpose 'any' only: the default icon isn't padded for the maskable
|
|
41
|
+
// safe zone, and claiming 'maskable' gets it cropped (Lighthouse flags
|
|
42
|
+
// the combined 'any maskable' for exactly this).
|
|
43
|
+
{ src: '/favicon-256.png', sizes: '256x256', type: 'image/png', purpose: 'any' },
|
|
44
|
+
{ src: '/favicon.ico', sizes: '64x64 32x32 16x16', type: 'image/x-icon' },
|
|
45
|
+
];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function buildWebAppManifest(siteConfig) {
|
|
49
|
+
const site = (siteConfig && siteConfig.site) || {};
|
|
50
|
+
const m = (site.manifest && typeof site.manifest === 'object') ? site.manifest : {};
|
|
51
|
+
|
|
52
|
+
const siteTitle = trim(site.title);
|
|
53
|
+
const name = trim(m.name, siteTitle) || 'Site';
|
|
54
|
+
const shortName = trim(m.shortName, name.length <= 12 ? name : name.slice(0, 12));
|
|
55
|
+
|
|
56
|
+
const description = trim(m.description, trim(site.description));
|
|
57
|
+
|
|
58
|
+
const themeColor = trim(m.themeColor, DEFAULT_THEME);
|
|
59
|
+
const backgroundColor = trim(m.backgroundColor, DEFAULT_BG);
|
|
60
|
+
const startUrl = trim(m.startUrl, DEFAULT_START);
|
|
61
|
+
const display = trim(m.display, DEFAULT_DISPLAY);
|
|
62
|
+
const lang = trim(m.lang);
|
|
63
|
+
|
|
64
|
+
const customIcons = Array.isArray(m.icons)
|
|
65
|
+
? m.icons
|
|
66
|
+
.filter((icon) => icon && typeof icon === 'object' && typeof icon.src === 'string')
|
|
67
|
+
.map((icon) => ({ ...icon }))
|
|
68
|
+
: null;
|
|
69
|
+
const icons = customIcons && customIcons.length ? customIcons : defaultIcons();
|
|
70
|
+
|
|
71
|
+
const manifest = {
|
|
72
|
+
name,
|
|
73
|
+
short_name: shortName,
|
|
74
|
+
start_url: startUrl,
|
|
75
|
+
display,
|
|
76
|
+
theme_color: themeColor,
|
|
77
|
+
background_color: backgroundColor,
|
|
78
|
+
icons,
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
if (description) manifest.description = description;
|
|
82
|
+
if (lang) manifest.lang = lang;
|
|
83
|
+
|
|
84
|
+
return manifest;
|
|
85
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Namespaced, guarded browser-storage access for framework state.
|
|
3
|
+
*
|
|
4
|
+
* All framework keys carry the `cms_` prefix so they cannot collide with
|
|
5
|
+
* site-author or extension keys on the same origin (the bare `locale` key in
|
|
6
|
+
* particular was a likely collision). Reads fall back to the pre-1.0
|
|
7
|
+
* unprefixed key and migrate the value forward, so returning visitors keep
|
|
8
|
+
* their consent choice and locale preference across the rename; the legacy
|
|
9
|
+
* key is left in place (harmless, and keeps still-cached pre-1.0 bundles
|
|
10
|
+
* working during a deploy window).
|
|
11
|
+
*
|
|
12
|
+
* Every access is guarded: Safari private mode and blocked-storage contexts
|
|
13
|
+
* throw on touch, and a storage failure must degrade to "no stored value" —
|
|
14
|
+
* never break the page.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
export const STORAGE_KEYS = {
|
|
18
|
+
locale: { kind: 'local', key: 'cms_locale', legacy: 'locale' },
|
|
19
|
+
consent: { kind: 'local', key: 'cms_cookie_consent', legacy: 'cookie_consent' },
|
|
20
|
+
consentTimestamp: { kind: 'local', key: 'cms_cookie_consent_timestamp', legacy: 'cookie_consent_timestamp' },
|
|
21
|
+
sessionId: { kind: 'session', key: 'cms_session_id', legacy: 'app_session_id' },
|
|
22
|
+
attribution: { kind: 'session', key: 'cms_attribution_v1', legacy: 'app_attribution_v1' },
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
function getStore(kind) {
|
|
26
|
+
try {
|
|
27
|
+
if (typeof window === 'undefined') return null;
|
|
28
|
+
return kind === 'session' ? window.sessionStorage : window.localStorage;
|
|
29
|
+
} catch {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Read a value. Falls back to `legacyKey` (migrating it forward) when the
|
|
36
|
+
* namespaced key is empty. Returns null when unavailable.
|
|
37
|
+
*/
|
|
38
|
+
export function readStorage(kind, key, legacyKey = '') {
|
|
39
|
+
const store = getStore(kind);
|
|
40
|
+
if (!store) return null;
|
|
41
|
+
try {
|
|
42
|
+
const value = store.getItem(key);
|
|
43
|
+
if (value !== null && value !== undefined) return value;
|
|
44
|
+
if (legacyKey) {
|
|
45
|
+
const legacy = store.getItem(legacyKey);
|
|
46
|
+
if (legacy !== null && legacy !== undefined) {
|
|
47
|
+
try {
|
|
48
|
+
store.setItem(key, legacy);
|
|
49
|
+
} catch {
|
|
50
|
+
/* migration is best-effort */
|
|
51
|
+
}
|
|
52
|
+
return legacy;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return null;
|
|
56
|
+
} catch {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Write a value. Returns false (never throws) when storage is unavailable. */
|
|
62
|
+
export function writeStorage(kind, key, value) {
|
|
63
|
+
const store = getStore(kind);
|
|
64
|
+
if (!store) return false;
|
|
65
|
+
try {
|
|
66
|
+
store.setItem(key, value);
|
|
67
|
+
return true;
|
|
68
|
+
} catch {
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Convenience wrappers for the shared locale preference. */
|
|
74
|
+
export function readStoredLocale() {
|
|
75
|
+
const { kind, key, legacy } = STORAGE_KEYS.locale;
|
|
76
|
+
return readStorage(kind, key, legacy);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function writeStoredLocale(value) {
|
|
80
|
+
const { kind, key } = STORAGE_KEYS.locale;
|
|
81
|
+
return writeStorage(kind, key, value);
|
|
82
|
+
}
|
package/templates/index.html
CHANGED
|
@@ -2,35 +2,28 @@
|
|
|
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">
|
|
18
|
-
<
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
"@type": "Organization",
|
|
22
|
-
"name": "<%= site %>",
|
|
23
|
-
"url": "<%= siteUrl %>",
|
|
24
|
-
"logo": "<%= ABSOLUTE_LOGO %>",
|
|
25
|
-
"sameAs": <%- siteSameAsJson %>
|
|
26
|
-
}
|
|
27
|
-
</script>
|
|
13
|
+
<link rel="manifest" href="/manifest.json">
|
|
14
|
+
<meta name="theme-color" content="<%= manifestThemeColor %>">
|
|
15
|
+
<script type="application/ld+json"><%- organizationJsonLd %></script>
|
|
28
16
|
</head>
|
|
29
17
|
<!-- Google Analytics is loaded dynamically after user consent -->
|
|
30
18
|
<body>
|
|
31
|
-
<
|
|
32
|
-
<
|
|
33
|
-
|
|
19
|
+
<noscript>
|
|
20
|
+
<style>
|
|
21
|
+
/* Scroll-reveal targets stay opacity:0 until JS observes them. If JS
|
|
22
|
+
never runs, force them visible so SSG content remains accessible. */
|
|
23
|
+
.fade-up-in { opacity: 1 !important; transform: none !important; }
|
|
24
|
+
</style>
|
|
25
|
+
</noscript>
|
|
26
|
+
<div id="app"></div>
|
|
34
27
|
<script type="module" src="/src/main.js"></script>
|
|
35
28
|
</body>
|
|
36
29
|
</html>
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# cms-ext-__SLUG__
|
|
2
|
+
|
|
3
|
+
__DISPLAY_NAME__ extension for [`@koehler8/cms`](https://github.com/koehler8/cms).
|
|
4
|
+
|
|
5
|
+
## Status
|
|
6
|
+
|
|
7
|
+
Starter scaffold. Currently empty (`components: []`). Add components as the design takes shape.
|
|
8
|
+
|
|
9
|
+
## Activate
|
|
10
|
+
|
|
11
|
+
After scaffolding (`npx cms-create-extension __SLUG__`), wire it into your site:
|
|
12
|
+
|
|
13
|
+
```jsonc
|
|
14
|
+
// package.json
|
|
15
|
+
"dependencies": {
|
|
16
|
+
"cms-ext-__SLUG__": "file:./extensions/__SLUG__"
|
|
17
|
+
}
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
```js
|
|
21
|
+
// vite.config.js
|
|
22
|
+
plugins: cms({
|
|
23
|
+
siteDir: './site',
|
|
24
|
+
extensions: ['cms-ext-__SLUG__'],
|
|
25
|
+
}),
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Run `npm install`, then build/preview.
|
|
29
|
+
|
|
30
|
+
## Add a component
|
|
31
|
+
|
|
32
|
+
1. Create `components/Foo.vue`. Follow the standard CMS pattern:
|
|
33
|
+
- Read content via `inject('pageContent')`
|
|
34
|
+
- Use `<style scoped>`
|
|
35
|
+
- Consume theme tokens via `var(--brand-*)`
|
|
36
|
+
2. Add an entry to [extension.config.json](extension.config.json) `components[]` — `name`, `label`, `description`, `module`, `configKey`.
|
|
37
|
+
3. Add a lazy dynamic import to [index.js](index.js)'s `components` map keyed by the same module path.
|
|
38
|
+
4. Reference the component by name in any page's `components[]` array (e.g. `"components[N]": "Foo"`).
|
|
39
|
+
5. Put translations under `content.{configKey}.*` in `pages/{pageId}.json`.
|
|
40
|
+
|
|
41
|
+
## When to put a component here vs `site/components/`
|
|
42
|
+
|
|
43
|
+
- **`site/components/`** — one-off components specific to a single site. Auto-globbed; no extension wrapper needed. **Default.**
|
|
44
|
+
- **This extension** — components destined to be shared across multiple sites (e.g. eventually published to npm).
|
|
45
|
+
|
|
46
|
+
## Extracting to a standalone repo
|
|
47
|
+
|
|
48
|
+
Once the extension carries enough to be worth sharing:
|
|
49
|
+
|
|
50
|
+
1. Copy `extensions/__SLUG__/` to a new repo named `cms-ext-__SLUG__/`
|
|
51
|
+
2. Rename `name` in `package.json` to `@koehler8/cms-ext-__SLUG__` and add the standard repo metadata (homepage, bugs, repository) following the pattern of `@koehler8/cms-ext-compliance`
|
|
52
|
+
3. Publish to GitHub Packages / npm
|
|
53
|
+
4. Replace the `file:` dependency in each consuming site with the published version
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import manifest from './extension.config.json' with { type: 'json' };
|
|
2
|
+
|
|
3
|
+
// Components contributed by this extension (use lazy dynamic imports so
|
|
4
|
+
// Vite splits each into its own chunk). Key by the same module path
|
|
5
|
+
// declared in extension.config.json's components[].module.
|
|
6
|
+
//
|
|
7
|
+
// To add a new component:
|
|
8
|
+
// 1. Create components/Foo.vue.
|
|
9
|
+
// 2. Add a {name, label, description, module, configKey} entry to
|
|
10
|
+
// extension.config.json's components[] array.
|
|
11
|
+
// 3. Add a `'./components/Foo.vue': () => import('./components/Foo.vue')`
|
|
12
|
+
// entry to the map below.
|
|
13
|
+
const components = {};
|
|
14
|
+
|
|
15
|
+
// Optional setup() hook — runs once during app initialization with
|
|
16
|
+
// { app, router, pinia, siteData, isClient }. Use it for runtime
|
|
17
|
+
// concerns the manifest doesn't cover (e.g. mounting a banner Vue app
|
|
18
|
+
// to document.body, registering a Pinia store, etc.). Remove if unused.
|
|
19
|
+
//
|
|
20
|
+
// async function setup({ app, router, pinia, siteData, isClient }) {
|
|
21
|
+
// // ...
|
|
22
|
+
// }
|
|
23
|
+
|
|
24
|
+
// Optional contentDefaults — a frozen object keyed by `configKey` that
|
|
25
|
+
// the resolver merges underneath each component's content. Use it to
|
|
26
|
+
// ship sensible defaults so consumer sites only override what they
|
|
27
|
+
// want to change. Remove if unused.
|
|
28
|
+
//
|
|
29
|
+
// import contentDefaults from './content.defaults.json' with { type: 'json' };
|
|
30
|
+
|
|
31
|
+
export default {
|
|
32
|
+
manifest,
|
|
33
|
+
components,
|
|
34
|
+
// setup,
|
|
35
|
+
// contentDefaults,
|
|
36
|
+
};
|
|
37
|
+
export { manifest };
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "cms-ext-__SLUG__",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "__DISPLAY_NAME__ extension for @koehler8/cms.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "./index.js",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": "./index.js",
|
|
10
|
+
"./config": "./extension.config.json"
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"index.js",
|
|
14
|
+
"extension.config.json",
|
|
15
|
+
"components",
|
|
16
|
+
"README.md",
|
|
17
|
+
"LICENSE"
|
|
18
|
+
],
|
|
19
|
+
"peerDependencies": {
|
|
20
|
+
"@koehler8/cms": "^1.0.0"
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# Site token used by some extensions and the provisioning tooling (safe to
|
|
2
|
+
# leave as-is for a plain content site).
|
|
3
|
+
VITE_TOKEN=__SLUG__
|
|
4
|
+
|
|
5
|
+
# Optional: override the framework version string reported in the client
|
|
6
|
+
# bundle (defaults to the installed @koehler8/cms package version).
|
|
7
|
+
# VITE_APP_VERSION=
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
20.19.0
|