Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 12 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -767,6 +767,7 @@ these suits, most specific first:
```json
{
"siteTitle": "Your Blog",
"siteUrl": "https://example.com/blog",
"author": "Your Name",
"disclosure": "<strong>How this was written:</strong> drafted with an AI assistant, then edited by me.",
"links": [{ "label": "Mastodon", "href": "https://example.social/@you" }],
Expand All @@ -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
Expand Down
21 changes: 19 additions & 2 deletions bin/blog-post.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import { configPaths, loadBlogConfig } from '../src/blog-config.ts';

const USAGE = `Usage:
blog-post new <title> --description <text> [--body file.html] [--date ISO]
[--canonical URL]
blog-post check
blog-post list
blog-post feed
Expand All @@ -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.
Expand All @@ -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;

/**
Expand Down Expand Up @@ -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,
);

Expand Down
1 change: 1 addition & 0 deletions blog.config.example.json
Original file line number Diff line number Diff line change
@@ -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": [
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
31 changes: 31 additions & 0 deletions src/blog-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand All @@ -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,
Expand Down Expand Up @@ -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[] => {
Expand All @@ -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),
Expand All @@ -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,
Expand Down
22 changes: 19 additions & 3 deletions src/blog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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);
Expand All @@ -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}</title>
<title>${heading}${site}</title>${canonicalTag}
<link rel="alternate" type="application/rss+xml"${feedTitle} href="feed.xml">
<meta name="date" content="${esc(date)}">
<meta name="description" content="${esc(description)}">
Expand Down Expand Up @@ -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');
Expand Down
28 changes: 28 additions & 0 deletions test/blog-config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
});
74 changes: 74 additions & 0 deletions test/blog.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand Down Expand Up @@ -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('<link rel="canonical" href="https://example.com/blog/007-post.html">');
});

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/"><script>alert(1)</script>',
});
expect(html).not.toContain('<script>alert(1)</script>');
expect(html).toContain('&quot;');
});

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('<link rel="canonical" href="https://example.com/blog/002-post.html">');
});

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('<link rel="canonical" href="https://elsewhere.example/original">');
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"');
});
});
Loading