Skip to content

Commit aef4e8d

Browse files
ralyodioclaude
andcommitted
Add blog-post, and expose the repo as a moshcode plugin marketplace
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 <noreply@anthropic.com>
1 parent fcaaebb commit aef4e8d

11 files changed

Lines changed: 895 additions & 0 deletions

File tree

.claude-plugin/marketplace.json

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
{
2+
"$schema": "https://anthropic.com/claude-code/marketplace.schema.json",
3+
"name": "cli-tools",
4+
"description": "Profullstack's command-line tools as installable plugins — publish to the plain-HTML blog without getting a convention wrong.",
5+
"owner": {
6+
"name": "profullstack",
7+
"url": "https://profullstack.com"
8+
},
9+
"plugins": [
10+
{
11+
"name": "blog",
12+
"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.",
13+
"source": "./plugins/blog",
14+
"category": "productivity",
15+
"author": {
16+
"name": "profullstack",
17+
"url": "https://profullstack.com"
18+
},
19+
"homepage": "https://github.com/profullstack/cli-tools#blog",
20+
"keywords": ["blog", "rss", "feed", "publishing", "smolweb"]
21+
}
22+
]
23+
}

README.md

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ TypeScript, installed as executables on `PATH`.
1010
| [`gh-prs-fix-all`](#gh-prs-fix-all) | Fix the open threatcrush-scan PRs that are broken because of us |
1111
| [`tcfeed`](#tcfeed) | Find repositories worth scanning, scan them, print a shortlist |
1212
| [`domainjson`](#domainjson) | whois-style, JSON-first name lookup |
13+
| [`blog-post`](#blog-post) | Publish to the plain-HTML blog without breaking the feed |
1314

1415
## Requirements
1516

@@ -170,6 +171,52 @@ goes through OpenRDAP. Either way `dig` adds records, hosts, reverse lookups and
170171
per-nameserver AXFR attempts. Errors are JSON too — a tool whose output gets
171172
parsed should not change shape when it fails.
172173

174+
### `blog-post`
175+
176+
Publishes to the plain-HTML blog at `~/public_html/blog`. That blog has no build
177+
step and no CMS: writing a file *is* publishing. This exists because nothing
178+
else catches a mistake before it is live.
179+
180+
```sh
181+
blog-post new "A title" --description "The one-line feed summary"
182+
blog-post new "A title" --description "..." --body draft.html
183+
blog-post check # posts that will break the feed
184+
blog-post list # every post with its date
185+
blog-post feed # regenerate feed.xml
186+
```
187+
188+
`new` picks the next `NNN-post.html`, renders the smolweb-valid template with
189+
the AI-drafting acknowledgment, splices the entry into the hand-maintained
190+
`index.html`, and runs the blog's own `build-feed.mjs`. Point it elsewhere with
191+
`--dir` or `$BLOG_DIR`.
192+
193+
What it refuses to do:
194+
195+
- **Date a post in the future.** Such a post sorts above every real post, and
196+
readers that filter future items drop it entirely — so the feed looks like it
197+
stopped updating while the files on disk look perfect. This has happened:
198+
three posts sat 7–10 hours ahead and did exactly that. `--allow-future` is
199+
there if you genuinely mean to schedule.
200+
- **Overwrite a post.** Two concurrent runs read the directory before either
201+
writes, so both pick the same number; the write uses `wx` and the loser fails
202+
loudly rather than silently replacing a post.
203+
- **Skip the description.** It is the entire RSS summary.
204+
205+
`check` reports missing, unparseable and future dates, empty descriptions and a
206+
missing `<h1>`, and exits non-zero, so it works as a pre-publish gate.
207+
208+
## As a moshcode plugin
209+
210+
This repo is also a plugin marketplace, exposing `blog-post` as slash commands:
211+
212+
```sh
213+
moshcode plugin marketplace add profullstack/cli-tools
214+
moshcode plugin install blog@cli-tools
215+
```
216+
217+
That adds `/blog:post`, `/blog:check`, `/blog:list` and `/blog:feed`. See
218+
[plugins/blog](plugins/blog/README.md).
219+
173220
## Aliases
174221

175222
Pit aliases live in `~/.moshcode/aliases.json`:
@@ -181,6 +228,7 @@ Pit aliases live in `~/.moshcode/aliases.json`:
181228
/alias set fixprs "gh-prs-fix-all"
182229
/alias set feed "tcfeed"
183230
/alias set whoisj "domainjson"
231+
/alias set blog "blog-post"
184232
185233
/alias # list
186234
/alias get merge # show one

bin/blog-post.ts

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
#!/usr/bin/env -S npx --yes tsx
2+
/**
3+
* blog-post — write to the plain-HTML blog without getting a convention wrong.
4+
*
5+
* The blog has no build step: writing a file is publishing. Nothing else
6+
* catches a post with no date (the feed generator skips it in silence) or one
7+
* dated in the future (it pins above every real post, and readers that filter
8+
* future items drop it, so the feed looks dead while the files look fine).
9+
*/
10+
11+
import { existsSync } from 'node:fs';
12+
import { readFile } from 'node:fs/promises';
13+
import { spawnSync } from 'node:child_process';
14+
import { join } from 'node:path';
15+
16+
import { parseArgs, UsageError } from '../src/args.ts';
17+
import { isMain } from '../src/is-main.ts';
18+
import {
19+
blogDir,
20+
createPost,
21+
DEFAULT_DIR,
22+
isoSeconds,
23+
lint,
24+
readPosts,
25+
} from '../src/blog.ts';
26+
27+
const USAGE = `Usage:
28+
blog-post new <title> --description <text> [--body file.html] [--date ISO]
29+
blog-post check
30+
blog-post list
31+
blog-post feed
32+
33+
Commands:
34+
new Write the next post, list it in index.html, rebuild the feed
35+
check Report posts that will break the feed (non-zero exit if any)
36+
list Every post with its date
37+
feed Regenerate feed.xml
38+
39+
Options:
40+
--description TEXT Feed summary. Required by \`new\`.
41+
--body FILE HTML fragment for the body (default: a stub)
42+
--date ISO Publish date (default: now). Refuses the future.
43+
--dir PATH Blog directory (default: $BLOG_DIR, else
44+
${DEFAULT_DIR})
45+
--allow-future Permit a future date. You almost never want this.
46+
-h, --help show this help
47+
`;
48+
49+
const SPEC = {
50+
boolean: ['--allow-future', '-h', '--help'],
51+
string: ['--description', '--body', '--date', '--dir'],
52+
} as const;
53+
54+
/**
55+
* Regenerate feed.xml by running the blog's own generator.
56+
*
57+
* Shelling out rather than reimplementing: build-feed.mjs lives beside the
58+
* posts and is the single source of truth for the feed's shape.
59+
*/
60+
function rebuildFeed(dir: string): number {
61+
const script = join(dir, 'build-feed.mjs');
62+
if (!existsSync(script)) {
63+
process.stderr.write(`no build-feed.mjs in ${dir} — feed not regenerated\n`);
64+
return 1;
65+
}
66+
return spawnSync(process.execPath, [script], { cwd: dir, stdio: 'inherit' }).status ?? 1;
67+
}
68+
69+
export async function run(argv: readonly string[]): Promise<number> {
70+
let parsed;
71+
try {
72+
parsed = parseArgs(argv, SPEC);
73+
} catch (error) {
74+
if (error instanceof UsageError) {
75+
process.stderr.write(`${error.message}\n\n${USAGE}`);
76+
return 2;
77+
}
78+
throw error;
79+
}
80+
81+
const { flags, values, positional } = parsed;
82+
83+
if (flags.has('-h') || flags.has('--help') || positional.length === 0) {
84+
process.stdout.write(USAGE);
85+
return positional.length === 0 && !flags.has('-h') && !flags.has('--help') ? 1 : 0;
86+
}
87+
88+
const [command, ...rest] = positional;
89+
const dir = blogDir(values.get('--dir'));
90+
91+
if (!existsSync(dir)) {
92+
process.stderr.write(`blog directory not found: ${dir}\n`);
93+
return 1;
94+
}
95+
96+
switch (command) {
97+
case 'new': {
98+
const title = rest.join(' ').trim();
99+
if (!title) {
100+
process.stderr.write('new: give a title\n');
101+
return 1;
102+
}
103+
104+
const description = (values.get('--description') ?? '').trim();
105+
if (!description) {
106+
process.stderr.write('new: --description is required (it becomes the feed summary)\n');
107+
return 1;
108+
}
109+
110+
const raw = values.get('--date');
111+
const when = raw ? new Date(raw) : new Date();
112+
if (Number.isNaN(when.getTime())) {
113+
process.stderr.write(`new: unparseable --date ${JSON.stringify(raw)}\n`);
114+
return 1;
115+
}
116+
if (when.getTime() > Date.now() && !flags.has('--allow-future')) {
117+
process.stderr.write(
118+
`new: ${isoSeconds(when)} is in the future.\n` +
119+
' A future-dated post sits above every real post, and readers that hide\n' +
120+
' future items drop it, so the feed looks dead. Pass --allow-future only\n' +
121+
' if you genuinely mean to schedule it.\n',
122+
);
123+
return 1;
124+
}
125+
126+
const bodyFile = values.get('--body');
127+
const body = bodyFile ? await readFile(bodyFile, 'utf8') : '';
128+
129+
const { file, path } = await createPost(dir, {
130+
title,
131+
description,
132+
date: isoSeconds(when),
133+
body,
134+
});
135+
136+
process.stdout.write(`created ${file}\n ${path}\n listed in index.html\n`);
137+
return rebuildFeed(dir);
138+
}
139+
140+
case 'check': {
141+
const problems = lint(await readPosts(dir));
142+
if (problems.length === 0) {
143+
process.stdout.write('all posts look publishable\n');
144+
return 0;
145+
}
146+
for (const problem of problems) {
147+
process.stderr.write(`${problem.file}: ${problem.problem}\n`);
148+
}
149+
return 1;
150+
}
151+
152+
case 'list': {
153+
for (const post of await readPosts(dir)) {
154+
const title = (post.title ?? '(no h1)').replace(/&mdash;/g, '—').slice(0, 52);
155+
process.stdout.write(`${post.file} ${(post.date ?? 'NO-DATE').padEnd(22)} ${title}\n`);
156+
}
157+
return 0;
158+
}
159+
160+
case 'feed':
161+
return rebuildFeed(dir);
162+
163+
default:
164+
process.stderr.write(`unknown command: ${command}\n\n${USAGE}`);
165+
return 1;
166+
}
167+
}
168+
169+
if (isMain(import.meta.url)) {
170+
process.exitCode = await run(process.argv.slice(2));
171+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
{
2+
"$schema": "https://anthropic.com/claude-code/plugin.schema.json",
3+
"name": "blog",
4+
"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.",
5+
"version": "0.1.0",
6+
"author": {
7+
"name": "profullstack",
8+
"url": "https://profullstack.com"
9+
},
10+
"homepage": "https://github.com/profullstack/cli-tools#blog",
11+
"license": "MIT",
12+
"keywords": ["blog", "rss", "feed", "publishing", "smolweb"]
13+
}

plugins/blog/README.md

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
# blog — publish to the plain-HTML blog 📝
2+
3+
Slash commands wrapping the `blog-post` CLI. The blog at
4+
`~/public_html/blog` has no build step and no CMS: writing a file *is*
5+
publishing. That is the appeal and also the hazard, because nothing catches a
6+
mistake before it is live.
7+
8+
| command | what it does |
9+
| --- | --- |
10+
| `/blog:post <title>` | draft, create and publish a post, then rebuild the feed |
11+
| `/blog:check` | find posts that will break the feed |
12+
| `/blog:list` | every post with its date |
13+
| `/blog:feed` | regenerate `feed.xml` |
14+
15+
## What this stops you doing
16+
17+
**Dating a post in the future.** It sorts above everything real, and readers
18+
that filter future items drop it, so the feed looks like it stopped updating
19+
while every file on disk looks perfect. Three posts once sat 7–10 hours ahead
20+
and did exactly that. `blog-post` refuses a future date unless you insist.
21+
22+
**Omitting `<meta name="date">`.** `build-feed.mjs` skips the post without
23+
saying anything useful.
24+
25+
**Breaking smolweb validity.** The generated template uses an explicit
26+
`<html lang>`, `<meta http-equiv="Content-Type">` rather than a bare
27+
`<meta charset>`, and closes everything.
28+
29+
**Forgetting the AI-drafting acknowledgment.** It goes in every post; Kagi
30+
Small Web and others require disclosure, and the index states the policy.
31+
32+
## Install
33+
34+
```bash
35+
moshcode plugin marketplace add profullstack/cli-tools
36+
moshcode plugin install blog@cli-tools
37+
```
38+
39+
The commands shell out to `blog-post`, which comes from this same repo. It is
40+
not published to npm — clone and link it onto `PATH`:
41+
42+
```sh
43+
git clone git@github.com:profullstack/cli-tools.git ~/src/profullstack/cli-tools
44+
cd ~/src/profullstack/cli-tools
45+
pnpm install
46+
pnpm link:bin
47+
```
48+
49+
Point it at a different blog with `--dir` or `$BLOG_DIR`.

plugins/blog/commands/check.md

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
---
2+
description: Find posts that will break the RSS feed — missing dates, future dates, empty summaries.
3+
allowed-tools: Bash(blog-post:*), Read, Edit
4+
---
5+
6+
## Task
7+
8+
Check the blog for posts that will not appear correctly in the feed.
9+
10+
```bash
11+
blog-post check
12+
```
13+
14+
## What it looks for
15+
16+
- **A date in the future.** The important one. Such a post sorts above every
17+
real post and is dropped entirely by readers that hide future items, so the
18+
feed appears to have stopped updating while everything looks fine on disk.
19+
Three posts once sat 7&ndash;10 hours ahead and did exactly that.
20+
- **No `<meta name="date">`.** `build-feed.mjs` skips the post silently.
21+
- **An unparseable date.** Same outcome, also silent.
22+
- **No `<meta name="description">`.** The item ships with an empty summary.
23+
- **No `<h1>`.** The feed title falls back to `<title>`, which carries the
24+
site-name suffix.
25+
26+
## Fixing
27+
28+
Edit the offending `<meta>` in the post, then rebuild:
29+
30+
```bash
31+
blog-post feed
32+
```
33+
34+
For a wrong date, prefer the file's real modification time over inventing one —
35+
that is the best evidence of when the post was actually written.
36+
37+
Exit status is non-zero when anything is wrong, so this is usable as a gate.

plugins/blog/commands/feed.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
---
2+
description: Regenerate feed.xml from the posts on disk.
3+
allowed-tools: Bash(blog-post:*), Bash(node:*)
4+
---
5+
6+
## Task
7+
8+
Rebuild the RSS feed.
9+
10+
```bash
11+
blog-post feed
12+
```
13+
14+
This runs the blog's own `build-feed.mjs`, which is the single source of truth
15+
for the feed's shape. It keeps the **10 most recent** posts and trims the rest,
16+
and warns about any post dated in the future.
17+
18+
Run it after editing a post's title, date or description by hand —
19+
`/blog:post` already does it for you when creating one.
20+
21+
The feed is served straight off disk at
22+
`https://dev.profullstack.com/~anthony/blog/feed.xml`, so it is live the moment
23+
the file is written. There is nothing to deploy.

0 commit comments

Comments
 (0)