Skip to content
Open
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
1 change: 1 addition & 0 deletions .nvmrc
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
22
13 changes: 11 additions & 2 deletions docs/edit.html
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
}


Expand All @@ -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('/') + "/";
}

Expand Down
67 changes: 57 additions & 10 deletions netlify/edge-functions/metadata.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand All @@ -17,6 +25,52 @@ 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
export const RENDER_ORIGIN = "https://og-svg.arfct.workers.dev";

// 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, 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 <svg> 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://<svg xmlns=..."`, i.e. no preview at
* all. Markup is now detected directly, so both a pasted SVG and an explicit
* `svg:` payload reach the renderer.
*
* @param {string|undefined} raw the `i` value from the path
* @param {string|undefined} targetUrl the `u` value, for resolving relatives
* @returns {string} the og:image URL, or "" when there is nothing to show
*/
export function resolveImageUrl(raw, targetUrl) {
if (!raw) return "";

const value = decodeURL(raw);

if (value.startsWith("svg:")) return renderUrl(value.substring(4));

// Raw SVG markup, or a bare fragment og-svg will wrap for us.
if (value.trimStart().startsWith("<")) return renderUrl(value);

if (value.startsWith("http")) return value;

if (targetUrl && (value.startsWith(".") || value.startsWith("/"))) {
return new URL(value, targetUrl).href;
}

return "https://" + value;
}

let urlValues = ["u","i","v","f"];
function pathToMetadata(path) {
let components = path.substring(1).split("/");
Expand Down Expand Up @@ -84,16 +138,9 @@ 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;
} 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"));
Expand Down
5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
"doc": "docs"
},
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
"test": "vitest run"
},
"repository": {
"type": "git",
Expand All @@ -24,6 +24,7 @@
"tweetnacl": "^1.0.3"
},
"devDependencies": {
"netlify-cli": "^27.0.1"
"netlify-cli": "^27.0.1",
"vitest": "^4.1.0"
}
}
99 changes: 99 additions & 0 deletions test/image-url.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import { describe, it, expect } from "vitest";
import { resolveImageUrl, RENDER_ORIGIN } from "../netlify/edge-functions/metadata.js";

const SVG =
'<svg xmlns="http://www.w3.org/2000/svg" width="600" height="315"><rect width="600" height="315" fill="teal"/></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://<svg ...".
const out = resolveImageUrl(asEditorEncodes(SVG));
expect(out.startsWith(`${RENDER_ORIGIN}/png?s=`)).toBe(true);
});

it("never produces an https:// prefixed blob of markup", () => {
const out = resolveImageUrl(asEditorEncodes(SVG));
expect(out).not.toContain("https://<svg");
});

it("still handles an explicit svg: marker", () => {
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 <svg> root with a default viewport.
const out = resolveImageUrl(asEditorEncodes('<circle cx="60" cy="60" r="50"/>'));
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 = /^</.test(payload) ? payload : atob(payload);
expect(decoded).toContain('fill="teal"');
});

it("preserves non-ASCII text in pasted SVG", () => {
// 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 =
'<svg xmlns="http://www.w3.org/2000/svg" width="400" height="200">' +
'<text x="30" y="120">café</text></svg>';
const out = resolveImageUrl(utf8Base64(svg));
const payload = decodeURIComponent(new URL(out).searchParams.get("s"));
const decoded = /^</.test(payload) ? payload : payload;
expect(decoded).toContain("café");
});

it("tolerates a Latin-1 encoded payload from the old editor", () => {
// Links built before the editor switched to UTF-8-safe base64.
const svg = '<svg xmlns="http://www.w3.org/2000/svg"><text>café</text></svg>';
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");
});
});