From 3cc7671dc3b6b81a3a749c3c227196aa57a3b858 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Sun, 6 Sep 2026 09:42:52 +0000 Subject: [PATCH] Give every blog post a canonical URL (0.28.0) These posts get syndicated. A readm3 release went to dev.to an hour ago with --canonical-url pointing back at the blog, which is right, but the blog page it points at made no claim about itself. The original was the one page in the set staying quiet, which is the wrong way round: the copy asserts where the original is and the original does not confirm it. So `blog-post` now writes ``. The URL is the post's own, built from a new `siteUrl` config field once `createPost` knows the file name, because that is the only point where both halves exist. `--canonical` overrides it for the case where the original really is somewhere else. `siteUrl` is validated rather than repaired: anything that is not an absolute http(s) URL is dropped, and a trailing slash is trimmed. A canonical pointing somewhere wrong is worse than no canonical at all, because search engines act on it, so `javascript:`, a bare host and a relative path all resolve to null and the tag is omitted. It is null by default, and `new` says so once rather than guessing a URL for a blog it knows nothing about. Nothing changes for a blog with no siteUrl configured: no tag, same page. 711 tests pass. `src/free-names.ts` has two pre-existing exactOptionalPropertyTypes errors on master; they are untouched here. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01GNpSGiTnfodDyCQ3MU6Nsk --- README.md | 15 ++++++-- bin/blog-post.ts | 21 ++++++++++-- blog.config.example.json | 1 + package.json | 2 +- src/blog-config.ts | 31 +++++++++++++++++ src/blog.ts | 22 ++++++++++-- test/blog-config.test.ts | 28 +++++++++++++++ test/blog.test.ts | 74 ++++++++++++++++++++++++++++++++++++++++ 8 files changed, 185 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 54aae8e..76c7579 100644 --- a/README.md +++ b/README.md @@ -767,6 +767,7 @@ these suits, most specific first: ```json { "siteTitle": "Your Blog", + "siteUrl": "https://example.com/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" }], @@ -775,9 +776,17 @@ these suits, most specific first: } ``` -`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. +`BLOG_SITE_TITLE`, `BLOG_SITE_URL`, `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. + +`siteUrl` is what gives each post a self-referential `rel="canonical"`. These +posts get syndicated to dev.to and Hashnode, which point their canonical back +here, so without it the original is the one page in the set making no claim +about itself. Pass `--canonical` to `blog-post new` when the original genuinely +lives somewhere else; anything that is not an absolute http(s) URL is dropped +rather than repaired, because a canonical pointing somewhere wrong is worse +than none. `trackerSiteId` and `adSlotId` are **accounts, not settings**: leave them null unless they are yours. A shared id would meter your readers' pageviews and your diff --git a/bin/blog-post.ts b/bin/blog-post.ts index 9d41621..e27f027 100755 --- a/bin/blog-post.ts +++ b/bin/blog-post.ts @@ -27,6 +27,7 @@ import { configPaths, loadBlogConfig } from '../src/blog-config.ts'; const USAGE = `Usage: blog-post new --description <text> [--body file.html] [--date ISO] + [--canonical URL] blog-post check blog-post list blog-post feed @@ -43,6 +44,9 @@ 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. + --canonical URL The original this post syndicates. Defaults to the post's + own URL when siteUrl is configured, which is what lets a + dev.to or Hashnode copy point back here. --dir PATH Blog directory (default: $BLOG_DIR, else ${DEFAULT_DIR}) --allow-future Permit a future date. You almost never want this. @@ -51,7 +55,7 @@ Options: const SPEC = { boolean: ['--allow-future', '-h', '--help'], - string: ['--description', '--body', '--date', '--dir'], + string: ['--description', '--body', '--date', '--dir', '--canonical'], } as const; /** @@ -137,9 +141,22 @@ export async function run(argv: readonly string[]): Promise<number> { ); } + const canonical = values.get('--canonical'); + if (canonical && !/^https?:\/\//.test(canonical)) { + process.stderr.write(`new: --canonical must be an absolute http(s) URL, got ${JSON.stringify(canonical)}\n`); + return 1; + } + if (!canonical && !config.siteUrl) { + process.stderr.write( + 'note: no siteUrl configured, so this post claims no canonical URL.\n' + + ' A syndicated copy on dev.to or Hashnode can still point here, but the\n' + + ' original will not say so itself. See `blog-post config`.\n', + ); + } + const { file, path } = await createPost( dir, - { title, description, date: isoSeconds(when), body }, + { title, description, date: isoSeconds(when), body, ...(canonical ? { canonical } : {}) }, config, ); diff --git a/blog.config.example.json b/blog.config.example.json index ce9a54c..59e1e75 100644 --- a/blog.config.example.json +++ b/blog.config.example.json @@ -1,5 +1,6 @@ { "siteTitle": "Your Blog", + "siteUrl": "https://example.com/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": [ diff --git a/package.json b/package.json index 93d4e31..b530fb0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@profullstack/cli-tools", - "version": "0.27.0", + "version": "0.28.0", "private": true, "description": "Local command-line tools, in TypeScript, exposed on PATH.", "type": "module", diff --git a/src/blog-config.ts b/src/blog-config.ts index 507da39..2d51c6c 100644 --- a/src/blog-config.ts +++ b/src/blog-config.ts @@ -25,6 +25,15 @@ export interface BlogLink { export interface BlogConfig { /** Site name, appended to each post's `<title>` and used as the feed link title. */ siteTitle: string | null; + /** + * Where the blog is served, with no trailing slash. + * + * Only used to give each post a self-referential `rel="canonical"`. That + * matters because these posts get syndicated to dev.to and Hashnode, which + * point their own canonical back here: without this the original is the one + * page in the set making no claim about itself. + */ + siteUrl: string | null; /** Byline name. Null omits the byline line entirely. */ author: string | null; /** Identity links in the footer. Empty omits the paragraph. */ @@ -42,6 +51,7 @@ export interface BlogConfig { /** The zero config: a post with no identity and no third-party scripts. */ export const EMPTY_CONFIG: BlogConfig = { siteTitle: null, + siteUrl: null, author: null, links: [], disclosure: null, @@ -72,6 +82,25 @@ function asString(value: unknown): string | null { return typeof value === 'string' && value.trim() ? value.trim() : null; } +/** + * A site URL, trailing slash trimmed, or null. + * + * Anything that is not an absolute http(s) URL is dropped rather than repaired: + * a canonical pointing somewhere wrong is worse than none, because search + * engines act on it. + */ +function asUrl(value: unknown): string | null { + const raw = asString(value); + if (!raw) return null; + try { + const url = new URL(raw); + if (url.protocol !== 'http:' && url.protocol !== 'https:') return null; + } catch { + return null; + } + return raw.replace(/\/+$/, ''); +} + function asLinks(value: unknown): BlogLink[] { if (!Array.isArray(value)) return []; return value.flatMap((entry): BlogLink[] => { @@ -89,6 +118,7 @@ export function normalizeConfig(raw: unknown): BlogConfig { const object = raw && typeof raw === 'object' ? (raw as Record<string, unknown>) : {}; return { siteTitle: asString(object.siteTitle), + siteUrl: asUrl(object.siteUrl), author: asString(object.author), links: asLinks(object.links), disclosure: asString(object.disclosure), @@ -103,6 +133,7 @@ export function applyEnv(config: BlogConfig, env: NodeJS.ProcessEnv = process.en return { ...config, siteTitle: asString(env.BLOG_SITE_TITLE) ?? config.siteTitle, + siteUrl: asUrl(env.BLOG_SITE_URL) ?? config.siteUrl, author: asString(env.BLOG_AUTHOR) ?? config.author, disclosure: asString(env.BLOG_DISCLOSURE) ?? config.disclosure, trackerSiteId: asString(env.CRAWLPROOF_SITE_ID) ?? config.trackerSiteId, diff --git a/src/blog.ts b/src/blog.ts index eea8803..1ed5a71 100644 --- a/src/blog.ts +++ b/src/blog.ts @@ -32,6 +32,14 @@ export interface NewPost { date: string; /** HTML fragment: h2/p only, no document shell. */ body?: string; + /** + * Absolute URL this post is the original of. + * + * Normally the post's own URL, which {@link createPost} fills in from + * `siteUrl` once it knows the file name. Set it by hand only when the + * original genuinely lives somewhere else. + */ + canonical?: string; } export interface Problem { @@ -168,7 +176,7 @@ function identity(links: readonly BlogLink[]): string { * meter traffic into an account it inherited from the repository. */ export function renderPost( - { title, description, date, body = '' }: NewPost, + { title, description, date, body = '', canonical }: NewPost, config: BlogConfig = EMPTY_CONFIG, ): string { const day = date.slice(0, 10); @@ -187,13 +195,16 @@ export function renderPost( ? `\n\n<p><small>${typogrify(config.disclosure)}</small></p>` : ''; const footer = adUnit(config); + // Omitted entirely rather than emitted empty: a canonical pointing nowhere is + // worse than none. + const canonicalTag = canonical ? `\n<link rel="canonical" href="${esc(canonical)}">` : ''; 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}${site} +${heading}${site}${canonicalTag} @@ -297,11 +308,16 @@ export async function createPost( const posts = await readPosts(dir); const file = `${nextNumber(posts)}-post.html`; const path = join(dir, file); + // The file name is only known here, so a self-canonical can only be built + // here. An explicit one wins: it means the original is somewhere else. + const canonical = post.canonical ?? (config.siteUrl ? `${config.siteUrl}/${file}` : undefined); // '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, config), { flag: 'wx' }); + await writeFile(path, renderPost({ ...post, ...(canonical ? { canonical } : {}) }, config), { + flag: 'wx', + }); const indexPath = join(dir, 'index.html'); const index = await readFile(indexPath, 'utf8'); diff --git a/test/blog-config.test.ts b/test/blog-config.test.ts index 5da6aa2..671d90c 100644 --- a/test/blog-config.test.ts +++ b/test/blog-config.test.ts @@ -135,3 +135,31 @@ describe('loadBlogConfig', () => { ).rejects.toThrow(path); }); }); + +describe('siteUrl', () => { + it('keeps an absolute http(s) URL and trims the trailing slash', () => { + expect(normalizeConfig({ siteUrl: 'https://example.com/blog/' }).siteUrl).toBe( + 'https://example.com/blog', + ); + expect(normalizeConfig({ siteUrl: 'http://example.com' }).siteUrl).toBe('http://example.com'); + }); + + // A canonical pointing somewhere wrong is worse than none, because search + // engines act on it. So anything not clearly a site URL is dropped. + it('drops anything that is not an absolute http(s) URL', () => { + for (const bad of ['example.com/blog', '/blog', 'javascript:alert(1)', 'ftp://example.com', '', ' ', 42]) { + expect(normalizeConfig({ siteUrl: bad }).siteUrl).toBeNull(); + } + }); + + it('defaults to null, so a post claims no canonical unless configured', () => { + expect(normalizeConfig({}).siteUrl).toBeNull(); + }); + + it('is overridable by BLOG_SITE_URL', () => { + const config = applyEnv(normalizeConfig({ siteUrl: 'https://file.example' }), { + BLOG_SITE_URL: 'https://env.example/blog/', + } as NodeJS.ProcessEnv); + expect(config.siteUrl).toBe('https://env.example/blog'); + }); +}); diff --git a/test/blog.test.ts b/test/blog.test.ts index 0a0ba28..0ca2cdc 100644 --- a/test/blog.test.ts +++ b/test/blog.test.ts @@ -26,6 +26,7 @@ const NOW = Date.parse('2026-08-16T11:00:00Z'); /** A fully populated config, so the identity-bearing branches are exercised. */ const CONFIGURED: BlogConfig = { siteTitle: "Someone's Blog", + siteUrl: 'https://example.com/blog', author: 'Some One', disclosure: 'How this was written: drafted with an AI assistant, then edited by me.', links: [ @@ -289,3 +290,76 @@ describe('isoSeconds', () => { expect(isoSeconds(new Date('2026-08-16T10:04:00.123Z'))).toBe('2026-08-16T10:04:00Z'); }); }); + +describe('canonical URL', () => { + it('emits no canonical link when there is nothing to point at', () => { + const html = renderPost({ title: 'T', description: 'd', date: '2026-08-16T10:00:00Z' }); + expect(html).not.toContain('rel="canonical"'); + }); + + it('emits the canonical link when the post carries one', () => { + const html = renderPost({ + title: 'T', + description: 'd', + date: '2026-08-16T10:00:00Z', + canonical: 'https://example.com/blog/007-post.html', + }); + expect(html).toContain(''); + }); + + it('escapes the canonical URL rather than trusting it', () => { + const html = renderPost({ + title: 'T', + description: 'd', + date: '2026-08-16T10:00:00Z', + canonical: 'https://example.com/">', + }); + expect(html).not.toContain(''); + expect(html).toContain('"'); + }); + + it('gives a new post a self-referential canonical built from siteUrl', async () => { + const dir = await fixture(); + + const { file } = await createPost( + dir, + { title: 'Second', description: 'two', date: '2026-08-16T10:00:00Z' }, + CONFIGURED, + ); + + const html = await readFile(join(dir, file), 'utf8'); + expect(html).toContain(''); + }); + + it('lets an explicit canonical win, for a post whose original is elsewhere', async () => { + const dir = await fixture(); + + const { file } = await createPost( + dir, + { + title: 'Second', + description: 'two', + date: '2026-08-16T10:00:00Z', + canonical: 'https://elsewhere.example/original', + }, + CONFIGURED, + ); + + const html = await readFile(join(dir, file), 'utf8'); + expect(html).toContain(''); + expect(html).not.toContain('example.com/blog/002-post.html'); + }); + + it('claims no canonical when no siteUrl is configured', async () => { + const dir = await fixture(); + + const { file } = await createPost( + dir, + { title: 'Second', description: 'two', date: '2026-08-16T10:00:00Z' }, + EMPTY_CONFIG, + ); + + const html = await readFile(join(dir, file), 'utf8'); + expect(html).not.toContain('rel="canonical"'); + }); +});