From 7ef8ab5851825943aae3cb54ab59e8e9021af89e Mon Sep 17 00:00:00 2001 From: Elijah Melton Date: Sun, 25 Oct 2026 16:23:46 -0700 Subject: [PATCH 1/5] Add blog.elimelt.com: a static Astro + MDX blog on Cloudflare Pages Posts are Markdown or MDX in blog/src/content/posts. MDX posts can import components for interactive figures; RandomWalk shows the pattern with a framework-free custom element whose script loads only where it's used. Includes an RSS feed, sitemap, drafts, and a first post. blog-deploy.yml builds the site and publishes it through publish-site.yml. Terraform adds the elimelt-blog Pages project, its custom domain and the DNS record. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/blog-deploy.yml | 62 + .github/workflows/publish-site.yml | 8 +- .github/workflows/test.yml | 3 +- .github/workflows/web-deploy.yml | 1 + README.md | 2 +- blog/.gitignore | 3 + blog/README.md | 54 + blog/astro.config.mjs | 16 + blog/package-lock.json | 5809 +++++++++++++++++ blog/package.json | 21 + blog/public/_headers | 10 + blog/public/assets/apple-touch.png | Bin 0 -> 26747 bytes blog/public/assets/fonts/Crimson-Pro-OFL.txt | 93 + .../assets/fonts/crimson-pro-latin.woff2 | Bin 0 -> 48200 bytes blog/public/robots.txt | 4 + blog/src/components/Figure.astro | 15 + blog/src/components/RandomWalk.astro | 95 + blog/src/content.config.ts | 18 + blog/src/content/posts/hello-world.mdx | 24 + blog/src/layouts/Base.astro | 53 + blog/src/layouts/Post.astro | 27 + blog/src/pages/404.astro | 8 + blog/src/pages/index.astro | 51 + blog/src/pages/posts/[...slug].astro | 17 + blog/src/pages/rss.xml.ts | 19 + blog/src/posts.ts | 13 + blog/src/styles/global.css | 200 + blog/tsconfig.json | 5 + infra/sites/README.md | 10 +- infra/terraform/main.tf | 23 +- 30 files changed, 6653 insertions(+), 11 deletions(-) create mode 100644 .github/workflows/blog-deploy.yml create mode 100644 blog/.gitignore create mode 100644 blog/README.md create mode 100644 blog/astro.config.mjs create mode 100644 blog/package-lock.json create mode 100644 blog/package.json create mode 100644 blog/public/_headers create mode 100644 blog/public/assets/apple-touch.png create mode 100644 blog/public/assets/fonts/Crimson-Pro-OFL.txt create mode 100644 blog/public/assets/fonts/crimson-pro-latin.woff2 create mode 100644 blog/public/robots.txt create mode 100644 blog/src/components/Figure.astro create mode 100644 blog/src/components/RandomWalk.astro create mode 100644 blog/src/content.config.ts create mode 100644 blog/src/content/posts/hello-world.mdx create mode 100644 blog/src/layouts/Base.astro create mode 100644 blog/src/layouts/Post.astro create mode 100644 blog/src/pages/404.astro create mode 100644 blog/src/pages/index.astro create mode 100644 blog/src/pages/posts/[...slug].astro create mode 100644 blog/src/pages/rss.xml.ts create mode 100644 blog/src/posts.ts create mode 100644 blog/src/styles/global.css create mode 100644 blog/tsconfig.json diff --git a/.github/workflows/blog-deploy.yml b/.github/workflows/blog-deploy.yml new file mode 100644 index 00000000..5fd8e258 --- /dev/null +++ b/.github/workflows/blog-deploy.yml @@ -0,0 +1,62 @@ +name: Deploy blog.elimelt.com + +on: + push: + branches: ["main"] + paths: + - "blog/**" + - ".github/workflows/blog-deploy.yml" + - ".github/workflows/publish-site.yml" + pull_request: + branches: ["main"] + paths: + - "blog/**" + - ".github/workflows/blog-deploy.yml" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: blog-deploy-${{ github.ref }} + cancel-in-progress: false + +jobs: + build: + name: Build blog + runs-on: ubuntu-latest + defaults: + run: + working-directory: blog + steps: + - name: Checkout + uses: actions/checkout@v5 + - name: Setup Node.js + uses: actions/setup-node@v5 + with: + node-version: "22" + cache: npm + cache-dependency-path: blog/package-lock.json + - name: Install dependencies + run: npm ci + - name: Check and build + run: npm run build + - name: Upload build + if: github.event_name != 'pull_request' + uses: actions/upload-artifact@v4 + with: + name: blog-site + path: blog/dist/ + if-no-files-found: error + retention-days: 7 + + publish: + if: github.event_name != 'pull_request' + needs: build + uses: ./.github/workflows/publish-site.yml + with: + site: blog + project: elimelt-blog + host: blog.elimelt.com + secrets: + CLOUDFLARE_PAGES_TOKEN: ${{ secrets.CLOUDFLARE_PAGES_TOKEN }} diff --git a/.github/workflows/publish-site.yml b/.github/workflows/publish-site.yml index 205a81e4..f9963f8b 100644 --- a/.github/workflows/publish-site.yml +++ b/.github/workflows/publish-site.yml @@ -1,13 +1,13 @@ name: Publish static site -# Called by web-deploy.yml and notes-deploy.yml after they upload a build -# artifact named "-site". Cloudflare Pages serves the result; nothing -# runs on the homelab. +# Called by web-deploy.yml, notes-deploy.yml and blog-deploy.yml after they +# upload a build artifact named "-site". Cloudflare Pages serves the +# result; nothing runs on the homelab. on: workflow_call: inputs: site: - description: "Build artifact prefix (web or notes)" + description: "Build artifact prefix (web, notes or blog)" required: true type: string project: diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 9037de47..3e112c99 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -3,9 +3,10 @@ name: Run Tests on: pull_request: branches: ["main"] - # Notes PRs are validated by notes-deploy.yml. + # Notes and blog PRs are validated by their deploy workflows. paths-ignore: - "notes/**" + - "blog/**" schedule: # Run daily at 8:00 AM UTC diff --git a/.github/workflows/web-deploy.yml b/.github/workflows/web-deploy.yml index 05e77d5e..c2c1cc42 100644 --- a/.github/workflows/web-deploy.yml +++ b/.github/workflows/web-deploy.yml @@ -6,6 +6,7 @@ on: paths: - "**" - "!notes/**" + - "!blog/**" - "!docs/**" - "!infra/**" workflow_dispatch: diff --git a/README.md b/README.md index 42b8c5eb..ef395420 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Personal Site -This repository holds the public site (elimelt.com), the notes site in [`notes/`](notes/README.md) (notes.elimelt.com), and the homelab in [`infra/`](infra/README.md). The two sites build and deploy independently. +This repository holds the public site (elimelt.com), the notes site in [`notes/`](notes/README.md) (notes.elimelt.com), the blog in [`blog/`](blog/README.md) (blog.elimelt.com), and the homelab in [`infra/`](infra/README.md). The three sites build and deploy independently. The public frontend uses [Astro](https://astro.build) with TypeScript. Astro renders the portfolio and music pages to static HTML and bundles their interactive modules. The homelab homepage remains a separate TypeScript application. diff --git a/blog/.gitignore b/blog/.gitignore new file mode 100644 index 00000000..ddce69b6 --- /dev/null +++ b/blog/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +dist/ +.astro/ diff --git a/blog/README.md b/blog/README.md new file mode 100644 index 00000000..7e388c78 --- /dev/null +++ b/blog/README.md @@ -0,0 +1,54 @@ +# Blog + +blog.elimelt.com: an [Astro](https://astro.build) site with +[MDX](https://docs.astro.build/en/guides/integrations-guide/mdx/), built to +static files and served by Cloudflare Pages. It builds and deploys on its own +(`.github/workflows/blog-deploy.yml`). + +```bash +cd blog +npm ci +npm run dev # http://localhost:3001, drafts included +npm run build # type checks, then builds dist/ +``` + +## Writing a post + +Add `src/content/posts/.md` or `.mdx`; the file name becomes +`/posts//`. Frontmatter is checked against `src/content.config.ts`: + +```yaml +--- +title: Post title +description: One sentence for the index, feed and link previews. +date: 2026-10-25 +updated: 2026-11-02 # optional +draft: true # optional; shown in dev, left out of the build +--- +``` + +## Interactive elements + +Use `.mdx` and import components: + +```mdx +import Figure from '../../components/Figure.astro'; +import RandomWalk from '../../components/RandomWalk.astro'; + +
+ +
+``` + +`src/components/RandomWalk.astro` shows the pattern: markup plus a ` diff --git a/blog/src/content.config.ts b/blog/src/content.config.ts new file mode 100644 index 00000000..4059ea8a --- /dev/null +++ b/blog/src/content.config.ts @@ -0,0 +1,18 @@ +import { defineCollection } from 'astro:content'; +import { glob } from 'astro/loaders'; +import { z } from 'astro/zod'; + +const posts = defineCollection({ + // Each post is src/content/posts/.md or .mdx; the file name is the URL. + loader: glob({ base: './src/content/posts', pattern: '**/*.{md,mdx}' }), + schema: z.object({ + title: z.string(), + description: z.string(), + date: z.coerce.date(), + updated: z.coerce.date().optional(), + // Drafts render in `npm run dev` but are left out of the build. + draft: z.boolean().default(false), + }), +}); + +export const collections = { posts }; diff --git a/blog/src/content/posts/hello-world.mdx b/blog/src/content/posts/hello-world.mdx new file mode 100644 index 00000000..65522b09 --- /dev/null +++ b/blog/src/content/posts/hello-world.mdx @@ -0,0 +1,24 @@ +--- +title: Hello, world +description: A new home for longer writing, and a quick tour of what a post can do. +date: 2026-10-25 +--- + +import Figure from '../../components/Figure.astro'; +import RandomWalk from '../../components/RandomWalk.astro'; + +This is where longer writing will live. [Notes](https://notes.elimelt.com) stays the place for reference material; posts here are dated and meant to be read start to finish. + +Posts are Markdown, so most of the time it's just text, `inline code`, and the occasional code block: + +```ts +const walk = (x: number, step: number) => x + (Math.random() - 0.5) * 2 * step; +``` + +When a post needs something you can poke at, it imports a component and drops it in: + +
+ +
+ +That one is a plain custom element with no framework, and its script only loads on pages that use it. diff --git a/blog/src/layouts/Base.astro b/blog/src/layouts/Base.astro new file mode 100644 index 00000000..d833f288 --- /dev/null +++ b/blog/src/layouts/Base.astro @@ -0,0 +1,53 @@ +--- +import '../styles/global.css'; + +interface Props { + title: string; + description: string; +} + +const { title, description } = Astro.props; +const canonicalUrl = new URL(Astro.url.pathname, Astro.site); +const socialImageUrl = new URL('/assets/apple-touch.png', Astro.site); +--- + + + + + + + {title} + + + + + + + + + + + + + + + + +
+ +
+ +
+ +
+ + diff --git a/blog/src/layouts/Post.astro b/blog/src/layouts/Post.astro new file mode 100644 index 00000000..1f19df1b --- /dev/null +++ b/blog/src/layouts/Post.astro @@ -0,0 +1,27 @@ +--- +import Base from './Base.astro'; +import { formatDate, type Post } from '../posts'; + +interface Props { + post: Post; +} + +const { post } = Astro.props; +const { title, description, date, updated } = post.data; +--- + + + +
+
+

{title}

+

+ + {updated && <> · updated } +

+
+
+ +
+
+ diff --git a/blog/src/pages/404.astro b/blog/src/pages/404.astro new file mode 100644 index 00000000..d4e69b3c --- /dev/null +++ b/blog/src/pages/404.astro @@ -0,0 +1,8 @@ +--- +import Base from '../layouts/Base.astro'; +--- + + +

Not found

+

There's nothing here. Back to all posts.

+ diff --git a/blog/src/pages/index.astro b/blog/src/pages/index.astro new file mode 100644 index 00000000..5dacdc3d --- /dev/null +++ b/blog/src/pages/index.astro @@ -0,0 +1,51 @@ +--- +import Base from '../layouts/Base.astro'; +import { formatDate, getPosts } from '../posts'; + +const posts = await getPosts(); +--- + + +

Posts

+
    + {posts.map((post) => ( +
  • + {post.data.title} + {post.data.draft && (draft)} +

    + · {post.data.description} +

    +
  • + ))} +
+ + + diff --git a/blog/src/pages/posts/[...slug].astro b/blog/src/pages/posts/[...slug].astro new file mode 100644 index 00000000..f59d0b22 --- /dev/null +++ b/blog/src/pages/posts/[...slug].astro @@ -0,0 +1,17 @@ +--- +import { render } from 'astro:content'; +import Post from '../../layouts/Post.astro'; +import { getPosts } from '../../posts'; + +export async function getStaticPaths() { + const posts = await getPosts(); + return posts.map((post) => ({ params: { slug: post.id }, props: { post } })); +} + +const { post } = Astro.props; +const { Content } = await render(post); +--- + + + + diff --git a/blog/src/pages/rss.xml.ts b/blog/src/pages/rss.xml.ts new file mode 100644 index 00000000..5c5a55e5 --- /dev/null +++ b/blog/src/pages/rss.xml.ts @@ -0,0 +1,19 @@ +import rss from '@astrojs/rss'; +import type { APIContext } from 'astro'; +import { getPosts } from '../posts'; + +export async function GET(context: APIContext) { + const posts = await getPosts(); + return rss({ + title: "Elijah Melton's blog", + description: 'Writing on distributed systems, infrastructure, and whatever else.', + site: context.site!, + trailingSlash: true, + items: posts.map((post) => ({ + title: post.data.title, + description: post.data.description, + pubDate: post.data.date, + link: `/posts/${post.id}/`, + })), + }); +} diff --git a/blog/src/posts.ts b/blog/src/posts.ts new file mode 100644 index 00000000..5364fb28 --- /dev/null +++ b/blog/src/posts.ts @@ -0,0 +1,13 @@ +import { getCollection, type CollectionEntry } from 'astro:content'; + +export type Post = CollectionEntry<'posts'>; + +/** Published posts, newest first. Drafts are included only in dev. */ +export async function getPosts(): Promise { + const posts = await getCollection('posts', ({ data }) => import.meta.env.DEV || !data.draft); + return posts.sort((a, b) => b.data.date.valueOf() - a.data.date.valueOf()); +} + +export function formatDate(date: Date): string { + return date.toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric', timeZone: 'UTC' }); +} diff --git a/blog/src/styles/global.css b/blog/src/styles/global.css new file mode 100644 index 00000000..9a29e068 --- /dev/null +++ b/blog/src/styles/global.css @@ -0,0 +1,200 @@ +@font-face { + font-family: 'Crimson Pro'; + font-style: normal; + font-weight: 400 600; + font-display: swap; + src: url('/assets/fonts/crimson-pro-latin.woff2') format('woff2'); +} + +:root { + --text: #1a1a1a; + --text-secondary: #666; + --bg: #ffffff; + --bg-secondary: #f5f5f5; + --accent: #0062ff; + --accent-hover: #5493fe; + --border: #d8d8d8; + + --font: -apple-system, system-ui, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; + --font-prose: 'Crimson Pro', Georgia, serif; + --font-mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + + --measure: 42rem; + color-scheme: light dark; +} + +@media (prefers-color-scheme: dark) { + :root { + --text: #f1f1f1; + --text-secondary: #b3b3b3; + --bg: #000000; + --bg-secondary: #1a1a1a; + --accent: #00ff08; + --accent-hover: #00ff0886; + --border: #333; + } + + .astro-code, + .astro-code span { + color: var(--shiki-dark) !important; + background-color: var(--shiki-dark-bg) !important; + } +} + +*, +*::before, +*::after { + box-sizing: border-box; +} + +body { + margin: 0; + background: var(--bg); + color: var(--text); + font-family: var(--font); + line-height: 1.6; + -webkit-font-smoothing: antialiased; +} + +a { + color: var(--accent); + text-underline-offset: 0.15em; +} + +a:hover { + color: var(--accent-hover); +} + +.site { + max-width: var(--measure); + margin: 0 auto; + padding: clamp(1.5rem, 5vw, 4rem) 1rem 4rem; +} + +.site-header { + display: flex; + justify-content: space-between; + align-items: baseline; + gap: 1rem; + margin-bottom: 3rem; + font-size: 0.9375rem; +} + +.site-header a { + color: var(--text); + text-decoration: none; +} + +.site-title { + font-weight: 600; +} + +.site-nav { + display: flex; + gap: 1rem; +} + +.site-nav a { + color: var(--text-secondary); +} + +.site-footer { + margin-top: 4rem; + padding-top: 1.5rem; + border-top: 1px solid var(--border); + color: var(--text-secondary); + font-size: 0.875rem; +} + +.meta { + color: var(--text-secondary); + font-size: 0.875rem; +} + +/* Post body */ +.prose { + font-family: var(--font-prose); + font-size: 1.25rem; + line-height: 1.65; +} + +.prose h1, +.prose h2, +.prose h3 { + font-family: var(--font); + line-height: 1.25; + margin: 2.5rem 0 1rem; +} + +.prose h2 { + font-size: 1.375rem; +} + +.prose h3 { + font-size: 1.125rem; +} + +.prose img, +.prose video { + max-width: 100%; + height: auto; +} + +.prose blockquote { + margin: 1.5rem 0; + padding-left: 1rem; + border-left: 3px solid var(--border); + color: var(--text-secondary); +} + +.prose code { + font-family: var(--font-mono); + font-size: 0.8em; +} + +.prose :not(pre) > code { + padding: 0.1em 0.3em; + border-radius: 4px; + background: var(--bg-secondary); +} + +.prose pre { + padding: 1rem; + border-radius: 6px; + overflow-x: auto; + font-size: 1rem; + line-height: 1.5; +} + +.prose table { + width: 100%; + border-collapse: collapse; + font-family: var(--font); + font-size: 0.9375rem; +} + +.prose th, +.prose td { + padding: 0.4rem 0.6rem; + border-bottom: 1px solid var(--border); + text-align: left; +} + +/* Interactive components get a plain frame and may break out of the text measure. */ +.figure { + margin: 2rem 0; + font-family: var(--font); + font-size: 1rem; +} + +.figure-wide { + width: min(100vw - 2rem, 64rem); + margin-left: 50%; + transform: translateX(-50%); +} + +.figure figcaption { + margin-top: 0.5rem; + color: var(--text-secondary); + font-size: 0.875rem; +} diff --git a/blog/tsconfig.json b/blog/tsconfig.json new file mode 100644 index 00000000..8bf91d3b --- /dev/null +++ b/blog/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "astro/tsconfigs/strict", + "include": [".astro/types.d.ts", "**/*"], + "exclude": ["dist"] +} diff --git a/infra/sites/README.md b/infra/sites/README.md index d0ef2e82..fbde7972 100644 --- a/infra/sites/README.md +++ b/infra/sites/README.md @@ -1,12 +1,14 @@ # Static sites -elimelt.com and notes.elimelt.com are plain static files on Cloudflare Pages. +elimelt.com, notes.elimelt.com and blog.elimelt.com are plain static files on +Cloudflare Pages. Nothing they serve runs on the homelab. | Host | Source | Workflow | Pages project | | --- | --- | --- | --- | | elimelt.com (www redirects) | repository root (Astro) | `.github/workflows/web-deploy.yml` | `elimelt` | | notes.elimelt.com | `notes/` (Quartz) | `.github/workflows/notes-deploy.yml` | `elimelt-notes` | +| blog.elimelt.com | `blog/` (Astro + MDX) | `.github/workflows/blog-deploy.yml` | `elimelt-blog` | Each workflow builds on a GitHub-hosted runner and uploads the output as an artifact. `publish-site.yml` downloads it and runs `wrangler pages deploy` @@ -14,8 +16,8 @@ against the project, then checks that the host answers. Pages keeps every deployment and purges its own cache, so changes show up immediately. The projects, custom domains, DNS records, and the www redirect are in -`infra/terraform/main.tf`. Response headers live in `frontend/public/_headers` -and `notes/quartz-site/routes/_headers`. +`infra/terraform/main.tf`. Response headers live in `frontend/public/_headers`, +`notes/quartz-site/routes/_headers` and `blog/public/_headers`. ## Setup @@ -29,7 +31,7 @@ and `notes/quartz-site/routes/_headers`. ## Deploy and roll back Redeploy the current `main` with `gh workflow run web-deploy.yml` or -`gh workflow run notes-deploy.yml`. Roll back from the project's Deployments +`gh workflow run notes-deploy.yml` (or `blog-deploy.yml`). Roll back from the project's Deployments tab in the Cloudflare dashboard (Rollback to this deployment). ## Homelab (offline) diff --git a/infra/terraform/main.tf b/infra/terraform/main.tf index 53910b6c..e82fdf0f 100644 --- a/infra/terraform/main.tf +++ b/infra/terraform/main.tf @@ -141,7 +141,7 @@ resource "cloudflare_record" "transcribe" { # ============================================================================= # Static sites on Cloudflare Pages # ============================================================================= -# .github/workflows/{web,notes}-deploy.yml upload builds to these projects with +# .github/workflows/{web,notes,blog}-deploy.yml upload builds to these projects with # wrangler (direct upload, no Git integration). resource "cloudflare_pages_project" "web" { @@ -156,6 +156,12 @@ resource "cloudflare_pages_project" "notes" { production_branch = "main" } +resource "cloudflare_pages_project" "blog" { + account_id = var.cloudflare_account_id + name = "elimelt-blog" + production_branch = "main" +} + resource "cloudflare_pages_domain" "web" { account_id = var.cloudflare_account_id project_name = cloudflare_pages_project.web.name @@ -168,6 +174,12 @@ resource "cloudflare_pages_domain" "notes" { domain = "notes.${var.domain}" } +resource "cloudflare_pages_domain" "blog" { + account_id = var.cloudflare_account_id + project_name = cloudflare_pages_project.blog.name + domain = "blog.${var.domain}" +} + # Apex domain (elimelt.com); Cloudflare flattens the CNAME at the apex. resource "cloudflare_record" "apex" { zone_id = data.cloudflare_zone.main.id @@ -197,6 +209,15 @@ resource "cloudflare_record" "notes" { comment = "Quartz notes (Cloudflare Pages)" } +resource "cloudflare_record" "blog" { + zone_id = data.cloudflare_zone.main.id + name = "blog" + type = "CNAME" + content = cloudflare_pages_project.blog.subdomain + proxied = true + comment = "Astro blog (Cloudflare Pages)" +} + resource "cloudflare_ruleset" "www_redirect" { zone_id = data.cloudflare_zone.main.id name = "Redirects" From 09ba9c23f4324d2f8786fb04b2bb37c33515f1ee Mon Sep 17 00:00:00 2001 From: Elijah Melton Date: Sun, 25 Oct 2026 16:27:55 -0700 Subject: [PATCH 2/5] Frame the blog as Elijah's own writing, notes as LLM-assisted reference Co-Authored-By: Claude Opus 5.5 --- blog/README.md | 4 +++- blog/src/content/posts/hello-world.mdx | 4 ++-- blog/src/pages/index.astro | 2 +- blog/src/pages/rss.xml.ts | 2 +- 4 files changed, 7 insertions(+), 5 deletions(-) diff --git a/blog/README.md b/blog/README.md index 7e388c78..1cec4589 100644 --- a/blog/README.md +++ b/blog/README.md @@ -1,6 +1,8 @@ # Blog -blog.elimelt.com: an [Astro](https://astro.build) site with +blog.elimelt.com: Elijah's own writing. (notes.elimelt.com is reference +material, often generated or expanded with an LLM.) It's an +[Astro](https://astro.build) site with [MDX](https://docs.astro.build/en/guides/integrations-guide/mdx/), built to static files and served by Cloudflare Pages. It builds and deploys on its own (`.github/workflows/blog-deploy.yml`). diff --git a/blog/src/content/posts/hello-world.mdx b/blog/src/content/posts/hello-world.mdx index 65522b09..46fd7e9a 100644 --- a/blog/src/content/posts/hello-world.mdx +++ b/blog/src/content/posts/hello-world.mdx @@ -1,13 +1,13 @@ --- title: Hello, world -description: A new home for longer writing, and a quick tour of what a post can do. +description: What goes here versus in my notes, and a quick tour of what a post can do. date: 2026-10-25 --- import Figure from '../../components/Figure.astro'; import RandomWalk from '../../components/RandomWalk.astro'; -This is where longer writing will live. [Notes](https://notes.elimelt.com) stays the place for reference material; posts here are dated and meant to be read start to finish. +Everything on this blog is my own writing. My [notes](https://notes.elimelt.com) are different: they're reference material, and a lot of them are generated, rewritten, or expanded with an LLM. If you want to know what I actually think, read this. Posts are Markdown, so most of the time it's just text, `inline code`, and the occasional code block: diff --git a/blog/src/pages/index.astro b/blog/src/pages/index.astro index 5dacdc3d..bf7a90b2 100644 --- a/blog/src/pages/index.astro +++ b/blog/src/pages/index.astro @@ -5,7 +5,7 @@ import { formatDate, getPosts } from '../posts'; const posts = await getPosts(); --- - +

Posts

    {posts.map((post) => ( diff --git a/blog/src/pages/rss.xml.ts b/blog/src/pages/rss.xml.ts index 5c5a55e5..39e0e2a2 100644 --- a/blog/src/pages/rss.xml.ts +++ b/blog/src/pages/rss.xml.ts @@ -6,7 +6,7 @@ export async function GET(context: APIContext) { const posts = await getPosts(); return rss({ title: "Elijah Melton's blog", - description: 'Writing on distributed systems, infrastructure, and whatever else.', + description: "Elijah Melton's own writing, on distributed systems, infrastructure, and whatever else.", site: context.site!, trailingSlash: true, items: posts.map((post) => ({ From 252aa8aacbfa84c26a6be1ba7543bd269ae00875 Mon Sep 17 00:00:00 2001 From: Elijah Melton Date: Sun, 25 Oct 2026 16:43:19 -0700 Subject: [PATCH 3/5] Rewrite the notes home page as neutral, LLM-authored reference State up front that models write most of the text and Elijah reviews it, point to the blog for his own writing, and replace the self-deprecating about section with a plain description of how notes are made and how to send corrections. Co-Authored-By: Claude Opus 5.5 --- notes/content/index.md | 52 +++++++++++++++--------------------------- 1 file changed, 19 insertions(+), 33 deletions(-) diff --git a/notes/content/index.md b/notes/content/index.md index 2669497c..763fe4b1 100644 --- a/notes/content/index.md +++ b/notes/content/index.md @@ -1,16 +1,18 @@ --- title: Elijah Melton's Technical Notes -description: Elijah Melton's notes on algorithms, machine learning, recommender systems, LLM serving, software, hardware, and mathematics. -updated: 2026-10-09 +description: Reference notes on algorithms, machine learning, systems, software, hardware, and mathematics, drafted with language models and reviewed by Elijah Melton. +updated: 2026-10-25 authors: - elimelt - gpt-5.6-sol - gpt-6 + - claude-opus-5-5 --- -Technical notes by Elijah Melton on algorithms, machine learning, systems, -software, hardware, and mathematics. I use this site to work through proofs, -papers, and implementation details that I want to be able to find again. +Reference notes on algorithms, machine learning, systems, software, hardware, +and mathematics. Language models write most of the text from Elijah Melton's +notes, papers, and code, and he reviews and edits each note before it goes up. +Elijah's own writing is on his [blog](https://blog.elimelt.com). ## Selected notes @@ -31,35 +33,19 @@ papers, and implementation details that I want to be able to find again. - [[systems/index|Systems]] - [[reference/index|Reference material]] -## About these notes +## How these notes are written -While in most cases I'd prefer reading the prose/thoughts of a fellow human, -these notes aren't for my own or anyone else's enjoyment, nor am I trying to -connect with anyone through my writing here. To be honest, I'm not even a great -writer, and have limited time to refine my thoughts and produce well-organized -and thoroughly cited works of literature. +Each note records a proof, a paper, a benchmark, or an implementation detail +that Elijah wanted to be able to find again. A model drafts the note and does +most of the revising. Elijah chooses the topics and sources and reviews each +draft. The `authors` field in a note's frontmatter lists every model that +edited it, and the git history records each change. -At this point, I'd give ~90% of the credit to whatever frontier model polished -and organized my thoughts for presentation here ("co-authorship" according to -the git history...), and would describe myself as more of a reviewer/curator of -these notes than the actual author. +Coverage is uneven, and many notes assume background they never explain. For a +first introduction to a subject, a textbook or a course is a better place to +start. -If you haven't built up a tolerance for AI slop, you might want to close this -tab now. If you're a student learning something for the first time and want a -rigorous and vetted introduction to a topic, I wouldn't look here. If your name -is Elijah Melton and you want a refresher on something you learned a half-decade -ago, then maybe these notes will be useful to you. +## Corrections -## What if it's wrong?!? - -LLMs are wrong all the time, but the amount they're wrong has quickly trended -downward, and even seems to approach never when frontier models are anchored to -the truth by documents without errors. In 2026, I wholeheartedly expect my own -writing to contain more mistakes than most frontier models. - -All that being said, yeah, there are probably a few errors here and there. I'd -expect the majority of them are caused either directly by my prompt, or by -errors in source documents I/the model cite. Still, any time I notice one, I -try to fix it. If you're the type of person that must right the wrong you -notice in the world, feel free to open a [PR](https://github.com/elimelt/web) -to correct it. +Notes can contain errors. They get fixed when someone finds them, and +corrections are welcome as a [pull request](https://github.com/elimelt/web). From 4fee2f73ae57401db3c57d5973e55a7af51cc929 Mon Sep 17 00:00:00 2001 From: Elijah Melton Date: Sun, 25 Oct 2026 16:45:09 -0700 Subject: [PATCH 4/5] Say Elijah reviews only some notes Co-Authored-By: Claude Opus 5.5 --- notes/content/index.md | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/notes/content/index.md b/notes/content/index.md index 763fe4b1..4b97b5ac 100644 --- a/notes/content/index.md +++ b/notes/content/index.md @@ -1,6 +1,6 @@ --- title: Elijah Melton's Technical Notes -description: Reference notes on algorithms, machine learning, systems, software, hardware, and mathematics, drafted with language models and reviewed by Elijah Melton. +description: Reference notes on algorithms, machine learning, systems, software, hardware, and mathematics, written mostly by language models and curated by Elijah Melton. updated: 2026-10-25 authors: - elimelt @@ -11,7 +11,7 @@ authors: Reference notes on algorithms, machine learning, systems, software, hardware, and mathematics. Language models write most of the text from Elijah Melton's -notes, papers, and code, and he reviews and edits each note before it goes up. +notes, papers, and code. He picks what goes in and reviews some of the drafts. Elijah's own writing is on his [blog](https://blog.elimelt.com). ## Selected notes @@ -37,9 +37,10 @@ Elijah's own writing is on his [blog](https://blog.elimelt.com). Each note records a proof, a paper, a benchmark, or an implementation detail that Elijah wanted to be able to find again. A model drafts the note and does -most of the revising. Elijah chooses the topics and sources and reviews each -draft. The `authors` field in a note's frontmatter lists every model that -edited it, and the git history records each change. +most of the revising. Elijah chooses the topics and sources and reviews some of +the drafts. The rest go up as the model wrote them. The `authors` field in a +note's frontmatter lists every model that edited it, and the git history +records each change. Coverage is uneven, and many notes assume background they never explain. For a first introduction to a subject, a textbook or a course is a better place to From f88e50b9fdbfbb538a5fbfc4f5cef1e41f58b161 Mon Sep 17 00:00:00 2001 From: Elijah Melton Date: Sun, 25 Oct 2026 18:25:05 -0700 Subject: [PATCH 5/5] Restyle the notes site as a plain technical reference Switch from Noto Serif on cream to IBM Plex Sans on white (near-black in dark mode) with a single blue accent, so the notes read as reference material and look distinct from the blog's serif. - Body type at 17px with 1.65 leading and normal paragraph spacing. - Links are underlined text instead of Quartz's highlighted chips; navigation, tags and search results stay plain. - Section headings get a rule above them; inline code, code blocks, tables and blockquotes are tidied. - Request IBM Plex Sans 600 for headings, and point the primary font preload (Head.tsx and the font patch) at its Latin woff2. - The standalone graph page uses the same palette and font. Co-Authored-By: Claude Opus 5.5 --- notes/quartz-site/custom.scss | 104 ++++++++++++++++++---- notes/quartz-site/overrides/Head.tsx | 4 +- notes/quartz-site/routes/graph/graph.css | 36 ++++---- notes/quartz-site/routes/graph/graph.js | 2 +- notes/quartz-site/routes/graph/index.html | 6 +- notes/quartz.config.yaml | 43 ++++----- notes/scripts/patch_google_font_fetch.py | 2 +- 7 files changed, 136 insertions(+), 61 deletions(-) diff --git a/notes/quartz-site/custom.scss b/notes/quartz-site/custom.scss index 269503d5..9184fc31 100644 --- a/notes/quartz-site/custom.scss +++ b/notes/quartz-site/custom.scss @@ -7,18 +7,47 @@ --site-page-width: 1440px; --site-article-width: 48rem; --site-column-gap: 2rem; - --site-body-size: 20.5px; - --site-body-leading: 1.35; - --site-paragraph-gap: 2rem; - --site-title-size: 1.6rem; - --site-heading-size: 1.15rem; - --site-code-radius: 0.75rem; - --site-media-radius: 0.4rem; + --site-body-size: 17px; + --site-body-leading: 1.65; + --site-paragraph-gap: 1.1em; + --site-title-size: 2rem; + --site-heading-size: 1.3rem; + --site-code-radius: 6px; + --site-media-radius: 6px; +} + +// Links read as text with an underline instead of Quartz's highlighted chips. +a, +a.internal { + font-weight: inherit; + background-color: transparent; + padding: 0; + border-radius: 0; + text-decoration: underline; + text-decoration-color: color-mix(in srgb, currentColor 35%, transparent); + text-decoration-thickness: 1px; + text-underline-offset: 0.18em; +} + +a:hover, +a.internal:hover { + text-decoration-color: currentColor; +} + +// Navigation, tags and titles are links too, but underlines there are noise. +.page-title a, +.breadcrumb-element a, +.explorer a, +.toc a, +a.tag-link, +.search a, +.popover a.internal { + text-decoration: none; } .page-title a { - font-weight: 700; - letter-spacing: -0.02em; + font-weight: 600; + letter-spacing: -0.01em; } .page { @@ -40,9 +69,11 @@ .article-title { margin-top: 0; - margin-bottom: 0.35rem; - line-height: 1.1; + margin-bottom: 0.5rem; + line-height: 1.2; font-size: var(--site-title-size); + font-weight: 600; + letter-spacing: -0.015em; } .content-meta { @@ -84,14 +115,46 @@ article p { margin-block: var(--site-paragraph-gap); } +article :is(h2, h3, h4) { + font-weight: 600; + letter-spacing: -0.01em; + line-height: 1.3; +} + article > h2 { font-size: var(--site-heading-size); + margin-top: 2.5rem; + padding-top: 1.25rem; + border-top: 1px solid var(--lightgray); +} + +article > h3 { + font-size: 1.1rem; + margin-top: 2rem; +} + +article li + li { + margin-top: 0.3em; } article pre { border: 1px solid var(--lightgray); border-radius: var(--site-code-radius); padding: 1rem; + font-size: 0.85em; + line-height: 1.55; +} + +article :not(pre) > code { + font-size: 0.88em; + padding: 0.1em 0.3em; + border-radius: 4px; + background: color-mix(in srgb, var(--lightgray) 55%, transparent); +} + +article blockquote { + border-left: 3px solid var(--lightgray); + color: var(--darkgray); } article img, @@ -106,10 +169,21 @@ article table { display: block; max-width: 100%; overflow-x: auto; - border: 1px solid var(--lightgray); - border-radius: var(--site-code-radius); - padding: 0.25rem; - background: color-mix(in srgb, var(--lightgray) 20%, transparent); + border-collapse: collapse; + font-size: 0.92em; + line-height: 1.45; +} + +article :is(th, td) { + padding: 0.5rem 0.75rem; + border-bottom: 1px solid var(--lightgray); + text-align: left; + vertical-align: top; +} + +article th { + font-weight: 600; + border-bottom: 2px solid var(--darkgray); } .sidebar.right { diff --git a/notes/quartz-site/overrides/Head.tsx b/notes/quartz-site/overrides/Head.tsx index 1b13d170..e02db1f2 100644 --- a/notes/quartz-site/overrides/Head.tsx +++ b/notes/quartz-site/overrides/Head.tsx @@ -8,10 +8,10 @@ import { googleFontHref, googleFontSubsetHref } from "../util/theme" import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types" import { unescapeHTML } from "../util/escape" -// Google Fonts' Latin subset for the normal Noto Serif variable face used by +// Google Fonts' Latin subset for the normal IBM Plex Sans variable face used by // body copy and headings. Keep this synchronized with the build-time font // validation in scripts/patch_google_font_fetch.py. -const primaryFontPreload = "/static/fonts/ga6daw1J5X9T9RW6j9bNVls-hfgvz8JcMofYTYf6D30.woff2" +const primaryFontPreload = "/static/fonts/zYXzKVElMYYaJe8bpLHnCwDKr932-G7dytD-Dmu1syxeKYY.woff2" export function canonicalUrl(baseUrl: string, slug: FullSlug): string { const path = simplifySlug(slug) diff --git a/notes/quartz-site/routes/graph/graph.css b/notes/quartz-site/routes/graph/graph.css index 3c4d359e..1234d6b7 100644 --- a/notes/quartz-site/routes/graph/graph.css +++ b/notes/quartz-site/routes/graph/graph.css @@ -1,23 +1,23 @@ :root { color-scheme: light dark; - --paper: #f7f3eb; - --ink: #292823; - --muted: #6e6a61; - --line: rgb(73 83 76 / 18%); - --panel: rgb(247 243 235 / 86%); - --accent: #276755; - --border: #d8d1c5; + --paper: #ffffff; + --ink: #141413; + --muted: #6f6f6b; + --line: rgb(20 20 19 / 14%); + --panel: rgb(255 255 255 / 88%); + --accent: #1f5fbf; + --border: #e4e4e1; } @media (prefers-color-scheme: dark) { :root { - --paper: #191d1c; - --ink: #eff0e9; - --muted: #aeb4ae; - --line: rgb(198 214 205 / 16%); - --panel: rgb(25 29 28 / 88%); - --accent: #84cbb5; - --border: #39423e; + --paper: #121212; + --ink: #f3f3f3; + --muted: #9a9a9a; + --line: rgb(243 243 243 / 14%); + --panel: rgb(18 18 18 / 88%); + --accent: #8ab4ff; + --border: #2b2b2b; } } @@ -39,9 +39,7 @@ body { radial-gradient(circle at 15% 10%, color-mix(in srgb, var(--accent) 9%, transparent), transparent 35%), var(--paper); color: var(--ink); - font-family: "Noto Serif", serif; - font-optical-sizing: auto; - font-variation-settings: "wdth" 100; + font-family: "IBM Plex Sans", system-ui, sans-serif; } /* Let an embedding page supply the background in either color scheme. */ @@ -80,9 +78,9 @@ html.embedded body { h1 { margin: 0; - font-family: "Noto Serif", serif; + font-family: "IBM Plex Sans", system-ui, sans-serif; font-size: clamp(1.8rem, 5vw, 3.4rem); - font-weight: 500; + font-weight: 600; letter-spacing: -0.045em; line-height: 0.95; } diff --git a/notes/quartz-site/routes/graph/graph.js b/notes/quartz-site/routes/graph/graph.js index 8f91604b..562739e0 100644 --- a/notes/quartz-site/routes/graph/graph.js +++ b/notes/quartz-site/routes/graph/graph.js @@ -77,7 +77,7 @@ function fitGraph(animated = true) { function drawLabel(node, emphasized = false) { const fontSize = emphasized ? 13 : 10 - context.font = `${emphasized ? 600 : 500} ${fontSize}px "Noto Serif", serif` + context.font = `${emphasized ? 600 : 500} ${fontSize}px "IBM Plex Sans", system-ui, sans-serif` context.textAlign = "center" context.textBaseline = "bottom" context.fillStyle = getComputedStyle(document.documentElement).getPropertyValue("--ink") diff --git a/notes/quartz-site/routes/graph/index.html b/notes/quartz-site/routes/graph/index.html index 2d8178fd..83bcc3f7 100644 --- a/notes/quartz-site/routes/graph/index.html +++ b/notes/quartz-site/routes/graph/index.html @@ -18,7 +18,7 @@ @@ -27,7 +27,7 @@ - +
    @@ -45,6 +45,6 @@

    Graph

    Drag to pan, scroll to zoom, select a node to open its note.

    - + diff --git a/notes/quartz.config.yaml b/notes/quartz.config.yaml index 2848d9ad..89096942 100644 --- a/notes/quartz.config.yaml +++ b/notes/quartz.config.yaml @@ -17,30 +17,33 @@ configuration: # is disabled, so visitors load the font files from this site. cdnCaching: false typography: - header: Noto Serif - body: Noto Serif + # Headings use 600, so request it instead of Quartz's default 700. + header: + name: IBM Plex Sans + weights: [400, 600] + body: IBM Plex Sans code: IBM Plex Mono colors: lightMode: - light: "#fffbf5" - lightgray: "#e5e2dc" - gray: "#8b8680" - darkgray: "#595959" - dark: "#252320" - secondary: "#07507d" - tertiary: "#8a5a44" - highlight: rgba(53, 104, 89, 0.15) - textHighlight: "#f2df9b88" + light: "#ffffff" + lightgray: "#e4e4e1" + gray: "#6f6f6b" + darkgray: "#33332f" + dark: "#141413" + secondary: "#1f5fbf" + tertiary: "#0f3f86" + highlight: rgba(31, 95, 191, 0.08) + textHighlight: "#fff1a3" darkMode: - light: "#1d1f21" - lightgray: "#35393d" - gray: "#8d969e" - darkgray: "#d2d6da" - dark: "#f3f4f5" - secondary: "#86c7b5" - tertiary: "#d6a184" - highlight: rgba(134, 199, 181, 0.15) - textHighlight: "#8a6f2b88" + light: "#121212" + lightgray: "#2b2b2b" + gray: "#9a9a9a" + darkgray: "#d6d6d6" + dark: "#f3f3f3" + secondary: "#8ab4ff" + tertiary: "#b9d1ff" + highlight: rgba(138, 180, 255, 0.1) + textHighlight: "#6b5a1f" plugins: - source: "@quartz-community/note-properties" enabled: true diff --git a/notes/scripts/patch_google_font_fetch.py b/notes/scripts/patch_google_font_fetch.py index 16a8c07e..c8c2e056 100644 --- a/notes/scripts/patch_google_font_fetch.py +++ b/notes/scripts/patch_google_font_fetch.py @@ -8,7 +8,7 @@ MARKER = "// Notes site: request unicode-subsetted WOFF2 font CSS." -PRIMARY_FONT_FILE = "ga6daw1J5X9T9RW6j9bNVls-hfgvz8JcMofYTYf6D30.woff2" +PRIMARY_FONT_FILE = "zYXzKVElMYYaJe8bpLHnCwDKr932-G7dytD-Dmu1syxeKYY.woff2" OLD = """ const theme = ctx.cfg.configuration.theme const response = await fetch(googleFontHref(theme))