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",