From aef4e8daa69269a43e17b6a4ebda036ca1817a85 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Sun, 16 Aug 2026 15:18:05 +0000 Subject: [PATCH] Add blog-post, and expose the repo as a moshcode plugin marketplace MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The blog at ~/public_html/blog has no build step — writing a file is publishing — so its conventions live only in the files already there and nothing catches a mistake before it is live. Three posts were recently dated 7-10 hours in the future, which pinned them above every real post and made feed.xml look like it had stopped updating. blog-post encodes the conventions: next NNN-post.html, the smolweb-valid template with the AI-drafting acknowledgment, the index.html entry, and a build-feed.mjs run. It refuses a future date unless forced, requires the description that becomes the RSS summary, and writes with 'wx' so two concurrent runs cannot land on the same number and lose a post. `blog-post check` reports what silently breaks the feed — missing, unparseable or future dates, empty summaries, missing h1 — and exits non-zero, so it works as a gate. Also adds .claude-plugin/ so the repo is an installable marketplace, exposing /blog:post, /blog:check, /blog:list and /blog:feed. Additive: no existing file changes except 48 new lines of README. 14 new tests (66 total), typecheck clean. Co-Authored-By: Claude Opus 5 --- .claude-plugin/marketplace.json | 23 +++ README.md | 48 +++++ bin/blog-post.ts | 171 +++++++++++++++++ plugins/blog/.claude-plugin/plugin.json | 13 ++ plugins/blog/README.md | 49 +++++ plugins/blog/commands/check.md | 37 ++++ plugins/blog/commands/feed.md | 23 +++ plugins/blog/commands/list.md | 19 ++ plugins/blog/commands/post.md | 54 ++++++ src/blog.ts | 239 ++++++++++++++++++++++++ test/blog.test.ts | 219 ++++++++++++++++++++++ 11 files changed, 895 insertions(+) create mode 100644 .claude-plugin/marketplace.json create mode 100755 bin/blog-post.ts create mode 100644 plugins/blog/.claude-plugin/plugin.json create mode 100644 plugins/blog/README.md create mode 100644 plugins/blog/commands/check.md create mode 100644 plugins/blog/commands/feed.md create mode 100644 plugins/blog/commands/list.md create mode 100644 plugins/blog/commands/post.md create mode 100644 src/blog.ts create mode 100644 test/blog.test.ts diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json new file mode 100644 index 0000000..b98b9d3 --- /dev/null +++ b/.claude-plugin/marketplace.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://anthropic.com/claude-code/marketplace.schema.json", + "name": "cli-tools", + "description": "Profullstack's command-line tools as installable plugins — publish to the plain-HTML blog without getting a convention wrong.", + "owner": { + "name": "profullstack", + "url": "https://profullstack.com" + }, + "plugins": [ + { + "name": "blog", + "description": "Write, check and publish posts on the plain-HTML blog: next post number, smolweb-valid template, index listing and feed regeneration, with a lint that catches the mistakes that silently break RSS.", + "source": "./plugins/blog", + "category": "productivity", + "author": { + "name": "profullstack", + "url": "https://profullstack.com" + }, + "homepage": "https://github.com/profullstack/cli-tools#blog", + "keywords": ["blog", "rss", "feed", "publishing", "smolweb"] + } + ] +} diff --git a/README.md b/README.md index 844718c..9b84b36 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ TypeScript, installed as executables on `PATH`. | [`gh-prs-fix-all`](#gh-prs-fix-all) | Fix the open threatcrush-scan PRs that are broken because of us | | [`tcfeed`](#tcfeed) | Find repositories worth scanning, scan them, print a shortlist | | [`domainjson`](#domainjson) | whois-style, JSON-first name lookup | +| [`blog-post`](#blog-post) | Publish to the plain-HTML blog without breaking the feed | ## Requirements @@ -170,6 +171,52 @@ goes through OpenRDAP. Either way `dig` adds records, hosts, reverse lookups and per-nameserver AXFR attempts. Errors are JSON too — a tool whose output gets parsed should not change shape when it fails. +### `blog-post` + +Publishes to the plain-HTML blog at `~/public_html/blog`. That blog has no build +step and no CMS: writing a file *is* publishing. This exists because nothing +else catches a mistake before it is live. + +```sh +blog-post new "A title" --description "The one-line feed summary" +blog-post new "A title" --description "..." --body draft.html +blog-post check # posts that will break the feed +blog-post list # every post with its date +blog-post feed # regenerate feed.xml +``` + +`new` picks the next `NNN-post.html`, renders the smolweb-valid template with +the AI-drafting acknowledgment, splices the entry into the hand-maintained +`index.html`, and runs the blog's own `build-feed.mjs`. Point it elsewhere with +`--dir` or `$BLOG_DIR`. + +What it refuses to do: + +- **Date a post in the future.** Such a post sorts above every real post, and + readers that filter future items drop it entirely — so the feed looks like it + stopped updating while the files on disk look perfect. This has happened: + three posts sat 7–10 hours ahead and did exactly that. `--allow-future` is + there if you genuinely mean to schedule. +- **Overwrite a post.** Two concurrent runs read the directory before either + writes, so both pick the same number; the write uses `wx` and the loser fails + loudly rather than silently replacing a post. +- **Skip the description.** It is the entire RSS summary. + +`check` reports missing, unparseable and future dates, empty descriptions and a +missing `

