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:
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;