From c9e46a93ba9b25ff4c41a28aef6dc9869663fe7e Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Wed, 19 Aug 2026 13:11:51 +0000 Subject: [PATCH 1/2] Make the blog identity configuration, so the repo can go public MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The post template hardcoded one person: a byline, a site name, a Mastodon handle, a GitHub profile and an email address. It also hardcoded two CrawlProof account ids — an analytics site id and an ad slot id. None of that is a property of the tool, and in a public repository all of it is worse than untidy: a fresh checkout would publish somebody else's name on every post, and meter that install's pageviews and ad impressions into an account it inherited from the repository rather than chose. So identity now comes from a config file, read from $BLOG_CONFIG, then /blog.config.json, then ~/.config/cli-tools/blog.json, with the environment able to override any scalar field. The defaults are empty and the empty case is a good one rather than a broken one: no byline, no identity links, and no third-party scripts at all — which is the only fully smolweb-valid output the template has ever been able to produce. `blog-post config` prints which file was picked up and what it resolved to, and `new` says so on stderr when it renders a post with no byline, because a silent anonymous post is the failure worth catching. Also replaces two colleagues' GitHub handles in gh-prs usage examples with placeholders, drops a personal dev URL from the feed plugin doc, and ignores blog.config.json plus the usual credential file shapes. Verified: rendering with the maintainer's config reproduces the previous template byte for byte. 98 tests pass, typecheck clean. Co-Authored-By: Claude Opus 5 (1M context) --- .gitignore | 11 +++ README.md | 49 ++++++++++-- bin/blog-post.ts | 40 ++++++++-- bin/gh-prs.ts | 6 +- blog.config.example.json | 13 ++++ plugins/blog/README.md | 9 ++- plugins/blog/commands/feed.md | 5 +- plugins/blog/commands/post.md | 3 +- src/blog-config.ts | 139 ++++++++++++++++++++++++++++++++++ src/blog.ts | 98 +++++++++++++++++------- test/blog-config.test.ts | 137 +++++++++++++++++++++++++++++++++ test/blog.test.ts | 78 ++++++++++++++++--- 12 files changed, 530 insertions(+), 58 deletions(-) create mode 100644 blog.config.example.json create mode 100644 src/blog-config.ts create mode 100644 test/blog-config.test.ts diff --git a/.gitignore b/.gitignore index 3c45938..7380fcc 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,14 @@ node_modules/ *.log .DS_Store + +# Your blog identity: byline, rel="me" links, analytics and ad account ids. +# Belongs in ~/.config/cli-tools/blog.json, never in the repository. +blog.config.json + +# Local environment and credentials, in every form they usually turn up in. +.env +.env.* +!.env.example +*.pem +*.key diff --git a/README.md b/README.md index 35a2c71..6cd96b6 100644 --- a/README.md +++ b/README.md @@ -71,8 +71,8 @@ and URL become clickable. ```sh gh-prs --orgs profullstack,moshcoder,h4kr,infernetprotocol -gh-prs --users ralyodio -gh-prs --orgs profullstack --users ralyodio --limit 50 +gh-prs --users octocat +gh-prs --orgs profullstack --users octocat --limit 50 gh-prs --orgs profullstack --no-links # plain text, for piping ``` @@ -226,12 +226,49 @@ 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 +blog-post config # where your identity is read from, and what is in effect ``` -`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`. +`new` picks the next `NNN-post.html`, renders the smolweb-valid template, +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`. + +#### Your identity is configuration, not code + +Nothing about *you* is baked into this repository. The byline, the site name, +the `rel="me"` links and any analytics or ad ids come from a config file, and +with none present a post renders with no byline, no identity links and **no +third-party scripts at all** — which is the only fully smolweb-valid output. + +Copy [`blog.config.example.json`](blog.config.example.json) to whichever of +these suits, most specific first: + +| Path | Use it for | +| --- | --- | +| `$BLOG_CONFIG` | a one-off, or CI | +| `/blog.config.json` | a second blog with its own identity | +| `~/.config/cli-tools/blog.json` | your own blog — the usual answer | + +```json +{ + "siteTitle": "Your Blog", + "author": "Your Name", + "disclosure": "How this was written: drafted with an AI assistant, then edited by me.", + "links": [{ "label": "Mastodon", "href": "https://example.social/@you" }], + "trackerSiteId": null, + "adSlotId": null +} +``` + +`BLOG_SITE_TITLE`, `BLOG_AUTHOR`, `BLOG_DISCLOSURE`, `CRAWLPROOF_SITE_ID`, +`CRAWLPROOF_AD_SLOT` and `CRAWLPROOF_AD_FORMAT` override the file. `links` is +the only field with no environment equivalent. + +`trackerSiteId` and `adSlotId` are **accounts, not settings**: leave them null +unless they are yours. A shared id would meter your readers' pageviews and your +ad impressions into somebody else's account, which is why they are not defaults. + +Run `blog-post config` to see which file was picked up and what it resolved to. What it refuses to do: diff --git a/bin/blog-post.ts b/bin/blog-post.ts index 39aef1d..10e3f4f 100755 --- a/bin/blog-post.ts +++ b/bin/blog-post.ts @@ -23,18 +23,21 @@ import { lint, readPosts, } from '../src/blog.ts'; +import { configPaths, loadBlogConfig } from '../src/blog-config.ts'; const USAGE = `Usage: blog-post new --description <text> [--body file.html] [--date ISO] blog-post check blog-post list blog-post feed + blog-post config 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 + config Where the blog identity is read from, and what is in effect Options: --description TEXT Feed summary. Required by \`new\`. @@ -126,12 +129,19 @@ export async function run(argv: readonly string[]): Promise<number> { const bodyFile = values.get('--body'); const body = bodyFile ? await readFile(bodyFile, 'utf8') : ''; - const { file, path } = await createPost(dir, { - title, - description, - date: isoSeconds(when), - body, - }); + const config = await loadBlogConfig(dir); + if (!config.author) { + process.stderr.write( + 'note: no blog config found, so this post has no byline and no identity links.\n' + + ` Write one to ${configPaths(dir).at(-1)} — see \`blog-post config\`.\n`, + ); + } + + const { file, path } = await createPost( + dir, + { title, description, date: isoSeconds(when), body }, + config, + ); process.stdout.write(`created ${file}\n ${path}\n listed in index.html\n`); return rebuildFeed(dir); @@ -157,6 +167,24 @@ export async function run(argv: readonly string[]): Promise<number> { return 0; } + case 'config': { + const config = await loadBlogConfig(dir); + const paths = configPaths(dir); + process.stdout.write('Config is read from the first of these that exists:\n'); + for (const path of paths) { + process.stdout.write(` ${existsSync(path) ? '*' : ' '} ${path}\n`); + } + process.stdout.write(`\nIn effect:\n${JSON.stringify(config, null, 2)}\n`); + if (!config.author) { + process.stdout.write( + '\nNothing is configured, so posts render with no byline, no identity links\n' + + 'and no third-party scripts. Copy blog.config.example.json to\n' + + `${paths.at(-1)} and fill it in.\n`, + ); + } + return 0; + } + case 'feed': return rebuildFeed(dir); diff --git a/bin/gh-prs.ts b/bin/gh-prs.ts index fde347e..003d022 100755 --- a/bin/gh-prs.ts +++ b/bin/gh-prs.ts @@ -3,8 +3,8 @@ * gh-prs — list every open pull request across the owners you name. * * gh-prs --orgs profullstack,moshcoder,h4kr,infernetprotocol - * gh-prs --users ralyodio,devpreshy - * gh-prs --orgs profullstack --users ralyodio + * gh-prs --users octocat,hubot + * gh-prs --orgs profullstack --users octocat */ import { csv, integer, parseArgs, UsageError } from '../src/args.ts'; @@ -24,7 +24,7 @@ Options: Examples: gh-prs --orgs profullstack,moshcoder,h4kr,infernetprotocol - gh-prs --users ralyodio,devpreshy + gh-prs --users octocat,hubot `; async function main(argv: string[]): Promise<number> { diff --git a/blog.config.example.json b/blog.config.example.json new file mode 100644 index 0000000..ce9a54c --- /dev/null +++ b/blog.config.example.json @@ -0,0 +1,13 @@ +{ + "siteTitle": "Your Blog", + "author": "Your Name", + "disclosure": "<strong>How this was written:</strong> drafted with an AI assistant from my own notes, then edited by me.", + "links": [ + { "label": "Mastodon", "href": "https://example.social/@you" }, + { "label": "GitHub", "href": "https://github.com/you" }, + { "label": "email", "href": "mailto:you@example.com" } + ], + "trackerSiteId": null, + "adSlotId": null, + "adFormat": "text_link" +} diff --git a/plugins/blog/README.md b/plugins/blog/README.md index 8687924..0fb5157 100644 --- a/plugins/blog/README.md +++ b/plugins/blog/README.md @@ -27,7 +27,14 @@ saying anything useful. `<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. +Small Web and others require disclosure, and the index states the policy. It +comes from the `disclosure` field of your blog config — `blog-post config` +shows whether one is set. + +**Publishing with nobody's name on it.** The byline, site name and `rel="me"` +links are configuration, not constants, so an unconfigured checkout writes a +post with no byline and no identity links. Copy `blog.config.example.json` to +`~/.config/cli-tools/blog.json` before the first post. ## Install diff --git a/plugins/blog/commands/feed.md b/plugins/blog/commands/feed.md index b81b591..fbe095b 100644 --- a/plugins/blog/commands/feed.md +++ b/plugins/blog/commands/feed.md @@ -18,6 +18,5 @@ 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. +The feed is served straight off disk, so it is live the moment the file is +written. There is nothing to deploy. diff --git a/plugins/blog/commands/post.md b/plugins/blog/commands/post.md index ae62ca4..1de666d 100644 --- a/plugins/blog/commands/post.md +++ b/plugins/blog/commands/post.md @@ -45,7 +45,8 @@ right the first time rather than fixing it live. 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. + policy. It comes from the `disclosure` field of the blog config, so if a post + renders without one, run `blog-post config` rather than pasting it by hand. - **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. diff --git a/src/blog-config.ts b/src/blog-config.ts new file mode 100644 index 0000000..507da39 --- /dev/null +++ b/src/blog-config.ts @@ -0,0 +1,139 @@ +import { readFile } from 'node:fs/promises'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; + +/** + * Who the blog belongs to, and which third-party ids its pages carry. + * + * None of this is a property of the *tool*, so none of it is baked into it. A + * byline, a Mastodon handle and an analytics site id are the author's, and a + * checkout that carried someone else's would publish their name on your posts + * and meter your pageviews and ad impressions into their account. So the + * defaults here are empty, every field is optional, and a post rendered without + * config is a clean post rather than a broken one: no byline, no identity + * links, and — the part that matters — no third-party scripts at all, which is + * the only configuration that is fully smolweb-valid. + */ + +export interface BlogLink { + label: string; + href: string; + /** Emitted as the anchor's `rel`. Defaults to `me`, which is what makes these verifiable. */ + rel?: string; +} + +export interface BlogConfig { + /** Site name, appended to each post's `<title>` and used as the feed link title. */ + siteTitle: string | null; + /** Byline name. Null omits the byline line entirely. */ + author: string | null; + /** Identity links in the footer. Empty omits the paragraph. */ + links: BlogLink[]; + /** How the post was written, as a short line under the byline. */ + disclosure: string | null; + /** CrawlProof site id for the pageview tag. Null emits no tracker. */ + trackerSiteId: string | null; + /** CrawlProof ad slot id. Null emits no ad unit. */ + adSlotId: string | null; + /** Ad format for the slot above. */ + adFormat: string; +} + +/** The zero config: a post with no identity and no third-party scripts. */ +export const EMPTY_CONFIG: BlogConfig = { + siteTitle: null, + author: null, + links: [], + disclosure: null, + trackerSiteId: null, + adSlotId: null, + adFormat: 'text_link', +}; + +function xdgConfigHome(env: NodeJS.ProcessEnv): string { + return env.XDG_CONFIG_HOME || join(homedir(), '.config'); +} + +/** + * Where a blog config may live, most specific first. + * + * The blog directory comes before the user directory so a second blog can carry + * its own identity without either one having to be passed on the command line. + */ +export function configPaths(dir?: string, env: NodeJS.ProcessEnv = process.env): string[] { + const paths: string[] = []; + if (env.BLOG_CONFIG) paths.push(env.BLOG_CONFIG); + if (dir) paths.push(join(dir, 'blog.config.json')); + paths.push(join(xdgConfigHome(env), 'cli-tools', 'blog.json')); + return paths; +} + +function asString(value: unknown): string | null { + return typeof value === 'string' && value.trim() ? value.trim() : null; +} + +function asLinks(value: unknown): BlogLink[] { + if (!Array.isArray(value)) return []; + return value.flatMap((entry): BlogLink[] => { + if (!entry || typeof entry !== 'object') return []; + const label = asString((entry as Record<string, unknown>).label); + const href = asString((entry as Record<string, unknown>).href); + if (!label || !href) return []; + const rel = asString((entry as Record<string, unknown>).rel); + return [rel ? { label, href, rel } : { label, href }]; + }); +} + +/** Coerce parsed JSON into a config, dropping anything malformed rather than trusting it. */ +export function normalizeConfig(raw: unknown): BlogConfig { + const object = raw && typeof raw === 'object' ? (raw as Record<string, unknown>) : {}; + return { + siteTitle: asString(object.siteTitle), + author: asString(object.author), + links: asLinks(object.links), + disclosure: asString(object.disclosure), + trackerSiteId: asString(object.trackerSiteId), + adSlotId: asString(object.adSlotId), + adFormat: asString(object.adFormat) ?? EMPTY_CONFIG.adFormat, + }; +} + +/** Environment overrides, applied over whatever the file supplied. */ +export function applyEnv(config: BlogConfig, env: NodeJS.ProcessEnv = process.env): BlogConfig { + return { + ...config, + siteTitle: asString(env.BLOG_SITE_TITLE) ?? config.siteTitle, + author: asString(env.BLOG_AUTHOR) ?? config.author, + disclosure: asString(env.BLOG_DISCLOSURE) ?? config.disclosure, + trackerSiteId: asString(env.CRAWLPROOF_SITE_ID) ?? config.trackerSiteId, + adSlotId: asString(env.CRAWLPROOF_AD_SLOT) ?? config.adSlotId, + adFormat: asString(env.CRAWLPROOF_AD_FORMAT) ?? config.adFormat, + }; +} + +/** + * Read the first config that exists, then let the environment override it. + * + * A missing file is not an error — running with no config at all is a supported + * mode. Malformed JSON *is*, because silently publishing a post stripped of the + * author's identity is worse than refusing to publish one. + */ +export async function loadBlogConfig( + dir?: string, + env: NodeJS.ProcessEnv = process.env, +): Promise<BlogConfig> { + for (const path of configPaths(dir, env)) { + let text: string; + try { + text = await readFile(path, 'utf8'); + } catch { + continue; + } + try { + return applyEnv(normalizeConfig(JSON.parse(text)), env); + } catch (error) { + throw new Error(`${path}: not valid JSON — ${(error as Error).message}`); + } + } + return applyEnv(EMPTY_CONFIG, env); +} diff --git a/src/blog.ts b/src/blog.ts index 381ce26..eea8803 100644 --- a/src/blog.ts +++ b/src/blog.ts @@ -2,6 +2,8 @@ import { readdir, readFile, writeFile } from 'node:fs/promises'; import { homedir } from 'node:os'; import { join } from 'node:path'; +import { type BlogConfig, type BlogLink, EMPTY_CONFIG } from './blog-config.ts'; + /** * The plain-HTML blog at ~/public_html/blog. * @@ -103,24 +105,52 @@ export function nextNumber(posts: readonly Pick<Post, 'n'>[]): string { * forbid scripts served from another host. Nothing on the page depends on it — * the post reads identically with JavaScript off — so the "usable without * JavaScript" half of the rule still holds. + * + * With no site id configured nothing is emitted at all, which is the fully + * valid case — and the default, so a fresh checkout never meters somebody + * else's traffic into an account it inherited from the repository. */ -export const TRACKER = - '<script data-site="099436d8-e1b1-4b4e-bc04-b3fbff5c4ead" src="https://crawlproof.com/stats.js" async></script>'; +export function tracker(siteId: string | null): string { + if (!siteId) return ''; + return `<script data-site="${esc(siteId)}" src="https://crawlproof.com/stats.js" async></script>`; +} /** * The sponsored bar that runs at the foot of every page. * - * `text_link` on purpose, not a 728x90 or 300x250: it is a 40px full-width + * `text_link` by default, not a 728x90 or 300x250: it is a 40px full-width * strip that carries its own "Sponsored" mark inside the frame, so `ad.js` * prepends no extra caption, and an unsold or blocked slot collapses to * nothing instead of leaving a banner-shaped hole. + * + * The slot id is the author's own, so it is configuration rather than a + * constant: a shared one would bill every installation's impressions to + * whoever happened to be in the file. `ad.js` loads only when there is a slot + * for it to fill, and the tracker is emitted here so no page carries it twice. */ -export const AD_UNIT = [ - '<aside data-cp-ad data-slot="50ba73a3-22b6-4264-9b2d-7f866759e287" data-format="text_link"></aside>', - '', - TRACKER, - '<script src="https://crawlproof.com/ad.js" async></script>', -].join('\n'); +export function adUnit( + config: Pick<BlogConfig, 'adSlotId' | 'adFormat' | 'trackerSiteId'>, +): string { + const tag = tracker(config.trackerSiteId); + if (!config.adSlotId) return tag; + + const slot = + `<aside data-cp-ad data-slot="${esc(config.adSlotId)}"` + + ` data-format="${esc(config.adFormat)}"></aside>`; + + return [slot, '', tag, '<script src="https://crawlproof.com/ad.js" async></script>'] + .filter((line, index, all) => line !== '' || all[index + 1] !== '') + .join('\n'); +} + +/** The footer identity links, or nothing when none are configured. */ +function identity(links: readonly BlogLink[]): string { + if (links.length === 0) return ''; + const anchors = links.map( + (link) => `<a rel="${esc(link.rel ?? 'me')}" href="${esc(link.href)}">${esc(link.label)}</a>`, + ); + return `\n<p>Find me: ${anchors.join(' ·\n')}</p>\n`; +} /** * Render a post file. @@ -128,21 +158,43 @@ export const AD_UNIT = [ * 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. The one exception is {@link TRACKER}, the - * external analytics tag, which smolweb's no-third-party-script rule forbids. + * attribute; and every `<p>` closed. The one exception is {@link tracker}, the + * external analytics tag, which smolweb's no-third-party-script rule forbids — + * and which is absent unless a site id is configured. + * + * Everything identifying the author comes from {@link BlogConfig}. Rendered + * with the default config the post carries no byline, no identity links and no + * third-party scripts, so a checkout cannot publish somebody else's name or + * meter traffic into an account it inherited from the repository. */ -export function renderPost({ title, description, date, body = '' }: NewPost): string { +export function renderPost( + { title, description, date, body = '' }: NewPost, + config: BlogConfig = EMPTY_CONFIG, +): string { const day = date.slice(0, 10); const heading = typogrify(title); const content = body.trim() || '<h2>Start here</h2>\n\n<p>…</p>'; + // esc rather than typogrify: the site name is emitted identically in the + // <title> and in the feed link's title attribute, and an attribute is the + // stricter of the two. The byline is typogrified because a byline is prose. + const site = config.siteTitle ? ` — ${esc(config.siteTitle)}` : ''; + const feedTitle = config.siteTitle ? ` title="${esc(config.siteTitle)}"` : ''; + const byline = config.author + ? `<p><em>${day}, by ${typogrify(config.author)}.</em></p>` + : `<p><em>${day}</em></p>`; + const disclosure = config.disclosure + ? `\n\n<p><small>${typogrify(config.disclosure)}</small></p>` + : ''; + const footer = adUnit(config); + 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}${site} + @@ -152,10 +204,7 @@ export function renderPost({ title, description, date, body = '' }: NewPost): st

${heading}

-

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

- -

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

+${byline}${disclosure}