@brightsideoy/kama 0.1.0-alpha.98 → 1.0.0-alpha.10

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 (119) hide show
  1. package/README.md +169 -0
  2. package/bin/cli.js +726 -261
  3. package/content/definitions/blocks/button.yaml +34 -0
  4. package/content/definitions/blocks/header.yaml +13 -0
  5. package/content/definitions/blocks/header_image.yaml +21 -0
  6. package/content/definitions/blocks/hero.yaml +28 -0
  7. package/content/definitions/blocks/html.yaml +13 -0
  8. package/content/definitions/blocks/image_card.yaml +56 -0
  9. package/content/definitions/blocks/node-list.yaml +61 -0
  10. package/content/definitions/blocks/single_image.yaml +27 -0
  11. package/content/definitions/blocks/text.yaml +15 -0
  12. package/content/definitions/channels/ecommerce.yaml +28 -0
  13. package/content/definitions/channels/website.yaml +41 -0
  14. package/{defaults → content}/definitions/field-sets/access.yaml +3 -5
  15. package/content/definitions/field-sets/channel-base.yaml +57 -0
  16. package/{defaults/definitions/field-sets/page-meta.yaml → content/definitions/field-sets/channel-seo.yaml} +3 -11
  17. package/content/definitions/field-sets/content-block.yaml +41 -0
  18. package/content/definitions/field-sets/page-base.yaml +44 -0
  19. package/content/definitions/field-sets/page-seo.yaml +34 -0
  20. package/{defaults → content}/definitions/field-sets/page-social.yaml +6 -8
  21. package/content/definitions/field-sets/schema-type.yaml +43 -0
  22. package/content/definitions/field-sets/tree-base.yaml +31 -0
  23. package/content/definitions/node-layouts/content.yml +12 -0
  24. package/{defaults → content}/definitions/node-layouts/landing-page.yml +8 -3
  25. package/{defaults → content}/definitions/node-layouts/single-column.yml +10 -5
  26. package/{defaults → content}/definitions/node-layouts/two-column.yml +14 -6
  27. package/content/definitions/nodes/address.yaml +40 -0
  28. package/content/definitions/nodes/article.yaml +65 -0
  29. package/content/definitions/nodes/event.yaml +55 -0
  30. package/content/definitions/nodes/external-link.yaml +29 -0
  31. package/content/definitions/nodes/page-skeleton.yaml +48 -0
  32. package/{defaults → content}/definitions/nodes/page.yaml +9 -20
  33. package/content/definitions/nodes/person.yaml +37 -0
  34. package/content/definitions/nodes/product.yaml +89 -0
  35. package/content/definitions/taxonomies/categories.yaml +15 -0
  36. package/content/definitions/taxonomies/tags.yaml +12 -0
  37. package/content/definitions/trees/navigation.yaml +24 -0
  38. package/definitions/editor/default.ts +1 -0
  39. package/dist/assets/index-Bzbme3Ob.css +1 -0
  40. package/dist/assets/index-DX98CiK-.js +164 -0
  41. package/dist/dist-CErcWOwp.js +27136 -0
  42. package/dist/index.html +4 -2
  43. package/dist/index.js +26159 -8575
  44. package/dist/schema-compiler.js +48 -0
  45. package/documentation/SCHEMAS.md +182 -0
  46. package/package.json +23 -25
  47. package/schema.sql +67 -23
  48. package/templates/astro-starter/astro.config.mjs +27 -0
  49. package/templates/astro-starter/package.json.template +20 -0
  50. package/templates/astro-starter/public/robots.txt +23 -0
  51. package/templates/astro-starter/src/components/blocks/BlockRenderer.astro +143 -0
  52. package/templates/astro-starter/src/components/blocks/ColumnLayout.astro +67 -0
  53. package/templates/astro-starter/src/components/blocks/HeaderImage.astro +126 -0
  54. package/templates/astro-starter/src/components/blocks/Hero.astro +218 -0
  55. package/templates/astro-starter/src/components/blocks/HtmlBlock.astro +25 -0
  56. package/templates/astro-starter/src/components/blocks/ImageBlock.astro +91 -0
  57. package/templates/astro-starter/src/components/blocks/NodeList.astro +278 -0
  58. package/templates/astro-starter/src/components/blocks/TextBlock.astro +15 -0
  59. package/templates/astro-starter/src/components/global/Breadcrumb.astro +108 -0
  60. package/templates/astro-starter/src/components/global/Footer.astro +50 -0
  61. package/templates/astro-starter/src/components/global/Header.astro +171 -0
  62. package/templates/astro-starter/src/components/global/MetaHead.astro +266 -0
  63. package/templates/astro-starter/src/components/global/NavigationMenu.astro +413 -0
  64. package/templates/astro-starter/src/components/ui/KamaImage.astro +180 -0
  65. package/templates/astro-starter/src/layouts/SkeletonLayout.astro +56 -0
  66. package/templates/astro-starter/src/layouts/StandardLayout.astro +63 -0
  67. package/templates/astro-starter/src/lib/cms.ts +219 -0
  68. package/templates/astro-starter/src/lib/media/kama-image-service.ts +67 -0
  69. package/templates/astro-starter/src/lib/media/media-proxy.ts +148 -0
  70. package/templates/astro-starter/src/lib/media/media-url.ts +112 -0
  71. package/templates/astro-starter/src/middleware.ts +508 -0
  72. package/templates/astro-starter/src/pages/404.astro +46 -0
  73. package/templates/astro-starter/src/pages/[...slug].astro +318 -0
  74. package/templates/astro-starter/src/pages/api/load-nodes.ts +40 -0
  75. package/templates/astro-starter/src/pages/llms-full.txt.ts +171 -0
  76. package/templates/astro-starter/src/pages/llms.txt.ts +113 -0
  77. package/templates/astro-starter/src/pages/media/[...path].ts +14 -0
  78. package/templates/astro-starter/src/pages/partials/footer.astro +69 -0
  79. package/templates/astro-starter/src/pages/partials/nav.astro +141 -0
  80. package/templates/astro-starter/src/styles/standard-theme.css +157 -0
  81. package/templates/astro-starter/src/utils/tiptap.ts +113 -0
  82. package/templates/astro-starter/tsconfig.json +0 -0
  83. package/templates/kama-worker/index.js +19 -0
  84. package/templates/kama-worker/server.js +104 -0
  85. package/wrangler.json +22 -0
  86. package/defaults/definitions/blocks/button.yaml +0 -15
  87. package/defaults/definitions/blocks/header.yaml +0 -15
  88. package/defaults/definitions/blocks/header_image.yaml +0 -22
  89. package/defaults/definitions/blocks/hero.yaml +0 -27
  90. package/defaults/definitions/blocks/image_card.yaml +0 -57
  91. package/defaults/definitions/blocks/single_image.yaml +0 -28
  92. package/defaults/definitions/blocks/text.yaml +0 -16
  93. package/defaults/definitions/channels/ecommerce.yaml +0 -24
  94. package/defaults/definitions/channels/website.yaml +0 -32
  95. package/defaults/definitions/field-sets/channel-base.yaml +0 -31
  96. package/defaults/definitions/field-sets/content-block.yaml +0 -46
  97. package/defaults/definitions/field-sets/page-base.yaml +0 -49
  98. package/defaults/definitions/field-sets/tree-base.yaml +0 -31
  99. package/defaults/definitions/node-layouts/three-column.yml +0 -41
  100. package/defaults/definitions/nodes/address.yaml +0 -32
  101. package/defaults/definitions/nodes/article.yaml +0 -43
  102. package/defaults/definitions/nodes/event.yaml +0 -49
  103. package/defaults/definitions/nodes/external-link.yaml +0 -26
  104. package/defaults/definitions/nodes/person.yaml +0 -37
  105. package/defaults/definitions/nodes/product.yaml +0 -86
  106. package/defaults/definitions/trees/navigation.yaml +0 -29
  107. package/defaults/definitions/trees/social-links.yaml +0 -9
  108. package/definitions/_page.yaml +0 -52
  109. package/dist/assets/index-TdW5F0pX.css +0 -1
  110. package/dist/assets/index-u9OkBauM.js +0 -1392
  111. package/dist/media/kama/2026/08/17a098c92bc54ea4b4441e531a34575b.jpg +0 -0
  112. package/dist/media/kama/2026/08/3e9ae457dd23461680cc699f42d07cf0.jpg +0 -0
  113. package/dist/media/kama/2026/08/8e5b3a2fd194459f9b962f8e12928bf0.jpg +0 -0
  114. package/dist/media/kama/2026/08/8ea5c21026cd4944bdf78c99fe2544a9.jpg +0 -0
  115. package/scripts/bundle-definitions.js +0 -176
  116. /package/{defaults → content}/definitions/block-layouts/three-column.yaml +0 -0
  117. /package/{defaults → content}/definitions/block-layouts/two-column.yaml +0 -0
  118. /package/{defaults → content}/definitions/field-sets/comments.yaml +0 -0
  119. /package/{defaults → content}/definitions/field-sets/visibility.yaml +0 -0
