Archilyzer · Source

archilyzer

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

commit ce51f06be88e5591c891663e54ff1262ede0167f
parent bc5b8f9713050b4eb9f5974e2852655e31e21d5c
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Mon, 28 Sep 2026 18:41:24 -0400

common: the social icon is checked by an allowlist on save and at render; a sized icon gets a viewBox; `featured` on a social link

- lib/socialSvg.ts (pure; settingsSchema re-exports it): the icon is read tag
  by tag and refused unless it is one well-formed <svg> of allowed elements and
  attributes — no script, style, foreignObject, a, image or HTML element; no
  event handler however it is separated; href and url(…) only to an id inside
  the icon, after decoding character references; plain ids. Comments, a leading
  XML declaration and a plain DOCTYPE are removed first, and the normalized
  output is checked again. socialSvgProblem names the reason class.
- A root with no viewBox but a numeric width and height (unitless or px) is
  given viewBox="0 0 W H" (read from its own attributes).
- SocialLink.featured (parsed only when exactly true, stored only when true).
- lib/socialLinks.ts: headerSocialLinks (at most four: the featured ones when
  any is marked, else all; the last four), safeSocialSvg (the render-time
  check), scopeSvgIds (plain ids only; entity-quoted and case-varied references),
  sizeSocialSvg (moved; re-exported).
- The save errors of settings, a site and the homepage config name the reason.
- Tests: synthetic sized icons, the review's five bypasses and each refused
  class, the shapes real icons take, the read path, scoped output re-checked.
- SETTINGS.md / SITE.md regenerated: what an icon may contain, `featured`.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

Diffstat:
MSETTINGS.md | 5+++--
MSITE.md | 5+++--
Mcommon/lib/homepage.ts | 3++-
Mcommon/lib/normalizeSocialSvg.test.ts | 235++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mcommon/lib/settings.ts | 3++-
Mcommon/lib/settingsSchema.ts | 173+++++++++++++++++--------------------------------------------------------------
Mcommon/lib/site.ts | 3++-
Acommon/lib/socialLinks.test.ts | 157+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acommon/lib/socialLinks.ts | 93+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acommon/lib/socialSvg.ts | 422+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
10 files changed, 954 insertions(+), 145 deletions(-)

