@brightsideoy/kama 0.1.0-alpha.12 → 0.1.0-alpha.121

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 (86) hide show
  1. package/README.md +195 -0
  2. package/bin/cli.js +602 -0
  3. package/content/definitions/block-layouts/three-column.yaml +20 -0
  4. package/content/definitions/block-layouts/two-column.yaml +29 -0
  5. package/content/definitions/blocks/button.yaml +24 -0
  6. package/content/definitions/blocks/header.yaml +13 -0
  7. package/content/definitions/blocks/header_image.yaml +21 -0
  8. package/content/definitions/blocks/hero.yaml +28 -0
  9. package/content/definitions/blocks/html.yaml +12 -0
  10. package/content/definitions/blocks/image_card.yaml +56 -0
  11. package/content/definitions/blocks/single_image.yaml +27 -0
  12. package/content/definitions/blocks/text.yaml +15 -0
  13. package/content/definitions/channels/ecommerce.yaml +28 -0
  14. package/content/definitions/channels/website.yaml +17 -0
  15. package/content/definitions/field-sets/access.yaml +27 -0
  16. package/content/definitions/field-sets/channel-base.yaml +50 -0
  17. package/content/definitions/field-sets/channel-seo.yaml +21 -0
  18. package/content/definitions/field-sets/comments.yaml +11 -0
  19. package/content/definitions/field-sets/content-block.yaml +41 -0
  20. package/content/definitions/field-sets/page-base.yaml +44 -0
  21. package/content/definitions/field-sets/page-seo.yaml +34 -0
  22. package/content/definitions/field-sets/page-social.yaml +78 -0
  23. package/content/definitions/field-sets/schema-type.yaml +43 -0
  24. package/content/definitions/field-sets/tree-base.yaml +31 -0
  25. package/content/definitions/field-sets/visibility.yaml +8 -0
  26. package/content/definitions/node-layouts/content.yml +12 -0
  27. package/content/definitions/node-layouts/landing-page.yml +25 -0
  28. package/content/definitions/node-layouts/single-column.yml +44 -0
  29. package/content/definitions/node-layouts/two-column.yml +49 -0
  30. package/content/definitions/nodes/address.yaml +40 -0
  31. package/content/definitions/nodes/article.yaml +48 -0
  32. package/content/definitions/nodes/event.yaml +55 -0
  33. package/content/definitions/nodes/external-link.yaml +29 -0
  34. package/content/definitions/nodes/page-skeleton.yaml +48 -0
  35. package/content/definitions/nodes/page.yaml +57 -0
  36. package/content/definitions/nodes/person.yaml +41 -0
  37. package/content/definitions/nodes/product.yaml +89 -0
  38. package/content/definitions/trees/navigation.yaml +24 -0
  39. package/definitions/editor/button.ts +11 -0
  40. package/definitions/editor/default.ts +21 -0
  41. package/definitions/editor/header.ts +36 -0
  42. package/definitions/editor/minimal.ts +15 -0
  43. package/definitions/validators/iban.ts +12 -0
  44. package/definitions/validators/magic-word.ts +7 -0
  45. package/dist/assets/index-C6GoXL-R.js +162 -0
  46. package/dist/assets/index-Drmp3h6P.css +1 -0
  47. package/dist/index.html +13 -0
  48. package/dist/index.js +12209 -2045
  49. package/documentation/SCHEMAS.md +182 -0
  50. package/package.json +14 -11
  51. package/schema.sql +204 -0
  52. package/templates/astro-starter/astro.config.mjs +40 -0
  53. package/templates/astro-starter/middleware/api-purge.ts +130 -0
  54. package/templates/astro-starter/middleware/index.ts +10 -0
  55. package/templates/astro-starter/middleware/kv-cache.ts +76 -0
  56. package/templates/astro-starter/middleware/response-headers.ts +43 -0
  57. package/templates/astro-starter/package.json.template +20 -0
  58. package/templates/astro-starter/public/css/standard-theme.css +157 -0
  59. package/templates/astro-starter/public/robots.txt +23 -0
  60. package/templates/astro-starter/src/components/blocks/BlockRenderer.astro +133 -0
  61. package/templates/astro-starter/src/components/blocks/ColumnLayout.astro +66 -0
  62. package/templates/astro-starter/src/components/blocks/HeaderImage.astro +131 -0
  63. package/templates/astro-starter/src/components/blocks/Hero.astro +218 -0
  64. package/templates/astro-starter/src/components/blocks/HtmlBlock.astro +24 -0
  65. package/templates/astro-starter/src/components/blocks/ImageBlock.astro +160 -0
  66. package/templates/astro-starter/src/components/blocks/TextBlock.astro +13 -0
  67. package/templates/astro-starter/src/components/global/Breadcrumb.astro +62 -0
  68. package/templates/astro-starter/src/components/global/Footer.astro +48 -0
  69. package/templates/astro-starter/src/components/global/Header.astro +92 -0
  70. package/templates/astro-starter/src/components/global/NavigationMenu.astro +450 -0
  71. package/templates/astro-starter/src/components/seo/SeoHead.astro +150 -0
  72. package/templates/astro-starter/src/components/ui/KamaImage.astro +149 -0
  73. package/templates/astro-starter/src/layouts/SkeletonLayout.astro +45 -0
  74. package/templates/astro-starter/src/layouts/StandardLayout.astro +52 -0
  75. package/templates/astro-starter/src/lib/cms.ts +149 -0
  76. package/templates/astro-starter/src/lib/media/kama-image-service.ts +41 -0
  77. package/templates/astro-starter/src/lib/media/media-proxy.ts +127 -0
  78. package/templates/astro-starter/src/lib/media/media-url.ts +34 -0
  79. package/templates/astro-starter/src/pages/404.astro +46 -0
  80. package/templates/astro-starter/src/pages/[...slug].astro +201 -0
  81. package/templates/astro-starter/src/pages/llms-full.txt.ts +161 -0
  82. package/templates/astro-starter/src/pages/llms.txt.ts +107 -0
  83. package/templates/astro-starter/src/pages/media/[...path].ts +7 -0
  84. package/templates/astro-starter/src/utils/tiptap.ts +113 -0
  85. package/templates/astro-starter/tsconfig.json +0 -0
  86. package/test-local.sh +76 -0
