@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.
Files changed (110) hide show
  1. package/CHANGELOG.md +1491 -0
  2. package/README.md +108 -28
  3. package/bin/cms-create-extension.js +7 -0
  4. package/bin/cms-create-site.js +7 -0
  5. package/bin/cms-create-theme.js +7 -0
  6. package/bin/cms-ssg-build.js +13 -0
  7. package/bin/cms-validate-extensions.js +4 -0
  8. package/bin/cms-validate-themes.js +11 -0
  9. package/extensions/manifest.schema.json +1 -1
  10. package/package.json +36 -16
  11. package/scripts/create-extension.mjs +11 -0
  12. package/scripts/create-site.mjs +11 -0
  13. package/scripts/create-theme.mjs +11 -0
  14. package/scripts/generate-public-assets.js +70 -36
  15. package/scripts/image-variants/cache.js +40 -0
  16. package/scripts/image-variants/classifier.js +53 -0
  17. package/scripts/image-variants/config.js +54 -0
  18. package/scripts/image-variants/index.js +18 -0
  19. package/scripts/image-variants/manifest.js +76 -0
  20. package/scripts/image-variants/planner.js +95 -0
  21. package/scripts/image-variants/reconcile.js +194 -0
  22. package/scripts/image-variants/renderer.js +51 -0
  23. package/scripts/lib/extension-manifests.mjs +70 -0
  24. package/scripts/scaffold.mjs +242 -0
  25. package/scripts/ssg-build/findEmptyRenders.mjs +42 -0
  26. package/scripts/ssg-build/index.mjs +253 -0
  27. package/scripts/validate-extensions.mjs +50 -382
  28. package/scripts/validate-themes.mjs +37 -5
  29. package/src/components/About.vue +4 -2
  30. package/src/components/AboutValue.vue +5 -4
  31. package/src/components/ComingSoon.vue +12 -1
  32. package/src/components/ComingSoonModal.vue +33 -61
  33. package/src/components/Contact.vue +216 -120
  34. package/src/components/DraftGate.vue +211 -0
  35. package/src/components/Footer.vue +15 -2
  36. package/src/components/FooterMinimal.vue +13 -12
  37. package/src/components/Header.vue +184 -10
  38. package/src/components/Hero.vue +17 -8
  39. package/src/components/Home.vue +81 -48
  40. package/src/components/Intro.vue +4 -2
  41. package/src/components/IntroGate.vue +30 -73
  42. package/src/components/NotFound.vue +100 -0
  43. package/src/components/Plan.vue +10 -4
  44. package/src/components/Portfolio.vue +29 -3
  45. package/src/components/Principles.vue +3 -1
  46. package/src/components/Spacer.vue +23 -0
  47. package/src/components/Spacer15.vue +3 -4
  48. package/src/components/Spacer30.vue +3 -4
  49. package/src/components/Spacer40.vue +3 -4
  50. package/src/components/Spacer60.vue +3 -4
  51. package/src/components/StickyCTA.vue +23 -1
  52. package/src/components/Team.vue +3 -1
  53. package/src/composables/useComponentResolver.js +49 -1
  54. package/src/composables/useDraftGate.js +93 -0
  55. package/src/composables/useFocusTrap.js +85 -0
  56. package/src/composables/usePageConfig.js +126 -28
  57. package/src/composables/usePageMeta.js +178 -5
  58. package/src/composables/usePromoBackgroundStyles.js +30 -14
  59. package/src/extensions/extensionLoader.js +34 -24
  60. package/src/main.js +69 -6
  61. package/src/router/index.js +98 -37
  62. package/src/styles/base.css +11 -2
  63. package/src/themes/buildCssVarMap.js +34 -0
  64. package/src/utils/appInfo.js +8 -5
  65. package/src/utils/breadcrumbs.js +121 -0
  66. package/src/utils/canonicalUrl.js +53 -0
  67. package/src/utils/componentRegistry.js +98 -16
  68. package/src/utils/cookieConsent.js +38 -24
  69. package/src/utils/draftMode.js +65 -0
  70. package/src/utils/imageSources.js +9 -1
  71. package/src/utils/jsonLd.js +66 -0
  72. package/src/utils/loadConfig.js +106 -6
  73. package/src/utils/robotsGenerator.js +95 -0
  74. package/src/utils/sha256.js +20 -0
  75. package/src/utils/sitemapGenerator.js +112 -0
  76. package/src/utils/socialMeta.js +100 -0
  77. package/src/utils/ssgRoutes.js +40 -0
  78. package/src/utils/trackingContext.js +16 -22
  79. package/src/utils/webAppManifest.js +85 -0
  80. package/src/utils/webStorage.js +82 -0
  81. package/templates/index.html +17 -24
  82. package/templates/scaffolds/extension/README.md +53 -0
  83. package/templates/scaffolds/extension/extension.config.json +10 -0
  84. package/templates/scaffolds/extension/index.js +37 -0
  85. package/templates/scaffolds/extension/package.json +22 -0
  86. package/templates/scaffolds/site/.env.example +7 -0
  87. package/templates/scaffolds/site/.nvmrc +1 -0
  88. package/templates/scaffolds/site/CLAUDE.md +121 -0
  89. package/templates/scaffolds/site/README.md +25 -0
  90. package/templates/scaffolds/site/amplify.yml +22 -0
  91. package/templates/scaffolds/site/package.json +26 -0
  92. package/templates/scaffolds/site/site/assets/img/.gitkeep +0 -0
  93. package/templates/scaffolds/site/site/components/.gitkeep +0 -0
  94. package/templates/scaffolds/site/site/content/content.config.json +3 -0
  95. package/templates/scaffolds/site/site/content/en/pages/home.json +8 -0
  96. package/templates/scaffolds/site/site/content/en/shared.json +5 -0
  97. package/templates/scaffolds/site/site/content/en/site.json +10 -0
  98. package/templates/scaffolds/site/site/style.css +4 -0
  99. package/templates/scaffolds/site/vite.config.js +15 -0
  100. package/templates/scaffolds/theme/README.md +50 -0
  101. package/templates/scaffolds/theme/index.js +8 -0
  102. package/templates/scaffolds/theme/package.json +21 -0
  103. package/templates/scaffolds/theme/theme.config.js +228 -0
  104. package/templates/scaffolds/theme/theme.css +14 -0
  105. package/themes/base/theme.config.js +100 -57
  106. package/vite-plugin.js +545 -32
  107. package/public/robots.txt +0 -5
  108. package/scripts/check-overflow.mjs +0 -33
  109. package/scripts/migrate-to-content-dirs.mjs +0 -223
  110. 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
