From 67c8b35b92d94a570222a4dfd5c2ced3c89f1046 Mon Sep 17 00:00:00 2001 From: Nicholas Jitkoff Date: Fri, 31 Jul 2026 06:14:16 -0700 Subject: [PATCH 1/3] Make the svg: og:image work by calling og-svg This repo pointed at /.netlify/functions/rasterize, which it never contained, so the svg: image feature has never worked at all. The payload is passed through byte-for-byte rather than re-encoded, since it may be base64 or percent-encoded SVG depending on who wrote the URL; og-svg tries base64 first and falls back to percent-decoding. Renderer: https://github.com/arfct/og-svg Co-Authored-By: Claude Opus 5 --- netlify/edge-functions/metadata.js | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/netlify/edge-functions/metadata.js b/netlify/edge-functions/metadata.js index 7d9d699..b7cff0d 100644 --- a/netlify/edge-functions/metadata.js +++ b/netlify/edge-functions/metadata.js @@ -17,6 +17,21 @@ function decodeURL(s) { function atou(b64) { return decodeURIComponent(escape(atob(b64))); } function utoa(data) { return btoa(unescape(encodeURIComponent(data))); } +// Shared SVG->PNG renderer: https://github.com/arfct/og-svg +const RENDER_ORIGIN = "https://og-svg.arfct.workers.dev"; + +// Builds a render URL from an `svg:` payload. +// +// This repo never contained the rasterize function it used to point at, so the +// svg: image feature has never worked. Nothing depends on a legacy encoding. +// +// The payload is passed through byte-for-byte rather than re-encoded, because it +// may be base64 or percent-encoded SVG depending on who wrote the URL. og-svg +// tries base64 first and falls back to percent-decoding, so both work. +function renderUrl(payload) { + return `${RENDER_ORIGIN}/png?s=${encodeURIComponent(payload)}`; +} + let urlValues = ["u","i","v","f"]; function pathToMetadata(path) { let components = path.substring(1).split("/"); @@ -86,7 +101,7 @@ export default async (request, context) => { if (info.i) { info.i = decodeURL(info.i) if (info.i.startsWith("svg:")) { - info.i = "/.netlify/functions/rasterize/" + info.i; + info.i = renderUrl(info.i.substring(4)); } else if (info.u && (info.i.startsWith(".") || info.i.startsWith("/"))) { info.i = new URL(info.i, info.u).href } else { From 8c69223db57d79a119176e82af442fe745e6b002 Mon Sep 17 00:00:00 2001 From: Nicholas Jitkoff Date: Fri, 31 Jul 2026 23:46:13 -0700 Subject: [PATCH 2/3] Pin Node 22 via .nvmrc Same pin as main (b83d6e2) so the deploy preview builds with a Node version that can install netlify-cli 27. Co-Authored-By: Claude Opus 5 --- .nvmrc | 1 + 1 file changed, 1 insertion(+) create mode 100644 .nvmrc diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..2bd5a0a --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +22 From c451c93b5069b9d236405719770ec035fcf4a4b9 Mon Sep 17 00:00:00 2001 From: Nicholas Jitkoff Date: Tue, 4 Aug 2026 02:09:07 -0700 Subject: [PATCH 3/3] Route pasted SVG to the renderer, and keep UTF-8 intact MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The editor invites SVG code ("Choose an emoji, image URL or SVG code") and base64s whatever it is given with no marker, so a pasted SVG arrived in metadata.js as raw markup. Only an explicit svg: prefix was routed to the renderer, so markup fell through to the bare-hostname branch and produced og:image="https:// --- docs/edit.html | 13 +++- netlify/edge-functions/metadata.js | 66 +++++++++++++++----- package.json | 5 +- test/image-url.test.js | 99 ++++++++++++++++++++++++++++++ 4 files changed, 162 insertions(+), 21 deletions(-) create mode 100644 test/image-url.test.js diff --git a/docs/edit.html b/docs/edit.html index acd1cb6..3ef4dbb 100644 --- a/docs/edit.html +++ b/docs/edit.html @@ -446,7 +446,16 @@ function decodeURL(s) { s = decodeURIComponent(s) if (s.startsWith("http")) s; - return atob(s.replace(/=/g,'')); + return atou(s.replace(/=/g,'')); +} + +// UTF-8-safe base64. Plain btoa throws on any character above U+00FF, so a +// pasted SVG containing an emoji or a checkmark would fail outright, and one +// containing an accent would silently encode as Latin-1 and render as mojibake. +function utoa(data) { return btoa(unescape(encodeURIComponent(data))); } +function atou(b64) { + const bytes = atob(b64); + try { return decodeURIComponent(escape(bytes)); } catch (e) { return bytes; } } @@ -455,7 +464,7 @@ let path = ["/" + encodePrettyComponent(data.title)]; if (data.description) path.push("d/" + encodePrettyComponent(data.description.substring(0,200).split(". ").shift())); if (data.favicon) path.push("f/" + encodeURIComponent(data.favicon)); - if (data.image) path.push("i/" + encodeURIComponent(btoa(data.image).replace(/=/g, ""))); + if (data.image) path.push("i/" + encodeURIComponent(utoa(data.image).replace(/=/g, ""))); return "/m" + path.join('/') + "/"; } diff --git a/netlify/edge-functions/metadata.js b/netlify/edge-functions/metadata.js index b7cff0d..5fa186b 100644 --- a/netlify/edge-functions/metadata.js +++ b/netlify/edge-functions/metadata.js @@ -8,7 +8,15 @@ function decodeURL(s) { if (s.startsWith(".")) return s; if (s.startsWith("/")) return s; try { - return atob(s.replace(/=/g,'')) + const bytes = atob(s.replace(/=/g,'')) + // atob yields Latin-1. Recover UTF-8 so pasted SVG containing accents or + // emoji survives; fall back to the raw bytes for genuinely Latin-1 payloads + // written by older versions of the editor. + try { + return decodeURIComponent(escape(bytes)) + } catch (e) { + return bytes + } } catch (e) { return s; } @@ -18,20 +26,51 @@ function atou(b64) { return decodeURIComponent(escape(atob(b64))); } function utoa(data) { return btoa(unescape(encodeURIComponent(data))); } // Shared SVG->PNG renderer: https://github.com/arfct/og-svg -const RENDER_ORIGIN = "https://og-svg.arfct.workers.dev"; +export const RENDER_ORIGIN = "https://og-svg.arfct.workers.dev"; -// Builds a render URL from an `svg:` payload. -// -// This repo never contained the rasterize function it used to point at, so the -// svg: image feature has never worked. Nothing depends on a legacy encoding. +// Builds a render URL from an SVG payload. // // The payload is passed through byte-for-byte rather than re-encoded, because it -// may be base64 or percent-encoded SVG depending on who wrote the URL. og-svg -// tries base64 first and falls back to percent-decoding, so both work. +// may be base64, percent-encoded, or raw markup depending on who wrote the URL. +// og-svg tries base64 first and falls back to percent-decoding, and wraps a bare +// fragment in an root, so all of those work. function renderUrl(payload) { return `${RENDER_ORIGIN}/png?s=${encodeURIComponent(payload)}`; } +/** + * Resolves the `i` field to a final og:image URL. + * + * The editor base64-encodes whatever is in the image field with no marker + * (docs/edit.html), so a user who pastes SVG code — which the prompt invites — + * arrives here as raw markup. That used to fall through to the bare-hostname + * branch and produce `og:image="https:// { } if (info.i) { - info.i = decodeURL(info.i) - if (info.i.startsWith("svg:")) { - info.i = renderUrl(info.i.substring(4)); - } else if (info.u && (info.i.startsWith(".") || info.i.startsWith("/"))) { - info.i = new URL(info.i, info.u).href - } else { - info.i = "https://" + info.i; - } + info.i = resolveImageUrl(info.i, info.u) - content.push(mProp("og:image", info.i)); + content.push(mProp("og:image", info.i)); if (info.iw) content.push(mProp("og:image:width", info.iw)); if (info.ih) content.push(mProp("og:image:width", info.ih)); content.push(mName("twitter:card", "summary_large_image")); diff --git a/package.json b/package.json index 984576c..c184045 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "doc": "docs" }, "scripts": { - "test": "echo \"Error: no test specified\" && exit 1" + "test": "vitest run" }, "repository": { "type": "git", @@ -24,6 +24,7 @@ "tweetnacl": "^1.0.3" }, "devDependencies": { - "netlify-cli": "^27.0.1" + "netlify-cli": "^27.0.1", + "vitest": "^4.1.0" } } diff --git a/test/image-url.test.js b/test/image-url.test.js new file mode 100644 index 0000000..c5fa315 --- /dev/null +++ b/test/image-url.test.js @@ -0,0 +1,99 @@ +import { describe, it, expect } from "vitest"; +import { resolveImageUrl, RENDER_ORIGIN } from "../netlify/edge-functions/metadata.js"; + +const SVG = + ''; + +// What docs/edit.html writes into the path for an image value. +const asEditorEncodes = (value) => utf8Base64(value).replace(/=/g, ""); + +// UTF-8-safe base64, matching the repo's existing utoa() helper. +const utf8Base64 = (value) => btoa(unescape(encodeURIComponent(value))); + +describe("resolveImageUrl", () => { + it("sends SVG markup pasted into the editor to the renderer", () => { + // The editor base64s the value with no svg: marker, so after decoding we + // hold raw markup. This previously produced "https:// { + const out = resolveImageUrl(asEditorEncodes(SVG)); + expect(out).not.toContain("https:// { + const out = resolveImageUrl(`svg:${asEditorEncodes(SVG)}`); + expect(out.startsWith(`${RENDER_ORIGIN}/png?s=`)).toBe(true); + }); + + it("sends a bare fragment to the renderer", () => { + // og-svg wraps a fragment in an root with a default viewport. + const out = resolveImageUrl(asEditorEncodes('')); + expect(out.startsWith(`${RENDER_ORIGIN}/png?s=`)).toBe(true); + }); + + it("round-trips the markup through the renderer payload", () => { + const out = resolveImageUrl(asEditorEncodes(SVG)); + const payload = decodeURIComponent(new URL(out).searchParams.get("s")); + // The payload must decode back to the original markup, base64 or otherwise. + const decoded = /^ { + // btoa is Latin-1 only, so 'é' arrives as a raw 0xE9 byte that is not valid + // UTF-8. Decoding must recover the character, or resvg rejects the document. + const svg = + '' + + 'café'; + const out = resolveImageUrl(utf8Base64(svg)); + const payload = decodeURIComponent(new URL(out).searchParams.get("s")); + const decoded = /^ { + // Links built before the editor switched to UTF-8-safe base64. + const svg = 'café'; + const latin1 = btoa(svg).replace(/=/g, ""); + expect(() => resolveImageUrl(latin1)).not.toThrow(); + expect(resolveImageUrl(latin1)).toContain(`${RENDER_ORIGIN}/png?s=`); + }); + + it("leaves an absolute https url alone", () => { + const url = "https://cdn.example.com/a.jpg"; + expect(resolveImageUrl(url)).toBe(url); + }); + + it("leaves an absolute http url alone", () => { + const url = "http://cdn.example.com/a.jpg"; + expect(resolveImageUrl(url)).toBe(url); + }); + + it("resolves a relative path against the target url", () => { + expect(resolveImageUrl("/img/a.png", "https://example.com/page")).toBe( + "https://example.com/img/a.png", + ); + }); + + it("resolves a dot-relative path against the target url", () => { + expect(resolveImageUrl("./a.png", "https://example.com/dir/page")).toBe( + "https://example.com/dir/a.png", + ); + }); + + it("prefixes a bare hostname with https", () => { + expect(resolveImageUrl("cdn.example.com/a.jpg")).toBe("https://cdn.example.com/a.jpg"); + }); + + it("returns an empty string for no input", () => { + expect(resolveImageUrl(undefined)).toBe(""); + }); + + it("does not treat a base64 payload that decodes to a url as svg", () => { + const out = resolveImageUrl(asEditorEncodes("https://cdn.example.com/b.jpg")); + expect(out).toBe("https://cdn.example.com/b.jpg"); + }); +});