chadtmiller.com/knotless
git that lives in your Atmosphere account
1
10
The pages serve a blog at /blog, built from Markdown posts in blog/
.knotless/workflows/docs.yml+8−3
| @@ -1,7 +1,7 @@ | ||
# The docs, as `just docs` checks them: every relative link and anchor | ||
# lands, and every Mermaid block draws with Pele, as the pages draw it | ||
# (harness/docs.mjs); and the site docs-site/ makes of them, served at | ||
# /docs, builds. Pele comes from web/'s packages and the site's from | ||
# /docs, builds; and so does the blog blog-site/ makes of blog/, at /blog. Pele comes from web/'s packages and the site's from | ||
# docs-site/'s, each kept between runs and installed again only when its | ||
# lockfile changes. | ||
image: node:24.22.1 | ||
| @@ -13,8 +13,8 @@ on: | ||
push: {refs: [refs/heads/main]} | ||
pull: | ||
base: [refs/heads/main, refs/heads/feature/*] | ||
paths: [docs/**, README.md, AGENTS.md, deploy/README.md, harness/docs.mjs, docs-site/**, lexicons/**, web/src/tokens.css, web/package-lock.json, .knotless/workflows/docs.yml, .knotless/uses.lock] | ||
cache: [web/node_modules, docs-site/node_modules] | ||
paths: [docs/**, README.md, AGENTS.md, deploy/README.md, harness/docs.mjs, docs-site/**, blog/**, blog-site/**, lexicons/**, web/src/tokens.css, web/package-lock.json, .knotless/workflows/docs.yml, .knotless/uses.lock] | ||
cache: [web/node_modules, docs-site/node_modules, blog-site/node_modules] | ||
steps: | ||
- uses: chadtmiller.com/steps/npm-ci@1 | ||
name: web | ||
| @@ -22,7 +22,12 @@ steps: | ||
- uses: chadtmiller.com/steps/npm-ci@1 | ||
name: docs-site | ||
with: {dir: docs-site} | ||
- uses: chadtmiller.com/steps/npm-ci@1 | ||
name: blog-site | ||
with: {dir: blog-site} | ||
- name: docs | ||
run: node harness/docs.mjs | ||
- name: site | ||
run: cd docs-site && npm run build | ||
- name: blog | ||
run: cd blog-site && npm run build | ||
blog/README.mdnew+19−0
| @@ -0,0 +1,19 @@ | ||
# The blog | ||
| ||
The posts the pages serve at `/blog`, built by [`blog-site`](../blog-site) | ||
([Development](../docs/development.md#the-blog)). A post is one Markdown | ||
file here, `<slug>.md`, served at `/blog/<slug>/`, with front matter: | ||
| ||
```yaml | ||
--- | ||
title: Introducing something | ||
description: One sentence for the index, the feed and link previews. | ||
date: 2026-11-01 | ||
author: Chad Miller | ||
draft: true | ||
--- | ||
``` | ||
| ||
The post's pictures go in `<slug>/` beside it, linked as | ||
``. `draft: true` keeps it off the | ||
built site; `just blog` shows drafts too. | ||
blog-site/src/pages/[slug].astronew+19−0
| @@ -0,0 +1,19 @@ | ||
--- | ||
import { render } from 'astro:content'; | ||
import Layout from '../Layout.astro'; | ||
import { day, posts } from '../posts'; | ||
| ||
export async function getStaticPaths() { | ||
return (await posts()).map((post) => ({ params: { slug: post.id }, props: { post } })); | ||
} | ||
const { post } = Astro.props; | ||
const { Content } = await render(post); | ||
--- | ||
| ||
<Layout title={`${post.data.title} · knotless`} description={post.data.description}> | ||
<article class="post"> | ||
<h1>{post.data.title}</h1> | ||
<p class="byline"><time datetime={post.data.date.toISOString()}>{day(post.data.date)}</time> · {post.data.author}{post.data.draft ? ' · draft' : ''}</p> | ||
<Content /> | ||
</article> | ||
</Layout> | ||
blog-site/src/pages/index.astronew+21−0
| @@ -0,0 +1,21 @@ | ||
--- | ||
import Layout from '../Layout.astro'; | ||
import { day, posts } from '../posts'; | ||
const list = await posts(); | ||
const base = import.meta.env.BASE_URL.replace(/\/$/, ''); | ||
--- | ||
| ||
<Layout title="blog · knotless" description="What is new in knotless, and why it works the way it does."> | ||
<h1>Blog</h1> | ||
{list.length === 0 ? <p class="empty">Nothing posted yet.</p> : ( | ||
<ol class="posts"> | ||
{list.map((p) => ( | ||
<li> | ||
<a href={`${base}/${p.id}/`}>{p.data.title}</a> | ||
<p>{p.data.description}</p> | ||
<time datetime={p.data.date.toISOString()}>{day(p.data.date)}</time> | ||
</li> | ||
))} | ||
</ol> | ||
)} | ||
</Layout> | ||
blog-site/src/pages/rss.xml.tsnew+15−0
| @@ -0,0 +1,15 @@ | ||
import type { APIContext } from 'astro'; | ||
import { posts } from '../posts'; | ||
| ||
const esc = (s: string) => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>'); | ||
| ||
/** The posts as an RSS feed, newest first. */ | ||
export async function GET(context: APIContext) { | ||
const base = new URL(import.meta.env.BASE_URL.replace(/\/?$/, '/'), context.site); | ||
const items = (await posts()).map((p) => { | ||
const link = new URL(`${p.id}/`, base).href; | ||
return `<item><title>${esc(p.data.title)}</title><link>${link}</link><guid>${link}</guid><description>${esc(p.data.description)}</description><pubDate>${p.data.date.toUTCString()}</pubDate></item>`; | ||
}); | ||
const body = `<?xml version="1.0" encoding="UTF-8"?><rss version="2.0"><channel><title>knotless blog</title><link>${base.href}</link><description>What is new in knotless, and why it works the way it does.</description>${items.join('')}</channel></rss>`; | ||
return new Response(body, { headers: { 'Content-Type': 'application/rss+xml; charset=utf-8' } }); | ||
} | ||
blog-site/src/blog.cssnew+37−0
| @@ -0,0 +1,37 @@ | ||
/* The blog in the pages' own look: every value a token from | ||
web/src/tokens.css, which follows the system's colour scheme. */ | ||
| ||
@import '../../web/src/tokens.css'; | ||
| ||
*,*::before,*::after{box-sizing:border-box} | ||
body{margin:0;background:var(--bg);color:var(--fg);font-family:var(--font-sans);font-size:var(--text-md);line-height:var(--leading)} | ||
a{color:var(--fg)} | ||
.bar{display:flex;align-items:baseline;gap:var(--space-2);padding:var(--space-3) var(--space-4);border-bottom:var(--border)} | ||
.brand{font-weight:var(--weight-semibold, 600);text-decoration:none} | ||
.section{color:var(--mute)} | ||
.bar nav{margin-left:auto} | ||
.bar nav a{color:var(--mute);text-decoration:none;font-size:var(--text-base)} | ||
.bar nav a:hover{color:var(--fg)} | ||
main{max-width:var(--measure);margin:0 auto;padding:var(--space-10) var(--space-4) var(--space-16)} | ||
h1{font-size:var(--text-display);line-height:1.1;margin:0 0 var(--space-3);letter-spacing:-0.02em} | ||
.byline,.posts time,.empty{color:var(--mute);font-size:var(--text-base)} | ||
.byline{margin:0 0 var(--space-8)} | ||
.posts{list-style:none;padding:0;margin:var(--space-8) 0 0} | ||
.posts li{padding:var(--space-4) 0;border-top:var(--border)} | ||
.posts a{font-size:var(--text-xl);font-weight:600;text-decoration:none} | ||
.posts a:hover{text-decoration:underline} | ||
.posts p{margin:var(--space-1) 0} | ||
.post{font-size:var(--text-lg);line-height:var(--leading-prose)} | ||
.post h2{font-size:var(--text-2xl);margin:var(--space-10) 0 var(--space-3)} | ||
.post h3{font-size:var(--text-xl);margin:var(--space-8) 0 var(--space-2)} | ||
.post p,.post ul,.post ol,.post pre,.post table,.post figure{margin:0 0 var(--space-5)} | ||
.post a{text-decoration:underline;text-decoration-color:var(--link-line);text-underline-offset:var(--underline-offset)} | ||
.post img{display:block;max-width:100%;height:auto;margin:var(--space-6) auto} | ||
.post pre{background:var(--soft);border-radius:var(--radius-md);padding:var(--space-3) var(--space-4);overflow:auto;font-size:var(--text-sm);line-height:var(--leading-code)} | ||
.post :not(pre)>code{background:var(--soft);padding:var(--space-0-5) var(--space-1-5);border-radius:var(--radius-sm);font-size:.85em} | ||
.post code{font-family:var(--font-mono)} | ||
.post table{border-collapse:collapse;display:block;overflow-x:auto;font-size:var(--text-base)} | ||
.post td,.post th{border:var(--border);padding:var(--space-1-5) var(--space-3);text-align:left} | ||
.post pre.astro-code{background-color:var(--soft)!important} | ||
/* Shiki's two themes: the light one inline, the dark one when the system asks. */ | ||
@media (prefers-color-scheme:dark){.astro-code,.astro-code span{color:var(--shiki-dark)!important;background-color:var(--soft)!important}} | ||
blog-site/src/content.config.tsnew+17−0
| @@ -0,0 +1,17 @@ | ||
import { defineCollection, z } from 'astro:content'; | ||
import { glob } from 'astro/loaders'; | ||
| ||
// The posts, as blog/ keeps them: one Markdown file each, its name the | ||
// post's address, its pictures in a directory of the same name beside it. | ||
export const collections = { | ||
posts: defineCollection({ | ||
loader: glob({ pattern: ['*.md', '!README.md'], base: '../blog' }), | ||
schema: z.object({ | ||
title: z.string(), | ||
description: z.string(), | ||
date: z.coerce.date(), | ||
author: z.string(), | ||
draft: z.boolean().default(false) | ||
}) | ||
}) | ||
}; | ||
blog-site/src/Layout.astronew+28−0
| @@ -0,0 +1,28 @@ | ||
--- | ||
// The pages' brand, home to the app, then the blog's own index. | ||
import './blog.css'; | ||
interface Props { title: string; description: string } | ||
const { title, description } = Astro.props; | ||
const blog = import.meta.env.BASE_URL; | ||
--- | ||
| ||
<!doctype html> | ||
<html lang="en"> | ||
<head> | ||
<meta charset="utf-8" /> | ||
<meta name="viewport" content="width=device-width, initial-scale=1" /> | ||
<title>{title}</title> | ||
<meta name="description" content={description} /> | ||
<meta property="og:title" content={title} /> | ||
<meta property="og:description" content={description} /> | ||
<link rel="alternate" type="application/rss+xml" title="knotless blog" href={`${blog}/rss.xml`.replace('//', '/')} /> | ||
</head> | ||
<body> | ||
<header class="bar"> | ||
<a class="brand" href="/">~ knotless</a> | ||
<a class="brand section" href={blog}>blog</a> | ||
<nav><a href="/docs/">Docs</a></nav> | ||
</header> | ||
<main><slot /></main> | ||
</body> | ||
</html> | ||
blog-site/src/posts.tsnew+10−0
| @@ -0,0 +1,10 @@ | ||
import { getCollection } from 'astro:content'; | ||
| ||
/** The posts to show, newest first: drafts only while writing (`astro dev`). */ | ||
export async function posts() { | ||
const all = await getCollection('posts', (p) => import.meta.env.DEV || !p.data.draft); | ||
return all.sort((a, b) => b.data.date.valueOf() - a.data.date.valueOf()); | ||
} | ||
| ||
export const day = (d: Date) => | ||
d.toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric', timeZone: 'UTC' }); | ||
blog-site/.gitignorenew+3−0
| @@ -0,0 +1,3 @@ | ||
node_modules | ||
/dist | ||
/.astro | ||
blog-site/astro.config.mjsnew+10−0
blog-site/package-lock.jsonnew+3759−0
A large diff, not shown.