diff --git a/SETTINGS.md b/SETTINGS.md @@ -471,9 +471,10 @@ Per entry — each entry spells its own values. | Key | Description | |---|---| -| `label` | Visible name, also the accessible label of the icon. | +| `label` | The link's name: the icon's accessible name and its tooltip, never text beside it. Shown as text only in place of an icon that fails the check at render. | | `url` | Link target: http(s), mailto: or a site-relative path. | -| `svg` | Inline SVG markup. Normalized on save (width/height stripped, fill="currentColor", aria-hidden) and rejected when unsafe (script, foreignObject, event handlers, javascript: URLs) or when it has no viewBox. | +| `svg` | Inline SVG markup: ONE well-formed `<svg>` element, checked on save and again every time it is rendered (a link whose icon fails at render shows its label instead). It may contain shapes, groups, defs, gradients, patterns, clip paths, masks, filters, text, title/desc and animate/animateTransform/set — no script, style, foreignObject, a, image or any HTML element; SVG presentation attributes plus aria-*, data-* and xmlns:* — no event handler (on…); an href or url(…) only to an id inside the icon; ids plain names. A leading XML declaration, a DOCTYPE without an internal subset and comments are removed. Normalized on save: width/height stripped, aria-hidden added, a single-colour icon's fills made fill="currentColor" (an icon of two or more colours keeps them). A root with no viewBox but a numeric width W and height H (unitless or px) is given `viewBox="0 0 W H"`, so a file pasted as downloaded is accepted. A refused icon's save names why. | +| `featured` | Show this link in a header. A header shows at most 4 links: the featured ones when any link is marked, else all of them; of those, the last 4 (a narrow header shows fewer). The footer shows every link. Written only when true. | Default: diff --git a/SITE.md b/SITE.md @@ -77,9 +77,10 @@ Per entry — each entry spells its own values. | Key | Description | |---|---| -| `label` | Visible name, also the accessible label of the icon. | +| `label` | The link's name: the icon's accessible name and its tooltip, never text beside it. Shown as text only in place of an icon that fails the check at render. | | `url` | Link target: http(s), mailto: or a site-relative path. | -| `svg` | Inline SVG markup. Normalized on save (width/height stripped, fill="currentColor", aria-hidden) and rejected when unsafe (script, foreignObject, event handlers, javascript: URLs) or when it has no viewBox. | +| `svg` | Inline SVG markup: ONE well-formed `<svg>` element, checked on save and again every time it is rendered (a link whose icon fails at render shows its label instead). It may contain shapes, groups, defs, gradients, patterns, clip paths, masks, filters, text, title/desc and animate/animateTransform/set — no script, style, foreignObject, a, image or any HTML element; SVG presentation attributes plus aria-*, data-* and xmlns:* — no event handler (on…); an href or url(…) only to an id inside the icon; ids plain names. A leading XML declaration, a DOCTYPE without an internal subset and comments are removed. Normalized on save: width/height stripped, aria-hidden added, a single-colour icon's fills made fill="currentColor" (an icon of two or more colours keeps them). A root with no viewBox but a numeric width W and height H (unitless or px) is given `viewBox="0 0 W H"`, so a file pasted as downloaded is accepted. A refused icon's save names why. | +| `featured` | Show this link in a header. A header shows at most 4 links: the featured ones when any link is marked, else all of them; of those, the last 4 (a narrow header shows fewer). The footer shows every link. Written only when true. | ## `groups` diff --git a/common/lib/homepage.ts b/common/lib/homepage.ts @@ -6,6 +6,7 @@ import { getSettings, normalizeSocialSvg, parseSocialLinks, + socialSvgProblem, type SiteSettings, type SocialLink, } from "./settings"; @@ -114,7 +115,7 @@ export async function writeHomepageConfig( for (const link of parseSocialLinks(config.socialLinks)) { const svg = normalizeSocialSvg(link.svg); if (svg === null) { - throw new Error(`Social link "${link.label}" has an invalid SVG`); + throw new Error(`Social link "${link.label}" has an invalid SVG: ${socialSvgProblem(link.svg)}`); } socialLinks.push({ ...link, svg }); } diff --git a/common/lib/normalizeSocialSvg.test.ts b/common/lib/normalizeSocialSvg.test.ts @@ -1,6 +1,7 @@ import { test } from "node:test"; import assert from "node:assert/strict"; -import { normalizeSocialSvg } from "./settingsSchema"; +import { normalizeSocialSvg, socialSvgProblem } from "./settingsSchema"; +import { SVG_PROBLEM } from "./socialSvg"; // 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 @@ -190,3 +191,235 @@ test("still refuses what is unsafe to inline", () => { assert.equal(normalizeSocialSvg(`<svg viewBox="0 0 1 1" onload="x()"></svg>`), null); assert.equal(normalizeSocialSvg(`<svg><path fill="white"/></svg>`), null, "no viewBox"); }); + +// A ROOT WITH A SIZE AND NO viewBox. A vendor's file often carries only its +// width and height; it used to be refused as pasted, and the operator had to +// add a viewBox by hand. Its size now becomes `viewBox="0 0 W H"` before the +// size is stripped. A synthetic two-colour disc with a letter, shaped like such +// a file: a dark offset disc, a light disc with a dark outline, a dark glyph, +// `fill="none"` on the root. +const SIZED_DISC = + `<svg xmlns="http://www.w3.org/2000/svg" width="81" height="81" fill="none">` + + `<circle cx="44" cy="43" r="37" fill="#1a1a1a"/>` + + `<circle cx="39" cy="39" r="38" fill="#f4c542" stroke="#1a1a1a" stroke-width="1.5"/>` + + `<path fill="#1a1a1a" d="M30 22h20v8H38v6h10v8H38v14h-8z"/></svg>`; + +test("a disc pasted with only its size: accepted, its size made the viewBox, both colours kept", () => { + const out = normalized(SIZED_DISC); + assert.ok( + out.startsWith('<svg aria-hidden="true" viewBox="0 0 81 81" xmlns="http://www.w3.org/2000/svg" fill="none">'), + out.slice(0, 120), + ); + assert.ok(!/\s(width|height)\s*=\s*"81"/.test(out.slice(0, out.indexOf(">"))), "size stripped"); + assert.deepEqual(fills(out), ["none", "#1a1a1a", "#f4c542", "#1a1a1a"]); + // Nothing below the root moved. + assert.equal(out.slice(out.indexOf(">")), SIZED_DISC.slice(SIZED_DISC.indexOf(">"))); +}); + +test("a size in px or unitless, either quote, decimals: the viewBox is the numbers", () => { + const box = (open: string) => { + const out = normalized(`${open}<path fill="#fff" d="M0 0"/></svg>`); + return /\sviewBox="([^"]*)"/.exec(out)?.[1]; + }; + assert.equal(box(`<svg width="24" height="24">`), "0 0 24 24"); + assert.equal(box(`<svg width="24px" height='16PX'>`), "0 0 24 16"); + assert.equal(box(`<svg height=" 12.5 " width="30">`), "0 0 30 12.5"); + assert.equal(box(`<svg width="048" height=".5">`), "0 0 48 0.5"); + // stroke-width on the root is not a width. + assert.equal(box(`<svg stroke-width="2" width="10" height="20">`), "0 0 10 20"); + // A size spelled inside another attribute's value is not the root's size. + assert.equal( + normalizeSocialSvg(`<svg data-a=' width="7" height="9"'><path d="M0 0"/></svg>`), + null, + ); +}); + +test("a size that is not a pixel size is still refused", () => { + for (const open of [ + `<svg width="100%" height="100%">`, + `<svg width="2em" height="2em">`, + `<svg width="24">`, + `<svg height="24">`, + `<svg width="0" height="24">`, + `<svg width="24" height="0">`, + `<svg width="" height="24">`, + `<svg width="-24" height="24">`, + `<svg width="auto" height="24">`, + `<svg stroke-width="2">`, + ]) { + assert.equal(normalizeSocialSvg(`${open}<path d="M0 0"/></svg>`), null, open); + assert.equal(socialSvgProblem(`${open}<path d="M0 0"/></svg>`), SVG_PROBLEM.viewBox, open); + } +}); + +test("a viewBox already there is never replaced by the size", () => { + const raw = `<svg width="81" height="81" viewBox="0 0 24 24"><path fill="#fff" d="M0 0"/></svg>`; + assert.equal( + normalized(raw), + `<svg aria-hidden="true" fill="currentColor" viewBox="0 0 24 24"><path fill="currentColor" d="M0 0"/></svg>`, + ); + // A single-quoted viewBox was refused before this change and still is: the + // size never adds a second one. + assert.equal( + normalizeSocialSvg(`<svg width="8" height="8" viewBox='0 0 8 8'><path d="M0 0"/></svg>`), + null, + ); +}); + +test("idempotent with a synthesized viewBox", () => { + const once = normalized(SIZED_DISC); + assert.equal(normalized(once), once); + const sized = normalized(`<svg width="24" height="24"><path fill="#fff" d="M0 0"/></svg>`); + assert.equal(normalized(sized), sized); +}); + +// ── WHAT AN ICON MAY CONTAIN ──────────────────────────────────────────────── +// The review's five inputs, each accepted by the old text denylist and each +// running script in Chromium, then one case per class the allowlist refuses. + +const REVIEW_BYPASSES: Record<string, string> = { + slash_handler: `<svg/onload="window.__x=1" viewBox="0 0 8 8"><path d="M0 0"/></svg>`, + deletion_join: `<svg viewBox="0 0 8 8" o width="1"nload="window.__x=1"><path d="M0 0"/></svg>`, + image_slash: `<svg viewBox="0 0 8 8"><image href="x:"/onerror="window.__x=1"/></svg>`, + charref_js: `<svg viewBox="0 0 8 8"><a href="jav&#97;script:window.__x=1"><rect width="8" height="8"/></a></svg>`, + breakout_img: `<svg viewBox="0 0 8 8"><img src="x:"/onerror="window.__x=1"></svg>`, +}; + +test("the review's five bypasses are refused", () => { + for (const [name, raw] of Object.entries(REVIEW_BYPASSES)) { + assert.equal(normalizeSocialSvg(raw), null, name); + assert.ok(socialSvgProblem(raw), name); + } +}); + +const OK = (inner: string, open = `<svg viewBox="0 0 8 8">`) => `${open}${inner}</svg>`; + +test("an event handler is refused wherever an attribute can start", () => { + for (const raw of [ + `<svg viewBox="0 0 8 8" onload="x()"><path d="M0 0"/></svg>`, + `<svg viewBox="0 0 8 8"\tonload="x()"><path d="M0 0"/></svg>`, + `<svg viewBox="0 0 8 8"\nONLOAD="x()"><path d="M0 0"/></svg>`, + `<svg viewBox="0 0 8 8"\fonclick="x()"><path d="M0 0"/></svg>`, + OK(`<path d="M0 0" onmouseover="x()"/>`), + OK(`<animate attributeName="onclick" to="x()"/>`), + ]) { + assert.equal(socialSvgProblem(raw), SVG_PROBLEM.handler, raw); + } + // A `/` between attributes (which HTML takes as a separator) is not markup + // this reads. + assert.equal(socialSvgProblem(`<svg/onload="x()" viewBox="0 0 8 8"></svg>`), SVG_PROBLEM.markup); +}); + +test("a script, or a link that runs one, in any spelling", () => { + for (const raw of [ + OK(`<script>x()</script>`), + OK(`<rect fill="url(#a)" style="fill:java&#x73;cript:x()"/>`), + OK(`<rect data-x="jav&#97script:x()"/>`), + OK(`<rect data-x="java&Tab;script:x()"/>`), + OK(`<rect data-x="java&#10;script:x()"/>`), + OK(`<rect data-x="javascript&colon;x()"/>`), + OK(`<rect data-x="VBScript:x()"/>`), + ]) { + assert.equal(socialSvgProblem(raw), SVG_PROBLEM.script, raw); + } +}); + +test("a link to anything but a fragment of this icon", () => { + for (const raw of [ + OK(`<use href="https://x.example/i.svg#a"/>`), + OK(`<use xlink:href="data:image/svg+xml,&lt;svg/&gt;"/>`), + OK(`<use href="&#35;a&quot;"/>`), + OK(`<linearGradient id="g" href="//x.example/g"/>`), + OK(`<rect fill="url(https://x.example/p.svg#a)"/>`), + OK(`<rect style="fill:url(data:image/png;base64,AAAA)"/>`), + OK(`<rect mask="url( '//x.example' )"/>`), + OK(`<set attributeName="href" to="#a"/>`), + ]) { + assert.equal(socialSvgProblem(raw), SVG_PROBLEM.external, raw); + } + // A fragment, in each spelling a reference takes, is fine. + for (const raw of [ + OK(`<defs><linearGradient id="g"/></defs><use href="#g"/><use xlink:href="#g"/>`), + OK(`<rect fill="url(#g)" style="fill:url('#g')" mask="url(&quot;#g&quot;)"/>`), + ]) { + assert.equal(socialSvgProblem(raw), null, raw); + } +}); + +test("elements an icon has no use for, named", () => { + for (const [inner, name] of [ + [`<foreignObject><div/></foreignObject>`, "foreignObject"], + [`<style>@import "x";</style>`, "style"], + [`<a href="#a"><rect/></a>`, "a"], + [`<image href="#a"/>`, "image"], + [`<iframe/>`, "iframe"], + [`<object/>`, "object"], + [`<embed/>`, "embed"], + [`<audio/>`, "audio"], + [`<video/>`, "video"], + [`<img/>`, "img"], + [`<p>x</p>`, "p"], + [`<sodipodi:namedview/>`, "sodipodi:namedview"], + ] as const) { + assert.equal(socialSvgProblem(OK(inner)), SVG_PROBLEM.element(name), inner); + } +}); + +test("attributes an icon has no use for, named; a style with an escape or an import", () => { + assert.equal(socialSvgProblem(OK(`<rect src="x"/>`)), SVG_PROBLEM.attribute("src")); + assert.equal(socialSvgProblem(OK(`<rect formaction="x"/>`)), SVG_PROBLEM.attribute("formaction")); + assert.equal(socialSvgProblem(OK(`<rect inkscape:label="x"/>`)), SVG_PROBLEM.attribute("inkscape:label")); + assert.equal(socialSvgProblem(OK(`<rect style="fill:u\\72l(x)"/>`)), SVG_PROBLEM.style); + assert.equal(socialSvgProblem(OK(`<rect style="@import 'x'"/>`)), SVG_PROBLEM.style); +}); + +test("markup that is not one well-formed <svg>: declarations, CDATA, instructions, strays", () => { + for (const raw of [ + OK(`<![CDATA[x]]>`), + OK(`<!ENTITY x "y">`), + OK(`<?php x ?>`), + OK(`<path d="M0 0">`), // never closed + OK(`</g>`), // closed, never opened + `<svg viewBox="0 0 8 8"></svg><svg viewBox="0 0 8 8"></svg>`, + `<svg viewBox="0 0 8 8"></svg>text<svg></svg>`, + OK(`<path d=M0/>`), // an unquoted value + OK(`<path d="M0 0"/ >`), + ]) { + assert.equal(socialSvgProblem(raw), SVG_PROBLEM.markup, raw); + } +}); + +test("comments are removed first; an XML declaration and a plain DOCTYPE at the start are too", () => { + const out = normalized( + `<?xml version="1.0" encoding="UTF-8"?>\n<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">\n` + + `<!-- Generator: a drawing app --><svg viewBox="0 0 8 8"><!-- a --><path d="M0 0"/></svg>`, + ); + assert.equal(out, `<svg aria-hidden="true" fill="currentColor" viewBox="0 0 8 8"><path d="M0 0"/></svg>`); + // A comment cannot hide a handler: what is left after removal is checked. + assert.equal(socialSvgProblem(`<svg viewBox="0 0 8 8" on<!-- -->load="x()"></svg>`), SVG_PROBLEM.handler); + // A DOCTYPE with an internal subset is not removed, and is refused. + assert.equal(socialSvgProblem(`<!DOCTYPE svg [<!ENTITY x "y">]><svg viewBox="0 0 8 8"></svg>`), SVG_PROBLEM.markup); +}); + +test("an id a rendered copy cannot prefix safely is refused", () => { + for (const id of ["->", "-!>", "1a", "a b", "a&amp;b", ""]) { + assert.equal(socialSvgProblem(OK(`<path id="${id}" d="M0 0"/>`)), SVG_PROBLEM.id, id); + } + assert.equal(socialSvgProblem(OK(`<path id="a.b:c-d_1" d="M0 0"/>`)), null); +}); + +// The shapes an operator's icons take keep passing: a gradient body with a +// solid part, a single path drawn white, a two-colour disc. +test("the shapes real icons take are accepted", () => { + const gradient = + `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><defs>` + + `<linearGradient id="grad_1" x1="0" y1="0" x2="0" y2="1" gradientUnits="objectBoundingBox">` + + `<stop offset="0" stop-color="#e0a030"/><stop offset="1" style="stop-color:#b04020"/></linearGradient></defs>` + + `<path fill="url(#grad_1)" style="fill:url(#grad_1)" d="M12 2 20 20H4z"/><circle fill="#333" cx="12" cy="15" r="2"/></svg>`; + const single = `<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M2 2h20v20H2z" fill="white"/></svg>`; + for (const raw of [gradient, single, SIZED_DISC]) { + assert.equal(socialSvgProblem(raw), null, raw.slice(0, 60)); + const out = normalized(raw); + assert.equal(socialSvgProblem(out), null); + } +}); diff --git a/common/lib/settings.ts b/common/lib/settings.ts @@ -44,6 +44,7 @@ import { normalizeSocialSvg, parseSocialLinks, sanitizeTranscriptionApps, + socialSvgProblem, siteSettingsSchema, type SiteSettings, type SocialLink, @@ -228,7 +229,7 @@ function validatedSocialLinks(value: unknown): SocialLink[] { for (const link of parseSocialLinks(value)) { const svg = normalizeSocialSvg(link.svg); if (svg === null) { - throw new Error(`Social link "${link.label}" has an invalid SVG`); + throw new Error(`Social link "${link.label}" has an invalid SVG: ${socialSvgProblem(link.svg)}`); } out.push({ ...link, svg }); } diff --git a/common/lib/settingsSchema.ts b/common/lib/settingsSchema.ts @@ -551,18 +551,37 @@ export type SocialLink = { label: string; url: string; svg: string; + // Stored only when true. What a header does with it: lib/socialLinks.ts. + featured?: boolean; }; export const SOCIAL_LINK_FIELD_DOCS: FieldDocs<SocialLink> = { label: - "Visible name, also the accessible label of the icon.", + "The link's name: the icon's accessible name and its tooltip, never " + + "text beside it. Shown as text only in place of an icon that fails the " + + "check at render.", url: "Link target: http(s), mailto: or a site-relative path.", svg: - "Inline SVG markup. Normalized on save (width/height stripped, " + - "fill=\"currentColor\", aria-hidden) and rejected when unsafe (script, " + - "foreignObject, event handlers, javascript: URLs) or when it has no " + - "viewBox.", + "Inline SVG markup: ONE well-formed `<svg>` element, checked on save and " + + "again every time it is rendered (a link whose icon fails at render shows " + + "its label instead). It may contain shapes, groups, defs, gradients, " + + "patterns, clip paths, masks, filters, text, title/desc and " + + "animate/animateTransform/set — no script, style, foreignObject, a, " + + "image or any HTML element; SVG presentation attributes plus aria-*, " + + "data-* and xmlns:* — no event handler (on…); an href or url(…) only to " + + "an id inside the icon; ids plain names. A leading XML declaration, a " + + "DOCTYPE without an internal subset and comments are removed. Normalized " + + "on save: width/height stripped, aria-hidden added, a single-colour " + + "icon's fills made fill=\"currentColor\" (an icon of two or more " + + "colours keeps them). A root with no viewBox but a numeric width W and " + + "height H (unitless or px) is given `viewBox=\"0 0 W H\"`, so a file " + + "pasted as downloaded is accepted. A refused icon's save names why.", + featured: + "Show this link in a header. A header shows at most 4 links: the " + + "featured ones when any link is marked, else all of them; of those, the " + + "last 4 (a narrow header shows fewer). The footer shows every link. " + + "Written only when true.", }; // Each field is documented in ARCHIVE_STORAGE_SETTINGS_FIELD_DOCS below (rendered into SETTINGS.md). @@ -1281,143 +1300,23 @@ export function parseSocialLinks(input: unknown): SocialLink[] { const svg = typeof r.svg === "string" ? r.svg : ""; if (!label || !url || !svg) continue; if (!SOCIAL_URL_RE.test(url)) continue; - out.push({ label, url, svg }); + // `featured` only when it is exactly `true`: absent and false read the + // same, and a file that never marked a link parses as it always did. + out.push({ label, url, svg, ...(r.featured === true ? { featured: true } : {}) }); } return out; } -// 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; - -// 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, 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}"${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}${paint(v)}`, - ); - return dq !== undefined ? `${pre}"${css}"` : `${pre}'${css}'`; - }, - ); -} +// The icon's SVG — what it may contain, and its normalized form — is +// lib/socialSvg.ts (pure, so the render path runs it too). Re-exported here for +// the importers that reach it through lib/settings. +export { normalizeSocialSvg, socialSvgProblem } from "./socialSvg"; -// 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) and a <clipPath> (a clip never paints: only its shape counts — -// Figma exports almost every icon as a path clipped by a -// `<clipPath><rect fill="white"/></clipPath>`, release 11, O2b) — -// self-closing first, so an empty `<mask …/>` or `<clipPath …/>` cannot -// swallow everything up to the next closing tag; -// - an animation tag, whose `fill="freeze"` / `fill="remove"` is timing. -const CHILD_TAG = - /<(?:mask|clipPath)\b[^>]*\/>|<mask\b[\s\S]*?<\/mask\s*>|<clipPath\b[\s\S]*?<\/clipPath\s*>|<[a-zA-Z][^>]*>/gi; -const PASSED_OVER = /^<(?:mask|clipPath|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. -// -// A SINGLE-COLOUR icon follows the theme: when its fills — the root's and its -// children's together, outside a <mask> or <clipPath> — 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(); - if (!trimmed.startsWith("<svg") || !trimmed.endsWith("</svg>")) return null; - if (/<script\b/i.test(trimmed)) return null; - if (/<foreignObject\b/i.test(trimmed)) return null; - if (/<iframe\b/i.test(trimmed)) return null; - if (/javascript:/i.test(trimmed)) return null; - if (/\son[a-z]+\s*=/i.test(trimmed)) return null; - if (/<\?|<!ENTITY/i.test(trimmed)) return null; - - const openEnd = trimmed.indexOf(">"); - if (openEnd < 0) return null; - let opening = trimmed.slice(0, 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, ""); - - 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"'); - } - return opening + body; -} - -// normalizeSocialSvg() deliberately STRIPS width/height so the icon scales to its -// wrapper. The cost is that a viewBox-only <svg> has no intrinsic size, so before -// the stylesheet loads on a static host it paints at the replaced-element default -// (huge) — the "flash of giant social icons" FOUC. sizeSocialSvg() re-injects an -// intrinsic pixel size at RENDER time (existing site.json files already have the -// attributes stripped, so this must run on read, not just on write). The size is -// an *attribute*, not inline style, so a wrapper's `w-*`/`h-*` utilities still win -// once CSS loads — it only governs the pre-CSS first paint. -export function sizeSocialSvg(svg: string, px = 20): string { - if (typeof svg !== "string") return svg; - if (/^<svg[^>]*\swidth\s*=/i.test(svg)) return svg; // already sized - return svg.replace(/^<svg\b/i, `<svg width="${px}" height="${px}"`); -} +// The render-time half (sizing, id scoping, the header's selection, the +// render-time check) lives in lib/socialLinks.ts, which imports only the pure +// socialSvg.ts, so a client tree can use it. Re-exported here for the importers +// that reach it through lib/settings. +export { sizeSocialSvg } from "./socialLinks"; export function clampSleepBetweenDownloadsSeconds(value: unknown): number { const n = diff --git a/common/lib/site.ts b/common/lib/site.ts @@ -8,6 +8,7 @@ import { getSettings, normalizeSocialSvg, parseSocialLinks, + socialSvgProblem, type SiteSettings, type SocialLink, } from "./settings"; @@ -236,7 +237,7 @@ export async function writeSite( for (const link of parseSocialLinks(site.socialLinks)) { const svg = normalizeSocialSvg(link.svg); if (svg === null) { - throw new Error(`Social link "${link.label}" has an invalid SVG`); + throw new Error(`Social link "${link.label}" has an invalid SVG: ${socialSvgProblem(link.svg)}`); } socialLinks.push({ ...link, svg }); } diff --git a/common/lib/socialLinks.test.ts b/common/lib/socialLinks.test.ts @@ -0,0 +1,157 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { + HEADER_SOCIAL_LINKS_MAX, + headerSocialLinks, + safeSocialSvg, + scopeSvgIds, + sizeSocialSvg, +} from "./socialLinks"; +import { normalizeSocialSvg, parseSocialLinks, type SocialLink } from "./settingsSchema"; + +const SVG = `<svg aria-hidden="true" fill="currentColor" viewBox="0 0 24 24"><path d="M1 1h2"/></svg>`; +const link = (label: string, featured?: boolean): SocialLink => ({ + label, + url: `https://${label.toLowerCase()}.example`, + svg: SVG, + ...(featured ? { featured: true } : {}), +}); +const labels = (links: SocialLink[]) => links.map((l) => l.label); + +test("the header bound is four", () => { + assert.equal(HEADER_SOCIAL_LINKS_MAX, 4); +}); + +test("header: four or fewer links, none marked → all of them, in order", () => { + assert.deepEqual(headerSocialLinks([]), []); + assert.deepEqual(labels(headerSocialLinks([link("A")])), ["A"]); + assert.deepEqual( + labels(headerSocialLinks([link("A"), link("B"), link("C"), link("D")])), + ["A", "B", "C", "D"], + ); +}); + +test("header: six links, none marked → the last four", () => { + const six = ["A", "B", "C", "D", "E", "F"].map((l) => link(l)); + assert.deepEqual(labels(headerSocialLinks(six)), ["C", "D", "E", "F"]); +}); + +test("header: any link marked → the marked ones only, in order", () => { + const six = [link("A"), link("B", true), link("C"), link("D", true), link("E"), link("F")]; + assert.deepEqual(labels(headerSocialLinks(six)), ["B", "D"]); + // Marking is choosing, whatever the count: one marked of three is one shown. + assert.deepEqual(labels(headerSocialLinks([link("A"), link("B", true), link("C")])), ["B"]); +}); + +test("header: more than four marked → the last four marked", () => { + const six = ["A", "B", "C", "D", "E", "F"].map((l) => link(l, l !== "F")); + assert.deepEqual(labels(headerSocialLinks(six)), ["B", "C", "D", "E"]); +}); + +test("header: the input is not changed", () => { + const six = ["A", "B", "C", "D", "E", "F"].map((l) => link(l)); + headerSocialLinks(six); + assert.equal(six.length, 6); +}); + +test("parseSocialLinks: featured is kept only when exactly true", () => { + const parsed = parseSocialLinks([ + { label: "A", url: "https://a.example", svg: SVG, featured: true }, + { label: "B", url: "https://b.example", svg: SVG, featured: false }, + { label: "C", url: "https://c.example", svg: SVG, featured: "yes" }, + { label: "D", url: "https://d.example", svg: SVG }, + ]); + assert.deepEqual(parsed[0], { label: "A", url: "https://a.example", svg: SVG, featured: true }); + for (const l of parsed.slice(1)) assert.ok(!("featured" in l), l.label); + // An old file — no link marked — parses exactly as it did before the key. + const old = [{ label: "A", url: "https://a.example", svg: SVG }]; + assert.deepEqual(parseSocialLinks(old), old); + assert.deepEqual(JSON.parse(JSON.stringify(parseSocialLinks(old))), old); +}); + +test("sizeSocialSvg: an intrinsic size on an unsized root, never a second one", () => { + assert.ok(sizeSocialSvg(SVG).startsWith('<svg width="20" height="20" aria-hidden')); + const sized = `<svg width="8" height="8" viewBox="0 0 8 8"></svg>`; + assert.equal(sizeSocialSvg(sized), sized); + assert.ok(sizeSocialSvg(SVG, 36).startsWith('<svg width="36" height="36"')); +}); + +// A gradient, a clip and a mask, referenced every way an icon references one. +const REFS = + `<svg id="root" viewBox="0 0 8 8"><defs>` + + `<linearGradient id="g"><stop offset="0" style="stop-color:#D7DF23"/></linearGradient>` + + `<linearGradient id="g2" xlink:href="#g"/>` + + `<clipPath id='c'><rect/></clipPath><mask id="m"><rect fill="white"/></mask></defs>` + + `<path fill="url(#g)" style="fill:url( '#g2' )" clip-path="url(#c)" mask="url(#m)"/>` + + `<use href="#g2"/><a href="#top"/><path fill="url(#gx)"/></svg>`; + +test("scopeSvgIds: every id and every reference to one gains the scope", () => { + const out = scopeSvgIds(REFS, "s1-0"); + for (const id of ["root", "g", "g2", "m"]) assert.ok(out.includes(`id="s1-0-${id}"`), id); + assert.ok(out.includes(`id='s1-0-c'`)); + assert.ok(out.includes(`fill="url(#s1-0-g)"`)); + assert.ok(out.includes(`style="fill:url('#s1-0-g2')"`)); + assert.ok(out.includes(`clip-path="url(#s1-0-c)"`)); + assert.ok(out.includes(`mask="url(#s1-0-m)"`)); + assert.ok(out.includes(`xlink:href="#s1-0-g"`)); + assert.ok(out.includes(`<use href="#s1-0-g2"/>`)); + // Not an id of this icon: left alone. So is a colour that looks like one. + assert.ok(out.includes(`<a href="#top"/>`)); + assert.ok(out.includes(`fill="url(#gx)"`)); + assert.ok(out.includes(`stop-color:#D7DF23`)); +}); + +test("scopeSvgIds: two scopes of one icon share no id; no ids → unchanged", () => { + const ids = (svg: string) => [...svg.matchAll(/\sid=["']([^"']+)["']/g)].map((m) => m[1]); + const a = ids(scopeSvgIds(REFS, "a")); + const b = ids(scopeSvgIds(REFS, "b")); + assert.equal(a.length, 5); + assert.ok(a.every((id) => !b.includes(id))); + assert.equal(scopeSvgIds(SVG, "a"), SVG); +}); + +test("scopeSvgIds: entity-quoted and case-varied references are scoped with their id", () => { + const svg = + `<svg viewBox="0 0 8 8"><defs><linearGradient ID="g"/></defs>` + + `<rect mask="url(&quot;#g&quot;)" fill="url(&#39;#g&#39;)"/><use HREF="#g"/></svg>`; + const out = scopeSvgIds(svg, "s"); + assert.ok(out.includes(`ID="s-g"`)); + assert.ok(out.includes(`mask="url(&quot;#s-g&quot;)"`)); + assert.ok(out.includes(`fill="url(&#39;#s-g&#39;)"`)); + assert.ok(out.includes(`HREF="#s-g"`)); +}); + +test("scopeSvgIds: an id that is not a plain name is left alone, so no comment can close early", () => { + for (const id of ["->", "-!>"]) { + const svg = `<svg viewBox="0 0 8 8"><!-- <path id="${id}"/> <image href="x"/> --></svg>`; + assert.equal(scopeSvgIds(svg, "s"), svg, id); + } +}); + +// Every icon the checker accepts, scoped as a page renders it, still passes the +// checker: the render never makes markup the save would have refused. +test("a scoped and sized icon still passes the checker", () => { + for (const raw of [ + `<svg viewBox="0 0 24 24"><defs><linearGradient id="g_1"><stop offset="0" stop-color="#e0a030"/></linearGradient></defs><path fill="url(#g_1)" style="fill:url(&quot;#g_1&quot;)" d="M0 0"/><use href="#g_1"/></svg>`, + `<svg viewBox="0 0 8 8"><clipPath id="c.1"><rect/></clipPath><g clip-path="url(#c.1)"><path fill="#fff" d="M0 0"/></g></svg>`, + ]) { + const stored = normalizeSocialSvg(raw); + assert.ok(stored, raw.slice(0, 60)); + const rendered = sizeSocialSvg(scopeSvgIds(stored, "sl_S_1_-0")); + assert.equal(normalizeSocialSvg(rendered)?.includes("sl_S_1_-0-"), true); + } +}); + +test("safeSocialSvg: a stored icon that fails the check is not rendered", () => { + assert.equal(safeSocialSvg(SVG), SVG); + for (const bad of [ + `<svg/onload="window.__x=1" viewBox="0 0 8 8"><path d="M0 0"/></svg>`, + `<svg viewBox="0 0 8 8"><a href="jav&#97;script:x()"><rect/></a></svg>`, + `<svg viewBox="0 0 8 8"><script>x()</script></svg>`, + "not an svg", + 42, + undefined, + ]) { + assert.equal(safeSocialSvg(bad), null, String(bad).slice(0, 40)); + } +}); diff --git a/common/lib/socialLinks.ts b/common/lib/socialLinks.ts @@ -0,0 +1,93 @@ +// THE SOCIAL ROW'S RENDER-TIME RULES — pure (a type, and the pure icon checker +// socialSvg.ts), so the shared component (common/components/SocialLinks.tsx) is +// safe in a server or a client tree. The stored shape is in settingsSchema.ts +// (`SocialLink`, `parseSocialLinks`). + +import type { SocialLink } from "./settingsSchema"; +import { normalizeSocialSvg, SVG_ID_RE } from "./socialSvg"; + +// A header holds at most this many social links. The footer always holds all. +export const HEADER_SOCIAL_LINKS_MAX = 4; + +// Which links a header shows, in their configured order: +// - any link marked `featured` → the marked ones only; +// - none marked → all of them; +// and of that list, the LAST `HEADER_SOCIAL_LINKS_MAX`. Last, not first: the +// newest link an operator adds goes at the end of the list, and is the one they +// most want seen. +export function headerSocialLinks<T extends Pick<SocialLink, "featured">>( + links: readonly T[], +): T[] { + const featured = links.filter((link) => link.featured === true); + const pool = featured.length > 0 ? featured : links; + return pool.slice(-HEADER_SOCIAL_LINKS_MAX); +} + +// normalizeSocialSvg() deliberately STRIPS width/height so the icon scales to its +// wrapper. The cost is that a viewBox-only <svg> has no intrinsic size, so before +// the stylesheet loads on a static host it paints at the replaced-element default +// (huge) — the "flash of giant social icons" FOUC. sizeSocialSvg() re-injects an +// intrinsic pixel size at RENDER time (existing site.json files already have the +// attributes stripped, so this must run on read, not just on write). The size is +// an *attribute*, not inline style, so a wrapper's `w-*`/`h-*` utilities still win +// once CSS loads — it only governs the pre-CSS first paint. +export function sizeSocialSvg(svg: string, px = 20): string { + if (typeof svg !== "string") return svg; + if (/^<svg[^>]*\swidth\s*=/i.test(svg)) return svg; // already sized + return svg.replace(/^<svg\b/i, `<svg width="${px}" height="${px}"`); +} + +// THE READ PATH. A stored icon is inlined only if it passes the save-time +// check AGAIN (socialSvg.ts): a file edited by hand, written by an older build, +// or read from another checkout never reaches a page unchecked. The result is +// the normalized SVG, or null — and a caller then shows the link's label as +// text instead of an icon. Every inlining goes through here. +export function safeSocialSvg(svg: unknown): string | null { + return typeof svg === "string" ? normalizeSocialSvg(svg) : null; +} + +const ID_ATTR = /\sid\s*=\s*(?:"([^"]+)"|'([^']+)')/gi; + +function escapeRegExp(s: string): string { + return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +// Every `id` inside one inlined icon, and every reference to one (`url(#…)` in +// an attribute or a style, `href="#…"`, `xlink:href="#…"`), prefixed with +// `scope`. The same icon is inlined more than once on a page (a header and a +// footer, and a header renders one row per breakpoint), and an id resolves to +// the FIRST element that carries it: when that copy is `display: none`, a +// gradient, mask or clip defined in it does not paint, and every visible copy +// that points at it loses that part of the icon. Scoping each copy makes each +// self-contained. The stored SVG is not changed; this runs at render. +export function scopeSvgIds(svg: string, scope: string): string { + if (typeof svg !== "string") return svg; + // Only a plain name is prefixed (the checker refuses any other id), so the + // prefix can never complete a `-->` or change the markup's structure. + const ids = new Set<string>(); + for (const m of svg.matchAll(ID_ATTR)) { + const id = m[1] ?? m[2]; + if (SVG_ID_RE.test(id)) ids.add(id); + } + if (ids.size === 0) return svg; + // Longest first, so an id that is a prefix of another never wins its match. + const alt = [...ids] + .sort((a, b) => b.length - a.length) + .map(escapeRegExp) + .join("|"); + // A reference's quote may be a character reference (`url(&quot;#a&quot;)`). + const Q = `["']|&quot;|&apos;|&#34;|&#39;|&#x22;|&#x27;`; + return svg + .replace( + new RegExp(`(\\sid\\s*=\\s*)(["'])(${alt})\\2`, "gi"), + (_m, pre: string, q: string, id: string) => `${pre}${q}${scope}-${id}${q}`, + ) + .replace( + new RegExp(`url\\(\\s*(${Q})?#(${alt})\\1\\s*\\)`, "g"), + (_m, q: string | undefined, id: string) => `url(${q ?? ""}#${scope}-${id}${q ?? ""})`, + ) + .replace( + new RegExp(`(\\s(?:xlink:)?href\\s*=\\s*)(["'])#(${alt})\\2`, "gi"), + (_m, pre: string, q: string, id: string) => `${pre}${q}#${scope}-${id}${q}`, + ); +} diff --git a/common/lib/socialSvg.ts b/common/lib/socialSvg.ts @@ -0,0 +1,422 @@ +// THE SOCIAL ICON'S SVG: what one may contain, and its normalized form. +// +// Pure, no imports: settingsSchema.ts (every save of a social link — Settings, +// a site's form, the homepage config) and socialLinks.ts (every render) both run +// it, so a stored icon is checked again each time it is inlined into a page. +// +// AN ALLOWLIST, TOKENIZED. An icon is inlined into every header and footer, and +// an operator pastes it from anywhere, so a text denylist is not enough: `/` +// separates attributes as well as whitespace does, a character reference spells +// `javascript:`, and a transform can join two fragments into a handler. The +// input is read once, tag by tag, and refused unless: +// - it is ONE well-formed <svg> element: every tag either self-closes or is +// closed in order, attributes are separated by HTML whitespace and quoted, +// and there is no other markup (no <!…> declaration, CDATA or processing +// instruction; an XML declaration and a DOCTYPE with no internal subset at +// the very start, and every comment, are removed first); +// - every element is on ELEMENTS (shapes, groups, gradients, clips, masks, +// filters, text, and the three animation elements) — so no script, +// foreignObject, style, a, image, iframe, object, embed, audio or video, +// and none of the HTML elements that break out of SVG; +// - every attribute is on ATTRIBUTES (the SVG presentation, geometry, filter +// and animation set, plus aria-*, data-* and xmlns:*), and none is an event +// handler (a name starting with "on"); +// - after decoding character references (numeric and named, with or without +// the `;`) and dropping the whitespace a browser ignores in a URL, no value +// holds `javascript:` or `vbscript:`; an `href` / `xlink:href` is a fragment +// of this icon (`#id`); every `url(…)` points at a fragment of this icon; a +// `style` has no escape, `@import`, `expression(`, `behavior` or binding; +// an animation never targets `href` or a handler; +// - every id is a plain name (`^[A-Za-z_][\w.:-]*$`), so a rendered copy can +// prefix it safely (socialLinks.ts scopeSvgIds). +// The OUTPUT of the normalization below is checked again the same way, so no +// transform can assemble what the input check refused. + +const ELEMENTS = new Set( + [ + "svg", "g", "defs", "symbol", "use", "title", "desc", + "path", "rect", "circle", "ellipse", "line", "polyline", "polygon", + "text", "tspan", + "linearGradient", "radialGradient", "stop", "pattern", "clipPath", "mask", + "filter", "feBlend", "feColorMatrix", "feComponentTransfer", "feComposite", + "feDropShadow", "feFlood", "feFuncA", "feFuncB", "feFuncG", "feFuncR", + "feGaussianBlur", "feMerge", "feMergeNode", "feMorphology", "feOffset", + "animate", "animateTransform", "set", + ].map((e) => e.toLowerCase()), +); + +const ANIMATION = new Set(["animate", "animatetransform", "set"]); + +const ATTRIBUTES = new Set( + [ + // core + "id", "class", "style", "lang", "xml:lang", "xml:space", "xmlns", "version", + "baseProfile", "role", "focusable", + // geometry + "viewBox", "preserveAspectRatio", "width", "height", "x", "y", "x1", "y1", + "x2", "y2", "cx", "cy", "r", "rx", "ry", "fx", "fy", "fr", "d", "points", + "pathLength", "transform", "transform-origin", + // paint and presentation + "fill", "fill-opacity", "fill-rule", "clip-rule", "clip-path", "clipPathUnits", + "mask", "maskUnits", "maskContentUnits", "filter", "filterUnits", + "primitiveUnits", "stroke", "stroke-width", "stroke-linecap", + "stroke-linejoin", "stroke-miterlimit", "stroke-dasharray", + "stroke-dashoffset", "stroke-opacity", "opacity", "color", "display", + "visibility", "overflow", "shape-rendering", "text-rendering", + "image-rendering", "color-interpolation", "color-interpolation-filters", + "color-rendering", "vector-effect", "paint-order", "mix-blend-mode", + "isolation", "enable-background", + // gradients and patterns + "stop-color", "stop-opacity", "offset", "gradientUnits", "gradientTransform", + "spreadMethod", "patternUnits", "patternContentUnits", "patternTransform", + // text + "font-family", "font-size", "font-weight", "font-style", "font-variant", + "font-stretch", "text-anchor", "dominant-baseline", "alignment-baseline", + "baseline-shift", "letter-spacing", "word-spacing", "text-decoration", + "writing-mode", "dx", "dy", "rotate", "textLength", "lengthAdjust", + // filter primitives + "in", "in2", "result", "stdDeviation", "flood-color", "flood-opacity", + "lighting-color", "operator", "k1", "k2", "k3", "k4", "mode", "values", + "type", "tableValues", "slope", "intercept", "amplitude", "exponent", + "radius", "edgeMode", + // animation + "attributeName", "attributeType", "from", "to", "by", "dur", "begin", "end", + "repeatCount", "repeatDur", "calcMode", "keyTimes", "keySplines", + "additive", "accumulate", "restart", "min", "max", + // references — a fragment of this icon only (checked below) + "href", "xlink:href", + ].map((a) => a.toLowerCase()), +); + +const ATTRIBUTE_PATTERNS = [/^aria-[a-z-]+$/, /^data-[a-z0-9_.-]+$/, /^xmlns:[a-z][a-z0-9_.-]*$/]; + +// An id a rendered copy can prefix (socialLinks.ts): a plain name. +export const SVG_ID_RE = /^[A-Za-z_][\w.:-]*$/; +const FRAGMENT_RE = /^#[A-Za-z_][\w.:-]*$/; + +// The reasons a pasted icon is refused, by class. The editor shows them after +// the link's label; none of them echoes the markup (a tag or attribute NAME is +// at most letters, digits and `_.:-`, and is cut to 40 characters). +export const SVG_PROBLEM = { + markup: "it is not one well-formed <svg> element", + handler: "it has an event handler attribute", + script: "it has a script, or a link that runs one", + external: "it links to something outside the icon", + style: "it has a style an icon cannot use (an escape, an import or a script)", + id: "it has an id an icon cannot use", + viewBox: "it has no viewBox, and no numeric width and height to make one from", + element: (name: string) => `it has an element an icon has no use for (${name.slice(0, 40)})`, + attribute: (name: string) => + `it has an attribute an icon has no use for (${name.slice(0, 40)})`, +} as const; + +// HTML whitespace, the only attribute separator this reads (a `/` between +// attributes, which HTML also takes, is refused as markup). +const WS = "[\\t\\n\\f\\r ]"; +const TAG_RE = new RegExp( + `<(\\/?)([A-Za-z][A-Za-z0-9_.:-]*)((?:${WS}+[^\\t\\n\\f\\r "'<>\\/=]+(?:${WS}*=${WS}*(?:"[^"]*"|'[^']*'))?)*)(${WS}*)(\\/?)>`, + "y", +); +const ATTR_RE = new RegExp( + `(${WS}+)([^\\t\\n\\f\\r "'<>\\/=]+)(?:${WS}*=${WS}*(?:"([^"]*)"|'([^']*)'))?`, + "g", +); + +type Attr = { name: string; value: string; raw: string }; +type Tag = { + close: boolean; + name: string; + attrs: Attr[]; + trailing: string; // the whitespace before `>` / `/>` + selfClose: boolean; + start: number; + end: number; // index just past `>` +}; + +// Character references a browser decodes in an attribute value, so that the +// checks see what the browser will: numeric (decimal, hex) and named, with or +// without the `;`. Named ones are matched case-insensitively — decoding MORE +// than a browser would only makes the checks stricter. +const NAMED_REFS: Record<string, string> = { + amp: "&", lt: "<", gt: ">", quot: '"', apos: "'", colon: ":", tab: "\t", + newline: "\n", nbsp: "\u00a0", lpar: "(", rpar: ")", sol: "/", bsol: "\\", + num: "#", period: ".", excl: "!", semi: ";", comma: ",", equals: "=", + plus: "+", dollar: "$", percnt: "%", ast: "*", lowbar: "_", hyphen: "-", + dash: "-", quest: "?", commat: "@", lsqb: "[", rsqb: "]", lcub: "{", + rcub: "}", verbar: "|", grave: "`", hat: "^", +}; + +export function decodeCharRefs(value: string): string { + return value.replace( + /&(#[xX][0-9a-fA-F]+|#[0-9]+|[A-Za-z][A-Za-z0-9]*);?/g, + (m, ref: string) => { + if (ref[0] === "#") { + const hex = ref[1] === "x" || ref[1] === "X"; + const cp = hex ? parseInt(ref.slice(2), 16) : parseInt(ref.slice(1), 10); + return Number.isFinite(cp) && cp > 0 && cp <= 0x10ffff ? String.fromCodePoint(cp) : "\ufffd"; + } + return NAMED_REFS[ref.toLowerCase()] ?? m; + }, + ); +} + +// Everything a browser ignores inside a URL, and case. +const squash = (v: string) => v.replace(/[\u0000-\u0020\u007f-\u00a0]+/g, ""); + +function attrProblem(element: string, a: Attr): string | null { + const name = a.name.toLowerCase(); + if (name.startsWith("on")) return SVG_PROBLEM.handler; + if (!ATTRIBUTES.has(name) && !ATTRIBUTE_PATTERNS.some((p) => p.test(name))) { + return SVG_PROBLEM.attribute(a.name); + } + const decoded = decodeCharRefs(a.value); + const tight = squash(decoded); + const lower = tight.toLowerCase(); + if (lower.includes("javascript:") || lower.includes("vbscript:") || lower.includes("livescript:")) { + return SVG_PROBLEM.script; + } + if (name === "href" || name === "xlink:href") { + if (!FRAGMENT_RE.test(decoded.trim())) return SVG_PROBLEM.external; + } + // Every url(…), wherever it is (a fill, a clip, a style): a fragment here. + for (const m of tight.matchAll(/url\(/gi)) { + const rest = tight.slice(m.index); + if (!/^url\((["']?)#[A-Za-z_][\w.:-]*\1\)/i.test(rest)) return SVG_PROBLEM.external; + } + if (name === "style") { + if (/\\|@import|expression\(|behavior|-moz-binding/i.test(tight)) return SVG_PROBLEM.style; + } + if (name === "id" && !SVG_ID_RE.test(a.value)) return SVG_PROBLEM.id; + if (ANIMATION.has(element) && name === "attributename") { + const target = lower.trim(); + if (target.startsWith("on")) return SVG_PROBLEM.handler; + if (target === "href" || target === "xlink:href") return SVG_PROBLEM.external; + } + return null; +} + +// One pass over the markup: its tags, or the first problem. +function scan(src: string): { tags: Tag[] } | { problem: string } { + const tags: Tag[] = []; + const stack: string[] = []; + let i = 0; + while (i < src.length) { + const lt = src.indexOf("<", i); + if (lt < 0) { + // Only whitespace may follow the root's closing tag. + return stack.length === 0 && tags.length > 0 && !src.slice(i).trim() + ? { tags } + : { problem: SVG_PROBLEM.markup }; + } + if (stack.length === 0 && tags.length > 0) return { problem: SVG_PROBLEM.markup }; + if (stack.length === 0 && src.slice(i, lt).trim()) return { problem: SVG_PROBLEM.markup }; + if (src.startsWith("<!", lt) || src.startsWith("<?", lt)) return { problem: SVG_PROBLEM.markup }; + TAG_RE.lastIndex = lt; + const m = TAG_RE.exec(src); + if (!m) return { problem: SVG_PROBLEM.markup }; + const [whole, slash, name, attrText, trailing, selfSlash] = m; + const close = slash === "/"; + const lname = name.toLowerCase(); + if (close && (attrText || selfSlash)) return { problem: SVG_PROBLEM.markup }; + if (!close) { + if (lname === "script") return { problem: SVG_PROBLEM.script }; + if (!ELEMENTS.has(lname)) return { problem: SVG_PROBLEM.element(name) }; + if (tags.length === 0 && lname !== "svg") return { problem: SVG_PROBLEM.markup }; + } + const attrs: Attr[] = []; + for (const a of attrText.matchAll(ATTR_RE)) { + attrs.push({ name: a[2], value: a[3] ?? a[4] ?? "", raw: a[0] }); + } + for (const a of attrs) { + const p = attrProblem(lname, a); + if (p) return { problem: p }; + } + if (close) { + if (stack.pop() !== lname) return { problem: SVG_PROBLEM.markup }; + } else if (selfSlash !== "/") { + stack.push(lname); + } + tags.push({ + close, + name, + attrs, + trailing, + selfClose: selfSlash === "/", + start: lt, + end: lt + whole.length, + }); + i = lt + whole.length; + } + return stack.length === 0 && tags.length > 0 ? { tags } : { problem: SVG_PROBLEM.markup }; +} + +// What a pasted icon looks like before it is read: trimmed, with an XML +// declaration and a DOCTYPE (with no internal subset) removed from the very +// start, and every comment removed. +function prepare(raw: string): string { + return raw + .trim() + .replace(/^<\?xml\b[^>]*\?>\s*/i, "") + .replace(/^<!DOCTYPE\s+svg\b[^>[]*>\s*/i, "") + .replace(/<!--[\s\S]*?-->/g, "") + .trim(); +} + +// 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; + +// 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, 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}"${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}${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) and a <clipPath> (a clip never paints: only its shape counts — +// Figma exports almost every icon as a path clipped by a +// `<clipPath><rect fill="white"/></clipPath>`, release 11, O2b) — +// self-closing first, so an empty `<mask …/>` or `<clipPath …/>` cannot +// swallow everything up to the next closing tag; +// - an animation tag, whose `fill="freeze"` / `fill="remove"` is timing. +const CHILD_TAG = + /<(?:mask|clipPath)\b[^>]*\/>|<mask\b[\s\S]*?<\/mask\s*>|<clipPath\b[\s\S]*?<\/clipPath\s*>|<[a-zA-Z][^>]*>/gi; +const PASSED_OVER = /^<(?:mask|clipPath|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))); +} + +// A root's own size as a viewBox — `0 0 W H` — when its `width` and `height` +// attributes are both positive numbers, unitless or in px. Anything else (a +// percentage, `em`, a missing or zero side) is no size. +const SVG_LENGTH_PX = /^\s*(\d+(?:\.\d+)?|\.\d+)\s*(?:px)?\s*$/i; + +function viewBoxFromSize(attrs: readonly Attr[]): string | null { + const side = (name: "width" | "height"): string | null => { + const a = attrs.find((x) => x.name.toLowerCase() === name); + const len = a ? SVG_LENGTH_PX.exec(a.value) : null; + const n = len ? Number(len[1]) : NaN; + return Number.isFinite(n) && n > 0 ? String(n) : null; + }; + const w = side("width"); + const h = side("height"); + return w && h ? `0 0 ${w} ${h}` : null; +} + +// Why a pasted icon cannot be stored, as one of SVG_PROBLEM's sentences, or +// null when normalizeSocialSvg accepts it. +export function socialSvgProblem(raw: unknown): string | null { + if (typeof raw !== "string") return SVG_PROBLEM.markup; + return normalize(raw).problem; +} + +// Normalize an admin-provided SVG snippet for inline use in a header or a +// footer. Returns null on anything the checks above refuse, or that has no +// viewBox to scale by. Steps: read and check (above); give a root with no +// viewBox, but a numeric width and height (unitless or px), `viewBox="0 0 W +// H"` from them — a vendor's file pasted as downloaded often carries only its +// size — then strip width/height; theme a single-colour icon; add +// aria-hidden; check the result again. +// +// A SINGLE-COLOUR icon follows the theme: when its fills — the root's and its +// children's together, outside a <mask> or <clipPath> — hold at most one solid +// colour, each becomes `currentColor`, the 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; + return normalize(raw).svg; +} + +function normalize(raw: string): { svg: string | null; problem: string | null } { + const refuse = (problem: string) => ({ svg: null, problem }); + const src = prepare(raw); + if (!src.startsWith("<svg") || !src.endsWith("</svg>")) return refuse(SVG_PROBLEM.markup); + const scanned = scan(src); + if ("problem" in scanned) return refuse(scanned.problem); + const root = scanned.tags[0]; + + // The root's opening tag, rebuilt from its own attributes (each exactly as + // written), so a size read from inside another attribute's value can never + // count, and stripping width/height removes those attributes and nothing else. + let attrs = root.attrs; + if (!attrs.some((a) => a.name.toLowerCase() === "viewbox")) { + const box = viewBoxFromSize(attrs); + if (box) attrs = [{ name: "viewBox", value: box, raw: ` viewBox="${box}"` }, ...attrs]; + } + // A viewBox in single quotes has always been refused; it still is. + if (!attrs.some((a) => a.name.toLowerCase() === "viewbox" && /^\s+viewBox\s*=\s*"/i.test(a.raw))) { + return refuse(SVG_PROBLEM.viewBox); + } + attrs = attrs.filter((a) => !/^(width|height)$/i.test(a.name)); + let opening = `<${root.name}${attrs.map((a) => a.raw).join("")}${root.trailing}`; + let body = src.slice(root.end - 1); // from the root's `>` + + 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"'); + } + const out = opening + body; + // The result is read and checked again: no transform above may assemble + // what the input check refused. + const again = scan(out); + if ("problem" in again) return refuse(again.problem); + return { svg: out, problem: null }; +}