@@ -0,0 +1,76 @@
1
+ import { defineMiddleware } from "astro:middleware";
2
+ import { env } from "cloudflare:workers";
3
+
4
+ export const kvCacheMiddleware = defineMiddleware(async (context, next) => {
5
+ const pathname = context.url.pathname;
6
+ const locals = context.locals as any;
7
+ const cfContext = locals.cfContext;
8
+ const workerEnv = (typeof env !== "undefined" ? env : (process?.env || {})) as any;
9
+ const HTML_CACHE = workerEnv?.HTML_CACHE;
10
+
11
+ const isWarmer = context.request.headers.get("X-Cache-Warmer") === "true";
12
+ const isHtmlPage = !pathname.includes('.') && !pathname.startsWith('/api/');
13
+ const cleanLookupPath = pathname.replace(/\/$/, "") || "/";
14
+
15
+ let activeCacheKey = cleanLookupPath;
16
+
17
+ // 🎯 1. READ PHASE
18
+ if (isHtmlPage && HTML_CACHE && !isWarmer) {
19
+ try {
20
+ const [siteVer, pageVer] = await Promise.all([
21
+ HTML_CACHE.get("site_version"),
22
+ HTML_CACHE.get(`v:${cleanLookupPath}`)
23
+ ]);
24
+
25
+ activeCacheKey = `${cleanLookupPath}:v:${siteVer || "1"}:${pageVer || "1"}`;
26
+
27
+ const cachedHtml = await HTML_CACHE.get(activeCacheKey);
28
+ if (cachedHtml) {
29
+ return new Response(cachedHtml, {
30
+ status: 200,
31
+ headers: {
32
+ "Content-Type": "text/html; charset=utf-8",
33
+ "X-Cache-Status": "HIT-KV-STATICFILE",
34
+ "X-Cache-Key": activeCacheKey,
35
+ "Cache-Control": "no-store, no-cache, must-revalidate, proxy-revalidate"
36
+ }
37
+ });
38
+ }
39
+ } catch (e) {
40
+ console.warn("[KV Cache] Lookup bypassed:", e);
41
+ }
42
+ }
43
+
44
+ // Process downstream SSR route
45
+ const response = await next();
46
+
47
+ // 🎯 2. WRITE PHASE
48
+ if (
49
+ isHtmlPage &&
50
+ HTML_CACHE &&
51
+ response.status === 200 &&
52
+ response.headers.get("content-type")?.includes("text/html")
53
+ ) {
54
+ const htmlText = await response.clone().text();
55
+
56
+ const writeTask = (async () => {
57
+ if (activeCacheKey === cleanLookupPath || isWarmer) {
58
+ const forcedVersion = Date.now().toString();
59
+ await HTML_CACHE.put(`v:${cleanLookupPath}`, forcedVersion);
60
+ const siteVer = await HTML_CACHE.get("site_version") || "1";
61
+ activeCacheKey = `${cleanLookupPath}:v:${siteVer}:${forcedVersion}`;
62
+ }
63
+
64
+ await HTML_CACHE.put(activeCacheKey, htmlText);
65
+ console.log(`✅ [KV Cache] Saved HTML under key: ${activeCacheKey}`);
66
+ })();
67
+
68
+ if (cfContext && typeof cfContext.waitUntil === 'function') {
69
+ cfContext.waitUntil(writeTask);
70
+ } else {
71
+ writeTask.catch((e: any) => console.error("KV write error:", e));
72
+ }
73
+ }
74
+
75
+ return response;
76
+ });
@@ -0,0 +1,43 @@
1
+ import { defineMiddleware } from "astro:middleware";
2
+
3
+ export const responseHeadersMiddleware = defineMiddleware(async (context, next) => {
4
+ // Process downstream routes first so we have the response to modify
5
+ const response = await next();
6
+ const pathname = context.url.pathname;
7
+
8
+ // 1. LLMs & XML Configs
9
+ if (pathname === "/llms.txt" || pathname === "/llms-full.txt") {
10
+ response.headers.set("Content-Type", "text/plain; charset=utf-8");
11
+ response.headers.set("Cache-Control", "public, max-age=3600");
12
+ response.headers.set("Access-Control-Allow-Origin", "*");
13
+ }
14
+ else if (pathname === "/llxml.txt") {
15
+ response.headers.set("Content-Type", "application/xml; charset=utf-8");
16
+ response.headers.set("Cache-Control", "public, max-age=3600");
17
+ response.headers.set("Access-Control-Allow-Origin", "*");
18
+ }
19
+
20
+ // 2. Static Asset Caching
21
+ else if (
22
+ pathname.startsWith("/media/") ||
23
+ pathname.startsWith("/_image") ||
24
+ pathname.match(/\.(png|jpg|jpeg|webp|avif|svg|gif|ico|woff2?|css|js)$/i)
25
+ ) {
26
+ response.headers.set("Cache-Control", "public, max-age=31536000, s-maxage=31536000, immutable");
27
+ response.headers.set("Access-Control-Allow-Origin", "*");
28
+ }
29
+
30
+ // 3. Security Headers for HTML
31
+ if (response.headers.get("content-type")?.includes("text/html")) {
32
+ response.headers.set("Strict-Transport-Security", "max-age=31536000; includeSubDomains; preload");
33
+ response.headers.set("X-Frame-Options", "SAMEORIGIN");
34
+ response.headers.set("X-Content-Type-Options", "nosniff");
35
+ response.headers.set("Cross-Origin-Opener-Policy", "same-origin-allow-popups");
36
+ response.headers.set(
37
+ "Content-Security-Policy",
38
+ "default-src 'self' https: data: 'unsafe-inline' 'unsafe-eval'; img-src 'self' https: data: blob:; connect-src 'self' https:; frame-ancestors 'self';"
39
+ );
40
+ }
41
+
42
+ return response;
43
+ });
@@ -0,0 +1,20 @@
1
+ {
2
+ "name": "{{SITE_NAME}}",
3
+ "type": "module",
4
+ "version": "0.0.1",
5
+ "engines": {
6
+ "node": ">=22.12.0"
7
+ },
8
+ "scripts": {
9
+ "dev": "astro dev",
10
+ "build": "astro build",
11
+ "preview": "astro preview",
12
+ "deploy": "astro build && wrangler deploy"
13
+ },
14
+ "dependencies": {
15
+ "@astrojs/cloudflare": "^14.2.5",
16
+ "@brightsideoy/kama": "{{KAMA_VERSION}}",
17
+ "astro": "^7.2.0",
18
+ "wrangler": "^4.0.0"
19
+ }
20
+ }
@@ -0,0 +1,157 @@
1
+ /* =======================================
2
+ 1. GLOBAL RESETS & TYPOGRAPHY
3
+ ======================================= */
4
+ *,
5
+ *::before,
6
+ *::after {
7
+ box-sizing: border-box;
8
+ border-radius: 0 !important;
9
+ }
10
+
11
+ html {
12
+ font-size: 16px;
13
+ -webkit-text-size-adjust: 100%;
14
+ -webkit-font-smoothing: antialiased;
15
+ -moz-osx-font-smoothing: grayscale;
16
+ text-rendering: optimizeLegibility;
17
+ }
18
+
19
+ body {
20
+ margin: 0;
21
+ padding: 0;
22
+ font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
23
+ font-display: swap;
24
+ background-color: #ffffff;
25
+ color: #1e293b;
26
+ min-height: 100vh;
27
+ display: flex;
28
+ flex-direction: column;
29
+ line-height: 1.65;
30
+ letter-spacing: -0.011em;
31
+ }
32
+
33
+ h1,
34
+ h2,
35
+ h3,
36
+ h4,
37
+ h5,
38
+ h6 {
39
+ color: #0f172a;
40
+ font-weight: 700;
41
+ line-height: 1.25;
42
+ letter-spacing: -0.022em;
43
+ margin-top: 1.6em;
44
+ margin-bottom: 0.6em;
45
+ }
46
+
47
+ h1 {
48
+ font-size: 2.25rem;
49
+ }
50
+
51
+ h2 {
52
+ font-size: 1.65rem;
53
+ }
54
+
55
+ h3 {
56
+ font-size: 1.35rem;
57
+ }
58
+
59
+ p {
60
+ margin-top: 0;
61
+ margin-bottom: 1.25em;
62
+ color: #334155;
63
+ font-size: 1.03rem;
64
+ }
65
+
66
+ a {
67
+ color: #0284c7;
68
+ text-decoration-thickness: 1px;
69
+ text-underline-offset: 3px;
70
+ transition: color 0.15s ease;
71
+ }
72
+
73
+ a:hover {
74
+ color: #0369a1;
75
+ }
76
+
77
+ li>p:last-child {
78
+ margin-bottom: 0;
79
+ }
80
+
81
+ /* =======================================
82
+ 2. MAIN LAYOUT SHELL
83
+ ======================================= */
84
+ .content-block {
85
+ flex: 1;
86
+ padding: 40px 0 64px 0;
87
+ }
88
+
89
+ .content-container {
90
+ max-width: 1400px;
91
+ margin: 0 auto;
92
+ padding: 0 24px;
93
+ width: 100%;
94
+ box-sizing: border-box;
95
+ }
96
+
97
+ /* =======================================
98
+ 3. ROUTER REGIONS (from slug.astro)
99
+ ======================================= */
100
+ .region-header {
101
+ margin-bottom: 24px;
102
+ }
103
+
104
+ .breadcrumb-wrapper {
105
+ margin-bottom: 24px;
106
+ }
107
+
108
+ .content-layout {
109
+ width: 100%;
110
+ display: block;
111
+ }
112
+
113
+ .main-column {
114
+ width: 100%;
115
+ min-width: 0;
116
+ }
117
+
118
+ @media (min-width: 768px) {
119
+ .content-layout.has-sidebar {
120
+ display: grid !important;
121
+ grid-template-columns: calc(100% - 360px) 310px !important;
122
+ column-gap: 48px !important;
123
+ align-items: start !important;
124
+ }
125
+
126
+ .main-column {
127
+ grid-column: 1 / 2 !important;
128
+ }
129
+
130
+ .sidebar-column {
131
+ grid-column: 2 / 3 !important;
132
+ position: sticky;
133
+ top: 32px;
134
+ }
135
+ }
136
+
137
+ .sidebar-column {
138
+ background: #f8fafc;
139
+ border: 1px solid #e2e8f0;
140
+ padding: 28px;
141
+ box-sizing: border-box;
142
+ }
143
+
144
+ @media (max-width: 767px) {
145
+ .sidebar-column {
146
+ margin-top: 40px;
147
+ }
148
+ }
149
+
150
+ .empty-content {
151
+ padding: 32px;
152
+ border: 1px dashed #cbd5e1;
153
+ background: #f8fafc;
154
+ color: #64748b;
155
+ font-size: 0.95rem;
156
+ text-align: center;
157
+ }
@@ -0,0 +1,23 @@
1
+ # Allow standard search engine crawlers
2
+ User-agent: *
3
+ Allow: /
4
+
5
+ # Block sensitive or irrelevant paths
6
+ Disallow: /api/
7
+ Disallow: /*?*search=
8
+
9
+ # Explicitly block AI scrapers / Training Bots (Optional)
10
+ # User-agent: GPTBot
11
+ # Disallow: /
12
+
13
+ # User-agent: CCBot
14
+ # Disallow: /
15
+
16
+ # User-agent: Anthropic-ai
17
+ # Disallow: /
18
+
19
+ # User-agent: Google-Extended
20
+ # Disallow: /
21
+
22
+ # XML Sitemap location
23
+ # Sitemap: https://yourdomain.com/sitemap-index.xml
@@ -0,0 +1,133 @@
1
+ ---
2
+ // src/components/blocks/BlockRenderer.astro
3
+ import ImageBlock from "@/components/blocks/ImageBlock.astro";
4
+ import HeaderImage from "@/components/blocks/HeaderImage.astro";
5
+ import ColumnLayout from "@/components/blocks/ColumnLayout.astro";
6
+ import HtmlBlock from "@/components/blocks/HtmlBlock.astro";
7
+ import { extractHtml } from "@/utils/tiptap";
8
+
9
+ interface Block {
10
+ instanceId: number;
11
+ blockId?: string;
12
+ componentId: string;
13
+ data: Record<string, any>;
14
+ }
15
+
16
+ interface Props {
17
+ blocks?: Block[];
18
+ contentMap?: Record<string, any[]>;
19
+ region?: string;
20
+ }
21
+
22
+ const { blocks = [], contentMap = {}, region = "main" } = Astro.props;
23
+ const safeBlocks = Array.isArray(blocks) ? blocks : [];
24
+
25
+ const visibleBlocks = safeBlocks.filter(b => {
26
+ if (!b || (!b.data && !b.componentId?.includes("column"))) return false;
27
+
28
+ const blockData = b.data || {};
29
+ if (
30
+ blockData.hidden === true ||
31
+ blockData.hidden === 1 ||
32
+ String(blockData.hidden) === "true"
33
+ ) {
34
+ return false;
35
+ }
36
+
37
+ const now = new Date();
38
+ if (blockData.startShowingAt) {
39
+ const start = new Date(blockData.startShowingAt);
40
+ if (!isNaN(start.getTime()) && now < start) return false;
41
+ }
42
+ if (blockData.stopShowingAt) {
43
+ const stop = new Date(blockData.stopShowingAt);
44
+ if (!isNaN(stop.getTime()) && now > stop) return false;
45
+ }
46
+
47
+ return true;
48
+ });
49
+ ---
50
+
51
+ {
52
+ visibleBlocks.map((block, index) => {
53
+ const data = block.data || {};
54
+ const blockId = block.blockId || data.blockId || "";
55
+ const componentId = (block.componentId || "").toLowerCase();
56
+
57
+ // Check for any column layout ending in column or two-column
58
+ if (componentId.includes("column")) {
59
+ return <ColumnLayout blockId={blockId} contentMap={contentMap} />;
60
+ }
61
+
62
+ switch (componentId) {
63
+ case "header":
64
+ case "headline":
65
+ case "header-block":
66
+ case "header_block": {
67
+ const headline = extractHtml(data.headline || data.title);
68
+ const subhead = extractHtml(data.subhead || data.body);
69
+ return (
70
+ <header class="cms-block cms-block-header">
71
+ {headline && <Fragment set:html={headline} />}
72
+ {subhead && <Fragment set:html={subhead} />}
73
+ </header>
74
+ );
75
+ }
76
+
77
+ case "text":
78
+ case "text-block":
79
+ case "text_block": {
80
+ const bodyText = extractHtml(data.body || data.text || data.content);
81
+ return (
82
+ <div class="cms-block cms-block-text">
83
+ <Fragment set:html={bodyText} />
84
+ </div>
85
+ );
86
+ }
87
+
88
+ case "html":
89
+ case "html-block":
90
+ case "html_block":
91
+ case "raw-html": {
92
+ return <HtmlBlock data={data} />;
93
+ }
94
+
95
+ case "header-image":
96
+ case "header_image": {
97
+ return (
98
+ <HeaderImage data={data} region={region} isFirst={index === 0} />
99
+ );
100
+ }
101
+
102
+ case "image":
103
+ case "image-block":
104
+ case "image_block":
105
+ case "single_image":
106
+ case "single-image": {
107
+ return <ImageBlock data={data} region={region} isFirst={index === 0} />;
108
+ }
109
+
110
+ default:
111
+ return import.meta.env.DEV ? (
112
+ <div class="unhandled-block">
113
+ <p>Unhandled component: {block.componentId}</p>
114
+ </div>
115
+ ) : null;
116
+ }
117
+ })
118
+ }
119
+
120
+ <style>
121
+ .cms-block {
122
+ margin-bottom: 1.5rem;
123
+ }
124
+
125
+ .unhandled-block {
126
+ padding: 12px 16px;
127
+ border: 1px dashed #cbd5e1;
128
+ background: #f8fafc;
129
+ color: #64748b;
130
+ font-size: 13px;
131
+ margin-bottom: 1.5rem;
132
+ }
133
+ </style>
@@ -0,0 +1,66 @@
1
+ ---
2
+ // src/components/blocks/ColumnLayout.astro
3
+ import BlockRenderer from "@/components/blocks/BlockRenderer.astro";
4
+
5
+ interface Props {
6
+ blockId: string;
7
+ contentMap: Record<string, any[]>;
8
+ }
9
+
10
+ const { blockId, contentMap } = Astro.props;
11
+
12
+ const order = ["left", "middle", "right", "col1", "col2", "col3", "col4"];
13
+
14
+ // 1. Find all sub-regions belonging to this specific layout instance
15
+ const regionKeys = Object.keys(contentMap).filter(
16
+ key => blockId && key.startsWith(`${blockId}_`),
17
+ );
18
+
19
+ // 2. Sort keys logically (left -> middle -> right or col1 -> col2 -> col3)
20
+ const sortedKeys = regionKeys.sort((a, b) => {
21
+ const suffixA = a.replace(`${blockId}_`, "");
22
+ const suffixB = b.replace(`${blockId}_`, "");
23
+ const idxA = order.indexOf(suffixA);
24
+ const idxB = order.indexOf(suffixB);
25
+
26
+ if (idxA === -1 && idxB === -1) return a.localeCompare(b);
27
+ if (idxA === -1) return 1;
28
+ if (idxB === -1) return -1;
29
+ return idxA - idxB;
30
+ });
31
+
32
+ const columnCount = Math.max(sortedKeys.length, 1);
33
+ ---
34
+
35
+ <div class="cms-column-layout" style={`--col-count: ${columnCount}`}>
36
+ {
37
+ sortedKeys.map(key => {
38
+ const columnBlocks = contentMap[key] || [];
39
+ return (
40
+ <div class="column">
41
+ <!-- 🎯 Passes region={key} down to BlockRenderer -->
42
+ <BlockRenderer
43
+ blocks={columnBlocks}
44
+ contentMap={contentMap}
45
+ region={key}
46
+ />
47
+ </div>
48
+ );
49
+ })
50
+ }
51
+ </div>
52
+
53
+ <style>
54
+ .cms-column-layout {
55
+ display: grid;
56
+ gap: 2rem;
57
+ margin-bottom: 1.5rem;
58
+ grid-template-columns: 1fr;
59
+ }
60
+
61
+ @media (min-width: 768px) {
62
+ .cms-column-layout {
63
+ grid-template-columns: repeat(var(--col-count, 1), 1fr);
64
+ }
65
+ }
66
+ </style>
@@ -0,0 +1,131 @@
1
+ ---
2
+ // src/components/blocks/HeaderImage.astro
3
+ import KamaImage from "@/components/ui/KamaImage.astro";
4
+
5
+ interface Props {
6
+ data: {
7
+ image?: any;
8
+ image_meta?: any;
9
+ width?: number | string;
10
+ height?: number | string;
11
+ };
12
+ region?: string;
13
+ isFirst?: boolean;
14
+ }
15
+
16
+ const { data, region = "main", isFirst = false } = Astro.props;
17
+
18
+ const img = data?.image;
19
+
20
+ if (!img) {
21
+ return null;
22
+ }
23
+
24
+ /**
25
+ * Determine if this image is likely to be the Largest Contentful Paint.
26
+ */
27
+ const isLcp = region === "header" || (region === "main" && isFirst);
28
+
29
+ /**
30
+ * Determine grid column count from the region name.
31
+ */
32
+ let columnCount = 1;
33
+
34
+ if (region.includes("_")) {
35
+ columnCount = 2;
36
+
37
+ if (region.includes("_middle") || region.includes("_col3")) {
38
+ columnCount = 3;
39
+ }
40
+
41
+ if (region.includes("_col4")) {
42
+ columnCount = 4;
43
+ }
44
+ }
45
+
46
+ /**
47
+ * Default single-column layout.
48
+ */
49
+ let maxWidth = "1352";
50
+ let targetColumnWidth = 1352;
51
+
52
+ // 1-col: Base mobile (~400), 2x mobile/tablet (~800), 1x desktop (1352), 2x desktop (2704)
53
+ let optimizedWidths = [400, 800, 1352, 2048, 2704];
54
+ let sizes = "(max-width: 1399px) calc(100vw - 48px), 1352px";
55
+
56
+ /**
57
+ * Two-column layout.
58
+ */
59
+ if (columnCount === 2) {
60
+ maxWidth = "660";
61
+ targetColumnWidth = 660;
62
+
63
+ // 2-col: 1x desktop (660), 2x mobile stacked (~800), 2x desktop (1320)
64
+ optimizedWidths = [400, 660, 800, 1320];
65
+ sizes =
66
+ "(max-width: 767px) calc(100vw - 48px), " +
67
+ "(max-width: 1399px) calc((100vw - 80px) / 2), " +
68
+ "660px";
69
+ }
70
+
71
+ /**
72
+ * Three-column layout.
73
+ */
74
+ else if (columnCount === 3) {
75
+ maxWidth = "429";
76
+ targetColumnWidth = 429;
77
+
78
+ // 3-col: 1x desktop (429), 2x desktop/tablet (~858), 2x/3x mobile stacked (~1000)
79
+ optimizedWidths = [300, 429, 858, 1024];
80
+ sizes =
81
+ "(max-width: 767px) calc(100vw - 48px), " +
82
+ "(max-width: 1399px) calc((100vw - 112px) / 3), " +
83
+ "429px";
84
+ }
85
+
86
+ /**
87
+ * Four-column layout.
88
+ */
89
+ else if (columnCount === 4) {
90
+ maxWidth = "314";
91
+ targetColumnWidth = 314;
92
+
93
+ // 4-col: 1x desktop (314), 2x desktop (628), 2x/3x mobile stacked (~1000)
94
+ optimizedWidths = [264, 314, 628, 1024];
95
+ sizes =
96
+ "(max-width: 767px) calc(100vw - 48px), " +
97
+ "(max-width: 1399px) calc((100vw - 144px) / 4), " +
98
+ "314px";
99
+ }
100
+ ---
101
+
102
+ <div class="cms-block cms-block-header-image" style={`--max-w: ${maxWidth}px;`}>
103
+ <KamaImage
104
+ image={img}
105
+ sizes={sizes}
106
+ desktopWidths={optimizedWidths}
107
+ idealWidth={targetColumnWidth}
108
+ width={data.width || data.image_meta?.width}
109
+ height={data.height || data.image_meta?.height}
110
+ class="responsive-header-image"
111
+ loading={isLcp ? "eager" : "lazy"}
112
+ fetchpriority={isLcp ? "high" : "auto"}
113
+ decoding={isLcp ? "sync" : "async"}
114
+ />
115
+ </div>
116
+
117
+ <style>
118
+ .cms-block-header-image {
119
+ width: 100%;
120
+ max-width: var(--max-w, 1352px);
121
+ margin: 0 auto 1.5rem auto;
122
+ }
123
+
124
+ :global(.responsive-header-image) {
125
+ width: 100%;
126
+ height: auto;
127
+ display: block;
128
+ border-radius: var(--cms-radius-md, 0.5rem);
129
+ max-width: 100%;
130
+ }
131
+ </style>