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 + + + + + + +
+ +

${heading}

+ +

${day}, by Anthony “chovy” Ettinger.

+ +

How this was written: drafted with an AI assistant from my own notes, +then edited by me.

+ + + +${content} + + + +
+ + + +`; +} + +/** + * 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('