From 9a4f8dc74621062164bce72b88a84dabd6d099f9 Mon Sep 17 00:00:00 2001 From: Dinesh <13635627+HumbleBee14@users.noreply.github.com> Date: Sun, 12 Jul 2026 08:02:43 -0700 Subject: [PATCH 1/6] Fix dark-mode contrast, add site-wide a11y, improve writer onboarding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Contrast (P0): white-on-accent was unreadable on the dark salmon accent — switch active topic tab, .btn-primary:hover, and playground live-tag from #fff to var(--paper) (matching the convention other accent buttons use). Accessibility: - Add a site-wide :focus-visible ring for links, buttons, and form controls - Restore a keyboard focus ring on the Title/Summary inputs (they hard- stripped outline) - Give Title/Summary accessible names via aria-label theme-color meta: drove browser chrome off prefers-color-scheme, but the site theme is data-theme (default light, manual toggle, no OS-follow). Set it from the resolved theme in the pre-paint script and update it on toggle so chrome always matches the page. Writer onboarding: - Lead the intro with email (the realistic path for non-coders); stop linking 'pull request' to /pulls, which can't accept a submission - Add a post-download panel explaining what the .zip is and the two ways to send it (email-first, PR for GitHub users); auto-dismiss on next edit - Skip saving/offering an effectively empty draft --- src/components/Nav.astro | 2 ++ src/layouts/BaseLayout.astro | 9 +++-- src/pages/index.astro | 2 +- src/pages/playground/index.astro | 2 +- src/pages/write.astro | 16 +++++---- src/styles/global.css | 7 +++- src/write/WritePortal.tsx | 53 +++++++++++++++++++++++++-- src/write/editor/editor-theme.css | 60 ++++++++++++++++++++++++++++++- src/write/meta/MetaForm.tsx | 2 ++ 9 files changed, 137 insertions(+), 16 deletions(-) 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/layouts/BaseLayout.astro b/src/layouts/BaseLayout.astro index c974e1f..72e2b2c 100644 --- a/src/layouts/BaseLayout.astro +++ b/src/layouts/BaseLayout.astro @@ -101,6 +101,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 +122,7 @@ const siteJsonLd = [ {noindex && } - - - + diff --git a/src/pages/index.astro b/src/pages/index.astro index b7554e0..027ed04 100644 --- a/src/pages/index.astro +++ b/src/pages/index.astro @@ -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/playground/index.astro b/src/pages/playground/index.astro index b96dbd4..8a24b6f 100644 --- a/src/pages/playground/index.astro +++ b/src/pages/playground/index.astro @@ -307,7 +307,7 @@ const catalogTools = allTools.sort((a, b) => { 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/write.astro b/src/pages/write.astro index 5d991b0..7a39126 100644 --- a/src/pages/write.astro +++ b/src/pages/write.astro @@ -22,16 +22,20 @@ const repoUrl = SITE.github;

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..4ac09e2 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -188,6 +188,11 @@ 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; +} html, body { margin: 0; @@ -551,7 +556,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..0b826c1 100644 --- a/src/write/WritePortal.tsx +++ b/src/write/WritePortal.tsx @@ -57,8 +57,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 +83,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 +93,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 +109,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 +150,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 +209,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 +334,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/editor-theme.css b/src/write/editor/editor-theme.css index 16eeb3a..181a6ac 100644 --- a/src/write/editor/editor-theme.css +++ b/src/write/editor/editor-theme.css @@ -38,6 +38,13 @@ outline: none; } +.write-title:focus-visible, +.write-summary:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 4px; + border-radius: 4px; +} + .write-meta-row { display: flex; flex-wrap: wrap; @@ -216,11 +223,17 @@ } .write-download { + display: inline-flex; + align-items: center; + justify-content: center; background: var(--accent); color: var(--paper); - border-color: var(--accent); + border: 1px solid var(--accent); + border-radius: 8px; font-size: 13px; padding: 10px 20px; + cursor: pointer; + text-decoration: none; } .write-download:disabled { @@ -228,6 +241,51 @@ cursor: default; } +.write-done { + margin-top: 24px; + padding: 18px 20px; + border: 1px solid var(--line-2); + border-radius: 10px; + background: var(--accent-soft); + font-size: 14px; + line-height: 1.55; +} + +.write-done-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 6px; +} + +.write-done-head strong { + font-size: 15px; +} + +.write-done p { + margin: 6px 0; + color: var(--ink-2); +} + +.write-done-ways { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 16px; + margin: 12px 0 4px; +} + +.write-done-alt { + color: var(--accent); + font-size: 13px; + text-decoration: none; +} + +.write-done-alt:hover { + text-decoration: underline; +} + .write-note-inline { font-size: 12px; color: var(--ink-3); 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 })} /> From 31ffc1ebd215e255c4b8a56b0b093dc06e8f4854 Mon Sep 17 00:00:00 2001 From: Dinesh <13635627+HumbleBee14@users.noreply.github.com> Date: Sun, 12 Jul 2026 08:16:28 -0700 Subject: [PATCH 2/6] Centralize GitHub/config identity; fix topic taxonomy drift Decoupling (for future host/backend migration): - Add SITE.org/repo/repoUrl/issuesUrl/pullsUrl/discussionsUrl derived from one org+repo constant; dedupe SITE.github vs SOCIALS.github and the two Twitter encodings into single sources - Replace ~10 hardcoded github.com/MLSysDev URLs across Footer, Comments, contact, community, contribute, index, authors, write, ArticleActions with SITE helpers; use SITE.pitchEmail in about - write portal repoUrl now points at the repo (was the org URL), fixing the guide link and the ZIP 'fork' instruction - Organization sameAs now uses SOCIALS (adds LinkedIn) Taxonomy single source of truth: - Add TOPIC_IDS + topicName() to data.ts; validate post topicId with z.enum(TOPIC_IDS) so an unknown/typo topic fails the build - Derive the display name from topicId everywhere (home, blog index, article, author page, RSS, OG image, pagefind meta) instead of the denormalized frontmatter 'topic' string, which had drifted (e.g. 'Inference' vs canonical 'Inference & Serving'); 'topic' is now optional and unused - Add optional 'updated' date to the posts schema (for dateModified) --- src/components/ArticleActions.tsx | 5 +++-- src/components/Comments.tsx | 13 +++++------- src/components/Footer.astro | 2 +- src/content/config.ts | 8 ++++++-- src/layouts/BaseLayout.astro | 4 ++-- src/lib/data.ts | 11 ++++++++++ src/lib/site.ts | 34 +++++++++++++++++++++---------- src/pages/about.astro | 2 +- src/pages/authors/[handle].astro | 7 ++++--- src/pages/authors/index.astro | 5 ++--- src/pages/blog/[slug].astro | 10 ++++----- src/pages/blog/index.astro | 4 ++-- src/pages/community/index.astro | 4 ++-- src/pages/contact.astro | 4 ++-- src/pages/contribute/index.astro | 11 ++-------- src/pages/index.astro | 6 +++--- src/pages/og/post/[slug].png.ts | 3 ++- src/pages/rss.xml.ts | 4 ++-- src/pages/write.astro | 4 ++-- 19 files changed, 80 insertions(+), 61 deletions(-) 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..26e2902 100644 --- a/src/components/Footer.astro +++ b/src/components/Footer.astro @@ -105,7 +105,7 @@ const socialIcons = [
  • Editorial policy
  • 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 72e2b2c..bda58fd 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'; @@ -50,7 +50,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', diff --git a/src/lib/data.ts b/src/lib/data.ts index ee62192..ca57e30 100644 --- a/src/lib/data.ts +++ b/src/lib/data.ts @@ -61,6 +61,17 @@ 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; +} + 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..62cc324 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 = [ { diff --git a/src/pages/authors/[handle].astro b/src/pages/authors/[handle].astro index 1e236c3..d8d86d8 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) @@ -140,7 +141,7 @@ const socials = SOCIAL_FIELDS.flatMap((f) => {
    {p.authors.map((au) => au.data.name).join(' · ')} - {p.data.topic} + {topicName(p.data.topicId)}
    diff --git a/src/pages/blog/[slug].astro b/src/pages/blog/[slug].astro index 8ca2c24..7440097 100644 --- a/src/pages/blog/[slug].astro +++ b/src/pages/blog/[slug].astro @@ -6,7 +6,7 @@ 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 } from '@/lib/data'; import { SITE } from '@/lib/site'; // Pre-build a page for every published post. @@ -60,7 +60,7 @@ const jsonLd = { }, mainEntityOfPage: { '@type': 'WebPage', '@id': canonical }, keywords: post.data.tags?.join(', '), - articleSection: post.data.topic, + articleSection: topicName(post.data.topicId), }; --- @@ -76,14 +76,14 @@ const jsonLd = {
    ← Back to archive @@ -141,7 +141,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..71d1b5d 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) => {

    {a.data.title}

    - {a.data.topic} + {topicName(a.data.topicId)} {a.data.tags?.slice(0, 2).map((t) => ( #{t} ))} 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..94d2e2e 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', }, { diff --git a/src/pages/contribute/index.astro b/src/pages/contribute/index.astro index eddf673..30d45b4 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

    diff --git a/src/pages/index.astro b/src/pages/index.astro index 027ed04..48b529c 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); @@ -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.

    !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/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/write.astro b/src/pages/write.astro index 7a39126..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; --- authoring guide.

    From dfa0cdfa912bff2dd36fe23b6c06c91ad895666b Mon Sep 17 00:00:00 2001 From: Dinesh <13635627+HumbleBee14@users.noreply.github.com> Date: Sun, 12 Jul 2026 08:24:01 -0700 Subject: [PATCH 3/6] SEO: URL consistency, tag pages, breadcrumbs, richer metadata Indexing & canonicals: - trailingSlash: 'never' so canonicals, internal links, and the sitemap all agree on the no-trailing-slash form (was split, canonical vs sitemap) - noindex the /search page and exclude it from the sitemap (thin content) New indexable surface: - /tags/[tag] archive pages for every post tag (keyword-targeted titles, CollectionPage + BreadcrumbList JSON-LD) + a /tags index; article-byline tags now link to them; /tags/* added to sitemap priorities Structured data: - BreadcrumbList JSON-LD on posts, topics, tags, authors, tools - ProfilePage + Person schema on author pages; Article authors now carry a url; BaseLayout jsonLd accepts an array Metadata: - Retarget homepage + topic-page titles/descriptions to core ML-systems keywords (drop the doubled topic phrasing); trim SITE.description; enrich thin about/contact/authors descriptions - og:image:alt + twitter:site sitewide; article:modified_time/section/tag on posts; dateModified + sitemap lastmod now honor an optional 'updated' date; add missing about/search/authors OG page images --- astro.config.mjs | 11 ++-- src/layouts/BaseLayout.astro | 21 +++++- src/lib/data.ts | 8 +++ src/pages/about.astro | 6 +- src/pages/authors/[handle].astro | 33 +++++++++- src/pages/authors/index.astro | 4 +- src/pages/blog/[slug].astro | 44 +++++++++++-- src/pages/contact.astro | 2 +- src/pages/index.astro | 4 +- src/pages/og/page/[slug].png.ts | 3 + src/pages/playground/[tool].astro | 11 ++++ src/pages/search.astro | 1 + src/pages/tags/[tag].astro | 105 ++++++++++++++++++++++++++++++ src/pages/tags/index.astro | 80 +++++++++++++++++++++++ src/pages/topics/[id].astro | 23 +++++-- 15 files changed, 330 insertions(+), 26 deletions(-) create mode 100644 src/pages/tags/[tag].astro create mode 100644 src/pages/tags/index.astro 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/layouts/BaseLayout.astro b/src/layouts/BaseLayout.astro index bda58fd..a544e55 100644 --- a/src/layouts/BaseLayout.astro +++ b/src/layouts/BaseLayout.astro @@ -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; @@ -132,15 +144,20 @@ const siteJsonLd = [ + {publishedTime && } + {modifiedTime && } + {section && } + {tags?.map((tag) => )} + diff --git a/src/lib/data.ts b/src/lib/data.ts index ca57e30..77153f8 100644 --- a/src/lib/data.ts +++ b/src/lib/data.ts @@ -72,6 +72,14 @@ 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/pages/about.astro b/src/pages/about.astro index 62cc324..14c259a 100644 --- a/src/pages/about.astro +++ b/src/pages/about.astro @@ -21,7 +21,11 @@ const links = [ ]; --- - +
    About
    diff --git a/src/pages/authors/[handle].astro b/src/pages/authors/[handle].astro index d8d86d8..61088b5 100644 --- a/src/pages/authors/[handle].astro +++ b/src/pages/authors/[handle].astro @@ -56,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 }, + ], +}; ---
    diff --git a/src/pages/blog/[slug].astro b/src/pages/blog/[slug].astro index 7440097..892a5e9 100644 --- a/src/pages/blog/[slug].astro +++ b/src/pages/blog/[slug].astro @@ -6,7 +6,7 @@ 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, topicName } from '@/lib/data'; +import { formatDate, sortPostsByDate, topicName, tagSlug } from '@/lib/data'; import { SITE } from '@/lib/site'; // Pre-build a page for every published post. @@ -40,18 +40,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 +66,22 @@ const jsonLd = { }, mainEntityOfPage: { '@type': 'WebPage', '@id': canonical }, keywords: post.data.tags?.join(', '), - articleSection: topicName(post.data.topicId), + 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,7 +92,10 @@ const jsonLd = { image={ogImage} ogType="article" publishedTime={dateISO} - jsonLd={jsonLd} + modifiedTime={modifiedISO} + section={topicLabel} + tags={post.data.tags} + jsonLd={[articleJsonLd, breadcrumbJsonLd]} >
    {topicName(post.data.topicId)} - {post.data.tags?.map((t) => #{t})} + { + post.data.tags?.map((t) => ( + + #{t} + + )) + }

    {post.data.title}

    diff --git a/src/pages/contact.astro b/src/pages/contact.astro index 94d2e2e..d1ed8fc 100644 --- a/src/pages/contact.astro +++ b/src/pages/contact.astro @@ -44,7 +44,7 @@ const channels = [
    diff --git a/src/pages/index.astro b/src/pages/index.astro index 48b529c..9bd7e81 100644 --- a/src/pages/index.astro +++ b/src/pages/index.astro @@ -45,8 +45,8 @@ const featuredTools = allTools ---
    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/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 }, + ], +}; ---
    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..4f981e7 100644 --- a/src/pages/topics/[id].astro +++ b/src/pages/topics/[id].astro @@ -28,9 +28,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 +42,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 }, + ], +}; ---
    From f8148a1d5730b2b514317dc1083e7a55cd9f5a1e Mon Sep 17 00:00:00 2001 From: Dinesh <13635627+HumbleBee14@users.noreply.github.com> Date: Sun, 12 Jul 2026 08:27:58 -0700 Subject: [PATCH 4/6] a11y, IA & performance polish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Accessibility: - Global prefers-reduced-motion block (was honored only on /about) — neutralizes hover lifts, search-modal pop, hero fade, anchor pop, etc. - Accessible names on Figure/Gallery remove buttons (aria-label + the glyph marked aria-hidden) - Wrap the top nav in a
    landmark Information architecture: - Hide zero-article topics on /topics and stop generating empty topic pages (no thin pages in the sitemap); consistent with the homepage - Footer 'Most read' (duplicate of /blog) → 'Tags' (also surfaces the new /tags index) - Homepage 'Read the style guide' now anchors to #style-guide - Blog empty-state copy is correct under the all-view vs a topic filter Performance: - HeroFigure hydrates client:visible instead of client:load (homepage main-thread win) - KaTeX CSS scoped to the article page + /write editor instead of the global sheet — non-math pages drop ~23KB (verified: 0 KaTeX font refs on home, present on articles) --- src/components/Footer.astro | 2 +- src/layouts/BaseLayout.astro | 4 ++-- src/pages/blog/[slug].astro | 1 + src/pages/blog/index.astro | 7 ++++++- src/pages/contribute/index.astro | 2 +- src/pages/index.astro | 4 ++-- src/pages/topics/[id].astro | 7 ++++++- src/pages/topics/index.astro | 9 ++++++--- src/styles/global.css | 14 ++++++++++++-- src/write/WritePortal.tsx | 1 + src/write/editor/blocks/FigureBlock.tsx | 3 ++- src/write/editor/blocks/GalleryBlock.tsx | 3 ++- 12 files changed, 42 insertions(+), 15 deletions(-) diff --git a/src/components/Footer.astro b/src/components/Footer.astro index 26e2902..cf570c3 100644 --- a/src/components/Footer.astro +++ b/src/components/Footer.astro @@ -83,7 +83,7 @@ const socialIcons = [
    diff --git a/src/layouts/BaseLayout.astro b/src/layouts/BaseLayout.astro index a544e55..02605e6 100644 --- a/src/layouts/BaseLayout.astro +++ b/src/layouts/BaseLayout.astro @@ -230,9 +230,9 @@ const siteJsonLd = [ -
    +
    +
    diff --git a/src/pages/blog/[slug].astro b/src/pages/blog/[slug].astro index 892a5e9..3e340c7 100644 --- a/src/pages/blog/[slug].astro +++ b/src/pages/blog/[slug].astro @@ -8,6 +8,7 @@ import Comments from '@/components/Comments.tsx'; import Avatar from '@/components/Avatar.astro'; 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() { diff --git a/src/pages/blog/index.astro b/src/pages/blog/index.astro index 71d1b5d..46c188d 100644 --- a/src/pages/blog/index.astro +++ b/src/pages/blog/index.astro @@ -78,7 +78,7 @@ all.forEach((a) => { id="blog-empty" style="display: none; padding: 80px 0; text-align: center; color: var(--ink-3);" > - No articles in this topic yet. Be the first to{' '} + No articles yet. Be the first to{' '} write one.
    @@ -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/contribute/index.astro b/src/pages/contribute/index.astro index 30d45b4..f81c955 100644 --- a/src/pages/contribute/index.astro +++ b/src/pages/contribute/index.astro @@ -143,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 9bd7e81..e50d6e8 100644 --- a/src/pages/index.astro +++ b/src/pages/index.astro @@ -107,7 +107,7 @@ const featuredTools = allTools

    - +
    @@ -307,7 +307,7 @@ const featuredTools = allTools
    diff --git a/src/pages/topics/[id].astro b/src/pages/topics/[id].astro index 4f981e7..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 { 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/styles/global.css b/src/styles/global.css index 4ac09e2..0f5a240 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; @@ -193,6 +193,16 @@ html { 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; diff --git a/src/write/WritePortal.tsx b/src/write/WritePortal.tsx index 0b826c1..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'; 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( ); }} > - ✕ + Date: Sun, 12 Jul 2026 08:29:13 -0700 Subject: [PATCH 5/6] Add --radius-* and --shadow-hover design tokens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces a border-radius scale (xs/sm/md/lg/pill/circle) and a shared card hover-elevation token. Pure additions — the /tags pages use the pill token now; adopting them across existing components and consolidating the duplicated card/chip/page-header patterns is a deliberate follow-up (see PR notes) to keep visual-regression risk reviewable. --- src/styles/global.css | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/styles/global.css b/src/styles/global.css index 0f5a240..1e6b8e6 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -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; From 8af2fabdbd3c2e6c6a8a8a8c43a780ec3a7447c8 Mon Sep 17 00:00:00 2001 From: Dinesh <13635627+HumbleBee14@users.noreply.github.com> Date: Sun, 12 Jul 2026 15:29:24 -0700 Subject: [PATCH 6/6] Add missing Table icon to the slash menu The Table item was the only slash-menu entry without an icon; add a grid-with-header glyph so it matches the others. --- src/write/editor/slashMenu.tsx | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/write/editor/slashMenu.tsx b/src/write/editor/slashMenu.tsx index 7cb7199..2104af2 100644 --- a/src/write/editor/slashMenu.tsx +++ b/src/write/editor/slashMenu.tsx @@ -64,6 +64,14 @@ const ICONS = { , ), + 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 }), },