Archilyzer · Source

archilyzer

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

commit 63a88a5d059cee15354771f1c73b4aeed9f60d78
parent d2ef48d7d9a1826c4d3b15e747acd4e26f65b0da
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Mon, 28 Sep 2026 19:52:23 -0400

common, editor: a stored icon the checker now refuses no longer blocks an unrelated save; doctor names it (re-review R3)

- socialLinksForSave (lib/socialLinks.ts): a link whose svg is byte-identical
  to one already stored is written back as it is; a new or edited icon is
  normalized or refused with its label and reason. Used by writeSettings,
  writeSite, writeHomepageConfig and the editor's settings and site actions.
- archilyzer doctor, "social icons": every stored icon (settings.json, each
  site.json, homepage.json) through the check, refused ones named by file and
  label with the reason class, never the markup (a warning, not a failure).
- Tests: a settings file holding an icon an older build stored survives a
  title change, a floor change, a lane pause and a channel priority byte for
  byte; editing it to a refused icon is refused, to a good one saved; the
  homepage writer the same; the doctor line.

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

Diffstat:
Mcommon/bin/doctor.test.ts | 34++++++++++++++++++++++++++++++++++
Mcommon/bin/doctor.ts | 66++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcommon/lib/homepage.test.ts | 16+++++++++++++++-
Mcommon/lib/homepage.ts | 19++++++++++---------
Mcommon/lib/settings.ts | 30++++++++++++++----------------
Mcommon/lib/site.ts | 29++++++++++++++++++++---------
Mcommon/lib/socialLinks.test.ts | 17+++++++++++++++++
Mcommon/lib/socialLinks.ts | 30+++++++++++++++++++++++++++++-
Meditor/app/settings/actions.ts | 23+++++++++++------------
Meditor/app/settings/saveSettings.test.ts | 36++++++++++++++++++++++++++++++++++++
Meditor/app/sites/actions.ts | 29+++++++++++++++++------------
11 files changed, 269 insertions(+), 60 deletions(-)

