@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
|
@@ -3,8 +3,10 @@
|
|
|
3
3
|
* Handles storing and retrieving user consent for analytics tracking
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
|
|
7
|
-
|
|
6
|
+
import { STORAGE_KEYS, readStorage, writeStorage } from './webStorage.js';
|
|
7
|
+
|
|
8
|
+
const CONSENT = STORAGE_KEYS.consent;
|
|
9
|
+
const CONSENT_TIMESTAMP = STORAGE_KEYS.consentTimestamp;
|
|
8
10
|
const TRUTHY_ENV_VALUES = new Set(['1', 'true', 'yes', 'on']);
|
|
9
11
|
|
|
10
12
|
export const ConsentStatus = {
|
|
@@ -39,16 +41,11 @@ export function isCookieBannerEnabled() {
|
|
|
39
41
|
* @returns {string} One of ConsentStatus values
|
|
40
42
|
*/
|
|
41
43
|
export function getConsentStatus() {
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
return consent;
|
|
46
|
-
}
|
|
47
|
-
return ConsentStatus.PENDING;
|
|
48
|
-
} catch (error) {
|
|
49
|
-
console.warn('Unable to access localStorage for consent:', error);
|
|
50
|
-
return ConsentStatus.PENDING;
|
|
44
|
+
const consent = readStorage(CONSENT.kind, CONSENT.key, CONSENT.legacy);
|
|
45
|
+
if (consent === ConsentStatus.ACCEPTED || consent === ConsentStatus.DECLINED) {
|
|
46
|
+
return consent;
|
|
51
47
|
}
|
|
48
|
+
return ConsentStatus.PENDING;
|
|
52
49
|
}
|
|
53
50
|
|
|
54
51
|
/**
|
|
@@ -56,14 +53,17 @@ export function getConsentStatus() {
|
|
|
56
53
|
* @param {string} status - One of ConsentStatus values
|
|
57
54
|
*/
|
|
58
55
|
export function setConsentStatus(status) {
|
|
56
|
+
const saved = writeStorage(CONSENT.kind, CONSENT.key, status);
|
|
57
|
+
writeStorage(CONSENT_TIMESTAMP.kind, CONSENT_TIMESTAMP.key, new Date().toISOString());
|
|
58
|
+
if (!saved) {
|
|
59
|
+
console.warn('Unable to persist consent status (storage unavailable)');
|
|
60
|
+
}
|
|
61
|
+
|
|
59
62
|
try {
|
|
60
|
-
localStorage.setItem(CONSENT_KEY, status);
|
|
61
|
-
localStorage.setItem(CONSENT_TIMESTAMP_KEY, new Date().toISOString());
|
|
62
|
-
|
|
63
63
|
// Trigger custom event for other parts of the app to react
|
|
64
64
|
window.dispatchEvent(new CustomEvent('consentChanged', { detail: { status } }));
|
|
65
|
-
} catch
|
|
66
|
-
|
|
65
|
+
} catch {
|
|
66
|
+
/* no window (SSR) */
|
|
67
67
|
}
|
|
68
68
|
}
|
|
69
69
|
|
|
@@ -113,20 +113,34 @@ export function isConsentPending() {
|
|
|
113
113
|
}
|
|
114
114
|
|
|
115
115
|
/**
|
|
116
|
-
*
|
|
117
|
-
*
|
|
116
|
+
* Consent mode — how the PENDING state (visitor hasn't answered the banner
|
|
117
|
+
* yet) is treated:
|
|
118
118
|
*
|
|
119
|
-
*
|
|
120
|
-
*
|
|
121
|
-
*
|
|
122
|
-
*
|
|
123
|
-
*
|
|
119
|
+
* 'opt-out' (default): analytics load while consent is pending. Matches
|
|
120
|
+
* the common US posture; tracking starts immediately and stops if the
|
|
121
|
+
* visitor declines.
|
|
122
|
+
* 'opt-in': analytics stay OFF until the visitor explicitly accepts.
|
|
123
|
+
* Required posture under GDPR / the ePrivacy Directive for EU/EEA
|
|
124
|
+
* audiences — set `"analytics.consentMode": "opt-in"` in site.json.
|
|
124
125
|
*
|
|
126
|
+
* Configured once at boot from site config (see createCmsApp); ACCEPTED and
|
|
127
|
+
* DECLINED behave identically in both modes.
|
|
128
|
+
*/
|
|
129
|
+
let analyticsConsentMode = 'opt-out';
|
|
130
|
+
|
|
131
|
+
export function configureAnalyticsConsentMode(mode) {
|
|
132
|
+
analyticsConsentMode = mode === 'opt-in' ? 'opt-in' : 'opt-out';
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Determine if analytics should be active.
|
|
125
137
|
* @returns {boolean}
|
|
126
138
|
*/
|
|
127
139
|
export function shouldEnableAnalytics() {
|
|
128
140
|
const status = getConsentStatus();
|
|
129
|
-
|
|
141
|
+
if (status === ConsentStatus.ACCEPTED) return true;
|
|
142
|
+
if (status === ConsentStatus.DECLINED) return false;
|
|
143
|
+
return analyticsConsentMode === 'opt-out';
|
|
130
144
|
}
|
|
131
145
|
|
|
132
146
|
let analyticsLoadPromise;
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure helpers for "draft mode" — letting authors mark a whole site, a URL
|
|
3
|
+
* prefix, or a single page as not-yet-public so search engines and casual
|
|
4
|
+
* visitors stay out while content is being iterated on.
|
|
5
|
+
*
|
|
6
|
+
* Used by:
|
|
7
|
+
* - vite-plugin.js: emit Disallow lines in robots.txt, skip URLs in sitemap.xml
|
|
8
|
+
* - usePageMeta.js: inject <meta name="robots" content="noindex,nofollow">
|
|
9
|
+
* - useDraftGate.js: decide whether to render the password gate instead of
|
|
10
|
+
* the page contents
|
|
11
|
+
*
|
|
12
|
+
* Resolution rule (page wins both ways, then site, then prefixes):
|
|
13
|
+
* 1. pageData.draft === true → draft
|
|
14
|
+
* 2. pageData.draft === false → published (overrides site/prefix)
|
|
15
|
+
* 3. site.draft === true → draft
|
|
16
|
+
* 4. any draftPaths prefix matches → draft
|
|
17
|
+
* 5. otherwise → published
|
|
18
|
+
*
|
|
19
|
+
* Prefix match is path-segment-aware: "/blog" matches "/blog" and
|
|
20
|
+
* "/blog/2026/post" but NOT "/blog-archive". The literal prefix "/" matches
|
|
21
|
+
* only "/" itself; for whole-site draft, set site.draft = true instead.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
export function normalizeDraftPath(value) {
|
|
25
|
+
if (typeof value !== 'string') return null;
|
|
26
|
+
const trimmed = value.trim();
|
|
27
|
+
if (!trimmed) return null;
|
|
28
|
+
let p = trimmed.startsWith('/') ? trimmed : `/${trimmed}`;
|
|
29
|
+
p = p.replace(/\/+/g, '/').replace(/\/+$/, '');
|
|
30
|
+
return p || '/';
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function pathMatchesPrefix(prefix, path) {
|
|
34
|
+
const np = normalizeDraftPath(prefix);
|
|
35
|
+
const target = normalizeDraftPath(path);
|
|
36
|
+
if (!np || !target) return false;
|
|
37
|
+
if (np === target) return true;
|
|
38
|
+
if (np === '/') return false;
|
|
39
|
+
return target.startsWith(`${np}/`);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function isPathDraft(siteData, pagePath, pageData) {
|
|
43
|
+
if (pageData && typeof pageData === 'object') {
|
|
44
|
+
if (pageData.draft === true) return true;
|
|
45
|
+
if (pageData.draft === false) return false;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const site = siteData?.site;
|
|
49
|
+
if (!site || typeof site !== 'object') return false;
|
|
50
|
+
|
|
51
|
+
if (site.draft === true) return true;
|
|
52
|
+
|
|
53
|
+
const draftPaths = Array.isArray(site.draftPaths) ? site.draftPaths : [];
|
|
54
|
+
for (const prefix of draftPaths) {
|
|
55
|
+
if (pathMatchesPrefix(prefix, pagePath)) return true;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function getDraftPasswordHash(siteData) {
|
|
62
|
+
const raw = siteData?.site?.draftPasswordHash;
|
|
63
|
+
if (typeof raw !== 'string') return '';
|
|
64
|
+
return raw.trim().toLowerCase();
|
|
65
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { computed, ref, watch, unref } from 'vue';
|
|
2
2
|
import { resolveAsset } from './assetResolver.js';
|
|
3
|
+
import { getImageVariantWidths } from './loadConfig.js';
|
|
3
4
|
|
|
4
5
|
const DEFAULT_FORMATS = ['avif', 'webp'];
|
|
5
6
|
|
|
@@ -69,7 +70,14 @@ function resolveBestFallback(basePath, widthList, format) {
|
|
|
69
70
|
export function useResponsiveImage(relativePath, options = {}) {
|
|
70
71
|
const basePath = computed(() => normalizeBasePath(unref(relativePath)));
|
|
71
72
|
const widthInput = options.widths;
|
|
72
|
-
|
|
73
|
+
// Default to the build's resolved variant widths: components that hardcode
|
|
74
|
+
// their own lists drift from what the pipeline generates (missing widths
|
|
75
|
+
// silently vanish from the srcset), and omitting widths used to disable
|
|
76
|
+
// responsive images entirely.
|
|
77
|
+
const widths = computed(() => {
|
|
78
|
+
const provided = normalizeWidths(unref(widthInput));
|
|
79
|
+
return provided.length ? provided : normalizeWidths(getImageVariantWidths());
|
|
80
|
+
});
|
|
73
81
|
const fallbackFormat = computed(() => unref(options.fallbackFormat) || 'jpg');
|
|
74
82
|
const formats = computed(() => {
|
|
75
83
|
const provided = unref(options.formats);
|
|
@@ -0,0 +1,66 @@
|
|
|
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
|
+
// JSON.stringify does not escape `<`, so an author string containing
|
|
36
|
+
// "</script>" would terminate the JSON-LD script element early and dump the
|
|
37
|
+
// rest of the block as markup. `<` is the standard hardening — still
|
|
38
|
+
// valid JSON, inert in HTML.
|
|
39
|
+
export function toJsonLdString(value) {
|
|
40
|
+
return JSON.stringify(value).replace(/</g, '\\u003c');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function collectBlocks(input) {
|
|
44
|
+
if (!input) return [];
|
|
45
|
+
if (Array.isArray(input)) {
|
|
46
|
+
return input.map(normalizeBlock).filter(Boolean);
|
|
47
|
+
}
|
|
48
|
+
const single = normalizeBlock(input);
|
|
49
|
+
return single ? [single] : [];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function buildJsonLdScripts({ siteData, currentPage, isDraft, isNotFound } = {}) {
|
|
53
|
+
if (isDraft || isNotFound) return [];
|
|
54
|
+
|
|
55
|
+
const siteBlocks = collectBlocks(siteData?.site?.jsonld);
|
|
56
|
+
const pageBlocks = collectBlocks(currentPage?.jsonld);
|
|
57
|
+
|
|
58
|
+
const all = [...siteBlocks, ...pageBlocks];
|
|
59
|
+
if (all.length === 0) return [];
|
|
60
|
+
|
|
61
|
+
return all.map((block, i) => ({
|
|
62
|
+
type: 'application/ld+json',
|
|
63
|
+
innerHTML: toJsonLdString(block),
|
|
64
|
+
key: `jsonld-${block['@type'] || 'block'}-${i}`,
|
|
65
|
+
}));
|
|
66
|
+
}
|
package/src/utils/loadConfig.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { inflateFlatConfig } from './inflateFlatConfig.js';
|
|
2
2
|
import { unwrapDefault } from './unwrapDefault.js';
|
|
3
|
+
import { readStoredLocale } from './webStorage.js';
|
|
3
4
|
|
|
4
5
|
function normalizeLocaleInput(value) {
|
|
5
6
|
if (value === undefined || value === null) {
|
|
@@ -34,6 +35,65 @@ export function cloneConfig(value) {
|
|
|
34
35
|
return value;
|
|
35
36
|
}
|
|
36
37
|
|
|
38
|
+
// Reduce a resolved config's `pages` map to a single page, preserving `site`
|
|
39
|
+
// and `shared` (which every page needs globally). Used by the SSG pass when a
|
|
40
|
+
// site opts into `site.trimInitialState` so each pre-rendered page embeds only
|
|
41
|
+
// its own page config in `window.__INITIAL_STATE__` instead of the entire
|
|
42
|
+
// site's pages (which is byte-identical on every page and can dominate the
|
|
43
|
+
// page weight). Marks the result `pagesPartial: true` so the client knows the
|
|
44
|
+
// embedded config can't resolve other pages and must lazy-load the full config
|
|
45
|
+
// before in-SPA navigation (see usePageConfig.hydrateFromSyncCache). Returns
|
|
46
|
+
// the config untouched when the page id is absent — a safe no-op fallback so a
|
|
47
|
+
// mis-resolved route never trims away the page actually being rendered.
|
|
48
|
+
export function trimConfigToPage(config, pageId) {
|
|
49
|
+
if (!config || typeof config !== 'object' || Array.isArray(config)) return config;
|
|
50
|
+
if (!config.pages || typeof config.pages !== 'object') return config;
|
|
51
|
+
if (!pageId || !Object.prototype.hasOwnProperty.call(config.pages, pageId)) return config;
|
|
52
|
+
return { ...config, pages: { [pageId]: config.pages[pageId] }, pagesPartial: true };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Map a route being pre-rendered to its page id, for the SSG trim above.
|
|
56
|
+
// Strips a leading `/{locale}` segment (non-base locales render at
|
|
57
|
+
// `/{locale}/path`), normalizes trailing/duplicate slashes, and matches against
|
|
58
|
+
// each page's `path` (defaulting a path-less page to '/'). Returns the id only
|
|
59
|
+
// on an UNAMBIGUOUS single match — home, path-less, or colliding routes return
|
|
60
|
+
// null so the caller keeps the full config (a safe no-op). Pure/exported so the
|
|
61
|
+
// locale-strip + matching rules are unit-testable without a full SSG build.
|
|
62
|
+
export function resolvePageIdForRoute(config, routePath, localeParam) {
|
|
63
|
+
if (typeof routePath !== 'string' || !routePath) return null;
|
|
64
|
+
let target = routePath;
|
|
65
|
+
if (localeParam && typeof localeParam === 'string') {
|
|
66
|
+
const prefix = `/${localeParam}`;
|
|
67
|
+
if (target === prefix) target = '/';
|
|
68
|
+
else if (target.startsWith(`${prefix}/`)) target = target.slice(prefix.length);
|
|
69
|
+
}
|
|
70
|
+
const normalize = (p) =>
|
|
71
|
+
(typeof p === 'string' ? p : '/').replace(/\/+/g, '/').replace(/\/+$/, '') || '/';
|
|
72
|
+
target = normalize(target);
|
|
73
|
+
const pages = (config && config.pages) || {};
|
|
74
|
+
|
|
75
|
+
// The SSG pre-renders `/404` from a site's custom `pages/404.json` (mirrors
|
|
76
|
+
// usePageConfig.selectPage's `/404` → `404` mapping). That page is
|
|
77
|
+
// conventionally path-less, so it can't be matched by `path` below — map the
|
|
78
|
+
// route to it directly. It is also deliberately NOT treated as living at `/`
|
|
79
|
+
// (see the id === '404' guard in the matcher) so it never collides with
|
|
80
|
+
// `home` there — the collision that otherwise makes `/` ambiguous on any
|
|
81
|
+
// site shipping a custom 404 page, suppressing the home-page trim.
|
|
82
|
+
const notFound = pages['404'];
|
|
83
|
+
const notFoundPathless = notFound && !(typeof notFound.path === 'string' && notFound.path.trim());
|
|
84
|
+
if (target === '/404' && notFoundPathless) return '404';
|
|
85
|
+
|
|
86
|
+
const matches = Object.entries(pages).filter(([id, data]) => {
|
|
87
|
+
const hasPath = data && typeof data.path === 'string' && data.path.trim();
|
|
88
|
+
// A page with no explicit `path` conventionally lives at `/` (home). The
|
|
89
|
+
// SSG-only `404` page is the exception: it is served at `/404`, never `/`.
|
|
90
|
+
const pagePath = hasPath ? data.path : (id === '404' ? null : '/');
|
|
91
|
+
if (pagePath === null) return false;
|
|
92
|
+
return normalize(pagePath) === target;
|
|
93
|
+
});
|
|
94
|
+
return matches.length === 1 ? matches[0][0] : null;
|
|
95
|
+
}
|
|
96
|
+
|
|
37
97
|
export function mergeConfigTrees(target, source, options = {}) {
|
|
38
98
|
const { cloneTarget = false, skipEmpty = true } = options;
|
|
39
99
|
|
|
@@ -224,12 +284,8 @@ export function createConfigLoader(allModules) {
|
|
|
224
284
|
|
|
225
285
|
let locale = explicitLocale;
|
|
226
286
|
|
|
227
|
-
if (!locale
|
|
228
|
-
|
|
229
|
-
locale = normalizeLocaleInput(localStorage.getItem('locale')) || undefined;
|
|
230
|
-
} catch {
|
|
231
|
-
locale = undefined;
|
|
232
|
-
}
|
|
287
|
+
if (!locale) {
|
|
288
|
+
locale = normalizeLocaleInput(readStoredLocale()) || undefined;
|
|
233
289
|
}
|
|
234
290
|
|
|
235
291
|
const normalizedLocale = typeof locale === 'string' ? locale.toLowerCase() : undefined;
|
|
@@ -307,13 +363,57 @@ let _loadConfigData = async () => {
|
|
|
307
363
|
};
|
|
308
364
|
|
|
309
365
|
let _availableLocales = [];
|
|
366
|
+
let _baseLocale = '';
|
|
367
|
+
let _imageVariantWidths = [];
|
|
368
|
+
|
|
369
|
+
// Synchronous, client-side cache of the config the SSR/SSG pass already
|
|
370
|
+
// resolved. Warmed once before mount (see main.js, from the vite-ssg-
|
|
371
|
+
// serialized `initialState.siteConfig`) so the first client render can
|
|
372
|
+
// populate synchronously and match the prerendered DOM — eliminating the
|
|
373
|
+
// paint -> blank -> paint flash caused by config arriving asynchronously via
|
|
374
|
+
// lazy dynamic-import chunks. Keyed by normalized locale; SSR never reads it.
|
|
375
|
+
const _syncConfigCache = new Map();
|
|
376
|
+
|
|
377
|
+
function normalizeConfigCacheKey(locale) {
|
|
378
|
+
if (typeof locale === 'string') {
|
|
379
|
+
const trimmed = locale.trim().toLowerCase();
|
|
380
|
+
if (trimmed) return trimmed;
|
|
381
|
+
}
|
|
382
|
+
return 'default';
|
|
383
|
+
}
|
|
310
384
|
|
|
311
385
|
export function setConfigLoader(instance) {
|
|
312
386
|
if (!instance) return;
|
|
313
387
|
_loadConfigData = instance.loadConfigData;
|
|
314
388
|
_availableLocales = instance.availableLocales || [];
|
|
389
|
+
_baseLocale = typeof instance.baseLocale === 'string' ? instance.baseLocale : '';
|
|
390
|
+
_imageVariantWidths = Array.isArray(instance.imageVariantWidths) ? instance.imageVariantWidths : [];
|
|
391
|
+
_syncConfigCache.clear();
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// Store a resolved config for synchronous retrieval during the first client
|
|
395
|
+
// render. Pass the config exactly as the server resolved it (e.g. the
|
|
396
|
+
// serialized `initialState.siteConfig`) so `peekConfigSync` returns a value
|
|
397
|
+
// that matches the prerendered DOM.
|
|
398
|
+
export function primeConfigSync(locale, config) {
|
|
399
|
+
if (!config || typeof config !== 'object') return;
|
|
400
|
+
_syncConfigCache.set(normalizeConfigCacheKey(locale), config);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// Retrieve a primed config synchronously, or null on a miss. Client-only fast
|
|
404
|
+
// path used by usePageConfig; a miss falls back to the async loader.
|
|
405
|
+
export function peekConfigSync(locale) {
|
|
406
|
+
return _syncConfigCache.get(normalizeConfigCacheKey(locale)) || null;
|
|
315
407
|
}
|
|
316
408
|
|
|
317
409
|
export { _availableLocales as availableLocales };
|
|
410
|
+
export { _baseLocale as baseLocale };
|
|
411
|
+
|
|
412
|
+
// The resolved image-variant width matrix from the build (site.imageVariants
|
|
413
|
+
// .widths or the pipeline default). useResponsiveImage defaults to this so
|
|
414
|
+
// srcsets only ever reference widths the pipeline actually generates.
|
|
415
|
+
export function getImageVariantWidths() {
|
|
416
|
+
return _imageVariantWidths;
|
|
417
|
+
}
|
|
318
418
|
|
|
319
419
|
export const loadConfigData = (...args) => _loadConfigData(...args);
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Build robots.txt body from the inflated site config.
|
|
3
|
+
*
|
|
4
|
+
* Replaces the static cms/public/robots.txt — the plugin writes this string
|
|
5
|
+
* to siteRoot/public/robots.txt at config time so per-site draft state
|
|
6
|
+
* regenerates the policy on every build.
|
|
7
|
+
*
|
|
8
|
+
* Compliance pages (/privacy, /terms, /cookies) are intentionally NOT
|
|
9
|
+
* disallowed: they are public, footer-linked, emit correct canonicals, and
|
|
10
|
+
* appear in sitemap.xml — disallowing them contradicts the sitemap and triggers
|
|
11
|
+
* "Blocked by robots.txt" (pages-in-a-sitemap) errors in Search Console.
|
|
12
|
+
*
|
|
13
|
+
* Draft pages and site.draftPaths prefixes are deliberately NOT disallowed
|
|
14
|
+
* here. robots.txt is public, so a per-path Disallow line is itself the
|
|
15
|
+
* discovery vector for an otherwise-unlinked draft URL — and a disallowed URL
|
|
16
|
+
* cannot be crawled, so Google never sees the page's noindex meta and can
|
|
17
|
+
* still index it URL-only from an external link ("Indexed, though blocked by
|
|
18
|
+
* robots.txt"). Draft privacy is enforced where it actually works: the
|
|
19
|
+
* password gate (SSG HTML on disk contains only the gate), the per-page
|
|
20
|
+
* <meta name="robots" content="noindex, nofollow"> from usePageMeta, and
|
|
21
|
+
* omission from sitemap.xml. Only the site-wide draft state emits a blanket
|
|
22
|
+
* "Disallow: /" — a pre-launch site has no individual URLs to disclose.
|
|
23
|
+
*
|
|
24
|
+
* site.robots.allowAiCrawlers (opt-in, default off): appends a dedicated block
|
|
25
|
+
* per known AI/LLM crawler (GPTBot, ClaudeBot, PerplexityBot, CCBot, etc.),
|
|
26
|
+
* each carrying the exact same Disallow list as the wildcard block — i.e.
|
|
27
|
+
* identical effective access, just declared explicitly per bot. Several sites
|
|
28
|
+
* were hand-editing this list directly into public/robots.txt; since that file
|
|
29
|
+
* is regenerated on every build, the hand edits were silently reverted on the
|
|
30
|
+
* next `build:ssg`/`generate:public-assets` run. This option makes the
|
|
31
|
+
* customization durable. Skipped entirely when site.draft === true — a
|
|
32
|
+
* pre-launch site shouldn't explicitly invite crawlers.
|
|
33
|
+
*
|
|
34
|
+
* If sitemapUrl is provided, appends a "Sitemap: <url>" line.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
// No framework-default disallows: every framework route is either public
|
|
38
|
+
// (pages, locales) or non-indexable by construction (/404 emits noindex).
|
|
39
|
+
const FRAMEWORK_DEFAULTS = [];
|
|
40
|
+
|
|
41
|
+
// Order/grouping mirrors the hand-rolled lists sites had converged on before
|
|
42
|
+
// this became a config option.
|
|
43
|
+
const AI_CRAWLER_GROUPS = [
|
|
44
|
+
{ comment: 'OpenAI / ChatGPT', agents: ['GPTBot', 'OAI-SearchBot', 'ChatGPT-User'] },
|
|
45
|
+
{ comment: 'Anthropic / Claude', agents: ['anthropic-ai', 'ClaudeBot'] },
|
|
46
|
+
{ comment: 'Perplexity', agents: ['PerplexityBot'] },
|
|
47
|
+
{ comment: 'Common Crawl (used by many LLM training datasets)', agents: ['CCBot'] },
|
|
48
|
+
{ comment: 'Google', agents: ['Googlebot', 'Googlebot-Extended'] },
|
|
49
|
+
{ comment: 'Microsoft / Bing', agents: ['Bingbot'] },
|
|
50
|
+
{ comment: 'Meta', agents: ['FacebookBot'] },
|
|
51
|
+
{ comment: 'Diffbot', agents: ['Diffbot'] },
|
|
52
|
+
];
|
|
53
|
+
|
|
54
|
+
export function buildRobotsTxt(siteConfig, sitemapUrl = '') {
|
|
55
|
+
const site = siteConfig?.site || {};
|
|
56
|
+
|
|
57
|
+
const disallows = [...FRAMEWORK_DEFAULTS];
|
|
58
|
+
|
|
59
|
+
if (site.draft === true) {
|
|
60
|
+
disallows.push('/');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const lines = ['User-agent: *'];
|
|
64
|
+
if (disallows.length === 0) {
|
|
65
|
+
// A UA record needs a Disallow field; the empty value means "allow all".
|
|
66
|
+
lines.push('Disallow:');
|
|
67
|
+
}
|
|
68
|
+
for (const d of disallows) {
|
|
69
|
+
lines.push(`Disallow: ${d}`);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (site.robots?.allowAiCrawlers === true && site.draft !== true) {
|
|
73
|
+
for (const group of AI_CRAWLER_GROUPS) {
|
|
74
|
+
lines.push('');
|
|
75
|
+
lines.push(`# ${group.comment}`);
|
|
76
|
+
for (const agent of group.agents) {
|
|
77
|
+
lines.push(`User-agent: ${agent}`);
|
|
78
|
+
if (disallows.length === 0) {
|
|
79
|
+
lines.push('Disallow:');
|
|
80
|
+
}
|
|
81
|
+
for (const d of disallows) {
|
|
82
|
+
lines.push(`Disallow: ${d}`);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const trimmedSitemap = (sitemapUrl || '').trim();
|
|
89
|
+
if (trimmedSitemap) {
|
|
90
|
+
lines.push('');
|
|
91
|
+
lines.push(`Sitemap: ${trimmedSitemap}`);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return `${lines.join('\n')}\n`;
|
|
95
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SHA-256 hex digest via Web Crypto API.
|
|
3
|
+
*
|
|
4
|
+
* Available in Node 19+ (globalThis.crypto.subtle), modern browsers, and
|
|
5
|
+
* happy-dom test environment (which inherits Node's globalThis.crypto). The
|
|
6
|
+
* CMS targets Node 20.19, so a single Web Crypto path is sufficient.
|
|
7
|
+
*
|
|
8
|
+
* Used by the draft gate: site.draftPassword is hashed at build time (in
|
|
9
|
+
* the Vite plugin) so the plaintext never reaches the bundle. At runtime,
|
|
10
|
+
* the user's input is hashed and compared against the stored hash.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export async function sha256Hex(input) {
|
|
14
|
+
const text = typeof input === 'string' ? input : String(input ?? '');
|
|
15
|
+
const bytes = new TextEncoder().encode(text);
|
|
16
|
+
const hashBuffer = await globalThis.crypto.subtle.digest('SHA-256', bytes);
|
|
17
|
+
return Array.from(new Uint8Array(hashBuffer))
|
|
18
|
+
.map((b) => b.toString(16).padStart(2, '0'))
|
|
19
|
+
.join('');
|
|
20
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Build sitemap.xml from the inflated site config.
|
|
3
|
+
*
|
|
4
|
+
* Pages where isPathDraft() is true are skipped — drafts must not be in the
|
|
5
|
+
* sitemap. Returns '' when site.url is missing (the sitemap protocol requires
|
|
6
|
+
* absolute URLs); the plugin treats empty output as "do not write the file."
|
|
7
|
+
*
|
|
8
|
+
* Single-locale sites emit only <loc> per page. Multi-locale sites emit one
|
|
9
|
+
* <url> per page × locale — every localized URL is a first-class sitemap
|
|
10
|
+
* entry (Google's multilingual guidance) — each carrying the full
|
|
11
|
+
* <xhtml:link rel="alternate" hreflang="..." /> cluster plus an `x-default`
|
|
12
|
+
* pointing at the base-locale URL. The single-locale output stays
|
|
13
|
+
* byte-identical to pre-hreflang.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { isPathDraft, normalizeDraftPath } from './draftMode.js';
|
|
17
|
+
import { buildCanonicalUrl } from './canonicalUrl.js';
|
|
18
|
+
|
|
19
|
+
function escapeXml(value) {
|
|
20
|
+
return String(value).replace(/[<>&'"]/g, (c) => ({
|
|
21
|
+
'<': '<',
|
|
22
|
+
'>': '>',
|
|
23
|
+
'&': '&',
|
|
24
|
+
"'": ''',
|
|
25
|
+
'"': '"',
|
|
26
|
+
}[c]));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function buildSitemap(siteConfig, options = {}) {
|
|
30
|
+
const siteUrl = (siteConfig?.site?.url || '').trim().replace(/\/+$/, '');
|
|
31
|
+
if (!siteUrl) return '';
|
|
32
|
+
const trailingSlash = siteConfig?.site?.trailingSlash === true;
|
|
33
|
+
|
|
34
|
+
// Whole-site draft → no sitemap. robots.txt emits "Disallow: /" in this
|
|
35
|
+
// state, and listing URLs in the sitemap that the same robots blocks
|
|
36
|
+
// would be inconsistent (and would cause crawl-error noise in Search
|
|
37
|
+
// Console). Page-level draft:false in a fully-draft site only affects
|
|
38
|
+
// the runtime gate; it is not advertised to crawlers.
|
|
39
|
+
if (siteConfig?.site?.draft === true) return '';
|
|
40
|
+
|
|
41
|
+
const baseLocale = typeof options.baseLocale === 'string' ? options.baseLocale.toLowerCase() : '';
|
|
42
|
+
const availableLocales = Array.isArray(options.availableLocales)
|
|
43
|
+
? options.availableLocales.map((l) => (typeof l === 'string' ? l.toLowerCase() : '')).filter(Boolean)
|
|
44
|
+
: [];
|
|
45
|
+
const isMultiLocale = availableLocales.length > 1;
|
|
46
|
+
|
|
47
|
+
const pages = siteConfig?.pages || {};
|
|
48
|
+
const entries = [];
|
|
49
|
+
const seenPaths = new Set();
|
|
50
|
+
|
|
51
|
+
for (const [, pageData] of Object.entries(pages)) {
|
|
52
|
+
if (!pageData || typeof pageData !== 'object') continue;
|
|
53
|
+
const pagePath = normalizeDraftPath(pageData.path) || '/';
|
|
54
|
+
if (isPathDraft(siteConfig, pagePath, pageData)) continue;
|
|
55
|
+
if (seenPaths.has(pagePath)) continue;
|
|
56
|
+
seenPaths.add(pagePath);
|
|
57
|
+
|
|
58
|
+
const baseUrl = buildCanonicalUrl({ siteUrl, baseLocale, locale: baseLocale, path: pagePath, trailingSlash });
|
|
59
|
+
if (!baseUrl) continue;
|
|
60
|
+
|
|
61
|
+
if (!isMultiLocale) {
|
|
62
|
+
entries.push({ loc: baseUrl, alternates: [] });
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Every locale version is its own <url> entry; the alternate cluster is
|
|
67
|
+
// identical across them (that's the spec — each entry lists all of its
|
|
68
|
+
// language versions, including itself).
|
|
69
|
+
const alternates = availableLocales.map((loc) => ({
|
|
70
|
+
hreflang: loc,
|
|
71
|
+
href: buildCanonicalUrl({ siteUrl, baseLocale, locale: loc, path: pagePath, trailingSlash }),
|
|
72
|
+
}));
|
|
73
|
+
alternates.push({ hreflang: 'x-default', href: baseUrl });
|
|
74
|
+
|
|
75
|
+
for (const loc of availableLocales) {
|
|
76
|
+
const localizedUrl = buildCanonicalUrl({ siteUrl, baseLocale, locale: loc, path: pagePath, trailingSlash });
|
|
77
|
+
if (localizedUrl) entries.push({ loc: localizedUrl, alternates });
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (entries.length === 0) return '';
|
|
82
|
+
|
|
83
|
+
entries.sort((a, b) => a.loc.localeCompare(b.loc));
|
|
84
|
+
|
|
85
|
+
const urlsetAttrs = isMultiLocale
|
|
86
|
+
? 'xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml"'
|
|
87
|
+
: 'xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"';
|
|
88
|
+
|
|
89
|
+
const renderUrlEntry = (entry) => {
|
|
90
|
+
if (entry.alternates.length === 0) {
|
|
91
|
+
return ` <url><loc>${escapeXml(entry.loc)}</loc></url>`;
|
|
92
|
+
}
|
|
93
|
+
const altLines = entry.alternates
|
|
94
|
+
.map((a) => ` <xhtml:link rel="alternate" hreflang="${escapeXml(a.hreflang)}" href="${escapeXml(a.href)}"/>`)
|
|
95
|
+
.join('\n');
|
|
96
|
+
return ` <url>\n <loc>${escapeXml(entry.loc)}</loc>\n${altLines}\n </url>`;
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
const urlEntries = entries.map(renderUrlEntry).join('\n');
|
|
100
|
+
|
|
101
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
102
|
+
<urlset ${urlsetAttrs}>
|
|
103
|
+
${urlEntries}
|
|
104
|
+
</urlset>
|
|
105
|
+
`;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function getSitemapUrl(siteConfig) {
|
|
109
|
+
const siteUrl = (siteConfig?.site?.url || '').trim().replace(/\/+$/, '');
|
|
110
|
+
if (!siteUrl) return '';
|
|
111
|
+
return `${siteUrl}/sitemap.xml`;
|
|
112
|
+
}
|