commit 533fa0affd829440ceb8c353dd48911d6005647a
parent 4527c33ee662add7bb6c0ff40c6ea962c5ecc0f6
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Mon, 28 Sep 2026 02:34:01 -0400
common: normalizeSocialSvg themes every fill colour, the children's too — a white or black path (X's official logo) becomes currentColor; none, url() paint and fills inside a <mask> are kept; idempotent; +8 unit tests
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Diffstat:
2 files changed, 154 insertions(+), 1 deletion(-)
diff --git a/common/lib/normalizeSocialSvg.test.ts b/common/lib/normalizeSocialSvg.test.ts
@@ -0,0 +1,106 @@
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import { normalizeSocialSvg } from "./settingsSchema";
+
+// normalizeSocialSvg runs on every save of a social link (Settings, a site's
+// form, the homepage config). It used to theme only the ROOT <svg>'s fill, so a
+// child that paints itself kept its colour: the operator's x.com icon — X's
+// official file, `fill="none"` on the root and `fill="white"` on its one path —
+// was white on the light footer (1.11:1; release 10, slice O's screenshots).
+// Every element's fill colour now becomes currentColor (the footer link's
+// colour, which follows the theme); no paint, gradients and mask values stay.
+
+// The two icons in the operator's settings.json, verbatim (2026-09-28).
+const X_COM = `<svg aria-hidden="true" viewBox="0 0 1200 1227" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M714.163 519.284L1160.89 0H1055.03L667.137 450.887L357.328 0H0L468.492 681.821L0 1226.37H105.866L515.491 750.218L842.672 1226.37H1200L714.137 519.284H714.163ZM569.165 687.828L521.697 619.934L144.011 79.6944H306.615L611.412 515.685L658.88 583.579L1055.08 1150.3H892.476L569.165 687.854V687.828Z" fill="white"/>
+</svg>`;
+
+const KIWI_FARMS = `<svg aria-hidden="true" fill="currentColor" version="1.1" id="Layer_1" x="0px" y="0px" viewBox="0 0 70.307863 74.803198" enable-background="new 0 0 198.689 74.803" xml:space="preserve" xmlns="http://www.w3.org/2000/svg" xmlns:svg="http://www.w3.org/2000/svg"><defs id="defs15"><linearGradient id="SVGID_1_" gradientUnits="userSpaceOnUse" x1="-296.84601" y1="-520.1969" x2="-296.84601" y2="-595" gradientTransform="matrix(1,0,0,-1,331.99986,-520.1968)"><stop offset="0" style="stop-color:#D7DF23" id="stop1" /><stop offset="1" style="stop-color:#8DC63F" id="stop5" /></linearGradient></defs><path fill="url(#SVGID_1_)" d="m 38.615858,74.803198 c 12.037,-10.62 z" id="path5" style="fill:url(#SVGID_1_)" /><circle fill="#414042" cx="31.16486" cy="9.6271973" r="2.1010001" id="circle5" /></svg>`;
+
+// The export e2e fixture's icon (already normalized) and the editor e2e's
+// un-normalized one.
+const FIXTURE = `<svg aria-hidden="true" fill="currentColor" viewBox="0 0 24 24"><path d="M1 1h2"/></svg>`;
+const BARE = `<svg viewBox="0 0 24 24"><path d="M0 0h24v24H0z"/></svg>`;
+
+// Every fill attribute / style declaration, in document order.
+const fills = (svg: string) =>
+ [...svg.matchAll(/\sfill\s*=\s*"([^"]*)"|[;"']\s*fill\s*:\s*([^;"']*)/g)].map(
+ (m) => m[1] ?? m[2],
+ );
+
+function normalized(raw: string): string {
+ const out = normalizeSocialSvg(raw);
+ assert.ok(out, "accepted");
+ return out;
+}
+
+test("x.com: the path's white becomes currentColor; the root keeps fill=none", () => {
+ const out = normalized(X_COM);
+ assert.deepEqual(fills(out), ["none", "currentColor"]);
+ assert.ok(out.startsWith('<svg aria-hidden="true" viewBox="0 0 1200 1227" fill="none"'));
+ assert.ok(!/white/i.test(out));
+ // Nothing else moved: only the one attribute value changed.
+ assert.equal(out, X_COM.replace('fill="white"', 'fill="currentColor"'));
+});
+
+test("Kiwi Farms: its gradient stays, its stops untouched; the grey dot follows the theme", () => {
+ const out = normalized(KIWI_FARMS);
+ assert.deepEqual(fills(out), [
+ "currentColor", // the root, as stored
+ "url(#SVGID_1_)", // the path's attribute
+ "url(#SVGID_1_)", // and its style
+ "currentColor", // the circle, was #414042
+ ]);
+ assert.ok(out.includes('style="stop-color:#D7DF23"'));
+ assert.ok(out.includes('style="stop-color:#8DC63F"'));
+ assert.equal(out, KIWI_FARMS.replace('fill="#414042"', 'fill="currentColor"'));
+});
+
+test("an icon that already follows the theme is unchanged", () => {
+ assert.equal(normalized(FIXTURE), FIXTURE);
+ const stroked = `<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor"><path d="M1 1h2" stroke="#000"/></svg>`;
+ assert.equal(normalized(stroked), stroked, "fill=none and strokes are not fills");
+});
+
+test("a bare icon still gains the root's fill and aria-hidden, as before", () => {
+ assert.equal(
+ normalized(BARE),
+ `<svg aria-hidden="true" fill="currentColor" viewBox="0 0 24 24"><path d="M0 0h24v24H0z"/></svg>`,
+ );
+});
+
+test("every colour spelling is themed, on the root and in a style; other fill-* are not", () => {
+ const out = normalized(
+ `<svg viewBox="0 0 8 8" fill="#FFFFFF"><path fill='#000' fill-rule="evenodd" d="M0 0"/>` +
+ `<rect style="opacity:.5; fill: rgb(255,255,255) ;fill-opacity:.4" fill-opacity="0.5"/>` +
+ `<circle fill="black"/><ellipse fill="url(#g) #fff"/><g fill="inherit"><path fill="transparent"/></g></svg>`,
+ );
+ assert.equal(
+ out,
+ `<svg aria-hidden="true" viewBox="0 0 8 8" fill="currentColor"><path fill='currentColor' fill-rule="evenodd" d="M0 0"/>` +
+ `<rect style="opacity:.5; fill: currentColor;fill-opacity:.4" fill-opacity="0.5"/>` +
+ `<circle fill="currentColor"/><ellipse fill="url(#g) #fff"/><g fill="inherit"><path fill="transparent"/></g></svg>`,
+ );
+});
+
+test("a mask's fills are how much shows through, not a colour: kept", () => {
+ const raw =
+ `<svg viewBox="0 0 8 8"><mask id="m"><rect fill="white" width="8" height="8"/><circle fill="black" r="2"/></mask>` +
+ `<path mask="url(#m)" fill="#fff" d="M0 0"/></svg>`;
+ const out = normalized(raw);
+ assert.ok(out.includes(`<mask id="m"><rect fill="white" width="8" height="8"/><circle fill="black" r="2"/></mask>`));
+ assert.ok(out.includes(`<path mask="url(#m)" fill="currentColor" d="M0 0"/>`));
+});
+
+test("idempotent: a stored icon normalizes to itself", () => {
+ for (const raw of [X_COM, KIWI_FARMS, FIXTURE, BARE]) {
+ const once = normalized(raw);
+ assert.equal(normalized(once), once);
+ }
+});
+
+test("still refuses what is unsafe to inline", () => {
+ assert.equal(normalizeSocialSvg(`<svg viewBox="0 0 1 1"><script>x()</script></svg>`), null);
+ assert.equal(normalizeSocialSvg(`<svg viewBox="0 0 1 1" onload="x()"></svg>`), null);
+ assert.equal(normalizeSocialSvg(`<svg><path fill="white"/></svg>`), null, "no viewBox");
+});
diff --git a/common/lib/settingsSchema.ts b/common/lib/settingsSchema.ts
@@ -1291,10 +1291,52 @@ export function parseSocialLinks(input: unknown): SocialLink[] {
return out;
}
+// A paint that is not a colour to swap for the footer's: no paint (`none`,
+// `transparent`), a paint server (a gradient or pattern, `url(#…)`, with or
+// without a fallback), or what the element takes from its parent already.
+const KEPT_PAINT = /^(?:none|transparent|currentcolor|inherit)$|^url\(/i;
+
+// Any other paint — white, black, #fff, rgb(…), a named colour — was drawn for
+// ONE background and vanishes on another (the official x.com icon is a
+// `<path fill="white">`: 1.11:1 on the light footer). It becomes
+// `currentColor`, the footer link's colour, which follows the theme.
+function themedPaint(value: string): string {
+ return KEPT_PAINT.test(value.trim()) ? value : "currentColor";
+}
+
+// Every fill of one tag: its `fill` attribute and any `fill:` declaration in
+// its `style` (which beats the attribute). `fill-rule`, `fill-opacity`,
+// strokes and gradient stops are not fills and are left alone.
+function themeTagFills(tag: string): string {
+ return tag
+ .replace(
+ /(\sfill\s*=\s*)(?:"([^"]*)"|'([^']*)')/gi,
+ (_m, pre: string, dq?: string, sq?: string) =>
+ dq !== undefined
+ ? `${pre}"${themedPaint(dq)}"`
+ : `${pre}'${themedPaint(sq ?? "")}'`,
+ )
+ .replace(
+ /(\sstyle\s*=\s*)(?:"([^"]*)"|'([^']*)')/gi,
+ (_m, pre: string, dq?: string, sq?: string) => {
+ const css = (dq ?? sq ?? "").replace(
+ /(^|;)(\s*fill\s*:\s*)([^;]*)/gi,
+ (_d, lead: string, prop: string, v: string) =>
+ `${lead}${prop}${themedPaint(v)}`,
+ );
+ return dq !== undefined ? `${pre}"${css}"` : `${pre}'${css}'`;
+ },
+ );
+}
+
// Normalize an admin-provided SVG snippet for inline use in the export
// footer. Returns null on anything that looks unsafe or unrenderable.
// Steps: trim, allowlist-check, strip width/height, force fill="currentColor"
// + aria-hidden on the root <svg>. Requires a viewBox so the icon scales.
+// Every element's fill colour — the root's and its children's — becomes
+// `currentColor` (themedPaint above), so a white or black icon shows on every
+// theme; `none`, gradients and the fills inside a <mask> (there white and black
+// say how much shows through, not what colour) are kept. Idempotent.
export function normalizeSocialSvg(raw: string): string | null {
if (typeof raw !== "string") return null;
const trimmed = raw.trim();
@@ -1316,13 +1358,18 @@ export function normalizeSocialSvg(raw: string): string | null {
opening = opening.replace(/\s(width|height)\s*=\s*"[^"]*"/gi, "");
opening = opening.replace(/\s(width|height)\s*=\s*'[^']*'/gi, "");
+ opening = themeTagFills(opening);
if (!/\sfill\s*=/i.test(opening)) {
opening = opening.replace(/^<svg/i, '<svg fill="currentColor"');
}
if (!/\saria-hidden\s*=/i.test(opening)) {
opening = opening.replace(/^<svg/i, '<svg aria-hidden="true"');
}
- return opening + rest;
+ // The children, tag by tag; a <mask> is passed over whole.
+ const body = rest.replace(/<mask\b[\s\S]*?<\/mask\s*>|<[a-zA-Z][^>]*>/gi, (m) =>
+ /^<mask\b/i.test(m) ? m : themeTagFills(m),
+ );
+ return opening + body;
}
// normalizeSocialSvg() deliberately STRIPS width/height so the icon scales to its