@brightsideoy/kama 1.0.0-alpha.20 → 1.0.0-alpha.21

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 (65) hide show
  1. package/content/definitions/blocks/image-gallery.yaml +34 -0
  2. package/content/definitions/blocks/youtube.yaml +8 -0
  3. package/content/definitions/field-sets/access.yaml +10 -0
  4. package/content/definitions/field-sets/page-base.yaml +16 -1
  5. package/content/definitions/taxonomies/media-tag.yaml +15 -0
  6. package/definitions/editor/default.ts +15 -1
  7. package/dist/assets/AssetEditor-B4W7tV1a.js +1 -0
  8. package/dist/assets/AssetEditor-D9sz6bfN.css +1 -0
  9. package/dist/assets/CmsFieldTaxonomy-CVlKL-l2.css +1 -0
  10. package/dist/assets/CmsFieldTaxonomy-C_AwHDwY.js +1 -0
  11. package/dist/assets/{CmsHelp-DyPJLGGi.js → CmsHelp-BQkr9KyU.js} +1 -1
  12. package/dist/assets/{CmsPublisher-DNLwjirk.js → CmsPublisher-XSCPrp5L.js} +1 -1
  13. package/dist/assets/NodeEditor-4TsNvk2f.css +1 -0
  14. package/dist/assets/NodeEditor-DA5GRtRP.js +161 -0
  15. package/dist/assets/{TrashWorkspace-DgIg1v6t.js → TrashWorkspace-CATe46WF.js} +1 -1
  16. package/dist/assets/index-CY0VYSyt.css +1 -0
  17. package/dist/assets/index-DdG9Pozx.js +14 -0
  18. package/dist/assets/{plus-FQRsZBoJ.js → plus-CX_GHzXs.js} +1 -1
  19. package/dist/assets/useNodes-CqFlc72m.js +1 -0
  20. package/dist/{dist-D_Q1y4Kh.js → dist-3Xc2TTZw.js} +1117 -456
  21. package/dist/index.html +4 -4
  22. package/dist/index.js +1117 -456
  23. package/dist/schema-compiler.js +1 -1
  24. package/package.json +5 -5
  25. package/schema.sql +10 -0
  26. package/templates/astro-starter/src/components/global/NavigationMenu.astro +11 -3
  27. package/dist/assets/AssetEditor-D5j7pwcK.css +0 -1
  28. package/dist/assets/AssetEditor-Dt1zCR7_.js +0 -1
  29. package/dist/assets/NodeEditor-CiXQKXK1.js +0 -147
  30. package/dist/assets/NodeEditor-pZI3gu4a.css +0 -1
  31. package/dist/assets/index-DQ6_RmCI.js +0 -14
  32. package/dist/assets/index-DcKiaRjI.css +0 -1
  33. package/dist/assets/useNodes-D2-VxsxR.js +0 -1
  34. package/templates/astro-starter/old_src/components/blocks/BlockRenderer.astro +0 -143
  35. package/templates/astro-starter/old_src/components/blocks/ColumnLayout.astro +0 -67
  36. package/templates/astro-starter/old_src/components/blocks/HeaderImage.astro +0 -126
  37. package/templates/astro-starter/old_src/components/blocks/Hero.astro +0 -218
  38. package/templates/astro-starter/old_src/components/blocks/HtmlBlock.astro +0 -25
  39. package/templates/astro-starter/old_src/components/blocks/ImageBlock.astro +0 -91
  40. package/templates/astro-starter/old_src/components/blocks/NodeList.astro +0 -274
  41. package/templates/astro-starter/old_src/components/blocks/TextBlock.astro +0 -15
  42. package/templates/astro-starter/old_src/components/global/Breadcrumb.astro +0 -108
  43. package/templates/astro-starter/old_src/components/global/Footer.astro +0 -50
  44. package/templates/astro-starter/old_src/components/global/Header.astro +0 -171
  45. package/templates/astro-starter/old_src/components/global/MetaHead.astro +0 -266
  46. package/templates/astro-starter/old_src/components/global/NavigationMenu.astro +0 -413
  47. package/templates/astro-starter/old_src/components/ui/KamaImage.astro +0 -180
  48. package/templates/astro-starter/old_src/layouts/SkeletonLayout.astro +0 -56
  49. package/templates/astro-starter/old_src/layouts/StandardLayout.astro +0 -63
  50. package/templates/astro-starter/old_src/lib/cms.ts +0 -219
  51. package/templates/astro-starter/old_src/lib/media/kama-image-service.ts +0 -67
  52. package/templates/astro-starter/old_src/lib/media/media-proxy.ts +0 -148
  53. package/templates/astro-starter/old_src/lib/media/media-url.ts +0 -112
  54. package/templates/astro-starter/old_src/middleware.ts +0 -536
  55. package/templates/astro-starter/old_src/pages/404.astro +0 -46
  56. package/templates/astro-starter/old_src/pages/[...slug].astro +0 -311
  57. package/templates/astro-starter/old_src/pages/api/load-nodes.ts +0 -68
  58. package/templates/astro-starter/old_src/pages/api/partials/node-cards.astro +0 -101
  59. package/templates/astro-starter/old_src/pages/llms-full.txt.ts +0 -171
  60. package/templates/astro-starter/old_src/pages/llms.txt.ts +0 -113
  61. package/templates/astro-starter/old_src/pages/media/[...path].ts +0 -14
  62. package/templates/astro-starter/old_src/pages/partials/footer.astro +0 -69
  63. package/templates/astro-starter/old_src/pages/partials/nav.astro +0 -141
  64. package/templates/astro-starter/old_src/styles/standard-theme.css +0 -157
  65. package/templates/astro-starter/old_src/utils/tiptap.ts +0 -113