@@ -0,0 +1,266 @@
1
+ ---
2
+ // src/components/global/MetaHead.astro
3
+
4
+ import type { ContentBlock } from "@/lib/cms";
5
+ import { getChannel, getChannelId } from "@/lib/cms";
6
+ import { getImageUri } from "@/lib/media/media-url";
7
+
8
+ interface Props {
9
+ node: Record<string, any>;
10
+ content: Record<string, ContentBlock[]>;
11
+ normalizedSlug: string;
12
+ isHomePage: boolean;
13
+ headerBlocks?: ContentBlock[];
14
+ resolvedLanguage?: string;
15
+ }
16
+
17
+ const {
18
+ node = {},
19
+ content = {},
20
+ normalizedSlug,
21
+ isHomePage,
22
+ headerBlocks = [],
23
+ resolvedLanguage
24
+ } = Astro.props;
25
+
26
+ // 🎯 0. Read the safely extracted environment from middleware (Fixes Astro 7 crash)
27
+ const safeEnv = (Astro.locals as any).safeEnv || {};
28
+ const cmsWorker = safeEnv.CMS_WORKER;
29
+
30
+ const customFetch = (input: RequestInfo | URL, init?: RequestInit) => {
31
+ const reqInit = { ...init };
32
+ delete reqInit.cache;
33
+
34
+ let urlStr = input instanceof URL ? input.toString() : String(input);
35
+
36
+ if (urlStr.startsWith("/")) {
37
+ const baseUrl = import.meta.env.PUBLIC_API_URL || safeEnv.PUBLIC_API_URL || "http://127.0.0.1:8787";
38
+ urlStr = `${baseUrl}${urlStr}`;
39
+ }
40
+
41
+ const req = new Request(urlStr, reqInit);
42
+
43
+ if (cmsWorker && typeof cmsWorker.fetch === "function") {
44
+ return cmsWorker.fetch(req);
45
+ }
46
+ return fetch(req);
47
+ };
48
+
49
+ // 🎯 Fetch Global Channel Metadata
50
+ const channel = await getChannel(getChannelId(), customFetch);
51
+
52
+ // 🎯 Language Cascade Final Resolution:
53
+ // Prop / Node Language -> Channel Default -> Undefined
54
+ const activeLanguage = resolvedLanguage || node?.language || channel?.defaultLanguage || channel?.language || undefined;
55
+
56
+ // Convert BCP 47 (e.g. et-EE, zh-CN) to Open Graph locale format (et_EE, zh_CN)
57
+ const ogLocale = activeLanguage ? activeLanguage.replace("-", "_") : undefined;
58
+
59
+ // 🎯 1. Resolve Origin Safely
60
+ let siteOrigin = import.meta.env.PUBLIC_FRONTEND_URL || safeEnv.PUBLIC_FRONTEND_URL || Astro.site?.origin || Astro.url.origin;
61
+ if (siteOrigin === "https://example.com") {
62
+ siteOrigin = Astro.url.origin;
63
+ }
64
+ siteOrigin = siteOrigin.replace(/\/$/, "");
65
+
66
+ // 🎯 2. Favicon Resolution (Channel -> Fallback Static)
67
+ const faviconUrl = getImageUri(channel?.favicon, {
68
+ fallback: "/favicon.png",
69
+ })!;
70
+
71
+ // Detect MIME type dynamically for the favicon link tag
72
+ const getFaviconType = (url: string) => {
73
+ if (url.endsWith(".svg")) return "image/svg+xml";
74
+ if (url.endsWith(".ico")) return "image/x-icon";
75
+ return "image/png";
76
+ };
77
+
78
+ // 🎯 3. Detect Block-level LCP Image Candidate
79
+ const firstHeaderBlock = headerBlocks && headerBlocks[0];
80
+ const firstContentBlock = content?.content && content.content[0];
81
+ const candidateBlock = (firstHeaderBlock?.data?.image ? firstHeaderBlock : null)
82
+ || (firstContentBlock?.data?.image ? firstContentBlock : null);
83
+
84
+ const lcpImageUrl = getImageUri(candidateBlock?.data?.image, {
85
+ width: 1352,
86
+ quality: 75,
87
+ });
88
+
89
+ // 🎯 4. Text Metadata Variables
90
+ const brandName = channel?.siteTitle || channel?.label || import.meta.env.PUBLIC_SITE_NAME || safeEnv.PUBLIC_SITE_NAME || "KAMA";
91
+ const agencyName = import.meta.env.PUBLIC_AGENCY_NAME || safeEnv.PUBLIC_AGENCY_NAME || "";
92
+
93
+ const brandLogo = getImageUri(channel?.logoImage || channel?.logo, {
94
+ width: 512,
95
+ absoluteOrigin: siteOrigin,
96
+ fallback: `${siteOrigin}/logo.png`,
97
+ })!;
98
+
99
+ const prefix = node?.titlePrefix?.trim() ? `${node.titlePrefix.trim()} ` : "";
100
+
101
+ // Suffix Waterfall: Page node -> Channel level (e.g. "- Suffix")
102
+ const channelSuffix = channel?.titleSuffix?.trim() || "";
103
+ const rawSuffix = node?.titleSuffix?.trim() || channelSuffix;
104
+ const suffix = rawSuffix ? ` ${rawSuffix}` : "";
105
+
106
+ const baseTitle = node?.title?.trim() || brandName;
107
+ const fullTitle = `${prefix}${baseTitle}${suffix}`;
108
+
109
+ // Description Waterfall: Page summary -> Channel metaDescription
110
+ const metaDescription = node?.summary || channel?.metaDescription || "KAMA – The headless content engine built for omnichannel delivery and Cloudflare Edge computing.";
111
+
112
+ const canonicalURL = new URL(Astro.url.pathname, siteOrigin).href;
113
+
114
+ // 🎯 5. Social Text Waterfall
115
+ const fbTitle = node?.fbTitle?.trim() || fullTitle;
116
+ const xTitle = node?.xTitle?.trim() || fbTitle;
117
+ const fbDescription = node?.fbDescription?.trim() || metaDescription;
118
+ const xDescription = node?.xDescription?.trim() || fbDescription;
119
+
120
+ // 🎯 6. Social Image Waterfall (Page -> LCP Candidate -> Channel Default -> Fallback)
121
+ const channelDefaultOg = channel?.defaultSeoImage;
122
+ const rawFbImage = node?.socialImages?.facebook?.url || candidateBlock?.data?.image || channelDefaultOg;
123
+ const rawXImage = node?.socialImages?.x?.url || candidateBlock?.data?.image || channelDefaultOg;
124
+
125
+ const fbImageUrl = getImageUri(rawFbImage, {
126
+ width: 1200,
127
+ quality: 80,
128
+ absoluteOrigin: siteOrigin,
129
+ fallback: `${siteOrigin}/default-og-image.png`,
130
+ })!;
131
+
132
+ const xImageUrl = getImageUri(rawXImage, {
133
+ width: 1200,
134
+ quality: 80,
135
+ absoluteOrigin: siteOrigin,
136
+ fallback: `${siteOrigin}/default-og-image.png`,
137
+ })!;
138
+
139
+ // 🎯 7. Structured Data Breadcrumbs
140
+ const breadcrumbListItems = [
141
+ {
142
+ "@type": "ListItem",
143
+ "position": 1,
144
+ "name": "Home",
145
+ "item": `${siteOrigin}/`,
146
+ },
147
+ ];
148
+
149
+ if (!isHomePage && normalizedSlug) {
150
+ const segments = normalizedSlug.split("/").filter(Boolean);
151
+ let currentPath = "";
152
+
153
+ segments.forEach((segment, index) => {
154
+ currentPath += `/${segment}`;
155
+ const isLast = index === segments.length - 1;
156
+
157
+ const fallbackTitle = segment
158
+ .replace(/-/g, " ")
159
+ .replace(/\b\w/g, (char) => char.toUpperCase());
160
+
161
+ const segmentTitle = isLast && node?.title ? node.title : fallbackTitle;
162
+
163
+ breadcrumbListItems.push({
164
+ "@type": "ListItem",
165
+ "position": index + 2,
166
+ "name": segmentTitle,
167
+ "item": `${siteOrigin}${currentPath}`,
168
+ });
169
+ });
170
+ }
171
+
172
+ // 🎯 8. JSON-LD Schema
173
+ const structuredDataGraph = {
174
+ "@context": "https://schema.org",
175
+ "@graph": [
176
+ {
177
+ "@type": "Organization",
178
+ "@id": `${siteOrigin}/#organization`,
179
+ "name": agencyName || brandName,
180
+ "url": siteOrigin,
181
+ "logo": {
182
+ "@type": "ImageObject",
183
+ "url": brandLogo,
184
+ "caption": brandName
185
+ },
186
+ "address": {
187
+ "@type": "PostalAddress",
188
+ "addressLocality": "",
189
+ "addressCountry": ""
190
+ }
191
+ },
192
+ {
193
+ "@type": "SoftwareApplication",
194
+ "@id": `${siteOrigin}/#application`,
195
+ "name": brandName,
196
+ "applicationCategory": "DeveloperApplication",
197
+ "softwareVersion": "v0.1.0",
198
+ "operatingSystem": "Cloudflare Workers, Node.js",
199
+ "description": metaDescription,
200
+ "url": siteOrigin,
201
+ "publisher": {
202
+ "@id": `${siteOrigin}/#organization`
203
+ },
204
+ },
205
+ {
206
+ "@type": "WebSite",
207
+ "@id": `${siteOrigin}/#website`,
208
+ "url": siteOrigin,
209
+ "name": brandName,
210
+ "description": metaDescription,
211
+ "inLanguage": activeLanguage,
212
+ "publisher": {
213
+ "@id": `${siteOrigin}/#organization`
214
+ }
215
+ },
216
+ {
217
+ "@type": "BreadcrumbList",
218
+ "@id": `${siteOrigin}${normalizedSlug ? `/${normalizedSlug}` : ''}/#breadcrumb`,
219
+ "itemListElement": breadcrumbListItems
220
+ }
221
+ ]
222
+ };
223
+ ---
224
+
225
+ <meta charset="utf-8" />
226
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
227
+
228
+ <title>{fullTitle}</title>
229
+ <meta name="description" content={metaDescription} />
230
+
231
+ <!-- 🎯 Channel Favicon -->
232
+ <link rel="icon" type={getFaviconType(faviconUrl)} href={faviconUrl} />
233
+
234
+ <link rel="canonical" href={canonicalURL} />
235
+ <link rel="author" type="text/plain" href="/llms.txt" title="LLM Directory" />
236
+
237
+ <!-- 🎯 Open Graph / Facebook -->
238
+ <meta property="og:type" content={isHomePage ? "website" : "article"} />
239
+ <meta property="og:url" content={canonicalURL} />
240
+ <meta property="og:title" content={fbTitle} />
241
+ <meta property="og:description" content={fbDescription} />
242
+ <meta property="og:image" content={fbImageUrl} />
243
+ <meta property="og:site_name" content={brandName} />
244
+ {ogLocale && <meta property="og:locale" content={ogLocale} />}
245
+
246
+ <!-- 🎯 X / Twitter -->
247
+ <meta name="twitter:card" content="summary_large_image" />
248
+ <meta name="twitter:url" content={canonicalURL} />
249
+ <meta name="twitter:title" content={xTitle} />
250
+ <meta name="twitter:description" content={xDescription} />
251
+ <meta name="twitter:image" content={xImageUrl} />
252
+
253
+ {lcpImageUrl && (
254
+ <link
255
+ rel="preload"
256
+ as="image"
257
+ href={lcpImageUrl}
258
+ fetchpriority="high"
259
+ />
260
+ )}
261
+
262
+ <script
263
+ type="application/ld+json"
264
+ is:inline
265
+ set:html={JSON.stringify(structuredDataGraph)}
266
+ />
@@ -0,0 +1,413 @@
1
+ ---
2
+ // src/components/global/NavigationMenu.astro
3
+ import type { TreeNode } from "@/lib/cms";
4
+
5
+ export interface Props {
6
+ tree?: TreeNode[];
7
+ }
8
+
9
+ const { tree = [] } = Astro.props;
10
+
11
+ const currentPath = Astro.url.pathname.replace(/\/$/, "") || "/";
12
+
13
+ // 🎯 FIX: Identify the root node in the tree structure
14
+ const rootNode =
15
+ tree.find(
16
+ item =>
17
+ item.path === "/" ||
18
+ item.path === "" ||
19
+ item.slug === "index" ||
20
+ (item as any).isRoot === true ||
21
+ (item as any).isRootNode === 1,
22
+ ) || tree[0];
23
+
24
+ // 🎯 FIX: If rootNode has children, display those children as top-level menu items.
25
+ // If not, filter out the root node by ID AND path.
26
+ const topLevelNavItems =
27
+ rootNode?.children && rootNode.children.length > 0
28
+ ? rootNode.children
29
+ : tree.filter(
30
+ item =>
31
+ item.id !== rootNode?.id &&
32
+ item.slug !== rootNode?.slug &&
33
+ item.path !== "/" &&
34
+ item.path !== "",
35
+ );
36
+
37
+ const isPathActive = (itemPath?: string) => {
38
+ const normItem = (itemPath || "").replace(/\/$/, "") || "/";
39
+ if (normItem === "/") return currentPath === "/";
40
+ return currentPath === normItem || currentPath.startsWith(`${normItem}/`);
41
+ };
42
+
43
+ const isExactActive = (itemPath?: string) => {
44
+ return currentPath === ((itemPath || "").replace(/\/$/, "") || "/");
45
+ };
46
+ ---
47
+
48
+ <!-- 2-LINE HAMBURGER TOGGLE BUTTON -->
49
+ <button
50
+ type="button"
51
+ class="mobile-toggle"
52
+ id="menu-toggle"
53
+ aria-label="Toggle Navigation Menu"
54
+ aria-expanded="false"
55
+ >
56
+ <span class="line line-1"></span>
57
+ <span class="line line-2"></span>
58
+ </button>
59
+
60
+ <!-- NAVIGATION BLOCK & FULLSCREEN CONTAINER -->
61
+ <nav class="nav-block" id="nav-overlay">
62
+ <ul class="nav-list">
63
+ {
64
+ topLevelNavItems.map(item => {
65
+ const hasChildren = item.children && item.children.length > 0;
66
+ const isParentActive = isPathActive(item.path);
67
+
68
+ if (hasChildren) {
69
+ return (
70
+ <li class="nav-item">
71
+ <details class="nav-details">
72
+ <summary
73
+ class:list={[
74
+ "nav-button",
75
+ "dropdown-summary",
76
+ { active: isParentActive },
77
+ ]}
78
+ >
79
+ <span>{item.title}</span>
80
+ <span class="indicator">▼</span>
81
+ </summary>
82
+ <div class="submenu-container">
83
+ <ul class="dropdown-panel">
84
+ <li>
85
+ <a
86
+ href={item.path}
87
+ class:list={[
88
+ "dropdown-item",
89
+ "overview-item",
90
+ { active: isExactActive(item.path) },
91
+ ]}
92
+ >
93
+ {item.title} Overview
94
+ </a>
95
+ </li>
96
+ {item.children?.map(child => (
97
+ <li>
98
+ <a
99
+ href={child.path}
100
+ class:list={[
101
+ "dropdown-item",
102
+ { active: isExactActive(child.path) },
103
+ ]}
104
+ >
105
+ {child.title}
106
+ </a>
107
+ </li>
108
+ ))}
109
+ </ul>
110
+ </div>
111
+ </details>
112
+ </li>
113
+ );
114
+ }
115
+
116
+ return (
117
+ <li class="nav-item">
118
+ <a
119
+ href={item.path}
120
+ class:list={["nav-button", { active: isExactActive(item.path) }]}
121
+ >
122
+ {item.title}
123
+ </a>
124
+ </li>
125
+ );
126
+ })
127
+ }
128
+ </ul>
129
+ </nav>
130
+
131
+ <script is:inline>
132
+ const initMobileMenu = () => {
133
+ const toggleBtn = document.getElementById("menu-toggle");
134
+ const navOverlay = document.getElementById("nav-overlay");
135
+
136
+ if (!toggleBtn || !navOverlay) return;
137
+
138
+ // Remove old listeners by replacing the nodes (prevents duplicate bindings)
139
+ const newToggleBtn = toggleBtn.cloneNode(true);
140
+ toggleBtn.parentNode.replaceChild(newToggleBtn, toggleBtn);
141
+
142
+ const closeMenu = () => {
143
+ newToggleBtn.setAttribute("aria-expanded", "false");
144
+ newToggleBtn.classList.remove("is-open");
145
+ navOverlay.classList.remove("is-open");
146
+ document.body.classList.remove("menu-lock-scroll");
147
+ };
148
+
149
+ newToggleBtn.addEventListener("click", e => {
150
+ e.stopPropagation();
151
+ const isOpen = newToggleBtn.getAttribute("aria-expanded") === "true";
152
+ newToggleBtn.setAttribute("aria-expanded", (!isOpen).toString());
153
+ newToggleBtn.classList.toggle("is-open");
154
+ navOverlay.classList.toggle("is-open");
155
+ document.body.classList.toggle("menu-lock-scroll");
156
+ });
157
+
158
+ // Close menu when clicking nav links
159
+ const navLinks = navOverlay.querySelectorAll("a");
160
+ navLinks.forEach(link => {
161
+ link.addEventListener("click", () => {
162
+ closeMenu();
163
+ });
164
+ });
165
+
166
+ // Close details dropdowns when clicking outside
167
+ document.addEventListener("click", event => {
168
+ const allDetails = document.querySelectorAll("details.nav-details");
169
+ allDetails.forEach(details => {
170
+ if (details.hasAttribute("open") && !details.contains(event.target)) {
171
+ details.removeAttribute("open");
172
+ }
173
+ });
174
+ });
175
+
176
+ window.addEventListener("pageshow", closeMenu);
177
+ };
178
+
179
+ // Run on first load
180
+ if (document.readyState === "loading") {
181
+ document.addEventListener("DOMContentLoaded", initMobileMenu);
182
+ } else {
183
+ initMobileMenu();
184
+ }
185
+
186
+ // Run on every Astro View Transition page navigation
187
+ document.addEventListener("astro:page-load", initMobileMenu);
188
+ </script>
189
+
190
+ <style>
191
+ .mobile-toggle {
192
+ display: flex;
193
+ flex-direction: column;
194
+ justify-content: center;
195
+ align-items: center;
196
+ gap: 6px;
197
+ width: 44px;
198
+ height: 44px;
199
+ background: transparent;
200
+ border: none;
201
+ cursor: pointer;
202
+ padding: 0;
203
+ z-index: 1001;
204
+ }
205
+ .mobile-toggle .line {
206
+ width: 22px;
207
+ height: 2px;
208
+ background-color: #0f172a;
209
+ transition:
210
+ transform 0.25s cubic-bezier(0.4, 0, 0.2, 1),
211
+ opacity 0.25s cubic-bezier(0.4, 0, 0.2, 1);
212
+ transform-origin: center;
213
+ }
214
+ .mobile-toggle.is-open .line-1 {
215
+ transform: translateY(4px) rotate(45deg);
216
+ }
217
+ .mobile-toggle.is-open .line-2 {
218
+ transform: translateY(-4px) rotate(-45deg);
219
+ }
220
+ .nav-block {
221
+ position: fixed;
222
+ top: 0;
223
+ left: 0;
224
+ width: 100vw;
225
+ height: 100vh;
226
+ background-color: #ffffff;
227
+ padding: 80px 24px 40px 24px;
228
+ box-sizing: border-box;
229
+ display: flex;
230
+ flex-direction: column;
231
+ overflow-y: auto;
232
+ opacity: 0;
233
+ visibility: hidden;
234
+ pointer-events: none;
235
+ transition:
236
+ opacity 0.3s ease,
237
+ visibility 0.3s ease;
238
+ z-index: 1000;
239
+ }
240
+ .nav-block.is-open {
241
+ opacity: 1;
242
+ visibility: visible;
243
+ pointer-events: auto;
244
+ }
245
+
246
+ .nav-list {
247
+ display: flex;
248
+ flex-direction: column;
249
+ gap: 8px;
250
+ list-style: none;
251
+ margin: 0;
252
+ padding: 0;
253
+ width: 100%;
254
+ }
255
+ .nav-item {
256
+ position: relative;
257
+ width: 100%;
258
+ }
259
+ .nav-button {
260
+ display: flex;
261
+ align-items: center;
262
+ justify-content: space-between;
263
+ width: 100%;
264
+ height: 52px;
265
+ padding: 0 16px;
266
+ font-size: 18px;
267
+ font-weight: 500;
268
+ color: #334155;
269
+ text-decoration: none;
270
+ background: transparent;
271
+ border: none;
272
+ border-radius: 4px;
273
+ cursor: pointer;
274
+ box-sizing: border-box;
275
+ }
276
+ .nav-button:hover {
277
+ color: #0f172a;
278
+ background: #f8fafc;
279
+ }
280
+ .nav-button.active {
281
+ color: #0c4a6e;
282
+ font-weight: 600;
283
+ }
284
+ .dropdown-summary {
285
+ list-style: none;
286
+ user-select: none;
287
+ }
288
+ .dropdown-summary::-webkit-details-marker {
289
+ display: none;
290
+ }
291
+ .indicator {
292
+ font-size: 11px;
293
+ color: #64748b;
294
+ transition: transform 0.2s ease;
295
+ }
296
+ details[open] .indicator {
297
+ transform: rotate(180deg);
298
+ }
299
+ details.nav-details {
300
+ border-radius: 6px;
301
+ transition: background-color 0.2s ease;
302
+ }
303
+ details[open].nav-details {
304
+ background: #f8fafc;
305
+ padding-bottom: 8px;
306
+ }
307
+ .submenu-container {
308
+ position: relative;
309
+ margin-left: 24px;
310
+ padding-left: 16px;
311
+ border-left: 2px solid #cbd5e1;
312
+ }
313
+ .dropdown-panel {
314
+ list-style: none;
315
+ margin: 4px 0 0 0;
316
+ padding: 0;
317
+ display: flex;
318
+ flex-direction: column;
319
+ gap: 4px;
320
+ }
321
+ .dropdown-item {
322
+ display: block;
323
+ padding: 10px 12px;
324
+ font-size: 16px;
325
+ font-weight: 400;
326
+ color: #475569;
327
+ text-decoration: none;
328
+ border-radius: 4px;
329
+ transition:
330
+ background 0.15s ease,
331
+ color 0.15s ease;
332
+ }
333
+ .overview-item {
334
+ font-weight: 500;
335
+ color: #334155;
336
+ }
337
+ .dropdown-item:hover {
338
+ background: #e2e8f0;
339
+ color: #0f172a;
340
+ }
341
+ .dropdown-item.active {
342
+ font-weight: 600;
343
+ color: #0c4a6e;
344
+ background: #e0f2fe;
345
+ }
346
+ body.menu-lock-scroll {
347
+ overflow: hidden !important;
348
+ }
349
+
350
+ @media (min-width: 768px) {
351
+ .mobile-toggle {
352
+ display: none;
353
+ }
354
+ .nav-block {
355
+ position: static;
356
+ width: 100%;
357
+ height: auto;
358
+ background: transparent;
359
+ padding: 0;
360
+ opacity: 1;
361
+ visibility: visible;
362
+ pointer-events: auto;
363
+ overflow-y: visible;
364
+ display: block;
365
+ }
366
+ .nav-list {
367
+ flex-direction: row;
368
+ gap: 4px;
369
+ justify-content: flex-end;
370
+ }
371
+ .nav-item {
372
+ width: auto;
373
+ }
374
+ .nav-button {
375
+ display: inline-flex;
376
+ justify-content: flex-start;
377
+ gap: 6px;
378
+ height: 40px;
379
+ padding: 0 16px;
380
+ font-size: 14px;
381
+ border-radius: 4px;
382
+ margin-top: 4px;
383
+ }
384
+ details[open].nav-details {
385
+ background: transparent;
386
+ padding-bottom: 0;
387
+ }
388
+ .submenu-container {
389
+ margin-left: 0;
390
+ padding-left: 0;
391
+ border-left: none;
392
+ }
393
+ .dropdown-panel {
394
+ position: absolute;
395
+ top: 100%;
396
+ left: 0;
397
+ min-width: 220px;
398
+ background: #ffffff;
399
+ border: 1px solid #e2e8f0;
400
+ margin: 0;
401
+ gap: 0;
402
+ box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.05);
403
+ border-radius: 4px;
404
+ padding: 4px;
405
+ }
406
+ .dropdown-item {
407
+ font-size: 14px;
408
+ padding: 8px 16px;
409
+ border-radius: 4px;
410
+ border-bottom: none;
411
+ }
412
+ }
413
+ </style>