// 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, socialSvgProblem, 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; // The header's two widths (the ruling of 2026-09-28): a header keeps the // site's NAME on a narrow screen and shows only the links marked for it; the // rest are in the footer, which always shows every link. export type HeaderWidth = "wide" | "narrow"; // Which links a header shows at `width`, in their configured order: // - wide: every link, up to HEADER_SOCIAL_LINKS_MAX; with more configured, // the `featured` ones are kept first, then the last of the rest fill the // row; // - narrow: the `featured` ones only, up to HEADER_SOCIAL_LINKS_MAX; none // marked → none (the name wins; the footer has them all). // Where a bound cuts, the LAST ones are kept: the newest link an operator adds // goes at the end of the list, and is the one they most want seen. export function headerSocialLinks>( links: readonly T[], width: HeaderWidth, ): T[] { const featured = links.filter((link) => link.featured === true).slice(-HEADER_SOCIAL_LINKS_MAX); if (width === "narrow") return featured; if (links.length <= HEADER_SOCIAL_LINKS_MAX) return [...links]; const room = HEADER_SOCIAL_LINKS_MAX - featured.length; const rest = room > 0 ? links.filter((link) => !featured.includes(link)).slice(-room) : []; const keep = new Set([...featured, ...rest]); return links.filter((link) => keep.has(link)); } // normalizeSocialSvg() deliberately STRIPS width/height so the icon scales to its // wrapper. The cost is that a viewBox-only 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 (/^]*\swidth\s*=/i.test(svg)) return svg; // already sized return svg.replace(/^ l.svg)); const links: SocialLink[] = []; for (const link of next) { if (kept.has(link.svg)) { links.push({ ...link }); continue; } const svg = normalizeSocialSvg(link.svg); if (svg === null) { return { refused: { label: link.label, problem: socialSvgProblem(link.svg) ?? "it is refused" } }; } links.push({ ...link, svg }); } return { links }; } 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(); 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("#a")`). const Q = `["']|"|'|"|'|"|'`; 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}`, ); }