${heading}
+ +${day}, by Anthony “chovy” Ettinger.
+ +How this was written: drafted with an AI assistant from my own notes, +then edited by me.
+ + + +${content} + + + +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 `
` + elements only. No ``, `
` or `` closed, no bare ``, 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, '"');
+}
+
+/**
+ * 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 ` closed.
+ */
+export function renderPost({ title, description, date, body = '' }: NewPost): string {
+ const day = date.slice(0, 10);
+ const heading = typogrify(title);
+ const content = body.trim() || ' … ${day}, by Anthony “chovy” Ettinger. How this was written: drafted with an AI assistant from my own notes,
+then edited by me.Start here
\n\n${heading}
+
+');
+ if (open === -1) return html;
+
+ const day = post.date.slice(0, 10);
+ const li = `\t
'.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
Blog
\n\n\t
\n\n\n',
+ );
+
+ return dir;
+}
+
+const post = (over: Partial