- const CONSENT_KEY = 'cookie_consent';
7
- const CONSENT_TIMESTAMP_KEY = 'cookie_consent_timestamp';
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
- try {
43
- const consent = localStorage.getItem(CONSENT_KEY);
44
- if (consent === ConsentStatus.ACCEPTED || consent === ConsentStatus.DECLINED) {
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 (error) {
66
- console.error('Unable to save consent status:', error);
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
- * Determine if analytics should be active.
117
- * Pending consent defaults to enabled so tracking starts immediately.
116
+ * Consent mode — how the PENDING state (visitor hasn't answered the banner
117
+ * yet) is treated:
118
118
  *
119
- * NOTE: In EU/EEA jurisdictions, GDPR and the ePrivacy Directive require
120
- * affirmative consent before setting non-essential cookies or loading
121
- * tracking scripts. If the site targets EU users, consider changing the
122
- * PENDING case to return false so analytics remain disabled until the
123
- * user explicitly accepts.
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
- return status === ConsentStatus.ACCEPTED || status === ConsentStatus.PENDING;
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
- const widths = computed(() => normalizeWidths(unref(widthInput)));
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
+ }
@@ -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 && typeof localStorage !== 'undefined') {
228
- try {
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
+ '<': '&lt;',
22
+ '>': '&gt;',
23
+ '&': '&amp;',
24
+ "'": '&apos;',
25
+ '"': '&quot;',
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
+ }