From c68a3a250799c76326236da173a6d326524aace3 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Sun, 6 Sep 2026 09:46:45 +0000 Subject: [PATCH] Honor --canonical-url on every network that has one (0.12.0) `--canonical-url` reached dev.to and nowhere else. Publishing the same article to `--to htmlblog,devto` therefore left two copies with no statement about which was the original, and a Hashnode or Ghost copy had no way to say at all. Now every long-form adapter that has a field for it sends one, and the field name differs every time, which is exactly why this failed quietly rather than loudly: dev.to `canonical_url`, Hashnode `originalArticleURL`, Ghost `canonical_url`, Tumblr `source_url` (the attribution link it has instead of a canonical), gitblog a `canonical:` frontmatter key, htmlblog a real ``. WordPress and Micro.blog are deliberately not in that list. WordPress core has no canonical field, only an SEO plugin's post meta, and Micropub defines no canonical property. Faking either would post something that looks configured and does nothing. htmlblog points a page at itself from the siteUrl it was logged in with, since the original should confirm what the copies claim; an explicit --canonical-url overrides it for an article first published elsewhere. That URL has to byte match the address the post is actually served at, so postUrl() is now the one place that joins siteUrl to a file name, used by the canonical, the link myna reports and the timeline alike. It trims trailing slashes, which the old inline concatenation did not. When cli-tools' blog-post writes the page instead, it applies its own siteUrl and myna forwards the flag only when one was given. That flag needs cli-tools 0.28.0. 242 tests pass, 11 new. The API adapters are driven against a stubbed fetch and the request body read back, because the assertion worth making is the field name on the wire. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01GNpSGiTnfodDyCQ3MU6Nsk --- README.md | 35 ++++- apps/api/package.json | 2 +- apps/cli/package.json | 2 +- packages/core/package.json | 2 +- packages/core/src/net/adapters/blogs.ts | 12 ++ packages/core/src/net/adapters/ownblogs.ts | 60 ++++++-- packages/core/src/version.ts | 2 +- packages/core/test/blog-canonical.test.ts | 157 +++++++++++++++++++++ packages/core/test/ownblogs.test.ts | 66 +++++++++ packages/mcp/package.json | 2 +- packages/plugin-dashboard/package.json | 2 +- 11 files changed, 326 insertions(+), 16 deletions(-) create mode 100644 packages/core/test/blog-canonical.test.ts diff --git a/README.md b/README.md index bd06694..7b00514 100644 --- a/README.md +++ b/README.md @@ -155,7 +155,40 @@ publication (and on your own blog, a commit), so those only post when named in `--to`. One post can name several: `--to htmlblog,devto` writes the page and the article from the same Markdown. Per-post flags: `--slug`, `--description`, `--tags`, `--date` (the future is refused), `--draft true`, -`--author`, and `--overwrite true` for a Git blog. +`--author`, `--canonical-url`, and `--overwrite true` for a Git blog. + +#### Which copy is the original + +`--to htmlblog,devto` publishes the same article twice, so one of them has to +be the original or search engines pick for you. `--canonical-url` says which: + +```sh +myna post --to devto --title "Release 1.2" \ + --canonical-url https://example.com/blog/042-post.html < post.md +``` + +It is honored wherever the network has a field for it, and the field is +different every time: + +| network | what it sends | +| --- | --- | +| dev.to | `canonical_url` | +| Hashnode | `originalArticleURL` | +| Ghost | `canonical_url` | +| Tumblr | `source_url`, the attribution link it has instead | +| gitblog | `canonical:` in the post's frontmatter, for the site template to render | +| htmlblog | `` in the page head | + +WordPress and Micro.blog are left out on purpose. WordPress core has no +canonical field (it belongs to an SEO plugin's post meta) and Micropub defines +no canonical property, so neither pretends to support one. + +**htmlblog points at itself** without being asked, using the `siteUrl` you +logged in with, because the original should confirm what the copies claim. +Pass `--canonical-url` only when the original really is elsewhere. When +profullstack/cli-tools' `blog-post` writes the page, it applies its own +`siteUrl` and myna forwards the flag only when you set one — that needs +cli-tools 0.28.0 or newer. ### YouTube: search, then comment diff --git a/apps/api/package.json b/apps/api/package.json index 91e0757..462de0a 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -1,6 +1,6 @@ { "name": "@profullstack/myna-api", - "version": "0.11.0", + "version": "0.12.0", "private": true, "type": "module", "scripts": { diff --git a/apps/cli/package.json b/apps/cli/package.json index 36ec438..bc8a988 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -1,6 +1,6 @@ { "name": "@profullstack/myna", - "version": "0.11.0", + "version": "0.12.0", "description": "A terminal social media manager. Log in, compose, schedule and post to every network from one TUI.", "license": "MIT", "type": "module", diff --git a/packages/core/package.json b/packages/core/package.json index 3eec1f5..e3948b3 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@profullstack/myna-core", - "version": "0.11.0", + "version": "0.12.0", "description": "Network adapters, credential vault, scheduling and the AI writer behind myna.", "license": "MIT", "type": "module", diff --git a/packages/core/src/net/adapters/blogs.ts b/packages/core/src/net/adapters/blogs.ts index 6acfd56..a76f1b2 100644 --- a/packages/core/src/net/adapters/blogs.ts +++ b/packages/core/src/net/adapters/blogs.ts @@ -4,6 +4,13 @@ * These take a title and a body, so a thread-length post becomes an article * rather than being split. All of them authenticate with a key you paste, * except WordPress, which has genuine application passwords. + * + * Syndicating the same article to several of these makes one of them the + * original, so `--canonical-url` is honored wherever the network has a field + * for it: dev.to `canonical_url`, Hashnode `originalArticleURL`, Ghost + * `canonical_url`, Tumblr `source_url`. WordPress core has no such field — + * canonical there belongs to an SEO plugin's post meta — and Micropub defines + * no canonical property, so neither of those pretends to support it. */ import type { Network, TimelineItem } from "../types.ts"; import { getJson, normalizeInstance, postJson, request } from "../../util/http.ts"; @@ -112,6 +119,7 @@ export const hashnode: Network = { publicationId: input.extra?.publicationId || account.meta.publicationId, title: input.title || firstLine(input.text), contentMarkdown: input.text, + ...(input.extra?.canonicalUrl ? { originalArticleURL: input.extra.canonicalUrl } : {}), tags: (input.extra?.tags ?? "") .split(",") .map((tag) => tag.trim().replace(/^#/, "")) @@ -167,6 +175,7 @@ export const ghost: Network = { title: input.title || firstLine(input.text), html: `

