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 [--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 {
);
}
+ 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": "How this was written: 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 `` 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) : {};
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
${typogrify(config.disclosure)}
`
: '';
const footer = adUnit(config);
+ // Omitted entirely rather than emitted empty: a canonical pointing nowhere is
+ // worse than none.
+ const canonicalTag = canonical ? `\n` : '';
return `
-${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"');
+ });
+});