diff --git a/astro.config.mjs b/astro.config.mjs index 3a1afa2..85adcbb 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -54,7 +54,8 @@ const SKIP_PATTERNS = ['/write', '/search']; function priorityFor(path) { if (path === '/' || path === '') return 1.0; if (path === '/blog' || path === '/topics') return 0.9; - if (path === '/playground' || path === '/community' || path === '/contribute') return 0.8; + if (path === '/playground' || path === '/community' || path === '/contribute' || path === '/why') + return 0.8; if (path.startsWith('/blog/')) return 0.7; if (path.startsWith('/topics/') || path.startsWith('/tags/')) return 0.6; if (path.startsWith('/authors/')) return 0.6; @@ -163,7 +164,9 @@ export default defineConfig({ output: 'static', build: { - inlineStylesheets: 'always', + // 'auto' inlines only small styles; the shared design-system sheet is emitted + // as one cacheable /_astro/*.css instead of being duplicated into every page. + inlineStylesheets: 'auto', // Flat files (blog/x.html) instead of blog/x/index.html, so URLs stay clean // with no trailing slash (pairs with trailingSlash: 'never'). Avoids // Cloudflare's directory-style 308 redirect that appended the slash. diff --git a/functions/api/create-pr.ts b/functions/api/create-pr.ts index 0768516..ae0cdb5 100644 --- a/functions/api/create-pr.ts +++ b/functions/api/create-pr.ts @@ -80,6 +80,26 @@ async function gh( return res.status === 204 ? {} : ((await res.json()) as Record); } +async function fileExistsOnMain( + owner: string, + name: string, + path: string, + token: string, +): Promise { + const res = await fetch( + `https://api.github.com/repos/${owner}/${name}/contents/${encodeURI(path)}?ref=main`, + { + headers: { + accept: 'application/vnd.github+json', + authorization: `Bearer ${token}`, + 'user-agent': UA, + 'x-github-api-version': '2022-11-28', + }, + }, + ); + return res.ok; +} + export async function onRequestPost(context: { request: Request; env: Env }): Promise { const { request, env } = context; @@ -121,48 +141,23 @@ export async function onRequestPost(context: { request: Request; env: Env }): Pr // A brand-new post must not silently overwrite an existing one at the same slug. // Edits (loaded via the portal's "Open existing post") are meant to, so skip then. - if (!isEdit) { - const existing = await fetch( - `https://api.github.com/repos/${owner}/${name}/contents/${encodeURI( - `src/content/posts/${slug}/index.mdx`, - )}?ref=main`, - { - headers: { - accept: 'application/vnd.github+json', - authorization: `Bearer ${token}`, - 'user-agent': UA, - 'x-github-api-version': '2022-11-28', - }, - }, + if ( + !isEdit && + (await fileExistsOnMain(owner, name, `src/content/posts/${slug}/index.mdx`, token)) + ) { + return json( + { error: 'A post with this URL already exists. Change the URL slug and try again.' }, + 409, ); - if (existing.ok) { - return json( - { error: 'A post with this URL already exists. Change the URL slug and try again.' }, - 409, - ); - } } // A newly registered author must not overwrite an existing profile at the same handle. const authorFile = files.find((f) => f.path.startsWith('src/content/authors/')); - if (authorFile) { - const existing = await fetch( - `https://api.github.com/repos/${owner}/${name}/contents/${encodeURI(authorFile.path)}?ref=main`, - { - headers: { - accept: 'application/vnd.github+json', - authorization: `Bearer ${token}`, - 'user-agent': UA, - 'x-github-api-version': '2022-11-28', - }, - }, + if (authorFile && (await fileExistsOnMain(owner, name, authorFile.path, token))) { + return json( + { error: 'That author handle is already taken. Pick another handle and try again.' }, + 409, ); - if (existing.ok) { - return json( - { error: 'That author handle is already taken. Pick another handle and try again.' }, - 409, - ); - } } const ref = (await gh(`/repos/${owner}/${name}/git/ref/heads/main`, token)) as { diff --git a/public/_headers b/public/_headers new file mode 100644 index 0000000..ec53d96 --- /dev/null +++ b/public/_headers @@ -0,0 +1,17 @@ +/* + X-Content-Type-Options: nosniff + Referrer-Policy: strict-origin-when-cross-origin + X-Frame-Options: SAMEORIGIN + Permissions-Policy: camera=(), microphone=(), geolocation=() + +/_astro/* + Cache-Control: public, max-age=31536000, immutable + +/_pagefind/* + Cache-Control: public, max-age=3600 + +/og/* + Cache-Control: public, max-age=86400 + +/og-default.png + Cache-Control: public, max-age=86400 diff --git a/public/robots.txt b/public/robots.txt index 7f181a8..73a473e 100644 --- a/public/robots.txt +++ b/public/robots.txt @@ -3,4 +3,3 @@ Allow: / Disallow: /api/ Sitemap: https://mlsystems.dev/sitemap-index.xml -Host: https://mlsystems.dev diff --git a/src/components/AnalyticsConsent.astro b/src/components/AnalyticsConsent.astro new file mode 100644 index 0000000..708adc4 --- /dev/null +++ b/src/components/AnalyticsConsent.astro @@ -0,0 +1,61 @@ +--- +interface Props { + measurementId?: string; +} +const { measurementId } = Astro.props; +--- + +{ + measurementId && ( + + ) +} + + diff --git a/src/components/AnalyticsConsent.tsx b/src/components/AnalyticsConsent.tsx deleted file mode 100644 index 7700b2a..0000000 --- a/src/components/AnalyticsConsent.tsx +++ /dev/null @@ -1,69 +0,0 @@ -'use client'; - -import { useEffect, useState } from 'react'; - -declare global { - interface Window { - dataLayer?: unknown[]; - gtag?: (...args: unknown[]) => void; - } -} - -const STORAGE_KEY = 'mlsystems-analytics-consent'; - -type Consent = 'accepted' | 'declined'; - -function updateConsent(granted: boolean) { - if (typeof window === 'undefined' || !window.gtag) return; - window.gtag('consent', 'update', { - analytics_storage: granted ? 'granted' : 'denied', - }); -} - -export default function AnalyticsConsent({ measurementId }: { measurementId?: string }) { - const [consent, setConsent] = useState(null); - const [mounted, setMounted] = useState(false); - - useEffect(() => { - try { - const stored = localStorage.getItem(STORAGE_KEY) as Consent | null; - setConsent(stored === 'accepted' || stored === 'declined' ? stored : null); - } catch {} - setMounted(true); - }, []); - - function choose(next: Consent) { - setConsent(next); - try { - localStorage.setItem(STORAGE_KEY, next); - } catch {} - updateConsent(next === 'accepted'); - } - - if (!mounted || !measurementId || consent) return null; - - return ( - - ); -} diff --git a/src/components/ArticleActions.tsx b/src/components/ArticleActions.tsx index e817cff..4ccfbdd 100644 --- a/src/components/ArticleActions.tsx +++ b/src/components/ArticleActions.tsx @@ -16,6 +16,19 @@ export default function ArticleActions({ }) { const [liked, setLiked] = useState(false); const [count, setCount] = useState(null); + const [copied, setCopied] = useState(false); + + const citation = `${author.split(' ').reverse().join(', ')}. "${title.split(':')[0]}." ${SITE.domain}, ${date}.`; + + const copyCitation = async () => { + try { + await navigator.clipboard.writeText(`${citation} ${window.location.href}`); + setCopied(true); + setTimeout(() => setCopied(false), 1400); + } catch { + /* clipboard blocked */ + } + }; useEffect(() => { const key = `mlsys-liked-${slug}`; @@ -116,9 +129,63 @@ export default function ArticleActions({ ↗ Share -
- Cite as: {author.split(' ').reverse().join(', ')}. "{title.split(':')[0]}."{' '} - {SITE.domain}, {date}. +
+ Cite as: {citation} +
); diff --git a/src/components/BlogFilter.astro b/src/components/BlogFilter.astro index 5d4a3e7..51ed049 100644 --- a/src/components/BlogFilter.astro +++ b/src/components/BlogFilter.astro @@ -46,7 +46,6 @@ const { activeTopic, allCount, topicCounts } = Astro.props; const topic = link.dataset.topic!; const newUrl = topic === 'all' ? '/blog' : `/blog?topic=${topic}`; history.pushState(null, '', newUrl); - // Update active chip document .querySelectorAll('[data-blog-filter] a[data-topic]') .forEach((a) => { diff --git a/src/components/Footer.astro b/src/components/Footer.astro index 5d5bf36..c32eb68 100644 --- a/src/components/Footer.astro +++ b/src/components/Footer.astro @@ -92,7 +92,7 @@ const socialIcons = (['discord', 'github', 'twitter', 'linkedin'] as const).map( diff --git a/src/components/IconLinkCard.astro b/src/components/IconLinkCard.astro index 02cf3f8..8f591b5 100644 --- a/src/components/IconLinkCard.astro +++ b/src/components/IconLinkCard.astro @@ -1,5 +1,4 @@ --- -// Icon + label + action link card. Shared by About and Contact. // Surface + hover-lift come from the global .card / .card--interactive classes. interface Props { href?: string | null; diff --git a/src/components/Nav.astro b/src/components/Nav.astro index 75a6c8e..2831f9b 100644 --- a/src/components/Nav.astro +++ b/src/components/Nav.astro @@ -26,7 +26,11 @@ const isActive = (path: string) => currentPath === path || currentPath.startsWit - - ); -} diff --git a/src/components/Stat.astro b/src/components/Stat.astro index 10a4dbf..70e5ff9 100644 --- a/src/components/Stat.astro +++ b/src/components/Stat.astro @@ -1,5 +1,4 @@ --- -// A single number + label stat (hero counters, etc.). interface Props { value: number | string; label: string; diff --git a/src/components/Step.astro b/src/components/Step.astro index ec7917b..a68cc28 100644 --- a/src/components/Step.astro +++ b/src/components/Step.astro @@ -1,5 +1,4 @@ --- -// A numbered process step (see the Contribute page). interface Props { n: string; title: string; diff --git a/src/components/ToolGlyph.tsx b/src/components/ToolGlyph.tsx index bb10ebe..4318051 100644 --- a/src/components/ToolGlyph.tsx +++ b/src/components/ToolGlyph.tsx @@ -28,19 +28,23 @@ export default function ToolGlyph({ id }: { id: string }) { ); - case 'cost-calc': + case 'gpu-mem-calc': return ( - - $ - + + + + + ); case 'model-card': diff --git a/src/env.d.ts b/src/env.d.ts index f964fe0..40c1907 100644 --- a/src/env.d.ts +++ b/src/env.d.ts @@ -1 +1,7 @@ /// + +interface Window { + dataLayer?: unknown[]; + gtag?: (...args: unknown[]) => void; + __mlsPagefind?: import('@/lib/pagefind').Pagefind; +} diff --git a/src/layouts/BaseLayout.astro b/src/layouts/BaseLayout.astro index 58df2c1..879eef1 100644 --- a/src/layouts/BaseLayout.astro +++ b/src/layouts/BaseLayout.astro @@ -5,8 +5,9 @@ import { APPEARANCE, SITE, SOCIALS } from '@/lib/site'; import Nav from '@/components/Nav.astro'; import Footer from '@/components/Footer.astro'; -import AnalyticsConsent from '@/components/AnalyticsConsent.tsx'; -import SearchModal from '@/components/SearchModal.tsx'; +import NewsletterStrip from '@/components/NewsletterStrip.astro'; +import AnalyticsConsent from '@/components/AnalyticsConsent.astro'; +import SearchModal from '@/components/SearchModal.astro'; import '@/styles/global.css'; // Self-hosted fonts (latin subset). No third-party request; @fontsource sets font-display: swap. @@ -26,6 +27,11 @@ import '@fontsource/jetbrains-mono/latin-400.css'; import '@fontsource/jetbrains-mono/latin-500.css'; import '@fontsource/jetbrains-mono/latin-600.css'; +// Above-the-fold faces on every page — preloaded to avoid the first-paint font swap. +import playfairWoff2 from '@fontsource/playfair-display/files/playfair-display-latin-400-normal.woff2?url'; +import hankenWoff2 from '@fontsource/hanken-grotesk/files/hanken-grotesk-latin-400-normal.woff2?url'; +import monoWoff2 from '@fontsource/jetbrains-mono/files/jetbrains-mono-latin-400-normal.woff2?url'; + interface Props { title?: string; description?: string; @@ -82,7 +88,7 @@ const siteJsonLd = [ url: SITE.url, logo: new URL('/favicon-96x96.png', SITE.url).toString(), description: SITE.description, - sameAs: [SOCIALS.github, SOCIALS.twitter, SOCIALS.linkedin].filter(Boolean), + sameAs: [SOCIALS.github, SOCIALS.twitter, SOCIALS.linkedin, SOCIALS.discord].filter(Boolean), }, { '@context': 'https://schema.org', @@ -90,6 +96,7 @@ const siteJsonLd = [ name: SITE.name, url: SITE.url, description: SITE.description, + publisher: { '@type': 'Organization', name: SITE.name, url: SITE.url }, potentialAction: { '@type': 'SearchAction', target: { @@ -128,6 +135,8 @@ const siteJsonLd = [ } if (prefs && (prefs.theme === 'light' || prefs.theme === 'dark')) { root.setAttribute('data-theme', prefs.theme); + } else if (window.matchMedia('(prefers-color-scheme: dark)').matches) { + root.setAttribute('data-theme', 'dark'); } const validAccents = ['oxide', 'indigo', 'emerald']; if (prefs && validAccents.indexOf(prefs.accent) !== -1) { @@ -178,10 +187,14 @@ const siteJsonLd = [ + + + + @@ -196,6 +209,8 @@ const siteJsonLd = [ + +