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"'); + }); +});