| 1 | --- |
| 2 | import FontPreloads from "../components/FontPreloads.astro"; |
| 3 | import "../styles/tokens.css"; |
| 4 | import "../styles/marketing.css"; |
| 5 | |
| 6 | import Nav from "../components/marketing/Nav.astro"; |
| 7 | import Footer from "../components/marketing/Footer.astro"; |
| 8 | import PostHog from "../components/analytics/PostHog.astro"; |
| 9 | import ThemeProvider from "../components/ThemeProvider.astro"; |
| 10 | import BrandIcons from "../brand/Icons.astro"; |
| 11 | import StructuredData from "../components/StructuredData.astro"; |
| 12 | |
| 13 | interface Props { |
| 14 | title?: string; |
| 15 | description?: string; |
| 16 | /** |
| 17 | * Path to the page's OG image, relative to the site root. Defaults to |
| 18 | * the build-time generated card for the current page (slug derived from |
| 19 | * the URL pathname). The OG endpoint at `/og/[...slug].webp` produces |
| 20 | * these during `astro build`. |
| 21 | */ |
| 22 | image?: string; |
| 23 | /** |
| 24 | * MDX/Markdown layouts receive their YAML frontmatter under this key |
| 25 | * rather than as flat props. We accept both shapes so an .astro page |
| 26 | * can pass `title=…` directly while an .mdx page's frontmatter just |
| 27 | * works. |
| 28 | */ |
| 29 | frontmatter?: { title?: string; description?: string; image?: string }; |
| 30 | } |
| 31 | |
| 32 | const props = Astro.props; |
| 33 | const fm = props.frontmatter ?? {}; |
| 34 | const title = props.title ?? fm.title ?? "Alchemy"; |
| 35 | const description = props.description ?? fm.description; |
| 36 | const image = props.image ?? fm.image; |
| 37 | |
| 38 | // Derive an OG image slug from the URL pathname when not explicitly set: |
| 39 | // "/" → /og/index.webp |
| 40 | // "/some/page" → /og/some/page.webp (currently unused, but future-proof) |
| 41 | const slug = (() => { |
| 42 | const p = Astro.url.pathname.replace(/^\/+|\/+$/g, ""); |
| 43 | return p === "" ? "index" : p; |
| 44 | })(); |
| 45 | const ogImage = new URL(image ?? `/og/${slug}.webp`, Astro.site); |
| 46 | const canonical = new URL(Astro.url.pathname, Astro.site); |
| 47 | --- |
| 48 | |
| 49 | <!doctype html> |
| 50 | <html lang="en"> |
| 51 | <head> |
| 52 | <meta charset="utf-8" /> |
| 53 | <meta name="viewport" content="width=device-width, initial-scale=1" /> |
| 54 | <FontPreloads /> |
| 55 | {/* Pre-paint theme script + color-scheme/theme-color metas. Must |
| 56 | run before any paint so navigating from a docs page (where the |
| 57 | Starlight head includes the same partial) doesn't drop the |
| 58 | user's theme on a marketing page. */} |
| 59 | <ThemeProvider /> |
| 60 | <title>{title}</title> |
| 61 | {description && <meta name="description" content={description} />} |
| 62 | <link rel="canonical" href={canonical.href} /> |
| 63 | |
| 64 | <BrandIcons /> |
| 65 | |
| 66 | {/* Open Graph */} |
| 67 | <meta property="og:type" content="website" /> |
| 68 | <meta property="og:site_name" content="alchemy" /> |
| 69 | <meta property="og:title" content={title} /> |
| 70 | {description && <meta property="og:description" content={description} />} |
| 71 | <meta property="og:url" content={canonical.href} /> |
| 72 | <meta property="og:image" content={ogImage.href} /> |
| 73 | <meta property="og:image:width" content="1200" /> |
| 74 | <meta property="og:image:height" content="630" /> |
| 75 | |
| 76 | {/* Twitter / X */} |
| 77 | <meta name="twitter:card" content="summary_large_image" /> |
| 78 | <meta name="twitter:site" content="@alchemy_run" /> |
| 79 | <meta name="twitter:title" content={title} /> |
| 80 | {description && <meta name="twitter:description" content={description} />} |
| 81 | <meta name="twitter:image" content={ogImage.href} /> |
| 82 | |
| 83 | <StructuredData /> |
| 84 | |
| 85 | <PostHog /> |
| 86 | </head> |
| 87 | <body class="alc-root"> |
| 88 | <header class="alc-marketing-header"> |
| 89 | <Nav marketing /> |
| 90 | </header> |
| 91 | <main> |
| 92 | <slot /> |
| 93 | </main> |
| 94 | <Footer /> |
| 95 | </body> |
| 96 | </html> |
| 97 | |
| 98 | <style is:global> |
| 99 | html, |
| 100 | body { |
| 101 | margin: 0; |
| 102 | padding: 0; |
| 103 | background: var(--alc-bg); |
| 104 | color: var(--alc-fg-2); |
| 105 | font-family: var(--alc-font-sans); |
| 106 | -webkit-font-smoothing: antialiased; |
| 107 | overflow-x: clip; |
| 108 | } |
| 109 | /* In-page links glide, and land clear of the 64px sticky header. |
| 110 | tokens.css turns this off for reduced motion. */ |
| 111 | html { |
| 112 | scroll-behavior: smooth; |
| 113 | scroll-padding-top: 80px; |
| 114 | } |
| 115 | a:focus-visible { |
| 116 | outline: 2px solid var(--alc-accent); |
| 117 | outline-offset: 2px; |
| 118 | } |
| 119 | .alc-marketing-header { |
| 120 | position: sticky; |
| 121 | top: 0; |
| 122 | z-index: 10; |
| 123 | height: 64px; |
| 124 | padding: 0 32px; |
| 125 | background: color-mix(in srgb, var(--alc-bg-nav) 92%, transparent); |
| 126 | backdrop-filter: saturate(140%) blur(8px); |
| 127 | border-bottom: 1px solid var(--alc-hairline); |
| 128 | } |
| 129 | @media (max-width: 640px) { |
| 130 | .alc-marketing-header { padding: 0 20px; } |
| 131 | } |
| 132 | @media (max-width: 480px) { |
| 133 | .alc-marketing-header { padding: 0 16px; } |
| 134 | } |
| 135 | </style> |