diff --git a/common/bin/doctor.test.ts b/common/bin/doctor.test.ts @@ -285,3 +285,37 @@ test("the source publish block: the tools, the operator files by count and mode r = await collectDoctorReport(deps({ filterRepo: { via: "git", version: "a40bce548d2c" }, gitleaks: null })); assert.equal(status(r, "filter-repo"), "ok"); }); + +// A stored icon the checker refuses is named by file and label with its +// reason — never its markup — and the doctor still exits 0 (a WARN). +test("social icons: a refused stored icon is named by file and label, not its markup", async () => { + const c = checkout(); + const sitesDir = path.join(c.paths.transcriptsDir, "sites"); + mkdirSync(path.join(sitesDir, "one"), { recursive: true }); + const secret = "SECRET-MARKUP-XYZ"; + writeFileSync(c.paths.settingsFile, JSON.stringify({ + socialLinks: [ + { label: "Good", url: "https://good.example", svg: `<svg viewBox="0 0 8 8"><path d="M0 0"/></svg>` }, + { label: "Old", url: "https://old.example", svg: `<svg viewBox="0 0 8 8"><style>.${secret}{}</style></svg>` }, + ], + })); + writeFileSync(path.join(sitesDir, "one", "site.json"), JSON.stringify({ + socialLinks: [{ label: "Site icon", url: "https://s.example", svg: `<svg/onload="x()" viewBox="0 0 8 8"></svg>` }], + })); + const report = await collectDoctorReport({ + env: {}, + paths: { ...c.paths, sitesDir, homepageConfigFile: path.join(sitesDir, "_homepage", "homepage.json") }, + probe: async (t) => ({ id: t.id, bin: t.bin ?? "", present: true, version: "1", neededBy: t.neededBy, required: t.required }) as never, + portInUse: async () => false, + portBlock: async () => null, + umtoolTools: async () => null, + sourceTools: async () => ({ filterRepo: null, gitleaks: null }), + }); + const line = report.checks.find((x) => x.section === "social icons")!; + assert.equal(line.status, "warn"); + assert.match(line.detail, /2 of 3 fail/); + assert.match(line.detail, /settings\.json: "Old" — it has an element an icon has no use for \(style\)/); + assert.match(line.detail, /site\.json: "Site icon" — it is not one well-formed <svg> element/); + assert.ok(!line.detail.includes(secret), "no markup"); + assert.ok(report.ok, "a warning, not a failure"); +}); diff --git a/common/bin/doctor.ts b/common/bin/doctor.ts @@ -169,6 +169,25 @@ export async function collectDoctorReport(deps: DoctorDeps): Promise<DoctorRepor add(S, "schema", "fail", `settings do not load: ${(err as Error).message}`); } + // ── social icons ───────────────────────────────────────────────────────── + // Every stored social link's icon — settings.json, each site.json, + // homepage.json — through the check a page runs before it inlines one + // (lib/socialSvg.ts). One that fails renders as its label on every page; + // editing its SVG in the editor is the fix. Named by file and label with the + // reason class — never the markup. + const SI = "social icons"; + const icons = await storedSocialIcons(paths); + if (icons.checked === 0) { + add(SI, "stored icons", "info", "no stored social icons"); + } else if (icons.refused.length === 0) { + add(SI, "stored icons", "ok", + `${icons.checked} icon${icons.checked === 1 ? "" : "s"} in ${icons.files} file${icons.files === 1 ? "" : "s"} pass the check`); + } else { + add(SI, "stored icons", "warn", + `${icons.refused.length} of ${icons.checked} fail the check and show as their label; edit each one's SVG:\n` + + icons.refused.map((r) => `${path.relative(root, r.file) || r.file}: "${r.label}" — ${r.problem}`).join("\n")); + } + // ── tools ──────────────────────────────────────────────────────────────── const T = "tools"; const hasCorpus = channelSlugs.length > 0; @@ -354,6 +373,53 @@ export async function main(opts: { json?: boolean; env?: NodeJS.ProcessEnv } = { // ── helpers ──────────────────────────────────────────────────────────────── +// The social links stored in settings.json, every sites/<id>/site.json and +// sites/_homepage/homepage.json, each checked. Read-only; a missing or +// unreadable file is skipped. +async function storedSocialIcons(paths: Paths): Promise<{ + files: number; + checked: number; + refused: { file: string; label: string; problem: string }[]; +}> { + const { parseSocialLinks } = await import("../lib/settingsSchema"); + const { socialSvgProblem } = await import("../lib/socialSvg"); + const candidates = [paths.settingsFile]; + if (paths.sitesDir) { + try { + for (const e of await readdir(paths.sitesDir, { withFileTypes: true })) { + if (e.isDirectory() && !e.name.startsWith("_")) { + candidates.push(path.join(paths.sitesDir, e.name, "site.json")); + } + } + } catch { + /* no sites directory */ + } + } + if (paths.homepageConfigFile) candidates.push(paths.homepageConfigFile); + let files = 0; + let checked = 0; + const refused: { file: string; label: string; problem: string }[] = []; + for (const file of candidates) { + const text = file ? readOrNull(file) : null; + if (text === null) continue; + let raw: unknown; + try { + raw = JSON.parse(text); + } catch { + continue; + } + const links = parseSocialLinks((raw as { socialLinks?: unknown } | null)?.socialLinks); + if (links.length === 0) continue; + files += 1; + for (const link of links) { + checked += 1; + const problem = socialSvgProblem(link.svg); + if (problem) refused.push({ file, label: link.label, problem }); + } + } + return { files, checked, refused }; +} + function versionAtLeast(v: string, min: readonly [number, number, number]): boolean { const parts = v.split(".").map((n) => Number.parseInt(n, 10) || 0); for (let i = 0; i < 3; i++) { diff --git a/common/lib/homepage.test.ts b/common/lib/homepage.test.ts @@ -1,6 +1,6 @@ import { test } from "node:test"; import assert from "node:assert/strict"; -import { mkdtemp, readFile } from "node:fs/promises"; +import { mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import type { Paths } from "./paths"; @@ -44,3 +44,17 @@ test("writeHomepageConfig persists transcriptDownloads only when false, and it r disk = JSON.parse(await readFile(paths.homepageConfigFile, "utf8")); assert.equal("transcriptDownloads" in disk, false); }); + +test("writeHomepageConfig keeps an unchanged stored icon the checker now refuses, and checks an edited one", async () => { + const paths = scratchPaths(await mkdtemp(path.join(os.tmpdir(), "homepage-"))); + const old = { label: "Old", url: "https://old.example", svg: `<svg viewBox="0 0 8 8"><metadata/></svg>` }; + await mkdir(path.dirname(paths.homepageConfigFile), { recursive: true }); + await writeFile(paths.homepageConfigFile, JSON.stringify({ socialLinks: [old] })); + const base = getHomepageConfig(paths); + await writeHomepageConfig({ ...base, siteTitle: "Renamed" }, paths); + assert.deepEqual(JSON.parse(await readFile(paths.homepageConfigFile, "utf8")).socialLinks, [old]); + await assert.rejects( + writeHomepageConfig({ ...base, socialLinks: [{ ...old, svg: `<svg viewBox="0 0 8 8"><script/></svg>` }] }, paths), + /Social link "Old" has an invalid SVG: it has a script/, + ); +}); diff --git a/common/lib/homepage.ts b/common/lib/homepage.ts @@ -4,12 +4,11 @@ import { getPaths, type Paths } from "./paths"; import { PROJECT_NAME, PROJECT_TAGLINE } from "./project"; import { getSettings, - normalizeSocialSvg, parseSocialLinks, - socialSvgProblem, type SiteSettings, type SocialLink, } from "./settings"; +import { socialLinksForSave } from "./socialLinks"; // The Archilyzer hub/homepage is a SINGLE, instance-level landing site (the // `homepage` SSG package) that sits above the per-content sites. Unlike a Site, @@ -109,16 +108,18 @@ export async function writeHomepageConfig( config: HomepageConfig, paths: Paths = getPaths(), ): Promise<void> { + // A link whose SVG is unchanged from homepage.json on disk is kept as it is; + // a new or edited one is checked (lib/socialLinks.ts socialLinksForSave). let socialLinks: SocialLink[] | undefined; if (config.socialLinks !== undefined) { - socialLinks = []; - 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: ${socialSvgProblem(link.svg)}`); - } - socialLinks.push({ ...link, svg }); + const r = socialLinksForSave( + parseSocialLinks(config.socialLinks), + getHomepageConfig(paths).socialLinks, + ); + if ("refused" in r) { + throw new Error(`Social link "${r.refused.label}" has an invalid SVG: ${r.refused.problem}`); } + socialLinks = r.links; } const merged: HomepageConfig = { siteTitle: config.siteTitle, diff --git a/common/lib/settings.ts b/common/lib/settings.ts @@ -38,13 +38,12 @@ import { } from "./workers"; import { migrateSweepsToLanes } from "./laneMigration"; import { migrateMediaRootToLocations } from "./storageLocations"; +import { socialLinksForSave } from "./socialLinks"; import { clampParallelTranscriptions, defaultStorage, - normalizeSocialSvg, parseSocialLinks, sanitizeTranscriptionApps, - socialSvgProblem, siteSettingsSchema, type SiteSettings, type SocialLink, @@ -221,19 +220,18 @@ function deriveWorkerShadow(next: SiteSettings): SiteSettings { return { ...next, workers, transcriptionApp, transcriptionApps }; } -// Every social link's SVG normalized for inline use, or a THROW naming the -// first one that is not safe to inline. The schema's own `parseSocialLinks` -// only checks shape; this is the write-side half. -function validatedSocialLinks(value: unknown): SocialLink[] { - const out: 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: ${socialSvgProblem(link.svg)}`); - } - out.push({ ...link, svg }); +// The social links to store: each new or edited SVG normalized for inline use, +// or a THROW naming the first that is refused; a link whose SVG is unchanged +// from the file is kept as it is (lib/socialLinks.ts socialLinksForSave). The +// schema's own `parseSocialLinks` only checks shape; this is the write-side +// half. +function validatedSocialLinks(value: unknown, file: string): SocialLink[] { + const stored = parseSocialLinks(rawObject(readRawSettings(file)).socialLinks); + const r = socialLinksForSave(parseSocialLinks(value), stored); + if ("refused" in r) { + throw new Error(`Social link "${r.refused.label}" has an invalid SVG: ${r.refused.problem}`); } - return out; + return r.links; } export async function writeSettings(next: SiteSettings): Promise<void> { @@ -243,10 +241,10 @@ export async function writeSettings(next: SiteSettings): Promise<void> { // (`transcriptionsPaused`, `downloadsPaused`, `digest.digestsPaused`, the // inverted `backfill.enabled`) loses them on this write. The gate is // `autoQueue[lane].held` and nothing else — see lib/pauseGates.ts. + const file = getPaths().settingsFile; const merged = siteSettingsSchema.parse({ ...deriveWorkerShadow(next), - socialLinks: validatedSocialLinks(next.socialLinks), + socialLinks: validatedSocialLinks(next.socialLinks, file), }); - const file = getPaths().settingsFile; await writeJsonAtomic(file, merged); } diff --git a/common/lib/site.ts b/common/lib/site.ts @@ -6,12 +6,11 @@ import { TAGS_FILENAME } from "./curatedTags"; import type { SiteChannelIndex } from "./channelPriority"; import { getSettings, - normalizeSocialSvg, parseSocialLinks, - socialSvgProblem, type SiteSettings, type SocialLink, } from "./settings"; +import { socialLinksForSave } from "./socialLinks"; import { readJsonFileSync, writeJsonAtomic } from "./jsonFile-server"; import { isValidSiteId, @@ -212,6 +211,16 @@ export function defaultSiteId(paths: Paths = getPaths()): string | null { return ids.length === 1 ? ids[0] : null; } +// The social links a JSON file on disk holds now (shape-checked only), or none. +function storedSocialLinks(file: string): SocialLink[] { + try { + const raw = JSON.parse(fs.readFileSync(file, "utf8")) as { socialLinks?: unknown }; + return parseSocialLinks(raw?.socialLinks); + } catch { + return []; + } +} + export async function writeSite( site: Site, paths: Paths = getPaths(), @@ -231,16 +240,18 @@ export async function writeSite( } // undefined socialLinks = inherit the global default; only validate/persist a // key when the site explicitly overrides (an array, even empty). + // A link whose SVG is unchanged from the file on disk is kept as it is; a new + // or edited one is checked (lib/socialLinks.ts socialLinksForSave). let socialLinks: SocialLink[] | undefined; if (site.socialLinks !== undefined) { - socialLinks = []; - 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: ${socialSvgProblem(link.svg)}`); - } - socialLinks.push({ ...link, svg }); + const r = socialLinksForSave( + parseSocialLinks(site.socialLinks), + storedSocialLinks(siteConfigFile(paths, site.siteId)), + ); + if ("refused" in r) { + throw new Error(`Social link "${r.refused.label}" has an invalid SVG: ${r.refused.problem}`); } + socialLinks = r.links; } await writeJsonAtomic( siteConfigFile(paths, site.siteId), diff --git a/common/lib/socialLinks.test.ts b/common/lib/socialLinks.test.ts @@ -6,6 +6,7 @@ import { safeSocialSvg, scopeSvgIds, sizeSocialSvg, + socialLinksForSave, } from "./socialLinks"; import { normalizeSocialSvg, parseSocialLinks, type SocialLink } from "./settingsSchema"; @@ -155,3 +156,19 @@ test("safeSocialSvg: a stored icon that fails the check is not rendered", () => assert.equal(safeSocialSvg(bad), null, String(bad).slice(0, 40)); } }); + +test("socialLinksForSave: an unchanged stored icon is kept as it is; a new or edited one is checked", () => { + const refusedByNow = `<svg viewBox="0 0 8 8"><style>*{}</style></svg>`; + const stored: SocialLink[] = [{ label: "Old", url: "https://old.example", svg: refusedByNow }]; + // Unchanged (a label or `featured` may change): kept byte-identical. + const same = socialLinksForSave([{ ...stored[0], label: "Renamed", featured: true }], stored); + assert.deepEqual(same, { links: [{ ...stored[0], label: "Renamed", featured: true }] }); + // Edited to something refused: the label and the reason, never the markup. + const bad = socialLinksForSave([{ ...stored[0], svg: `<svg viewBox="0 0 8 8" onload="x()"></svg>` }], stored); + assert.ok("refused" in bad && bad.refused.label === "Old" && /event handler/.test(bad.refused.problem)); + // New and good: normalized. + const fresh = socialLinksForSave([{ label: "New", url: "https://new.example", svg: `<svg viewBox="0 0 8 8"><path d="M0 0"/></svg>` }], stored); + assert.ok("links" in fresh && fresh.links[0].svg.startsWith('<svg aria-hidden="true" fill="currentColor"')); + // Nothing stored: everything is checked. + assert.ok("refused" in socialLinksForSave(stored, undefined)); +}); diff --git a/common/lib/socialLinks.ts b/common/lib/socialLinks.ts @@ -4,7 +4,7 @@ // (`SocialLink`, `parseSocialLinks`). import type { SocialLink } from "./settingsSchema"; -import { normalizeSocialSvg, SVG_ID_RE } from "./socialSvg"; +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; @@ -46,6 +46,34 @@ export function safeSocialSvg(svg: unknown): string | null { return typeof svg === "string" ? normalizeSocialSvg(svg) : null; } +// THE WRITE PATH, for every writer of a social-link list (settings.json, a +// site.json, homepage.json, and the editor's two forms). A link whose `svg` is +// byte-identical to a link already stored is kept exactly as it is — whatever +// the checker now thinks of it — so a save that did not touch the links (a +// lane pause, a priority, a title) never fails on an icon an older build +// stored; the render re-checks it and shows the label if it fails, and +// `archilyzer doctor` names it. Every new or edited icon is normalized, or the +// save is refused with the link's label and the reason. +export function socialLinksForSave( + next: readonly SocialLink[], + stored: readonly SocialLink[] | undefined, +): { links: SocialLink[] } | { refused: { label: string; problem: string } } { + const kept = new Set((stored ?? []).map((l) => 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 { diff --git a/editor/app/settings/actions.ts b/editor/app/settings/actions.ts @@ -3,6 +3,7 @@ import { revalidatePath } from "next/cache"; import { AUTO_REFRESH_INTERVAL_MAX_SECONDS, + getSettings, AUTO_REFRESH_INTERVAL_MIN_SECONDS, DEFAULT_REPORT_DEBOUNCE_PRESET, defaultBuildPipeline, @@ -10,8 +11,6 @@ import { MIN_FREE_DISK_GB_MAX, RESUME_MARGIN_GB_DEFAULT, RESUME_MARGIN_GB_MAX, - normalizeSocialSvg, - socialSvgProblem, parseSocialLinks, PARALLEL_TRANSCRIPTIONS_DEFAULT, SLEEP_BETWEEN_DOWNLOADS_MAX_SECONDS, @@ -21,6 +20,7 @@ import { type SocialLink, } from "yt-dlp-transcript-common/lib/settings"; import { saveSettings } from "./saveSettings"; +import { socialLinksForSave } from "yt-dlp-transcript-common/lib/socialLinks"; import { DEFAULT_COOKIE_MODE, isCookieMode, @@ -167,17 +167,16 @@ export async function saveSettingsAction( "Each social link needs a label, URL (http(s)://, mailto:, or /), and SVG.", }; } - const socialLinks: SocialLink[] = []; - for (const link of socialParsed) { - const svg = normalizeSocialSvg(link.svg); - if (svg === null) { - return { - ok: false, - error: `Social link "${link.label}" has an invalid SVG: ${socialSvgProblem(link.svg)}.`, - }; - } - socialLinks.push({ ...link, svg }); + // A link whose SVG is unchanged from settings.json is kept as it is; a new + // or edited one is checked (lib/socialLinks.ts socialLinksForSave). + const checked = socialLinksForSave(socialParsed, getSettings().socialLinks); + if ("refused" in checked) { + return { + ok: false, + error: `Social link "${checked.refused.label}" has an invalid SVG: ${checked.refused.problem}.`, + }; } + const socialLinks: SocialLink[] = checked.links; // Build pipeline. Values are clamped/coerced by sanitizeBuildPipeline inside // the settings schema on save, so we only read the form here (NaN/blank → default). diff --git a/editor/app/settings/saveSettings.test.ts b/editor/app/settings/saveSettings.test.ts @@ -76,3 +76,39 @@ test("saveSettings writes the merged result and touches nothing else", async () const onDisk = JSON.parse(readFileSync(process.env.SETTINGS_FILE!, "utf8")); assert.equal(onDisk.adminTitle, "Kept"); }); + +// A STORED ICON THE CHECKER NOW REFUSES does not block an unrelated save: an +// older build stored it, and a lane pause or a title must still save. The link +// is written back byte-identical; only a NEW or EDITED icon is checked. +test("an icon stored by an older build survives unrelated saves; editing it is checked", async () => { + const old = `<svg aria-hidden="true" fill="currentColor" viewBox="0 0 8 8" inkscape:version="1.0"><defs><style>.a{fill:#f00}</style></defs><metadata/><path class="a" d="M0 0"/></svg>`; + const good = `<svg viewBox="0 0 8 8"><path d="M0 0"/></svg>`; + const stored = { label: "Old", url: "https://old.example", svg: old }; + writeFileSync(process.env.SETTINGS_FILE!, JSON.stringify({ socialLinks: [stored] })); + const linkOnDisk = () => + (JSON.parse(readFileSync(process.env.SETTINGS_FILE!, "utf8")) as { socialLinks: unknown[] }).socialLinks; + + await saveSettings({ adminTitle: "Renamed" }); + await saveSettings({ minFreeDiskGB: 2 }); + const s = getSettings(); + // A lane pause, the way the editor's pause control writes it. + const lane = Object.keys(s.autoQueue)[0] as keyof typeof s.autoQueue; + await saveSettings({ autoQueue: { ...s.autoQueue, [lane]: { ...s.autoQueue[lane], held: true } } }); + await saveSettings({ + channelPriority: { ...s.channelPriority, channels: { some: { tier: "paused" } } }, + }); + assert.deepEqual(linkOnDisk(), [stored], "byte-identical after four unrelated saves"); + + // Edited to something still refused: the save is refused, with the reason. + await assert.rejects( + saveSettings({ socialLinks: [{ ...stored, svg: `<svg viewBox="0 0 8 8"><style>*{}</style></svg>` }] }), + /Social link "Old" has an invalid SVG: it has an element an icon has no use for \(style\)/, + ); + assert.deepEqual(linkOnDisk(), [stored]); + + // Edited to a good icon: saved, normalized. + await saveSettings({ socialLinks: [{ ...stored, svg: good }] }); + assert.deepEqual(linkOnDisk(), [ + { ...stored, svg: `<svg aria-hidden="true" fill="currentColor" viewBox="0 0 8 8"><path d="M0 0"/></svg>` }, + ]); +}); diff --git a/editor/app/sites/actions.ts b/editor/app/sites/actions.ts @@ -11,6 +11,7 @@ import { wordmarkLeadFor, } from "yt-dlp-transcript-common/lib/brand"; import { + getSite, writeSite, deleteSite, isValidSiteId, @@ -20,8 +21,6 @@ import { type Site, } from "yt-dlp-transcript-common/lib/site"; import { - normalizeSocialSvg, - socialSvgProblem, parseSocialLinks, type SocialLink, } from "yt-dlp-transcript-common/lib/settings"; @@ -30,6 +29,7 @@ import { resolveDefaultGroupId, } from "yt-dlp-transcript-common/lib/channelGroups"; import { migrateToSites } from "yt-dlp-transcript-common/controller/migrateToSites"; +import { socialLinksForSave } from "yt-dlp-transcript-common/lib/socialLinks"; export type SaveResult = { ok: true; siteId: string } | { ok: false; error: string }; @@ -177,17 +177,22 @@ export async function saveSiteAction( "Each social link needs a label, URL (http(s)://, mailto:, or /), and SVG.", }; } - socialLinks = []; - for (const link of socialParsed) { - const svg = normalizeSocialSvg(link.svg); - if (svg === null) { - return { - ok: false, - error: `Social link "${link.label}" has an invalid SVG: ${socialSvgProblem(link.svg)}.`, - }; - } - socialLinks.push({ ...link, svg }); + // A link whose SVG is unchanged from the site's file is kept as it is; a + // new or edited one is checked (lib/socialLinks.ts socialLinksForSave). + let stored: SocialLink[] = []; + try { + stored = getSite(siteId, getPaths()).socialLinks ?? []; + } catch { + stored = []; + } + const checked = socialLinksForSave(socialParsed, stored); + if ("refused" in checked) { + return { + ok: false, + error: `Social link "${checked.refused.label}" has an invalid SVG: ${checked.refused.problem}.`, + }; } + socialLinks = checked.links; } let channelsInput: unknown;