@@ -1,108 +0,0 @@
1
- ---
2
- // src/components/global/Breadcrumb.astro
3
-
4
- interface Props {
5
- title: string;
6
- slug?: string;
7
- isHomePage?: boolean;
8
- }
9
-
10
- const { title, isHomePage } = Astro.props;
11
-
12
- const currentPath = Astro.url.pathname;
13
-
14
- // Extract non-empty path segments
15
- const rawSegments = currentPath.split("/").filter(Boolean);
16
-
17
- // Detect homepage
18
- const isHome = isHomePage || currentPath === "/" || rawSegments.length === 0;
19
-
20
- function formatSegmentTitle(segment: string): string {
21
- return segment
22
- .split("-")
23
- .map(word => word.charAt(0).toUpperCase() + word.slice(1))
24
- .join(" ");
25
- }
26
-
27
- // Build progressive paths: /some-page, /some-page/child-page, etc.
28
- let runningPath = "";
29
- const breadcrumbItems = rawSegments.map((segment, index) => {
30
- runningPath += `/${segment}`;
31
- const isLast = index === rawSegments.length - 1;
32
-
33
- return {
34
- label: isLast ? title : formatSegmentTitle(segment),
35
- href: runningPath,
36
- isCurrent: isLast,
37
- };
38
- });
39
- ---
40
-
41
- <nav class="breadcrumb-nav" aria-label="Breadcrumb">
42
- <ol class="breadcrumb-list">
43
- <!-- Home Link -->
44
- <li class="breadcrumb-item">
45
- <a href="/">Home</a>
46
- </li>
47
-
48
- <!-- Subpage Breadcrumbs -->
49
- {
50
- !isHome &&
51
- breadcrumbItems.map(item => (
52
- <li
53
- class={`breadcrumb-item ${item.isCurrent ? "active" : ""}`}
54
- aria-current={item.isCurrent ? "page" : undefined}
55
- >
56
- <span class="separator">/</span>
57
- {item.isCurrent ? (
58
- <span>{item.label}</span>
59
- ) : (
60
- <a href={item.href}>{item.label}</a>
61
- )}
62
- </li>
63
- ))
64
- }
65
- </ol>
66
- </nav>
67
-
68
- <style>
69
- .breadcrumb-nav {
70
- margin-bottom: 16px;
71
- }
72
-
73
- .breadcrumb-list {
74
- display: flex;
75
- align-items: center;
76
- gap: 8px;
77
- list-style: none;
78
- margin: 0;
79
- padding: 0;
80
- font-size: 13px;
81
- color: #64748b;
82
- }
83
-
84
- .breadcrumb-item {
85
- display: flex;
86
- align-items: center;
87
- }
88
-
89
- .breadcrumb-item a {
90
- color: #334155;
91
- text-decoration: none;
92
- }
93
-
94
- .breadcrumb-item a:hover {
95
- color: #0f172a;
96
- text-decoration: underline;
97
- }
98
-
99
- .breadcrumb-item.active {
100
- color: #0f172a;
101
- font-weight: 500;
102
- }
103
-
104
- .separator {
105
- margin-right: 8px;
106
- color: #94a3b8;
107
- }
108
- </style>
@@ -1,50 +0,0 @@
1
- ---
2
- // src/components/global/Footer.astro
3
-
4
- import BlockRenderer from "@/components/blocks/BlockRenderer.astro";
5
-
6
- interface Props {
7
- blocks?: any[];
8
- contentMap?: Record<string, any[]>;
9
- }
10
-
11
- const { blocks = [], contentMap = {} } = Astro.props;
12
- ---
13
-
14
- <footer class="site-footer">
15
- <div class="footer-container">
16
- {
17
- blocks && blocks.length > 0 ? (
18
- <BlockRenderer
19
- blocks={blocks}
20
- contentMap={contentMap}
21
- region="footer"
22
- />
23
- ) : (
24
- <div class="footer-default">
25
- <p>&copy; {new Date().getFullYear()} all rights reserved.</p>
26
- </div>
27
- )
28
- }
29
- </div>
30
- </footer>
31
-
32
- <style>
33
- .site-footer {
34
- background: #ffffff;
35
- border-top: 1px solid #e2e8f0;
36
- margin-top: auto;
37
- }
38
-
39
- .footer-container {
40
- max-width: 1400px;
41
- margin: 0 auto;
42
- padding: 24px 20px;
43
- }
44
-
45
- .footer-default p {
46
- margin: 0;
47
- font-size: 14px;
48
- color: #64748b;
49
- }
50
- </style>
@@ -1,171 +0,0 @@
1
- ---
2
- // src/components/global/Header.astro
3
-
4
- import NavigationMenu from "@/components/global/NavigationMenu.astro";
5
- import KamaImage from "@/components/ui/KamaImage.astro";
6
- import { getTree, getTreeId, getChannel, getChannelId } from "@/lib/cms";
7
- import type { TreeNode } from "@/lib/cms";
8
-
9
- interface Props {
10
- node?: Record<string, any>;
11
- tree?: TreeNode[];
12
- }
13
-
14
- const props = Astro.props as Props;
15
- const currentPropsNode = props.node || {};
16
- let rawTree: TreeNode[] = props.tree || [];
17
-
18
- const safeEnv = (Astro.locals as any).safeEnv || {};
19
- const cmsWorker = safeEnv.CMS_WORKER;
20
-
21
- const customFetch = (input: RequestInfo | URL, init?: RequestInit) => {
22
- const reqInit = { ...init };
23
- delete reqInit.cache;
24
-
25
- let urlStr = input instanceof URL ? input.toString() : String(input);
26
-
27
- if (urlStr.startsWith("/")) {
28
- const baseUrl =
29
- import.meta.env.PUBLIC_API_URL ||
30
- safeEnv.PUBLIC_API_URL ||
31
- "http://127.0.0.1:8787";
32
- urlStr = `${baseUrl}${urlStr}`;
33
- }
34
-
35
- const req = new Request(urlStr, reqInit);
36
-
37
- if (cmsWorker && typeof cmsWorker.fetch === "function") {
38
- return cmsWorker.fetch(req);
39
- }
40
- return fetch(req);
41
- };
42
-
43
- // 🎯 Resolve Channel ID dynamically from env/lib without hardcoding "website"
44
- const channelId = getChannelId();
45
- const treeId = getTreeId();
46
-
47
- let channelData: Record<string, any> | null = null;
48
-
49
- try {
50
- if (!rawTree || rawTree.length === 0) {
51
- const [fetchedChannel, fetchedTree] = await Promise.all([
52
- getChannel(channelId, customFetch),
53
- getTree(treeId, channelId, customFetch),
54
- ]);
55
- channelData = fetchedChannel;
56
- rawTree = fetchedTree || [];
57
- } else {
58
- channelData = await getChannel(channelId, customFetch);
59
- }
60
- } catch (e) {
61
- console.error("[Header] Failed to load layout data:", e);
62
- }
63
-
64
- const rawLogoText = channelData?.logoText;
65
- const rawSiteTitle = channelData?.siteTitle;
66
- const rawLabel = channelData?.label;
67
-
68
- let logoText = "";
69
-
70
- if (typeof rawLogoText === "string" && rawLogoText.trim() !== "") {
71
- logoText = rawLogoText;
72
- } else if (typeof rawSiteTitle === "string" && rawSiteTitle.trim() !== "") {
73
- logoText = rawSiteTitle;
74
- } else if (typeof rawLabel === "string" && rawLabel.trim() !== "") {
75
- logoText = rawLabel;
76
- } else if (currentPropsNode.logoText) {
77
- logoText = currentPropsNode.logoText;
78
- } else {
79
- logoText = import.meta.env.PUBLIC_SITE_NAME || "KAMA";
80
- }
81
-
82
- const rawLogoAsset = channelData?.logoImage || channelData?.logo;
83
- const targetLogo = Array.isArray(rawLogoAsset) ? rawLogoAsset[0] : rawLogoAsset;
84
-
85
- const hasValidLogoImage = Boolean(
86
- targetLogo &&
87
- ((typeof targetLogo === "string" && targetLogo.trim() !== "") ||
88
- targetLogo.url ||
89
- targetLogo.path ||
90
- targetLogo.variants),
91
- );
92
- ---
93
-
94
- <header class="site-header">
95
- <div class="header-container main-header-row">
96
- <a href="/" class="logo-block">
97
- {
98
- hasValidLogoImage ? (
99
- <KamaImage
100
- image={targetLogo}
101
- idealWidth={300}
102
- class="logo-image"
103
- loading="eager"
104
- fetchpriority="high"
105
- />
106
- ) : (
107
- <span class="logo-text">{logoText}</span>
108
- )
109
- }
110
- </a>
111
- <NavigationMenu tree={rawTree} />
112
- </div>
113
- </header>
114
-
115
- <style>
116
- .site-header {
117
- background: #ffffff;
118
- color: #1e293b;
119
- position: sticky;
120
- top: 0;
121
- z-index: 100;
122
- border-bottom: 1px solid #e2e8f0;
123
- }
124
-
125
- .header-container {
126
- max-width: 1400px;
127
- margin: 0 auto;
128
- padding: 0 20px;
129
- width: 100%;
130
- box-sizing: border-box;
131
- }
132
-
133
- .main-header-row {
134
- display: flex;
135
- align-items: center;
136
- justify-content: space-between;
137
- height: 64px;
138
- }
139
-
140
- .logo-block {
141
- text-decoration: none;
142
- color: #0f172a;
143
- font-weight: 700;
144
- font-size: 20px;
145
- display: inline-flex;
146
- align-items: center;
147
- z-index: 1001;
148
- }
149
-
150
- :global(.logo-image) {
151
- height: 16px !important;
152
- max-height: 16px !important;
153
- width: auto !important;
154
- object-fit: contain;
155
- display: block;
156
- }
157
-
158
- .logo-text {
159
- white-space: nowrap;
160
- font-size: 16px;
161
- line-height: 16px;
162
- }
163
-
164
- @media (min-width: 768px) {
165
- .main-header-row {
166
- height: 64px;
167
- justify-content: space-between;
168
- gap: 32px;
169
- }
170
- }
171
- </style>
@@ -1,266 +0,0 @@
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
- />