`, and exits non-zero, so it works as a pre-publish gate. + +## As a moshcode plugin + +This repo is also a plugin marketplace, exposing `blog-post` as slash commands: + +```sh +moshcode plugin marketplace add profullstack/cli-tools +moshcode plugin install blog@cli-tools +``` + +That adds `/blog:post`, `/blog:check`, `/blog:list` and `/blog:feed`. See +[plugins/blog](plugins/blog/README.md). + ## Aliases Pit aliases live in `~/.moshcode/aliases.json`: @@ -181,6 +228,7 @@ Pit aliases live in `~/.moshcode/aliases.json`: /alias set fixprs "gh-prs-fix-all" /alias set feed "tcfeed" /alias set whoisj "domainjson" +/alias set blog "blog-post" /alias # list /alias get merge # show one diff --git a/bin/blog-post.ts b/bin/blog-post.ts new file mode 100755 index 0000000..39aef1d --- /dev/null +++ b/bin/blog-post.ts @@ -0,0 +1,171 @@ +#!/usr/bin/env -S npx --yes tsx +/** + * blog-post — write to the plain-HTML blog without getting a convention wrong. + * + * The blog has no build step: writing a file is publishing. Nothing else + * catches a post with no date (the feed generator skips it in silence) or one + * dated in the future (it pins above every real post, and readers that filter + * future items drop it, so the feed looks dead while the files look fine). + */ + +import { existsSync } from 'node:fs'; +import { readFile } from 'node:fs/promises'; +import { spawnSync } from 'node:child_process'; +import { join } from 'node:path'; + +import { parseArgs, UsageError } from '../src/args.ts'; +import { isMain } from '../src/is-main.ts'; +import { + blogDir, + createPost, + DEFAULT_DIR, + isoSeconds, + lint, + readPosts, +} from '../src/blog.ts'; + +const USAGE = `Usage: + blog-post new --description <text> [--body file.html] [--date ISO] + blog-post check + blog-post list + blog-post feed + +Commands: + new Write the next post, list it in index.html, rebuild the feed + check Report posts that will break the feed (non-zero exit if any) + list Every post with its date + feed Regenerate feed.xml + +Options: + --description TEXT Feed summary. Required by \`new\`. + --body FILE HTML fragment for the body (default: a stub) + --date ISO Publish date (default: now). Refuses the future. + --dir PATH Blog directory (default: $BLOG_DIR, else + ${DEFAULT_DIR}) + --allow-future Permit a future date. You almost never want this. + -h, --help show this help +`; + +const SPEC = { + boolean: ['--allow-future', '-h', '--help'], + string: ['--description', '--body', '--date', '--dir'], +} as const; + +/** + * Regenerate feed.xml by running the blog's own generator. + * + * Shelling out rather than reimplementing: build-feed.mjs lives beside the + * posts and is the single source of truth for the feed's shape. + */ +function rebuildFeed(dir: string): number { + const script = join(dir, 'build-feed.mjs'); + if (!existsSync(script)) { + process.stderr.write(`no build-feed.mjs in ${dir} — feed not regenerated\n`); + return 1; + } + return spawnSync(process.execPath, [script], { cwd: dir, stdio: 'inherit' }).status ?? 1; +} + +export async function run(argv: readonly string[]): Promise<number> { + let parsed; + try { + parsed = parseArgs(argv, SPEC); + } catch (error) { + if (error instanceof UsageError) { + process.stderr.write(`${error.message}\n\n${USAGE}`); + return 2; + } + throw error; + } + + const { flags, values, positional } = parsed; + + if (flags.has('-h') || flags.has('--help') || positional.length === 0) { + process.stdout.write(USAGE); + return positional.length === 0 && !flags.has('-h') && !flags.has('--help') ? 1 : 0; + } + + const [command, ...rest] = positional; + const dir = blogDir(values.get('--dir')); + + if (!existsSync(dir)) { + process.stderr.write(`blog directory not found: ${dir}\n`); + return 1; + } + + switch (command) { + case 'new': { + const title = rest.join(' ').trim(); + if (!title) { + process.stderr.write('new: give a title\n'); + return 1; + } + + const description = (values.get('--description') ?? '').trim(); + if (!description) { + process.stderr.write('new: --description is required (it becomes the feed summary)\n'); + return 1; + } + + const raw = values.get('--date'); + const when = raw ? new Date(raw) : new Date(); + if (Number.isNaN(when.getTime())) { + process.stderr.write(`new: unparseable --date ${JSON.stringify(raw)}\n`); + return 1; + } + if (when.getTime() > Date.now() && !flags.has('--allow-future')) { + process.stderr.write( + `new: ${isoSeconds(when)} is in the future.\n` + + ' A future-dated post sits above every real post, and readers that hide\n' + + ' future items drop it, so the feed looks dead. Pass --allow-future only\n' + + ' if you genuinely mean to schedule it.\n', + ); + return 1; + } + + const bodyFile = values.get('--body'); + const body = bodyFile ? await readFile(bodyFile, 'utf8') : ''; + + const { file, path } = await createPost(dir, { + title, + description, + date: isoSeconds(when), + body, + }); + + process.stdout.write(`created ${file}\n ${path}\n listed in index.html\n`); + return rebuildFeed(dir); + } + + case 'check': { + const problems = lint(await readPosts(dir)); + if (problems.length === 0) { + process.stdout.write('all posts look publishable\n'); + return 0; + } + for (const problem of problems) { + process.stderr.write(`${problem.file}: ${problem.problem}\n`); + } + return 1; + } + + case 'list': { + for (const post of await readPosts(dir)) { + const title = (post.title ?? '(no h1)').replace(/—/g, '—').slice(0, 52); + process.stdout.write(`${post.file} ${(post.date ?? 'NO-DATE').padEnd(22)} ${title}\n`); + } + return 0; + } + + case 'feed': + return rebuildFeed(dir); + + default: + process.stderr.write(`unknown command: ${command}\n\n${USAGE}`); + return 1; + } +} + +if (isMain(import.meta.url)) { + process.exitCode = await run(process.argv.slice(2)); +} diff --git a/plugins/blog/.claude-plugin/plugin.json b/plugins/blog/.claude-plugin/plugin.json new file mode 100644 index 0000000..b0bbf39 --- /dev/null +++ b/plugins/blog/.claude-plugin/plugin.json @@ -0,0 +1,13 @@ +{ + "$schema": "https://anthropic.com/claude-code/plugin.schema.json", + "name": "blog", + "description": "Write, check and publish posts on the plain-HTML blog: next post number, smolweb-valid template, index listing and feed regeneration, with a lint that catches the mistakes that silently break RSS.", + "version": "0.1.0", + "author": { + "name": "profullstack", + "url": "https://profullstack.com" + }, + "homepage": "https://github.com/profullstack/cli-tools#blog", + "license": "MIT", + "keywords": ["blog", "rss", "feed", "publishing", "smolweb"] +} diff --git a/plugins/blog/README.md b/plugins/blog/README.md new file mode 100644 index 0000000..8687924 --- /dev/null +++ b/plugins/blog/README.md @@ -0,0 +1,49 @@ +# blog — publish to the plain-HTML blog 📝 + +Slash commands wrapping the `blog-post` CLI. The blog at +`~/public_html/blog` has no build step and no CMS: writing a file *is* +publishing. That is the appeal and also the hazard, because nothing catches a +mistake before it is live. + +| command | what it does | +| --- | --- | +| `/blog:post <title>` | draft, create and publish a post, then rebuild the feed | +| `/blog:check` | find posts that will break the feed | +| `/blog:list` | every post with its date | +| `/blog:feed` | regenerate `feed.xml` | + +## What this stops you doing + +**Dating a post in the future.** It sorts above everything real, and readers +that filter future items drop it, so the feed looks like it stopped updating +while every file on disk looks perfect. Three posts once sat 7–10 hours ahead +and did exactly that. `blog-post` refuses a future date unless you insist. + +**Omitting `<meta name="date">`.** `build-feed.mjs` skips the post without +saying anything useful. + +**Breaking smolweb validity.** The generated template uses an explicit +`<html lang>`, `<meta http-equiv="Content-Type">` rather than a bare +`<meta charset>`, and closes everything. + +**Forgetting the AI-drafting acknowledgment.** It goes in every post; Kagi +Small Web and others require disclosure, and the index states the policy. + +## Install + +```bash +moshcode plugin marketplace add profullstack/cli-tools +moshcode plugin install blog@cli-tools +``` + +The commands shell out to `blog-post`, which comes from this same repo. It is +not published to npm — clone and link it onto `PATH`: + +```sh +git clone git@github.com:profullstack/cli-tools.git ~/src/profullstack/cli-tools +cd ~/src/profullstack/cli-tools +pnpm install +pnpm link:bin +``` + +Point it at a different blog with `--dir` or `$BLOG_DIR`. diff --git a/plugins/blog/commands/check.md b/plugins/blog/commands/check.md new file mode 100644 index 0000000..4283dc8 --- /dev/null +++ b/plugins/blog/commands/check.md @@ -0,0 +1,37 @@ +--- +description: Find posts that will break the RSS feed — missing dates, future dates, empty summaries. +allowed-tools: Bash(blog-post:*), Read, Edit +--- + +## Task + +Check the blog for posts that will not appear correctly in the feed. + +```bash +blog-post check +``` + +## What it looks for + +- **A date in the future.** The important one. Such a post sorts above every + real post and is dropped entirely by readers that hide future items, so the + feed appears to have stopped updating while everything looks fine on disk. + Three posts once sat 7–10 hours ahead and did exactly that. +- **No `<meta name="date">`.** `build-feed.mjs` skips the post silently. +- **An unparseable date.** Same outcome, also silent. +- **No `<meta name="description">`.** The item ships with an empty summary. +- **No `<h1>`.** The feed title falls back to `<title>`, which carries the + site-name suffix. + +## Fixing + +Edit the offending `<meta>` in the post, then rebuild: + +```bash +blog-post feed +``` + +For a wrong date, prefer the file's real modification time over inventing one — +that is the best evidence of when the post was actually written. + +Exit status is non-zero when anything is wrong, so this is usable as a gate. diff --git a/plugins/blog/commands/feed.md b/plugins/blog/commands/feed.md new file mode 100644 index 0000000..b81b591 --- /dev/null +++ b/plugins/blog/commands/feed.md @@ -0,0 +1,23 @@ +--- +description: Regenerate feed.xml from the posts on disk. +allowed-tools: Bash(blog-post:*), Bash(node:*) +--- + +## Task + +Rebuild the RSS feed. + +```bash +blog-post feed +``` + +This runs the blog's own `build-feed.mjs`, which is the single source of truth +for the feed's shape. It keeps the **10 most recent** posts and trims the rest, +and warns about any post dated in the future. + +Run it after editing a post's title, date or description by hand — +`/blog:post` already does it for you when creating one. + +The feed is served straight off disk at +`https://dev.profullstack.com/~anthony/blog/feed.xml`, so it is live the moment +the file is written. There is nothing to deploy. diff --git a/plugins/blog/commands/list.md b/plugins/blog/commands/list.md new file mode 100644 index 0000000..6f7146c --- /dev/null +++ b/plugins/blog/commands/list.md @@ -0,0 +1,19 @@ +--- +description: Every blog post with its publish date, oldest first. +allowed-tools: Bash(blog-post:*) +--- + +## Task + +List the posts on the blog. + +```bash +blog-post list +``` + +Each line is the filename, the `<meta name="date">` value, and the `<h1>`. +`NO-DATE` means `build-feed.mjs` skips that post entirely — run +`/blog:check` for the full diagnosis. + +Useful before writing: it shows the next post number, what has been covered +recently, and whether the numbering has a gap. diff --git a/plugins/blog/commands/post.md b/plugins/blog/commands/post.md new file mode 100644 index 0000000..ae62ca4 --- /dev/null +++ b/plugins/blog/commands/post.md @@ -0,0 +1,54 @@ +--- +description: Draft and publish a new blog post, then regenerate the feed. +argument-hint: <title> +allowed-tools: Bash(blog-post:*), Bash(node:*), Read, Write, Edit +--- + +## Task + +Write and publish a post titled `$ARGUMENTS` on the plain-HTML blog. + +The blog has **no build step** — writing the file *is* publishing — so get it +right the first time rather than fixing it live. + +## Steps + +1. **Draft the body first**, as a fragment: `<h2>` sections and closed `<p>` + elements only. No `<html>`, `<head>` or `<body>` — the tool wraps it. Write + it to a scratch file. + +2. **Write a one-line description.** It becomes the RSS summary and the + `<meta name="description">`, so it has to stand alone in a reader with no + surrounding page. One sentence, concrete, no "in this post I". + +3. **Create the post:** + + ```bash + blog-post new "$ARGUMENTS" -d "<the one-line description>" --body /path/to/body.html + ``` + + That picks the next `NNN-post.html`, renders the template, splices the entry + into `index.html`, and runs `build-feed.mjs`. + +4. **Verify:** + + ```bash + blog-post check + ``` + +## Rules + +- **Never pass `--date` in the future.** A future-dated post pins itself above + every real post and is hidden outright by readers that filter future items — + the feed looks dead while the files on disk look perfect. This has already + happened once. `blog-post` refuses it unless you pass `--allow-future`, and + you almost never mean to. +- **Keep the AI-drafting acknowledgment** the template inserts. Kagi Small Web + and others require disclosure of heavy LLM use, and the index states the + policy. +- **Stay smolweb-valid**: every `<p>` closed, no bare `<meta charset>`, no + unclosed tags. The template handles the shell; your body has to hold up its + end. +- **Match the voice of the existing posts.** Read one or two first. They are + first-person, specific, and do not oversell. +- Entities over literal punctuation in prose: `—`, `“`, `”`. diff --git a/src/blog.ts b/src/blog.ts new file mode 100644 index 0000000..f882396 --- /dev/null +++ b/src/blog.ts @@ -0,0 +1,239 @@ +import { readdir, readFile, writeFile } from 'node:fs/promises'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; + +/** + * The plain-HTML blog at ~/public_html/blog. + * + * That blog has no build step and no CMS — writing a file *is* publishing — so + * every convention it depends on lives only in the files already there, and + * nothing catches a mistake before it is live. This module holds the + * conventions so a new post cannot quietly get one wrong. + */ + +export const DEFAULT_DIR = join(homedir(), 'public_html', 'blog'); + +const POST_RE = /^(\d+)-post\.html$/; + +export interface Post { + file: string; + n: number; + date: string | null; + title: string | null; + description: string | null; +} + +export interface NewPost { + title: string; + description: string; + /** ISO 8601, e.g. 2026-08-16T10:04:00Z */ + date: string; + /** HTML fragment: h2/p only, no document shell. */ + body?: string; +} + +export interface Problem { + file: string; + problem: string; +} + +/** Where the blog lives: an explicit path wins, then $BLOG_DIR, then the default. */ +export function blogDir(explicit?: string, env: NodeJS.ProcessEnv = process.env): string { + return explicit || env.BLOG_DIR || DEFAULT_DIR; +} + +/** Escape text for an HTML attribute or element body. */ +export function esc(value: string): string { + return String(value ?? '') + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"'); +} + +/** + * Apply the blog's typographic conventions to a title. + * + * The existing posts use HTML entities rather than literal punctuation. Only + * the entities already in use are emitted, because smolweb validity requires + * every entity to be one XML defines or a numeric one. + */ +export function typogrify(value: string): string { + return String(value ?? '') + .replace(/&/g, '&') + .replace(/ -- | — /g, ' — ') + .replace(/"([^"]+)"/g, '“$1”') + .replace(/'/g, '’'); +} + +function match(html: string, re: RegExp): string | null { + const found = html.match(re); + return found?.[1] ? found[1].trim() : null; +} + +/** Every post file with its number, date, title and description. */ +export async function readPosts(dir: string): Promise<Post[]> { + const files = (await readdir(dir)).filter((file) => POST_RE.test(file)); + const posts: Post[] = []; + + for (const file of files.sort()) { + const html = await readFile(join(dir, file), 'utf8'); + posts.push({ + file, + n: Number(POST_RE.exec(file)![1]), + date: match(html, /<meta\s+name="date"\s+content="([^"]+)"/i), + description: match(html, /<meta\s+name="description"\s+content="([^"]+)"/i), + title: match(html, /<h1[^>]*>([\s\S]*?)<\/h1>/i), + }); + } + + return posts.sort((a, b) => a.n - b.n); +} + +/** Next post number, zero-padded, following the highest that exists. */ +export function nextNumber(posts: readonly Pick<Post, 'n'>[]): string { + const highest = posts.reduce((max, post) => Math.max(max, post.n), 0); + return String(highest + 1).padStart(3, '0'); +} + +/** + * Render a post file. + * + * Deliberately smolweb-valid, which is stricter than "valid HTML": an explicit + * `<html lang>`, `<head>` and `<body>`; `<meta http-equiv="Content-Type">` + * rather than a bare `<meta charset>`, because every `<meta>` needs a `content` + * attribute; and every `<p>` closed. + */ +export function renderPost({ title, description, date, body = '' }: NewPost): string { + const day = date.slice(0, 10); + const heading = typogrify(title); + const content = body.trim() || '<h2>Start here</h2>\n\n<p>…</p>'; + + return `<!doctype html> +<html lang="en"> +<head> +<meta http-equiv="Content-Type" content="text/html; charset=utf-8"> +<meta name="viewport" content="width=device-width,initial-scale=1"> +<title>${heading} — Chovy's Blog + + + + + + + + + + +`; +} + +/** + * Splice a post into index.html's list, newest first. + * + * index.html is hand-maintained — build-feed.mjs never touches it — which is + * exactly why it drifts. Inserting one `
  • ` leaves the rest of the page as + * the author wrote it. + */ +export function insertIntoIndex( + html: string, + post: { file: string; title: string; date: string }, +): string { + if (html.includes(`href="${post.file}"`)) return html; + + const open = html.indexOf('
      '); + if (open === -1) return html; + + const day = post.date.slice(0, 10); + const li = `\t
    • ${typogrify(post.title)} — ${day}
    • `; + const at = open + '
        '.length; + + return `${html.slice(0, at)}\n${li}${html.slice(at)}`; +} + +/** + * Problems that make a post invisible or mis-sorted in the feed. + * + * The future-date check is the one that matters. A post dated ahead of now pins + * itself above every real post *and* is dropped outright by readers that filter + * future items, so the feed looks like it stopped updating while every file on + * disk looks perfect. Three posts once sat 7-10 hours ahead and did exactly + * that; nothing else surfaced it. + */ +export function lint(posts: readonly Post[], now: number = Date.now()): Problem[] { + const problems: Problem[] = []; + + for (const post of posts) { + if (!post.date) { + problems.push({ file: post.file, problem: 'no — build-feed.mjs skips it' }); + continue; + } + + const when = new Date(post.date); + if (Number.isNaN(when.getTime())) { + problems.push({ file: post.file, problem: `unparseable date "${post.date}"` }); + } else if (when.getTime() > now) { + problems.push({ + file: post.file, + problem: `dated in the future (${post.date}) — pins to the top of the feed and readers may hide it`, + }); + } + + if (!post.description) { + problems.push({ + file: post.file, + problem: 'no — empty feed summary', + }); + } + if (!post.title) { + problems.push({ file: post.file, problem: 'no

        — feed falls back to ' }); + } + } + + return problems; +} + +/** Write the next post and list it in index.html. */ +export async function createPost( + dir: string, + post: NewPost, +): Promise<{ file: string; path: string }> { + const posts = await readPosts(dir); + const file = `${nextNumber(posts)}-post.html`; + const path = join(dir, file); + + // 'wx' rather than a plain write: two concurrent runs both read the directory + // before either writes, so both pick the same number. Losing a post to that + // race would be invisible until somebody noticed it missing. + await writeFile(path, renderPost(post), { flag: 'wx' }); + + const indexPath = join(dir, 'index.html'); + const index = await readFile(indexPath, 'utf8'); + await writeFile(indexPath, insertIntoIndex(index, { file, title: post.title, date: post.date })); + + return { file, path }; +} + +/** ISO 8601 with the milliseconds trimmed, matching the format the posts use. */ +export function isoSeconds(date: Date): string { + return date.toISOString().replace(/\.\d+Z$/, 'Z'); +} diff --git a/test/blog.test.ts b/test/blog.test.ts new file mode 100644 index 0000000..9eb366c --- /dev/null +++ b/test/blog.test.ts @@ -0,0 +1,219 @@ +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { + blogDir, + createPost, + DEFAULT_DIR, + esc, + insertIntoIndex, + isoSeconds, + lint, + nextNumber, + readPosts, + renderPost, + typogrify, + type Post, +} from '../src/blog.ts'; + +const NOW = Date.parse('2026-08-16T11:00:00Z'); + +const dirs: string[] = []; + +afterEach(async () => { + await Promise.all(dirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))); +}); + +/** A throwaway blog directory holding one post and an index. */ +async function fixture(): Promise<string> { + const dir = await mkdtemp(join(tmpdir(), 'blog-')); + dirs.push(dir); + + await writeFile( + join(dir, '001-post.html'), + renderPost({ title: 'First', description: 'the first one', date: '2026-08-14T11:21:00Z' }), + ); + await writeFile( + join(dir, 'index.html'), + '<!doctype html>\n<html lang="en">\n<body>\n<h1>Blog</h1>\n<ul>\n\t<li><a href="001-post.html">First</a> — 2026-08-14</li>\n</ul>\n</body>\n</html>\n', + ); + + return dir; +} + +const post = (over: Partial<Post> & Pick<Post, 'file'>): Post => ({ + n: 1, + date: '2026-08-16T09:00:00Z', + title: 't', + description: 'd', + ...over, +}); + +describe('blogDir', () => { + it('prefers an explicit path, then $BLOG_DIR, then the default', () => { + expect(blogDir('/tmp/x', {})).toBe('/tmp/x'); + expect(blogDir(undefined, { BLOG_DIR: '/tmp/y' })).toBe('/tmp/y'); + expect(blogDir(undefined, {})).toBe(DEFAULT_DIR); + }); +}); + +describe('nextNumber', () => { + it('pads and follows the highest existing post, not the last', () => { + expect(nextNumber([])).toBe('001'); + expect(nextNumber([{ n: 1 }, { n: 2 }])).toBe('003'); + expect(nextNumber([{ n: 11 }, { n: 2 }])).toBe('012'); + expect(nextNumber([{ n: 9 }])).toBe('010'); + }); +}); + +describe('esc and typogrify', () => { + it('escapes what would break an attribute', () => { + expect(esc('a & b')).toBe('a & b'); + expect(esc('say "hi"')).toBe('say "hi"'); + expect(esc('<script>')).toBe('<script>'); + }); + + it('matches the entity style the existing posts use', () => { + expect(typogrify('a -- b')).toBe('a — b'); + expect(typogrify('the "best" thing')).toBe('the “best” thing'); + expect(typogrify("don't")).toBe('don’t'); + expect(typogrify('rock & roll')).toBe('rock & roll'); + }); +}); + +describe('renderPost', () => { + const html = renderPost({ + title: 'A Post', + description: 'about things', + date: '2026-08-16T10:00:00Z', + }); + + it('emits the smolweb-valid shape the blog requires', () => { + expect(html).toContain('<html lang="en">'); + // http-equiv rather than a bare charset: every <meta> needs a content attribute. + expect(html).toContain('<meta http-equiv="Content-Type" content="text/html; charset=utf-8">'); + expect(html).not.toMatch(/<meta charset/); + expect(html).toContain('<meta name="date" content="2026-08-16T10:00:00Z">'); + expect(html).toContain('<meta name="description" content="about things">'); + expect(html).toContain('<h1>A Post</h1>'); + expect(html).toContain('href="feed.xml"'); + }); + + it('keeps the AI-drafting acknowledgment, which is required disclosure', () => { + expect(html).toContain('How this was written:'); + }); + + it('escapes a description that tries to break out of its attribute', () => { + const hostile = renderPost({ + title: 'x', + description: '" onload="alert(1)', + date: '2026-08-16T10:00:00Z', + }); + expect(hostile).not.toContain('onload="alert(1)"'); + expect(hostile).toContain('"'); + }); +}); + +describe('insertIntoIndex', () => { + const index = '<ul>\n\t<li><a href="001-post.html">First</a> — 2026-08-14</li>\n</ul>'; + const second = { file: '002-post.html', title: 'Second', date: '2026-08-16T10:00:00Z' }; + + it('splices one li at the top', () => { + const once = insertIntoIndex(index, second); + expect(once).toContain('href="002-post.html"'); + expect(once.indexOf('002-post.html')).toBeLessThan(once.indexOf('001-post.html')); + }); + + it('is idempotent', () => { + const once = insertIntoIndex(index, second); + expect(insertIntoIndex(once, second)).toBe(once); + }); +}); + +describe('lint', () => { + it('catches a future date — the failure that made the live feed look dead', () => { + const problems = lint([post({ file: 'a.html', date: '2026-08-16T21:00:00Z' })], NOW); + expect(problems).toHaveLength(1); + expect(problems[0]!.problem).toMatch(/future/); + }); + + it('catches the silent feed-generator skips', () => { + const problems = lint( + [ + post({ file: 'b.html', date: null }), + post({ file: 'c.html', date: 'not-a-date' }), + post({ file: 'd.html', description: null }), + post({ file: 'e.html', title: null }), + ], + NOW, + ); + + const forFile = (file: string) => + problems.filter((p) => p.file === file).map((p) => p.problem).join(' '); + + expect(forFile('b.html')).toMatch(/no <meta name="date">/); + expect(forFile('c.html')).toMatch(/unparseable/); + expect(forFile('d.html')).toMatch(/description/); + expect(forFile('e.html')).toMatch(/no <h1>/); + }); + + it('reports nothing for a healthy post, including one dated exactly now', () => { + expect(lint([post({ file: 'ok.html' })], NOW)).toEqual([]); + expect(lint([post({ file: 'ok.html', date: '2026-08-16T11:00:00Z' })], NOW)).toEqual([]); + }); +}); + +describe('createPost', () => { + it('writes the next post, lists it, and produces something that lints clean', async () => { + const dir = await fixture(); + + const { file } = await createPost(dir, { + title: 'Second Post', + description: 'number two', + date: '2026-08-16T10:00:00Z', + }); + expect(file).toBe('002-post.html'); + + const posts = await readPosts(dir); + expect(posts).toHaveLength(2); + expect(posts[1]).toMatchObject({ + title: 'Second Post', + date: '2026-08-16T10:00:00Z', + description: 'number two', + }); + + const index = await readFile(join(dir, 'index.html'), 'utf8'); + expect(index).toContain('href="002-post.html"'); + expect(index.indexOf('002-post')).toBeLessThan(index.indexOf('001-post')); + + expect(lint(posts, Date.parse('2026-08-17T00:00:00Z'))).toEqual([]); + }); + + it('cannot lose a post to two concurrent runs', async () => { + const dir = await fixture(); + + // Both calls read the directory before either writes, so both pick 002. + // The 'wx' flag is what stops the loser from silently replacing the winner. + const results = await Promise.allSettled([ + createPost(dir, { title: 'Racer A', description: 'a', date: '2026-08-16T10:00:00Z' }), + createPost(dir, { title: 'Racer B', description: 'b', date: '2026-08-16T10:00:00Z' }), + ]); + + expect(results.filter((r) => r.status === 'fulfilled')).toHaveLength(1); + const rejected = results.filter((r) => r.status === 'rejected'); + expect(rejected).toHaveLength(1); + expect(String((rejected[0] as PromiseRejectedResult).reason)).toMatch(/EEXIST/); + + const posts = await readPosts(dir); + expect(posts).toHaveLength(2); + expect(posts[1]!.title).toMatch(/^Racer [AB]$/); + }); +}); + +describe('isoSeconds', () => { + it('trims milliseconds to match the format the posts use', () => { + expect(isoSeconds(new Date('2026-08-16T10:04:00.123Z'))).toBe('2026-08-16T10:04:00Z'); + }); +});