diff --git a/astro.config.mjs b/astro.config.mjs index 82e214e..1579777 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -23,11 +23,13 @@ try { const fm = content.match(/^---\n([\s\S]*?)\n---/); if (!fm) continue; const dateMatch = fm[1].match(/^date:\s*["']?(\d{4}-\d{2}-\d{2})["']?\s*$/m); + const updatedMatch = fm[1].match(/^updated:\s*["']?(\d{4}-\d{2}-\d{2})["']?\s*$/m); const draftMatch = fm[1].match(/^draft:\s*(true|false)\s*$/m); if (draftMatch && draftMatch[1] === 'true') continue; - if (dateMatch) { + const stamp = updatedMatch?.[1] ?? dateMatch?.[1]; + if (stamp) { const slug = file.replace(/\.(mdx?|md)$/, ''); - postLastmod.set(`/blog/${slug}`, new Date(dateMatch[1])); + postLastmod.set(`/blog/${slug}`, new Date(stamp)); } } catch (err) { console.warn( @@ -43,7 +45,7 @@ try { } /** @type {string[]} */ -const SKIP_PATTERNS = ['/write']; +const SKIP_PATTERNS = ['/write', '/search']; /** * @param {string} path @@ -54,6 +56,7 @@ function priorityFor(path) { if (path === '/blog' || path === '/topics') return 0.9; if (path === '/playground' || path === '/community' || path === '/contribute') 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; return 0.5; } @@ -70,7 +73,7 @@ function changefreqFor(path) { export default defineConfig({ site: 'https://mlsystems.dev', - trailingSlash: 'ignore', + trailingSlash: 'never', markdown: { shikiConfig: { theme: 'github-dark' }, }, diff --git a/src/components/ArticleActions.tsx b/src/components/ArticleActions.tsx index 9710f3d..6c86406 100644 --- a/src/components/ArticleActions.tsx +++ b/src/components/ArticleActions.tsx @@ -1,6 +1,7 @@ 'use client'; import { useState } from 'react'; +import { SITE } from '@/lib/site'; export default function ArticleActions({ title, @@ -77,8 +78,8 @@ export default function ArticleActions({
- Cite as: {author.split(' ').reverse().join(', ')}. "{title.split(':')[0]}." - mlsystems.dev, {date}. + Cite as: {author.split(' ').reverse().join(', ')}. "{title.split(':')[0]}."{' '} + {SITE.domain}, {date}.
); diff --git a/src/components/Comments.tsx b/src/components/Comments.tsx index 4077f7e..2c4b576 100644 --- a/src/components/Comments.tsx +++ b/src/components/Comments.tsx @@ -1,15 +1,16 @@ 'use client'; import { useEffect, useRef } from 'react'; +import { SITE } from '@/lib/site'; // To enable comments: // 1. Make the repo public + enable GitHub Discussions. // 2. Install the giscus app: https://github.com/apps/giscus -// 3. Visit https://giscus.app, fill in MLSysDev/mlsystems.dev + the "Comments" +// 3. Visit https://giscus.app, fill in the repo + the "Comments" // category, and paste the resulting IDs into the two PUBLIC_* env vars. // If either ID is missing, this component renders a graceful "not configured" // note instead of breaking the page. -const REPO = 'MLSysDev/mlsystems.dev'; +const REPO = SITE.repo; const REPO_ID = import.meta.env.PUBLIC_GISCUS_REPO_ID as string | undefined; const CATEGORY = 'Comments'; const CATEGORY_ID = import.meta.env.PUBLIC_GISCUS_CATEGORY_ID as string | undefined; @@ -73,12 +74,8 @@ export default function Comments() { Giscus . Once configured, replies live in{' '} - - MLSysDev/mlsystems.dev + + {SITE.repo} {' '} discussions.

diff --git a/src/components/Footer.astro b/src/components/Footer.astro index 31ca1aa..cf570c3 100644 --- a/src/components/Footer.astro +++ b/src/components/Footer.astro @@ -83,7 +83,7 @@ const socialIcons = [ @@ -105,7 +105,7 @@ const socialIcons = [
  • Editorial policy
  • diff --git a/src/components/Nav.astro b/src/components/Nav.astro index f2fca98..75a6c8e 100644 --- a/src/components/Nav.astro +++ b/src/components/Nav.astro @@ -152,6 +152,8 @@ const isActive = (path: string) => currentPath === path || currentPath.startsWit const current = document.documentElement.getAttribute('data-theme') || 'light'; const next = current === 'dark' ? 'light' : 'dark'; document.documentElement.setAttribute('data-theme', next); + const themeColor = document.querySelector('meta[name="theme-color"]'); + if (themeColor) themeColor.setAttribute('content', next === 'dark' ? '#0e0d0b' : '#f5f1e8'); try { const raw = localStorage.getItem('mls.prefs'); const prefs = raw ? JSON.parse(raw) : {}; diff --git a/src/content/config.ts b/src/content/config.ts index cc395d6..cb4e0ee 100644 --- a/src/content/config.ts +++ b/src/content/config.ts @@ -1,5 +1,6 @@ import { defineCollection, reference, z } from 'astro:content'; import { glob } from 'astro/loaders'; +import { TOPIC_IDS } from '@/lib/data'; const authors = defineCollection({ loader: glob({ pattern: '**/*.json', base: './src/content/authors' }), @@ -27,9 +28,12 @@ const posts = defineCollection({ authors: z.array(reference('authors')).min(1), date: z.coerce.date(), readMin: z.number().int().positive(), - topic: z.string(), - topicId: z.string(), + // Display name is derived from topicId via topicName(); kept optional for + // backward compat with existing frontmatter but no longer rendered. + topic: z.string().optional(), + topicId: z.enum(TOPIC_IDS), tags: z.array(z.string()).optional(), + updated: z.coerce.date().optional(), cover: z.union([image(), z.string().url()]).optional(), featured: z.boolean().optional().default(false), draft: z.boolean().optional().default(false), diff --git a/src/layouts/BaseLayout.astro b/src/layouts/BaseLayout.astro index c974e1f..02605e6 100644 --- a/src/layouts/BaseLayout.astro +++ b/src/layouts/BaseLayout.astro @@ -2,7 +2,7 @@ // Base layout for every page — handles fonts, SEO, OG tags, JSON-LD, theme attrs. // Pages set their own title/description via props; everything else is sensible defaults. -import { APPEARANCE, SITE } from '@/lib/site'; +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'; @@ -20,10 +20,18 @@ interface Props { ogType?: 'website' | 'article'; /** ISO 8601 date — used for article OG metadata */ publishedTime?: string; + /** ISO 8601 date — article last-modified time */ + modifiedTime?: string; + /** Article section (topic) — OG article metadata */ + section?: string; + /** Article tags — emitted as article:tag OG metadata */ + tags?: string[]; + /** Alt text for the OG image — defaults to the page title */ + imageAlt?: string; /** Set true to keep the page out of search engine indexes */ noindex?: boolean; - /** JSON-LD structured data — pass an object that gets stringified */ - jsonLd?: Record; + /** JSON-LD structured data — one object or an array of objects, stringified as-is */ + jsonLd?: Record | Record[]; } const { @@ -33,6 +41,10 @@ const { image = '/og-default.png', ogType = 'website', publishedTime, + modifiedTime, + section, + tags, + imageAlt, noindex = false, jsonLd, } = Astro.props; @@ -50,7 +62,7 @@ const siteJsonLd = [ url: SITE.url, logo: new URL('/favicon-96x96.png', SITE.url).toString(), description: SITE.description, - sameAs: [SITE.github, 'https://x.com/MLSystemsDev'], + sameAs: [SOCIALS.github, SOCIALS.twitter, SOCIALS.linkedin].filter(Boolean), }, { '@context': 'https://schema.org', @@ -101,6 +113,11 @@ const siteJsonLd = [ if (prefs && validAccents.indexOf(prefs.accent) !== -1) { root.setAttribute('data-accent', prefs.accent); } + const theme = root.getAttribute('data-theme') === 'dark' ? 'dark' : 'light'; + const meta = document.createElement('meta'); + meta.setAttribute('name', 'theme-color'); + meta.setAttribute('content', theme === 'dark' ? '#0e0d0b' : '#f5f1e8'); + document.head.appendChild(meta); } catch {} })(); @@ -117,9 +134,7 @@ const siteJsonLd = [ {noindex && } - - - + @@ -129,15 +144,20 @@ const siteJsonLd = [ + {publishedTime && } + {modifiedTime && } + {section && } + {tags?.map((tag) => )} + @@ -210,9 +230,9 @@ const siteJsonLd = [ -
    +
    +
    diff --git a/src/lib/data.ts b/src/lib/data.ts index ee62192..77153f8 100644 --- a/src/lib/data.ts +++ b/src/lib/data.ts @@ -61,6 +61,25 @@ export const TOPICS: Topic[] = [ }, ]; +// Canonical topic IDs — the single source of truth for the taxonomy. +// Used to validate post frontmatter (see src/content/config.ts). +export const TOPIC_IDS = TOPICS.map((t) => t.id) as [string, ...string[]]; + +const TOPIC_BY_ID = new Map(TOPICS.map((t) => [t.id, t])); + +/** Canonical display name for a topic ID; falls back to the ID if unknown. */ +export function topicName(id: string): string { + return TOPIC_BY_ID.get(id)?.name ?? id; +} + +/** URL-safe slug for a free-text tag (e.g. "KV-cache" → "kv-cache"). */ +export function tagSlug(tag: string): string { + return tag + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, ''); +} + export function countPostsByTopic( posts: T[], ): Record { diff --git a/src/lib/site.ts b/src/lib/site.ts index cb3b406..91ab981 100644 --- a/src/lib/site.ts +++ b/src/lib/site.ts @@ -1,15 +1,35 @@ // Site-wide constants. Edit before launch. +const GITHUB_ORG = 'MLSysDev'; +const GITHUB_REPO = `${GITHUB_ORG}/mlsystems.dev`; +const X_HANDLE = 'MLSystemsDev'; + +// Social links. Set to null to render as a disabled "coming soon" icon. +export const SOCIALS: Record<'discord' | 'github' | 'twitter' | 'linkedin', string | null> = { + discord: 'https://discord.gg/pxEvXN28tc', + github: `https://github.com/${GITHUB_ORG}`, + twitter: `https://x.com/${X_HANDLE}`, + linkedin: 'https://www.linkedin.com/company/mlsystems-dev', +}; + export const SITE = { name: 'ML Systems', domain: 'mlsystems.dev', url: 'https://mlsystems.dev', description: - 'Open community writing about machine learning systems — inference engines, training infrastructure, GPU memory, embeddings, and deployments. Articles, primers, and case studies from anyone learning or building in the space.', + 'Deep dives on machine learning systems: inference, training infrastructure, GPU memory, quantization, and LLM architecture — articles, primers, and case studies.', tagline: 'Machine learning, from kernels to clusters.', author: 'The ML Systems Community', - twitter: '@MLSystemsDev', - github: 'https://github.com/MLSysDev', + twitter: `@${X_HANDLE}`, + // GitHub identity — single source of truth for the org/repo and derived URLs. + github: SOCIALS.github as string, + org: GITHUB_ORG, + repo: GITHUB_REPO, + repoUrl: `https://github.com/${GITHUB_REPO}`, + issuesUrl: `https://github.com/${GITHUB_REPO}/issues`, + pullsUrl: `https://github.com/${GITHUB_REPO}/pulls`, + discussionsUrl: `https://github.com/${GITHUB_REPO}/discussions`, + orgDiscussionsUrl: `https://github.com/orgs/${GITHUB_ORG}/discussions`, pitchEmail: 'admin@mlsystems.dev', // Used in nav, footer, etc. startYear: 2026, @@ -22,11 +42,3 @@ export const APPEARANCE = { background: 'plain', density: 'comfortable', } as const; - -// Social links. Set to null to render as a disabled "coming soon" icon. -export const SOCIALS: Record<'discord' | 'github' | 'twitter' | 'linkedin', string | null> = { - discord: 'https://discord.gg/pxEvXN28tc', - github: 'https://github.com/MLSysDev', - twitter: 'https://x.com/MLSystemsDev', - linkedin: 'https://www.linkedin.com/company/mlsystems-dev', -}; diff --git a/src/pages/about.astro b/src/pages/about.astro index be08055..14c259a 100644 --- a/src/pages/about.astro +++ b/src/pages/about.astro @@ -3,7 +3,7 @@ import BaseLayout from '@/layouts/BaseLayout.astro'; import { SITE } from '@/lib/site'; const GITHUB_URL = 'https://github.com/HumbleBee14'; -const ADMIN_EMAIL = 'admin@mlsystems.dev'; +const ADMIN_EMAIL = SITE.pitchEmail; const links = [ { @@ -21,7 +21,11 @@ const links = [ ]; --- - +
    About
    diff --git a/src/pages/authors/[handle].astro b/src/pages/authors/[handle].astro index 1e236c3..61088b5 100644 --- a/src/pages/authors/[handle].astro +++ b/src/pages/authors/[handle].astro @@ -2,7 +2,7 @@ import { getCollection, getEntries } from 'astro:content'; import type { CollectionEntry } from 'astro:content'; import BaseLayout from '@/layouts/BaseLayout.astro'; -import { formatDate, sortPostsByDate } from '@/lib/data'; +import { formatDate, sortPostsByDate, topicName } from '@/lib/data'; import { SITE } from '@/lib/site'; export async function getStaticPaths() { @@ -27,7 +27,8 @@ const posts = ( ).sort(sortPostsByDate); const topicCounts = posts.reduce>((acc, p) => { - acc[p.data.topic] = (acc[p.data.topic] ?? 0) + 1; + const name = topicName(p.data.topicId); + acc[name] = (acc[name] ?? 0) + 1; return acc; }, {}); const topTopics = Object.entries(topicCounts) @@ -55,13 +56,44 @@ const socials = SOCIAL_FIELDS.flatMap((f) => { if (!value || typeof value !== 'string') return []; return [{ label: f.label, href: f.prefix ? `${f.prefix}${value}` : value }]; }); +const sameAs = socials.map((s) => s.href).filter((h) => h.startsWith('http')); + +const description = + author.data.bio ?? + `Articles by ${author.data.name} on machine learning systems${ + topTopics.length ? ` — ${topTopics.join(', ')}` : '' + }.`; + +const profileJsonLd = { + '@context': 'https://schema.org', + '@type': 'ProfilePage', + mainEntity: { + '@type': 'Person', + name: author.data.name, + url: canonical, + ...(author.data.role ? { jobTitle: author.data.role } : {}), + ...(author.data.bio ? { description: author.data.bio } : {}), + ...(sameAs.length ? { sameAs } : {}), + }, +}; + +const breadcrumbJsonLd = { + '@context': 'https://schema.org', + '@type': 'BreadcrumbList', + itemListElement: [ + { '@type': 'ListItem', position: 1, name: 'Home', item: SITE.url }, + { '@type': 'ListItem', position: 2, name: 'Contributors', item: `${SITE.url}/authors` }, + { '@type': 'ListItem', position: 3, name: author.data.name, item: canonical }, + ], +}; ---
    {
    {p.authors.map((au) => au.data.name).join(' · ')} - {p.data.topic} + {topicName(p.data.topicId)}
    {formatDate(p.data.date.toISOString())} diff --git a/src/pages/authors/index.astro b/src/pages/authors/index.astro index c879d9f..e7083d0 100644 --- a/src/pages/authors/index.astro +++ b/src/pages/authors/index.astro @@ -1,6 +1,7 @@ --- import { getCollection } from 'astro:content'; import BaseLayout from '@/layouts/BaseLayout.astro'; +import { SITE } from '@/lib/site'; const authors = await getCollection('authors'); const posts = await getCollection('posts', ({ data }) => !data.draft); @@ -32,8 +33,8 @@ const contributors = authors
    @@ -42,9 +43,7 @@ const contributors = authors

    {contributors.length} writers from labs, startups, and research groups working on the hard parts of ML systems. Every article is attributed — and most of them are open to questions on{' '} - Discussions. + Discussions.

    diff --git a/src/pages/blog/[slug].astro b/src/pages/blog/[slug].astro index 8ca2c24..3e340c7 100644 --- a/src/pages/blog/[slug].astro +++ b/src/pages/blog/[slug].astro @@ -6,8 +6,9 @@ import { mdxComponents } from '@/components/MDXComponents.tsx'; import ArticleActions from '@/components/ArticleActions.tsx'; import Comments from '@/components/Comments.tsx'; import Avatar from '@/components/Avatar.astro'; -import { formatDate, sortPostsByDate } from '@/lib/data'; +import { formatDate, sortPostsByDate, topicName, tagSlug } from '@/lib/data'; import { SITE } from '@/lib/site'; +import 'katex/dist/katex.min.css'; // Pre-build a page for every published post. export async function getStaticPaths() { @@ -40,18 +41,24 @@ const related = await Promise.all( const canonical = `${SITE.url}/blog/${post.id}`; const dateISO = post.data.date.toISOString(); +const modifiedISO = (post.data.updated ?? post.data.date).toISOString(); +const topicLabel = topicName(post.data.topicId); const cover = post.data.cover; const ogImage = cover ? (typeof cover === 'string' ? cover : cover.src) : `/og/post/${post.id}.png`; -const jsonLd = { +const articleJsonLd = { '@context': 'https://schema.org', '@type': 'Article', headline: post.data.title, description: post.data.summary, image: new URL(ogImage, SITE.url).toString(), datePublished: dateISO, - dateModified: dateISO, - author: authors.map((a) => ({ '@type': 'Person', name: a.data.name })), + dateModified: modifiedISO, + author: authors.map((a) => ({ + '@type': 'Person', + name: a.data.name, + url: `${SITE.url}/authors/${a.id}`, + })), publisher: { '@type': 'Organization', name: SITE.name, @@ -60,7 +67,22 @@ const jsonLd = { }, mainEntityOfPage: { '@type': 'WebPage', '@id': canonical }, keywords: post.data.tags?.join(', '), - articleSection: post.data.topic, + articleSection: topicLabel, +}; + +const breadcrumbJsonLd = { + '@context': 'https://schema.org', + '@type': 'BreadcrumbList', + itemListElement: [ + { '@type': 'ListItem', position: 1, name: 'Home', item: SITE.url }, + { + '@type': 'ListItem', + position: 2, + name: topicLabel, + item: `${SITE.url}/topics/${post.data.topicId}`, + }, + { '@type': 'ListItem', position: 3, name: post.data.title, item: canonical }, + ], }; --- @@ -71,21 +93,30 @@ const jsonLd = { image={ogImage} ogType="article" publishedTime={dateISO} - jsonLd={jsonLd} + modifiedTime={modifiedISO} + section={topicLabel} + tags={post.data.tags} + jsonLd={[articleJsonLd, breadcrumbJsonLd]} >
    ← Back to archive

    {post.data.title}

    @@ -141,7 +172,7 @@ const jsonLd = { {related.map((r) => (

    {r.data.title} diff --git a/src/pages/blog/index.astro b/src/pages/blog/index.astro index b20ede9..46c188d 100644 --- a/src/pages/blog/index.astro +++ b/src/pages/blog/index.astro @@ -2,7 +2,7 @@ import { getCollection, getEntries } from 'astro:content'; import BaseLayout from '@/layouts/BaseLayout.astro'; import BlogFilter from '@/components/BlogFilter.astro'; -import { TOPICS, formatDate, formatMonth, sortPostsByDate } from '@/lib/data'; +import { TOPICS, formatDate, formatMonth, sortPostsByDate, topicName } from '@/lib/data'; const url = new URL(Astro.request.url); const activeTopic = url.searchParams.get('topic') || 'all'; @@ -56,7 +56,7 @@ all.forEach((a) => { @@ -110,6 +110,11 @@ all.forEach((a) => { }); const empty = document.getElementById('blog-empty'); if (empty) empty.style.display = visibleCount === 0 ? '' : 'none'; + const emptyMsg = document.getElementById('blog-empty-msg'); + if (emptyMsg) { + emptyMsg.textContent = + topic === 'all' ? 'No articles yet.' : 'No articles in this topic yet.'; + } } applyFilter(); window.addEventListener('popstate', applyFilter); diff --git a/src/pages/community/index.astro b/src/pages/community/index.astro index 7533a4b..d93af6b 100644 --- a/src/pages/community/index.astro +++ b/src/pages/community/index.astro @@ -8,7 +8,7 @@ const channels = [ blurb: 'Long-form Q&A, paper threads, RFCs. Tied to your GitHub identity — searchable, attributed, permanent.', action: 'Open Discussions', - href: 'https://github.com/orgs/MLSysDev/discussions', + href: SITE.orgDiscussionsUrl, tag: 'asynchronous', }, { @@ -23,7 +23,7 @@ const channels = [ label: 'GitHub Issues', blurb: 'Found a bug on the site, a broken link, or a typo in an article? File it on the repo.', action: 'Report an issue', - href: 'https://github.com/MLSysDev/mlsystems.dev/issues', + href: SITE.issuesUrl, tag: 'bug reports', }, { diff --git a/src/pages/contact.astro b/src/pages/contact.astro index 9d4edcf..d1ed8fc 100644 --- a/src/pages/contact.astro +++ b/src/pages/contact.astro @@ -18,13 +18,13 @@ const channels = [ { label: 'Discussions', action: 'Start a thread', - href: 'https://github.com/orgs/MLSysDev/discussions', + href: SITE.orgDiscussionsUrl, icon: 'M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12', }, { label: 'Issues', action: 'Report a bug', - href: 'https://github.com/MLSysDev/mlsystems.dev/issues', + href: SITE.issuesUrl, icon: 'M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0 1 12 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.203 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.02 10.02 0 0 0 22 12.017C22 6.484 17.522 2 12 2z', }, { @@ -44,7 +44,7 @@ const channels = [
    diff --git a/src/pages/contribute/index.astro b/src/pages/contribute/index.astro index eddf673..f81c955 100644 --- a/src/pages/contribute/index.astro +++ b/src/pages/contribute/index.astro @@ -83,7 +83,7 @@ const STEPS = [ Draft in your browser Email an idea - - Open a PR - + Open a PR

    @@ -150,7 +143,7 @@ const STEPS = [
    -

    Style

    +

    Style

    Concrete over abstract. Numbers over adjectives. One idea per paragraph. Show your work, including the parts that didn't. diff --git a/src/pages/index.astro b/src/pages/index.astro index b7554e0..e50d6e8 100644 --- a/src/pages/index.astro +++ b/src/pages/index.astro @@ -3,7 +3,7 @@ import { getCollection, getEntries } from 'astro:content'; import BaseLayout from '@/layouts/BaseLayout.astro'; import HeroFigure from '@/components/HeroFigure.tsx'; import ToolGlyph from '@/components/ToolGlyph.tsx'; -import { TOPICS, countPostsByTopic, formatDate, sortPostsByDate } from '@/lib/data'; +import { TOPICS, countPostsByTopic, formatDate, sortPostsByDate, topicName } from '@/lib/data'; import { SITE, SOCIALS } from '@/lib/site'; const allPostsRaw = (await getCollection('posts', ({ data }) => !data.draft)).sort(sortPostsByDate); @@ -45,8 +45,8 @@ const featuredTools = allTools ---

    @@ -107,7 +107,7 @@ const featuredTools = allTools
    - +
    @@ -128,7 +128,7 @@ const featuredTools = allTools featuredFinal.map((p) => (

    {p.data.title}

    {p.data.summary}

    @@ -234,7 +234,7 @@ const featuredTools = allTools discussions, and feature proposals. Searchable, attributed, permanent.

    @@ -389,7 +389,7 @@ const featuredTools = allTools } .topic-tab.active { background: var(--accent); - color: #fff; + color: var(--paper); border-color: var(--accent); } .topic-tab-count { diff --git a/src/pages/og/page/[slug].png.ts b/src/pages/og/page/[slug].png.ts index 44ca2c4..e7d8f0f 100644 --- a/src/pages/og/page/[slug].png.ts +++ b/src/pages/og/page/[slug].png.ts @@ -9,6 +9,9 @@ const SECTIONS: Record = { playground: 'Playground tools', contribute: 'Write with us', contact: 'Contact us', + authors: 'Contributors', + about: 'About ML Systems', + search: 'Search', }; export async function getStaticPaths() { diff --git a/src/pages/og/post/[slug].png.ts b/src/pages/og/post/[slug].png.ts index b95b1aa..8762f6e 100644 --- a/src/pages/og/post/[slug].png.ts +++ b/src/pages/og/post/[slug].png.ts @@ -3,6 +3,7 @@ import { getCollection, getEntries } from 'astro:content'; import type { CollectionEntry } from 'astro:content'; import { generateOgPng } from '@/lib/og'; import { pngResponseWithFallback } from '@/lib/og-response'; +import { topicName } from '@/lib/data'; export async function getStaticPaths() { const posts = await getCollection('posts', ({ data }) => !data.draft && !data.cover); @@ -19,7 +20,7 @@ export const GET: APIRoute = async ({ props }) => { return generateOgPng({ title: post.data.title, authorNames: authors.map((a) => a.data.name).join(', '), - topic: post.data.topic, + topic: topicName(post.data.topicId), }); }, `post:${post.id}`); }; diff --git a/src/pages/playground/[tool].astro b/src/pages/playground/[tool].astro index a5d3680..548ad93 100644 --- a/src/pages/playground/[tool].astro +++ b/src/pages/playground/[tool].astro @@ -23,6 +23,16 @@ const { Content } = await render(tool); const authors = tool.data.authors ? await getEntries(tool.data.authors) : []; const canonical = `${SITE.url}/playground/${tool.id}`; const ogImage = `/og/tool/${tool.id}.png`; + +const breadcrumbJsonLd = { + '@context': 'https://schema.org', + '@type': 'BreadcrumbList', + itemListElement: [ + { '@type': 'ListItem', position: 1, name: 'Home', item: SITE.url }, + { '@type': 'ListItem', position: 2, name: 'Playground', item: `${SITE.url}/playground` }, + { '@type': 'ListItem', position: 3, name: tool.data.name, item: canonical }, + ], +}; ---
    { border-radius: 99px; } .tool-card-live .tool-card-tag { - color: #fff; + color: var(--paper); background: var(--accent); border-color: var(--accent); } diff --git a/src/pages/rss.xml.ts b/src/pages/rss.xml.ts index f735672..6200112 100644 --- a/src/pages/rss.xml.ts +++ b/src/pages/rss.xml.ts @@ -2,7 +2,7 @@ import rss from '@astrojs/rss'; import { getCollection, getEntries } from 'astro:content'; import type { APIContext } from 'astro'; import { SITE } from '@/lib/site'; -import { sortPostsByDate } from '@/lib/data'; +import { sortPostsByDate, topicName } from '@/lib/data'; export async function GET(context: APIContext) { const postsRaw = (await getCollection('posts', ({ data }) => !data.draft)).sort(sortPostsByDate); @@ -24,7 +24,7 @@ export async function GET(context: APIContext) { pubDate: p.data.date, link: `/blog/${p.id}/`, author: p.authorNames, - categories: [p.data.topic, ...(p.data.tags ?? [])], + categories: [topicName(p.data.topicId), ...(p.data.tags ?? [])], customData: `${escapeXml(p.authorNames)}`, })), customData: `en-us`, diff --git a/src/pages/search.astro b/src/pages/search.astro index 3df99d5..7a09cb2 100644 --- a/src/pages/search.astro +++ b/src/pages/search.astro @@ -8,6 +8,7 @@ import { TOPICS } from '@/lib/data'; title="Search" description="Search the ML Systems archive — articles, topics, tools, and authors." image="/og/page/search.png" + noindex >
    diff --git a/src/pages/tags/[tag].astro b/src/pages/tags/[tag].astro new file mode 100644 index 0000000..5e023c6 --- /dev/null +++ b/src/pages/tags/[tag].astro @@ -0,0 +1,105 @@ +--- +import { getCollection, getEntries } from 'astro:content'; +import BaseLayout from '@/layouts/BaseLayout.astro'; +import { formatDate, sortPostsByDate, topicName, tagSlug } from '@/lib/data'; +import { SITE } from '@/lib/site'; + +export async function getStaticPaths() { + const posts = await getCollection('posts', ({ data }) => !data.draft); + const byslug = new Map(); + for (const p of posts) { + for (const tag of p.data.tags ?? []) { + const slug = tagSlug(tag); + if (slug && !byslug.has(slug)) byslug.set(slug, tag); + } + } + return [...byslug.entries()].map(([slug, label]) => ({ + params: { tag: slug }, + props: { slug, label }, + })); +} + +interface Props { + slug: string; + label: string; +} +const { slug, label } = Astro.props; + +const allRaw = (await getCollection('posts', ({ data }) => !data.draft)) + .filter((p) => (p.data.tags ?? []).some((t) => tagSlug(t) === slug)) + .sort(sortPostsByDate); +const posts = await Promise.all( + allRaw.map(async (p) => ({ ...p, authors: await getEntries(p.data.authors) })), +); + +const canonical = `${SITE.url}/tags/${slug}`; + +const collectionJsonLd = { + '@context': 'https://schema.org', + '@type': 'CollectionPage', + name: `#${label} — ${SITE.name}`, + description: `Articles tagged ${label} on ${SITE.name}.`, + url: canonical, + isPartOf: { '@type': 'WebSite', name: SITE.name, url: SITE.url }, + mainEntity: { + '@type': 'ItemList', + itemListElement: posts.map((p, i) => ({ + '@type': 'ListItem', + position: i + 1, + url: `${SITE.url}/blog/${p.id}`, + name: p.data.title, + })), + }, +}; + +const breadcrumbJsonLd = { + '@context': 'https://schema.org', + '@type': 'BreadcrumbList', + itemListElement: [ + { '@type': 'ListItem', position: 1, name: 'Home', item: SITE.url }, + { '@type': 'ListItem', position: 2, name: 'Tags', item: `${SITE.url}/tags` }, + { '@type': 'ListItem', position: 3, name: `#${label}`, item: canonical }, + ], +}; +--- + + +
    +
    +
    +
    Tag
    +

    #{label}

    +

    + {posts.length} + {posts.length === 1 ? 'article' : 'articles'} tagged {label}. +

    +
    +
    + + +
    +
    diff --git a/src/pages/tags/index.astro b/src/pages/tags/index.astro new file mode 100644 index 0000000..45e74ef --- /dev/null +++ b/src/pages/tags/index.astro @@ -0,0 +1,80 @@ +--- +import { getCollection } from 'astro:content'; +import BaseLayout from '@/layouts/BaseLayout.astro'; +import { SITE } from '@/lib/site'; +import { tagSlug } from '@/lib/data'; + +const posts = await getCollection('posts', ({ data }) => !data.draft); +const counts = new Map(); +for (const p of posts) { + for (const tag of p.data.tags ?? []) { + const slug = tagSlug(tag); + if (!slug) continue; + const entry = counts.get(slug) ?? { label: tag, count: 0 }; + entry.count += 1; + counts.set(slug, entry); + } +} +const tags = [...counts.entries()] + .map(([slug, { label, count }]) => ({ slug, label, count })) + .sort((a, b) => b.count - a.count || a.label.localeCompare(b.label)); + +const canonical = `${SITE.url}/tags`; +--- + + +
    +
    +
    Browse
    +

    Tags.

    +

    Every topic tag across the archive, most-used first.

    +
    + +
    + { + tags.map((t) => ( + + #{t.label} {t.count} + + )) + } +
    +
    + + +
    diff --git a/src/pages/topics/[id].astro b/src/pages/topics/[id].astro index 1265b51..6615563 100644 --- a/src/pages/topics/[id].astro +++ b/src/pages/topics/[id].astro @@ -5,7 +5,12 @@ import { TOPICS, formatDate, formatMonth, sortPostsByDate } from '@/lib/data'; import { SITE } from '@/lib/site'; export async function getStaticPaths() { - return TOPICS.map((t) => ({ params: { id: t.id }, props: { topic: t } })); + const posts = await getCollection('posts', ({ data }) => !data.draft); + const active = new Set(posts.map((p) => p.data.topicId)); + return TOPICS.filter((t) => active.has(t.id)).map((t) => ({ + params: { id: t.id }, + props: { topic: t }, + })); } interface Props { @@ -28,9 +33,9 @@ posts.forEach((a) => { grouped[m].push(a); }); -const canonical = `${SITE.url}/topics/${topic.id}/`; +const canonical = `${SITE.url}/topics/${topic.id}`; -const jsonLd = { +const collectionJsonLd = { '@context': 'https://schema.org', '@type': 'CollectionPage', name: `${topic.name} — ${SITE.name}`, @@ -42,18 +47,29 @@ const jsonLd = { itemListElement: posts.map((p, i) => ({ '@type': 'ListItem', position: i + 1, - url: `${SITE.url}/blog/${p.id}/`, + url: `${SITE.url}/blog/${p.id}`, name: p.data.title, })), }, }; + +const breadcrumbJsonLd = { + '@context': 'https://schema.org', + '@type': 'BreadcrumbList', + itemListElement: [ + { '@type': 'ListItem', position: 1, name: 'Home', item: SITE.url }, + { '@type': 'ListItem', position: 2, name: 'Topics', item: `${SITE.url}/topics` }, + { '@type': 'ListItem', position: 3, name: topic.name, item: canonical }, + ], +}; ---
    diff --git a/src/pages/topics/index.astro b/src/pages/topics/index.astro index 7823880..e8d0e82 100644 --- a/src/pages/topics/index.astro +++ b/src/pages/topics/index.astro @@ -5,6 +5,7 @@ import { TOPICS, countPostsByTopic } from '@/lib/data'; const posts = await getCollection('posts', ({ data }) => !data.draft); const topicCounts = countPostsByTopic(posts); +const visibleTopics = TOPICS.filter((t) => (topicCounts[t.id] ?? 0) > 0); --- { - TOPICS.map((t, i) => ( - + visibleTopics.map((t, i) => ( + {String(i + 1).padStart(2, '0')}

    {t.name}

    {t.desc} - {topicCounts[t.id] ?? 0} articles + + {topicCounts[t.id]} {topicCounts[t.id] === 1 ? 'article' : 'articles'} +
    )) } diff --git a/src/pages/write.astro b/src/pages/write.astro index 5d991b0..e8109f0 100644 --- a/src/pages/write.astro +++ b/src/pages/write.astro @@ -11,7 +11,7 @@ const authors = authorEntries .sort((a, b) => (a.id === 'guest' ? -1 : b.id === 'guest' ? 1 : a.name.localeCompare(b.name))); const topics = TOPICS.map((t) => ({ id: t.id, name: t.name })); -const repoUrl = SITE.github; +const repoUrl = SITE.repoUrl; ---

    Write an article

    - Draft your post here — no Markdown, no setup. When you’re done, download a ready-to-publish - folder and send it to us as a pull request or by email. Prefer - writing MDX by hand? That works too — see the authoring guide.

    - + diff --git a/src/styles/global.css b/src/styles/global.css index 9d04fbe..1e6b8e6 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -1,6 +1,6 @@ /* ML Systems — design tokens & base */ - -@import 'katex/dist/katex.min.css'; +/* KaTeX CSS is imported per-context (article page + /write editor), not + globally, so non-math pages don't ship ~23KB of unused CSS. */ html { overflow-x: hidden; @@ -81,6 +81,17 @@ html { --sp-9: 96px; --sp-10: 128px; + /* Border radius scale */ + --radius-xs: 3px; + --radius-sm: 6px; + --radius-md: 8px; + --radius-lg: 12px; + --radius-pill: 99px; + --radius-circle: 50%; + + /* Elevation — shared hover lift used by cards */ + --shadow-hover: 0 8px 24px -14px color-mix(in srgb, var(--accent) 40%, transparent); + /* Layout */ --content-w: 1180px; --text-w: 680px; @@ -188,6 +199,21 @@ html { *::after { box-sizing: border-box; } +:where(a, button, input, textarea, select, summary, [tabindex]):focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; + border-radius: 3px; +} +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + scroll-behavior: auto !important; + } +} html, body { margin: 0; @@ -551,7 +577,7 @@ main { .btn-primary:hover { background: var(--accent); border-color: var(--accent); - color: #fff; + color: var(--paper); } .btn-arrow { transition: transform 0.15s ease; diff --git a/src/write/WritePortal.tsx b/src/write/WritePortal.tsx index fbde10b..376cb7d 100644 --- a/src/write/WritePortal.tsx +++ b/src/write/WritePortal.tsx @@ -9,6 +9,7 @@ import { } from '@blocknote/react'; import { BlockNoteView } from '@blocknote/mantine'; import '@blocknote/mantine/style.css'; +import 'katex/dist/katex.min.css'; import { schema } from './editor/schema'; import { getSlashItems } from './editor/slashMenu'; import { MetaForm, type Option } from './meta/MetaForm'; @@ -57,8 +58,18 @@ type Props = { authors: Option[]; topics: Option[]; repoUrl: string; + contactEmail: string; }; +function isEmptyDraft(meta: PostMeta, blocks: SBlock[]): boolean { + const metaEmpty = + !meta.title.trim() && !meta.summary.trim() && !meta.slug.trim() && meta.tags.length === 0; + const blocksEmpty = blocks.every( + (b) => b.type === 'paragraph' && (!Array.isArray(b.content) || b.content.length === 0), + ); + return metaEmpty && blocksEmpty; +} + function emptyMeta(defaultAuthor: string): PostMeta { return { title: '', @@ -73,7 +84,7 @@ function emptyMeta(defaultAuthor: string): PostMeta { }; } -export default function WritePortal({ authors, topics, repoUrl }: Props) { +export default function WritePortal({ authors, topics, repoUrl, contactEmail }: Props) { const editor = useCreateBlockNote({ schema }); const [meta, setMeta] = useState(() => emptyMeta(authors[0]?.id ?? 'guest')); const [tableVariants, setTableVariants] = useState>({}); @@ -83,6 +94,7 @@ export default function WritePortal({ authors, topics, repoUrl }: Props) { const [issues, setIssues] = useState([]); const [storageOff, setStorageOff] = useState(false); const [busy, setBusy] = useState(false); + const [sentFile, setSentFile] = useState(null); const [siteTheme, setSiteTheme] = useState<'light' | 'dark'>('light'); const variantCss = useMemo(() => tableVariantCss(tableVariants), [tableVariants]); @@ -98,7 +110,7 @@ export default function WritePortal({ authors, topics, repoUrl }: Props) { useEffect(() => { const draft = loadDraft(); - if (draft) setRestore({ savedAt: draft.savedAt }); + if (draft && !isEmptyDraft(draft.meta, draft.blocks)) setRestore({ savedAt: draft.savedAt }); }, []); useEffect(() => { @@ -139,8 +151,10 @@ export default function WritePortal({ authors, topics, repoUrl }: Props) { const autosave = useCallback(() => { if (restore) return; + setSentFile(null); + if (isEmptyDraft(meta, editor.document as unknown as SBlock[])) return; saveDraftDebounced(getDraft, () => setStorageOff(true)); - }, [getDraft, restore]); + }, [getDraft, restore, editor, meta]); useEditorSelectionChange(() => { try { @@ -196,6 +210,7 @@ export default function WritePortal({ authors, topics, repoUrl }: Props) { URL.revokeObjectURL(url); clearDraft(); await clearStoredAssets().catch(() => undefined); + setSentFile(`${meta.slug}.zip`); } catch { setIssues(['Something went wrong while packaging your post. Please try downloading again.']); } finally { @@ -320,6 +335,39 @@ export default function WritePortal({ authors, topics, repoUrl }: Props) {
    )} + {sentFile && ( +
    +
    + Downloaded {sentFile} ✓ + +
    +

    + That file is your whole post — text, images, everything we need to publish it. Here’s + how to send it to us: +

    + +

    + Emailing? Attach the {sentFile} file — your browser saved it to your Downloads folder. +

    +
    + )} +
    {storageOff && ( Autosave is off — your browser blocked storage. diff --git a/src/write/editor/blocks/FigureBlock.tsx b/src/write/editor/blocks/FigureBlock.tsx index c57bcd6..648174c 100644 --- a/src/write/editor/blocks/FigureBlock.tsx +++ b/src/write/editor/blocks/FigureBlock.tsx @@ -92,13 +92,14 @@ export const createFigureBlock = createReactBlockSpec(
    { removeAsset(name); @@ -53,7 +54,7 @@ export const createGalleryBlock = createReactBlockSpec( ); }} > - ✕ +
    , ), + table: svg( + <> + + + + + , + ), note: svg(), divider: svg( <> @@ -118,6 +126,7 @@ export function getSlashItems(editor: WriteEditor) { subtext: 'A 3×3 table — the first row is the header', aliases: ['table', 'grid', 'rows', 'columns'], group: 'Basic blocks', + icon: ICONS.table, onItemClick: () => insertOrUpdateBlockForSlashMenu(editor, { type: 'table', content: EMPTY_3X3 }), }, diff --git a/src/write/meta/MetaForm.tsx b/src/write/meta/MetaForm.tsx index f73ec7b..e03b455 100644 --- a/src/write/meta/MetaForm.tsx +++ b/src/write/meta/MetaForm.tsx @@ -31,6 +31,7 @@ export function MetaForm({ authors, topics, meta, onChange }: Props) { type="text" className="write-title" placeholder="Title" + aria-label="Post title" value={meta.title} onChange={(e) => set({ title: e.target.value, ...(slugLocked ? {} : { slug: slugify(e.target.value) }) }) @@ -40,6 +41,7 @@ export function MetaForm({ authors, topics, meta, onChange }: Props) { className="write-summary" rows={2} placeholder="A one-sentence summary — shows under the title and on social cards" + aria-label="Post summary" value={meta.summary} onChange={(e) => set({ summary: e.target.value })} />