${input.text.replace(/\n\n/g, "

").replace(/\n/g, "
")}

`, status: input.extra?.draft === "true" ? "draft" : "published", + ...(input.extra?.canonicalUrl ? { canonical_url: input.extra.canonicalUrl } : {}), ...(input.extra?.tags ? { tags: input.extra.tags.split(",").map((name) => ({ name: name.trim() })) } : {}), }, ], @@ -353,6 +362,9 @@ export const tumblr: Network = { { type: "text", text: input.text }, ], state: input.extra?.draft === "true" ? "draft" : "published", + // Tumblr has no rel=canonical of its own; source_url is the attribution + // link it does have, and it points where a canonical would. + ...(input.extra?.canonicalUrl ? { source_url: input.extra.canonicalUrl } : {}), tags: (input.extra?.tags ?? "").split(",").map((tag) => tag.trim().replace(/^#/, "")).filter(Boolean).join(","), }; // The signature covers only the OAuth parameters when the body is JSON. diff --git a/packages/core/src/net/adapters/ownblogs.ts b/packages/core/src/net/adapters/ownblogs.ts index 35d53c0..410d265 100644 --- a/packages/core/src/net/adapters/ownblogs.ts +++ b/packages/core/src/net/adapters/ownblogs.ts @@ -154,6 +154,9 @@ export function gitblogFile(account: Account, input: PostInput, now: Date = new title, date: date.toISOString().slice(0, 10), description, + // The site template has to render this; myna only records it. A repo blog + // that syndicates elsewhere needs it, and one that does not ignores it. + canonical: input.extra?.canonicalUrl || undefined, author: input.extra?.author || account.meta.author || undefined, tags: tagList(input.extra?.tags), draft: input.extra?.draft === "true" ? true : undefined, @@ -302,6 +305,17 @@ export const gitblog: Network = { const POST_FILE = /^(\d+)-post\.html$/; +/** + * The address a post file is served at. + * + * Shared by the link myna reports, the feed's timeline and the canonical tag, + * because a canonical that does not byte-match the real URL is a canonical + * pointing at a different page. + */ +export function postUrl(siteUrl: string, file: string): string { + return `${String(siteUrl).replace(/\/+$/, "")}/${file}`; +} + interface HtmlBlogConfig { siteTitle?: string | null; author?: string | null; @@ -334,19 +348,22 @@ export function nextPostNumber(names: string[]): string { /** A whole page in the shape the plain-HTML blog uses. */ export function renderHtmlPost( - post: { title: string; description: string; date: string; body: string }, + post: { title: string; description: string; date: string; body: string; canonical?: string }, config: HtmlBlogConfig = {}, ): string { const day = post.date.slice(0, 10); const site = config.siteTitle ? ` — ${escapeHtml(config.siteTitle)}` : ""; const byline = config.author ? `

${day}, by ${escapeHtml(config.author)}.

` : `

${day}

`; const disclosure = config.disclosure ? `\n\n

${config.disclosure}

` : ""; + // Omitted when there is nothing to point at: a canonical aimed at nowhere is + // worse than none, because search engines act on it. + const canonical = post.canonical ? `\n` : ""; return ` -${escapeHtml(post.title)}${site} +${escapeHtml(post.title)}${site}${canonical} @@ -400,21 +417,43 @@ function runQuiet(command: string, args: string[], cwd: string): { ok: boolean; * one written by hand. When it is not installed, myna writes the page itself * in the same shape, minus what only that config knows. */ -function writeWithBlogPost(tool: string, dir: string, post: { title: string; description: string; date: string; body: string }): string { +function writeWithBlogPost( + tool: string, + dir: string, + post: { title: string; description: string; date: string; body: string }, + canonical?: string, +): string { const scratch = mkdtempSync(join(tmpdir(), "myna-blog-")); const bodyFile = join(scratch, "body.html"); writeFileSync(bodyFile, post.body); - const result = runQuiet(tool, ["new", post.title, "--description", post.description, "--body", bodyFile, "--date", post.date, "--dir", dir], dir); + // Only an explicit override is passed. Left alone, blog-post points the page + // at itself from its own siteUrl, which it knows and myna would be guessing. + // The flag needs cli-tools 0.28.0 or newer. + const args = ["new", post.title, "--description", post.description, "--body", bodyFile, "--date", post.date, "--dir", dir]; + if (canonical) args.push("--canonical", canonical); + const result = runQuiet(tool, args, dir); if (!result.ok) throw new Error(`blog-post failed: ${result.output || "no output"}`); const created = /created\s+(\S+-post\.html)/.exec(result.output); if (!created) throw new Error(`blog-post did not say which file it created:\n${result.output}`); return created[1]; } -function writeNatively(dir: string, post: { title: string; description: string; date: string; body: string }): string { +function writeNatively( + dir: string, + post: { title: string; description: string; date: string; body: string }, + siteUrl?: string, + canonical?: string, +): string { const file = `${nextPostNumber(readdirSync(dir))}-post.html`; + // The file name is only settled here, so a self-canonical can only be built + // here. An explicit one wins: it means the original is somewhere else. + const href = canonical ?? (siteUrl ? postUrl(siteUrl, file) : undefined); // 'wx': two writers that both read the directory would pick the same number. - writeFileSync(join(dir, file), renderHtmlPost(post, readBlogConfig(dir)), { flag: "wx" }); + writeFileSync( + join(dir, file), + renderHtmlPost({ ...post, ...(href ? { canonical: href } : {}) }, readBlogConfig(dir)), + { flag: "wx" }, + ); const indexPath = join(dir, "index.html"); if (existsSync(indexPath)) { writeFileSync(indexPath, insertIntoIndex(readFileSync(indexPath, "utf8"), { file, title: post.title, date: post.date })); @@ -504,9 +543,12 @@ export const htmlblog: Network = { if (!description) throw new Error("A post needs a description for the feed. Write a first paragraph, or pass --description."); const post = { title, description, date, body: renderMarkdown(markdown) }; + const canonical = input.extra?.canonicalUrl; const tool = findOnPath("blog-post"); - const file = tool ? writeWithBlogPost(tool, dir, post) : writeNatively(dir, post); - const url = `${siteUrl}/${file}`; + const file = tool + ? writeWithBlogPost(tool, dir, post, canonical) + : writeNatively(dir, post, siteUrl, canonical); + const url = postUrl(siteUrl, file); if (mirror) { const problem = pushMirror(dir, mirror, [file, "index.html", "feed.xml"], `${file.replace(/-post\.html$/, "")}: ${title}`); @@ -533,7 +575,7 @@ export const htmlblog: Network = { handle: account.handle, text: title.replace(/<[^>]+>/g, "").replace(/—/g, "—").trim(), createdAt: date, - url: `${siteUrl}/${name}`, + url: postUrl(siteUrl, name), }; }); }, diff --git a/packages/core/src/version.ts b/packages/core/src/version.ts index e49b2cd..10c9f0a 100644 --- a/packages/core/src/version.ts +++ b/packages/core/src/version.ts @@ -6,4 +6,4 @@ * binary reporting the wrong version, and nothing would fail. A test asserts * this matches the package.json it is published under. */ -export const VERSION = "0.11.0"; +export const VERSION = "0.12.0"; diff --git a/packages/core/test/blog-canonical.test.ts b/packages/core/test/blog-canonical.test.ts new file mode 100644 index 0000000..8e9a45b --- /dev/null +++ b/packages/core/test/blog-canonical.test.ts @@ -0,0 +1,157 @@ +/** + * `--canonical-url` on the long-form networks that have a field for it. + * + * Each adapter is driven against a stubbed fetch and the request body is read + * back, because the thing worth asserting is the field name on the wire: they + * all differ, and getting one wrong fails silently as a post with no canonical + * rather than as an error. + */ +import { test, expect } from "bun:test"; +import { devto, hashnode, ghost, tumblr } from "../src/net/adapters/blogs.ts"; +import type { Account, PostInput } from "../src/net/types.ts"; + +const CANONICAL = "https://example.com/~me/blog/042-post.html"; + +/** Run one post against a stubbed fetch and hand back the request bodies. */ +async function capture( + run: () => Promise, + reply: (url: string) => Response, +): Promise[]> { + const bodies: Record[] = []; + const realFetch = globalThis.fetch; + globalThis.fetch = (async (url: string, init?: RequestInit) => { + if (init?.body) { + const raw = String(init.body); + try { + bodies.push(JSON.parse(raw) as Record); + } catch { + bodies.push(Object.fromEntries(new URLSearchParams(raw))); + } + } + return reply(String(url)); + }) as typeof fetch; + try { + await run(); + } finally { + globalThis.fetch = realFetch; + } + return bodies; +} + +const account = (network: string, creds: Record, meta: Record = {}): Account => + ({ + id: `${network}:me`, + network, + handle: "me", + addedAt: new Date().toISOString(), + creds, + meta, + }) as unknown as Account; + +const input = (canonical?: string): PostInput => + ({ + text: "Hello\n\nA body.", + title: "Hello", + ...(canonical ? { extra: { canonicalUrl: canonical } } : {}), + }) as PostInput; + +/* ------------------------------------------------------------------ dev.to --- */ + +test("dev.to sends canonical_url, and omits it when there is none", async () => { + const reply = (): Response => new Response(JSON.stringify({ id: 1, url: "https://dev.to/x" }), { status: 200 }); + const acct = account("devto", { apiKey: "k" }); + + const [withUrl] = await capture(() => devto.post(acct, input(CANONICAL)), reply); + expect(withUrl.article.canonical_url).toBe(CANONICAL); + + const [without] = await capture(() => devto.post(acct, input()), reply); + expect(without.article).not.toHaveProperty("canonical_url"); +}); + +/* ---------------------------------------------------------------- hashnode --- */ + +test("hashnode sends originalArticleURL, its own name for the same thing", async () => { + const reply = (): Response => + new Response(JSON.stringify({ data: { publishPost: { post: { id: "1", url: "https://h/x" } } } }), { status: 200 }); + const acct = account("hashnode", { token: "t" }, { publicationId: "p" }); + + const [withUrl] = await capture(() => hashnode.post(acct, input(CANONICAL)), reply); + expect(withUrl.variables.input.originalArticleURL).toBe(CANONICAL); + + const [without] = await capture(() => hashnode.post(acct, input()), reply); + expect(without.variables.input).not.toHaveProperty("originalArticleURL"); +}); + +/* ------------------------------------------------------------------- ghost --- */ + +test("ghost sends canonical_url on the post", async () => { + const reply = (): Response => + new Response(JSON.stringify({ posts: [{ id: "1", url: "https://g/x" }] }), { status: 200 }); + // A syntactically valid admin key: it is split on ":" and hex-decoded to sign. + const acct = account("ghost", { adminApiKey: `${"a".repeat(24)}:${"b".repeat(64)}` }, { url: "https://g" }); + + const [withUrl] = await capture(() => ghost.post(acct, input(CANONICAL)), reply); + expect(withUrl.posts[0].canonical_url).toBe(CANONICAL); + + const [without] = await capture(() => ghost.post(acct, input()), reply); + expect(without.posts[0]).not.toHaveProperty("canonical_url"); +}); + +/* ------------------------------------------------------------------ tumblr --- */ + +test("tumblr sends source_url, the attribution link it has instead of a canonical", async () => { + const reply = (): Response => new Response(JSON.stringify({ response: { id_string: "1" } }), { status: 200 }); + const acct = account( + "tumblr", + { consumerKey: "ck", consumerSecret: "cs", token: "t", tokenSecret: "ts" }, + { blog: "me.tumblr.com" }, + ); + + const [withUrl] = await capture(() => tumblr.post(acct, input(CANONICAL)), reply); + expect(withUrl.source_url).toBe(CANONICAL); + + const [without] = await capture(() => tumblr.post(acct, input()), reply); + expect(without).not.toHaveProperty("source_url"); +}); + +/* ------------------------------------------------------------------- shape --- */ + +test("every long-form network that claims canonical support actually sends it", async () => { + // A guard against adding an adapter, documenting canonical support, and + // wiring nothing: each of these must put the URL somewhere in its request. + const cases: [string, () => Promise, (url: string) => Response][] = [ + [ + "devto", + () => devto.post(account("devto", { apiKey: "k" }), input(CANONICAL)), + () => new Response(JSON.stringify({ id: 1, url: "u" }), { status: 200 }), + ], + [ + "hashnode", + () => hashnode.post(account("hashnode", { token: "t" }, { publicationId: "p" }), input(CANONICAL)), + () => new Response(JSON.stringify({ data: { publishPost: { post: { id: "1", url: "u" } } } }), { status: 200 }), + ], + [ + "ghost", + () => + ghost.post( + account("ghost", { adminApiKey: `${"a".repeat(24)}:${"b".repeat(64)}` }, { url: "https://g" }), + input(CANONICAL), + ), + () => new Response(JSON.stringify({ posts: [{ id: "1", url: "u" }] }), { status: 200 }), + ], + [ + "tumblr", + () => + tumblr.post( + account("tumblr", { consumerKey: "ck", consumerSecret: "cs", token: "t", tokenSecret: "ts" }, { blog: "b" }), + input(CANONICAL), + ), + () => new Response(JSON.stringify({ response: { id_string: "1" } }), { status: 200 }), + ], + ]; + + for (const [name, run, reply] of cases) { + const bodies = await capture(run, reply); + expect(JSON.stringify(bodies), `${name} dropped the canonical URL`).toContain(CANONICAL); + } +}); diff --git a/packages/core/test/ownblogs.test.ts b/packages/core/test/ownblogs.test.ts index ffb44bc..8f04bd9 100644 --- a/packages/core/test/ownblogs.test.ts +++ b/packages/core/test/ownblogs.test.ts @@ -20,6 +20,7 @@ import { nextPostNumber, insertIntoIndex, renderHtmlPost, + postUrl, } from "../src/net/adapters/ownblogs.ts"; import { getNetwork, authSummary } from "../src/net/registry.ts"; import { tailor } from "../src/core/poster.ts"; @@ -230,3 +231,68 @@ test("login checks the directory and the URL", async () => { expect(account.meta.dir).toBe(dir); expect(account.meta.siteUrl).toBe("https://x.y/blog"); }); + +/* ------------------------------------------------------------- canonical --- */ + +test("a page points at itself, so the copies on dev.to agree with the original", async () => { + const result = await htmlblog.post(htmlAccount(), { text: "Hello\n\nBody text.", title: "Hello" }); + const page = readFileSync(join(dir, "042-post.html"), "utf8"); + expect(page).toContain(''); + // The canonical has to be the URL, not merely a URL for the same page. + expect(page).toContain(``); +}); + +test("an explicit canonical wins, for a post first published elsewhere", async () => { + await htmlblog.post(htmlAccount(), { + text: "Hello\n\nBody text.", + title: "Hello", + extra: { canonicalUrl: "https://elsewhere.example/original" }, + }); + const page = readFileSync(join(dir, "042-post.html"), "utf8"); + expect(page).toContain(''); + expect(page).not.toContain("example.com/~me/blog/042-post.html\">"); +}); + +test("a blog with no siteUrl claims no canonical rather than guessing one", () => { + const page = renderHtmlPost({ title: "T", description: "d", date: "2026-01-01T00:00:00Z", body: "

x

" }); + expect(page).not.toContain("rel=\"canonical\""); +}); + +test("the canonical URL is escaped rather than trusted", () => { + const page = renderHtmlPost({ + title: "T", + description: "d", + date: "2026-01-01T00:00:00Z", + body: "

x

", + canonical: 'https://x.y/">', + }); + expect(page).not.toContain(""); +}); + +test("a trailing slash on siteUrl does not double up in the URL or the canonical", () => { + expect(postUrl("https://x.y/blog/", "007-post.html")).toBe("https://x.y/blog/007-post.html"); + expect(postUrl("https://x.y/blog", "007-post.html")).toBe("https://x.y/blog/007-post.html"); + expect(postUrl("https://x.y/blog///", "007-post.html")).toBe("https://x.y/blog/007-post.html"); +}); + +test("gitblog records the canonical in frontmatter, and omits it when absent", () => { + const account = { + id: "gitblog:o/r", + network: "gitblog", + handle: "o/r", + addedAt: "", + creds: {}, + meta: { repo: "o/r", dir: "content/blog", branch: "main", ext: ".md", author: "" }, + } as unknown as Account; + + const withUrl = gitblogFile(account, { + text: "Title\n\nBody.", + title: "Title", + extra: { canonicalUrl: "https://example.com/blog/x.html" }, + }); + // Quoted, because a bare value holding a colon is not the string YAML reads back. + expect(withUrl.content).toContain('canonical: "https://example.com/blog/x.html"'); + + const without = gitblogFile(account, { text: "Title\n\nBody.", title: "Title" }); + expect(without.content).not.toContain("canonical:"); +}); diff --git a/packages/mcp/package.json b/packages/mcp/package.json index 3ca48a9..0d12f99 100644 --- a/packages/mcp/package.json +++ b/packages/mcp/package.json @@ -1,6 +1,6 @@ { "name": "@profullstack/myna-mcp", - "version": "0.11.0", + "version": "0.12.0", "description": "MCP server for myna. Lets an agent post, schedule and read across every connected social account.", "license": "MIT", "type": "module", diff --git a/packages/plugin-dashboard/package.json b/packages/plugin-dashboard/package.json index 983959c..6e9bbf5 100644 --- a/packages/plugin-dashboard/package.json +++ b/packages/plugin-dashboard/package.json @@ -1,6 +1,6 @@ { "name": "@profullstack/myna-plugin-dashboard", - "version": "0.11.0", + "version": "0.12.0", "description": "myna plugin: a local dashboard for what went out, what is queued, and which network is gated.", "license": "MIT", "type": "module",