Archilyzer · Source

archilyzer

Archilyzer
git clone https://archilyzer.pages.dev/source/archilyzer.git
Log | Files | Refs | README | LICENSE

commit 7daa86515342c77a155a31d73011626fd700eaee
parent 1847e6c78a0aee7409678f6182bbd85d9983cc02
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Mon, 28 Sep 2026 03:06:38 -0400

common: normalizeSocialSvg themes only a single-colour icon (review should-fix) — at most one solid colour across root and children outside <mask> (spellings folded: #fff = white = rgb(255,255,255)) becomes currentColor; two or more (YouTube's mark, a badge) keep every colour as pasted; animation tags (fill=freeze/remove) passed over; a self-closing <mask/> no longer swallows what follows; +5 unit tests (13)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

Diffstat:
Mcommon/lib/normalizeSocialSvg.test.ts | 69+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------
Mcommon/lib/settingsSchema.ts | 90++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------------
2 files changed, 123 insertions(+), 36 deletions(-)

diff --git a/common/lib/normalizeSocialSvg.test.ts b/common/lib/normalizeSocialSvg.test.ts @@ -7,8 +7,10 @@ import { normalizeSocialSvg } from "./settingsSchema"; // 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. +// A SINGLE-colour icon's fills — root and children — now become currentColor +// (the footer link's colour, which follows the theme); no paint, gradients and +// mask values stay. An icon of two or more colours draws its shape with them +// (YouTube's mark, a badge) and keeps every colour as pasted (review, O1). // 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"> @@ -22,6 +24,13 @@ const KIWI_FARMS = `<svg aria-hidden="true" fill="currentColor" version="1.1" id 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>`; +// YouTube's official mark (Wikimedia's "YouTube full-color icon"): a red +// rounded rectangle and a white play triangle. Two colours ARE its shape. +const YOUTUBE = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 159 110"><path d="m154 17.5c-1.82-6.73-7.07-12-13.8-13.8-9.04-3.49-96.6-5.2-122 0.1-6.73 1.82-12 7.07-13.8 13.8-4.08 17.9-4.39 56.6 0.1 74.9 1.82 6.73 7.07 12 13.8 13.8 17.9 4.12 103 4.7 122 0 6.73-1.82 12-7.07 13.8-13.8 4.35-19.5 4.66-55.8-0.1-75z" fill="#f00"/><path d="m105 55-40.8-23.4v46.8z" fill="#fff"/></svg>`; + +// The common "badge": a dark disc with a white glyph cut into it. +const BADGE = `<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="12" fill="#1d1d1d"/><path fill="#ffffff" d="M7 7h10v10H7z"/></svg>`; + // Every fill attribute / style declaration, in document order. const fills = (svg: string) => [...svg.matchAll(/\sfill\s*=\s*"([^"]*)"|[;"']\s*fill\s*:\s*([^;"']*)/g)].map( @@ -69,20 +78,42 @@ test("a bare icon still gains the root's fill and aria-hidden, as before", () => ); }); -test("every colour spelling is themed, on the root and in a style; other fill-* are not", () => { +test("one colour in every 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>`, + `<svg viewBox="0 0 8 8" fill="#FFFFFF"><path fill='#fff' fill-rule="evenodd" d="M0 0"/>` + + `<rect style="opacity:.5; fill: rgb(255, 255, 255) ;fill-opacity:.4" fill-opacity="0.5"/>` + + `<circle fill="White"/><ellipse fill="url(#g) #000"/><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>`, + `<circle fill="currentColor"/><ellipse fill="url(#g) #000"/><g fill="inherit"><path fill="transparent"/></g></svg>`, + ); +}); + +test("YouTube's mark keeps its red and its white: two colours are its shape", () => { + const out = normalized(YOUTUBE); + assert.deepEqual(fills(out), ["currentColor", "#f00", "#fff"]); + // Only the root gains what it always did (a fill for any unpainted child, + // aria-hidden); the paths are as pasted. + assert.equal( + out, + YOUTUBE.replace("<svg ", '<svg aria-hidden="true" fill="currentColor" '), ); }); +test("a dark disc with a white glyph keeps both colours", () => { + const out = normalized(BADGE); + assert.deepEqual(fills(out), ["currentColor", "#1d1d1d", "#ffffff"]); + assert.equal(out, BADGE.replace("<svg ", '<svg aria-hidden="true" fill="currentColor" ')); +}); + +test("a root colour and a different child colour are two colours: nothing is themed", () => { + const raw = `<svg aria-hidden="true" viewBox="0 0 8 8" fill="#fff"><path fill="#000" d="M0 0"/><path d="M1 1"/></svg>`; + assert.equal(normalized(raw), raw); +}); + 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>` + @@ -92,8 +123,30 @@ test("a mask's fills are how much shows through, not a colour: kept", () => { assert.ok(out.includes(`<path mask="url(#m)" fill="currentColor" d="M0 0"/>`)); }); +test("an animation's fill is timing, not paint: never touched, never counted", () => { + const raw = + `<svg viewBox="0 0 8 8"><path fill="#fff" d="M0 0">` + + `<animate attributeName="opacity" from="0" to="1" dur="1s" fill="freeze"/>` + + `<animateTransform attributeName="transform" type="rotate" dur="1s" fill="remove"/></path>` + + `<set attributeName="opacity" to="1" fill="freeze"/></svg>`; + const out = normalized(raw); + assert.ok(out.includes(`<path fill="currentColor" d="M0 0">`), "the one colour is themed"); + assert.ok(out.includes(`dur="1s" fill="freeze"/>`)); + assert.ok(out.includes(`type="rotate" dur="1s" fill="remove"/>`)); + assert.ok(out.includes(`<set attributeName="opacity" to="1" fill="freeze"/>`)); +}); + +test("an empty self-closing <mask/> does not swallow what follows it", () => { + const raw = + `<svg viewBox="0 0 8 8"><mask id="a"/><path fill="#fff" d="M0 0"/>` + + `<mask id="b"><rect fill="white" width="8" height="8"/></mask></svg>`; + const out = normalized(raw); + assert.ok(out.includes(`<mask id="a"/><path fill="currentColor" d="M0 0"/>`)); + assert.ok(out.includes(`<mask id="b"><rect fill="white" width="8" height="8"/></mask>`)); +}); + test("idempotent: a stored icon normalizes to itself", () => { - for (const raw of [X_COM, KIWI_FARMS, FIXTURE, BARE]) { + for (const raw of [X_COM, KIWI_FARMS, FIXTURE, BARE, YOUTUBE, BADGE]) { const once = normalized(raw); assert.equal(normalized(once), once); } diff --git a/common/lib/settingsSchema.ts b/common/lib/settingsSchema.ts @@ -1294,52 +1294,78 @@ 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. +// A paint that is not a colour: 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. Never counted, never swapped. 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"; +// One solid colour however it is spelled, so `#FFF`, `#ffffff`, `white` and +// `rgb(255, 255, 255)` count as ONE colour of an icon, not four. +function paintKey(value: string): string { + const v = value.trim().toLowerCase().replace(/\s+/g, ""); + if (v === "white") return "#ffffff"; + if (v === "black") return "#000000"; + const short = /^#([0-9a-f])([0-9a-f])([0-9a-f])$/.exec(v); + if (short) return `#${short[1]}${short[1]}${short[2]}${short[2]}${short[3]}${short[3]}`; + const rgb = /^rgb\((\d{1,3}),(\d{1,3}),(\d{1,3})\)$/.exec(v); + if (rgb) { + return `#${rgb + .slice(1, 4) + .map((n) => Math.min(255, Number(n)).toString(16).padStart(2, "0")) + .join("")}`; + } + return v; } -// 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 { +// Every fill of one tag, mapped: 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 mapTagFills(tag: string, paint: (value: string) => string): string { return tag .replace( /(\sfill\s*=\s*)(?:"([^"]*)"|'([^']*)')/gi, (_m, pre: string, dq?: string, sq?: string) => - dq !== undefined - ? `${pre}"${themedPaint(dq)}"` - : `${pre}'${themedPaint(sq ?? "")}'`, + dq !== undefined ? `${pre}"${paint(dq)}"` : `${pre}'${paint(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)}`, + (_d, lead: string, prop: string, v: string) => `${lead}${prop}${paint(v)}`, ); return dq !== undefined ? `${pre}"${css}"` : `${pre}'${css}'`; }, ); } +// The children, tag by tag. Passed over whole, never read or changed: +// - a <mask> (its white and black say how much shows through, not what +// colour) — self-closing first, so an empty `<mask …/>` cannot swallow +// everything up to the next `</mask>`; +// - an animation tag, whose `fill="freeze"` / `fill="remove"` is timing. +const CHILD_TAG = /<mask\b[^>]*\/>|<mask\b[\s\S]*?<\/mask\s*>|<[a-zA-Z][^>]*>/gi; +const PASSED_OVER = /^<(?:mask|animate\w*|set)\b/i; + +function mapChildFills(body: string, paint: (value: string) => string): string { + return body.replace(CHILD_TAG, (m) => (PASSED_OVER.test(m) ? m : mapTagFills(m, paint))); +} + // 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. +// +// A SINGLE-COLOUR icon follows the theme: when its fills — the root's and its +// children's together, outside a <mask> — hold at most one solid colour, each +// becomes `currentColor`, the footer link's colour. Drawn for one background, +// such an icon vanishes on another (X's official logo is a `<path +// fill="white">`: 1.11:1 on the light footer). An icon of TWO or more colours +// draws its shape with them (YouTube's mark is a red rounded rectangle with a +// white play triangle; flattened, it is a blank rectangle), carries its own +// contrast, and keeps every colour as pasted. Paints that are not colours +// (`none`, `url(…)`, `inherit`) are never counted or changed. Idempotent: a +// themed icon has no solid colour left. export function normalizeSocialSvg(raw: string): string | null { if (typeof raw !== "string") return null; const trimmed = raw.trim(); @@ -1354,24 +1380,32 @@ export function normalizeSocialSvg(raw: string): string | null { const openEnd = trimmed.indexOf(">"); if (openEnd < 0) return null; let opening = trimmed.slice(0, openEnd); - const rest = trimmed.slice(openEnd); + let body = trimmed.slice(openEnd); if (!/\sviewBox\s*=\s*"/i.test(opening)) return null; opening = opening.replace(/\s(width|height)\s*=\s*"[^"]*"/gi, ""); opening = opening.replace(/\s(width|height)\s*=\s*'[^']*'/gi, ""); - opening = themeTagFills(opening); + const colours = new Set<string>(); + const count = (v: string) => { + if (!KEPT_PAINT.test(v.trim())) colours.add(paintKey(v)); + return v; + }; + mapTagFills(opening, count); + mapChildFills(body, count); + if (colours.size <= 1) { + const themed = (v: string) => (KEPT_PAINT.test(v.trim()) ? v : "currentColor"); + opening = mapTagFills(opening, themed); + body = mapChildFills(body, themed); + } + 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"'); } - // 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; }