~ knotless Search/
↑↓ move↵ open⇧↵ all results

chadtmiller.com/knotless

git that lives in your Atmosphere account

knotless.dev
1 collaborator

knotless 0.13.0 Latest 2 packages, 1 image

1
10

The pages serve a blog at /blog, built from Markdown posts in blog/

Closed chadtmiller.com asked to merge into main from chadtmiller.com/knotless:web/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
`![What it shows](./<slug>/picture.svg)`. `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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
/** 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.
blog-site/package.jsonnew+13−0
docs/design/serve.md+2−0
docs/development.md+16−0
.dockerignore+3−0
AGENTS.md+5−0
Dockerfile+12−0
Justfile+4−0