Archilyzer · Source

archilyzer

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

commit bff87f0a506762ece7912df4aabfc5e2fb7b86e6
parent 70a4e9131fbdca8d1134be6f956395caeb17ec75
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Fri, 25 Sep 2026 20:55:57 -0400

common: site.json takes an accent id and a wordmarkLead (brand S0)

`accent` parses through parseAccentSetting: a named accent id (signal,
brass, vermilion, violet, sakura, blue, green) or a custom #rrggbb; absent
is Signal. New optional `wordmarkLead` after `headerTitle` — the heavy
subject part of the wordmark — kept on read and written by siteToDisk only
when it is a proper prefix of headerTitle (the object step resolves it
beside defaultGroupId). SITE.md regenerated; --check clean.

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

Diffstat:
MSITE.md | 9++++++++-
Mcommon/lib/siteSchema.test.ts | 58++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcommon/lib/siteSchema.ts | 31+++++++++++++++++++++++--------
3 files changed, 89 insertions(+), 9 deletions(-)

diff --git a/SITE.md b/SITE.md @@ -14,6 +14,7 @@ Regenerate this file with `pnpm --filter yt-dlp-transcript-common exec tsx bin/f | [`siteTitle`](#sitetitle) | `"Transcript Browser"` | | [`siteDescription`](#sitedescription) | `"Browse and search video transcripts"` | | [`headerTitle`](#headertitle) | `"Transcript Browser"` | +| [`wordmarkLead`](#wordmarklead) | absent | | [`homeTagline`](#hometagline) | `""` | | [`socialLinks`](#sociallinks) | absent | | [`groups`](#groups) | list — see below | @@ -52,6 +53,12 @@ The title shown in the site header. Default: `"Transcript Browser"` +## `wordmarkLead` + +The heavy first part of the header wordmark: the subject's name, e.g. `"Jer"` for `"Jeralyzer"`; the rest is set light. Kept only when it is a proper prefix of `headerTitle` (case-sensitive, shorter than it); anything else is dropped, and a site without one sets its whole title heavy. Never guessed from the title. + +Default: absent + ## `homeTagline` Tagline under the home page title. Empty = none. @@ -139,7 +146,7 @@ Default: absent ## `accent` -Per-site brand accent, `"#rrggbb"`. Overrides the family brass on this site's public build. Absent = inherit the family brass. Any other spelling is dropped. +Per-site brand accent: a named accent id (`signal`, `brass`, `vermilion`, `violet`, `sakura`, `blue`, `green`) or a custom `"#rrggbb"`. It is the site's default accent — a reader can pick another. Absent = `signal`, the family default. A custom hex is darkened or lightened per base until it reaches 4.5:1. The public `/site.json` always carries a hex: an id is published as its on-dark value. Any other spelling is dropped. Default: absent diff --git a/common/lib/siteSchema.test.ts b/common/lib/siteSchema.test.ts @@ -158,6 +158,56 @@ test("siteToDisk persists only the non-defaults", () => { assert.deepEqual(full.socialLinks, []); }); +test("accent: a named id or a custom hex is kept; anything else is dropped", () => { + assert.equal(parseSite("s", { accent: "brass" }).accent, "brass"); + assert.equal(parseSite("s", { accent: " Sakura " }).accent, "sakura"); + assert.equal(parseSite("s", { accent: "#CC3366" }).accent, "#cc3366"); + for (const v of ["gold", "custom", "#abc", 7, null]) { + assert.equal(parseSite("s", { accent: v }).accent, undefined, String(v)); + } + const disk = siteToDisk(parseSite("s", { accent: "Violet" })) as Record<string, unknown>; + assert.equal(disk.accent, "violet"); + assert.equal("accent" in siteToDisk(parseSite("s", { accent: "gold" })), false); +}); + +test("wordmarkLead: kept only as a proper prefix of headerTitle, and written after it", () => { + const site = parseSite("s", { headerTitle: "Jeralyzer", wordmarkLead: " Jer " }); + assert.equal(site.wordmarkLead, "Jer"); + // The key sits right after headerTitle, in the read AND on disk. + const keys = Object.keys(site); + assert.equal(keys[keys.indexOf("headerTitle") + 1], "wordmarkLead"); + const disk = siteToDisk(site) as Record<string, unknown>; + const diskKeys = Object.keys(disk); + assert.equal(disk.wordmarkLead, "Jer"); + assert.equal(diskKeys[diskKeys.indexOf("headerTitle") + 1], "wordmarkLead"); + // Not a prefix, wrong case, the whole title, longer, blank, not a string: + // dropped on read, and never written. + for (const lead of ["Hasan", "jer", "Jeralyzer", "Jeralyzers", "", " ", 3]) { + const s2 = parseSite("s", { headerTitle: "Jeralyzer", wordmarkLead: lead }); + assert.equal(s2.wordmarkLead, undefined, JSON.stringify(lead)); + assert.ok("wordmarkLead" in s2, "every key is emitted"); + assert.equal("wordmarkLead" in siteToDisk(s2), false, JSON.stringify(lead)); + } + // siteToDisk re-checks it: a caller's non-prefix lead is not persisted. + const edited = { ...site, headerTitle: "Hasanalyzer" }; + assert.equal("wordmarkLead" in siteToDisk(edited), false); +}); + +test("writeSite → getSite round-trips a named accent and a wordmark lead", async () => { + const paths = scratchPaths(await mkdtemp(path.join(os.tmpdir(), "site-"))); + const site = parseSite("s", { + siteTitle: "Rekietalyzer", + headerTitle: "Rekietalyzer", + wordmarkLead: "Rekieta", + accent: "vermilion", + }); + await writeSite(site, paths); + assert.deepEqual(getSite("s", paths), site); + const disk = JSON.parse(await readFile(siteConfigFile(paths, "s"), "utf8")); + assert.equal(disk.accent, "vermilion"); + assert.equal(disk.wordmarkLead, "Rekieta"); +}); + function fixtures(): Array<[string, unknown]> { const out: Array<[string, unknown]> = [ ["empty", {}], @@ -192,6 +242,14 @@ function fixtures(): Array<[string, unknown]> { }, ], ]; + out.push([ + "named accent + wordmark lead", + { headerTitle: "Jeralyzer", wordmarkLead: "Jer", accent: "Brass" }, + ]); + out.push([ + "non-prefix lead + unknown accent", + { headerTitle: "Jeralyzer", wordmarkLead: "Hasan", accent: "gold" }, + ]); const e2e = path.join(HERE, "..", "..", "editor", "e2e", "fixtures", "sites", "testsite", "site.json"); out.push(["e2e testsite", JSON.parse(fs.readFileSync(e2e, "utf8"))]); return out; diff --git a/common/lib/siteSchema.ts b/common/lib/siteSchema.ts @@ -4,14 +4,15 @@ // one-core phase 3 slice 4b, on slice 4a's pattern (lib/settingsSchema.ts): // every field is `settingsField(coerce)` over the parser that already existed — // parseChannelGroups, resolveDefaultGroupId, parseSiteChannels, -// parseSocialLinks, parseAccent, parseSiteUrl, parseRelatedSites — so no +// parseSocialLinks, parseAccentSetting, parseSiteUrl, parseRelatedSites — so no // boundary moved; zod supplies the key plumbing and strips unknown keys. No // `.default()`, no `.passthrough()`. // // WHAT A READ PROMISES (parseSite): it never throws, and it emits EVERY key — // an absent optional key is present with the value `undefined` — exactly as the -// hand-written parseSite did. Two fields depend on a sibling (the default group -// must be one of the groups; a membership's group must exist), so the schema is +// hand-written parseSite did. Three fields depend on a sibling (the default +// group must be one of the groups; a membership's group must exist; the +// wordmark lead must be a proper prefix of the header title), so the schema is // the per-key object followed by ONE object-level step that resolves them. // // WHAT A WRITE PROMISES (writeSite in lib/site.ts): the throwing validations, @@ -35,7 +36,8 @@ import { resolveDefaultGroupId, type ChannelGroup, } from "./channelGroups"; -import { parseAccent } from "./accent"; +import { parseAccentSetting } from "./accent"; +import { wordmarkLeadFor } from "./brand"; import { parseSocialLinks, type SocialLink } from "./settingsSchema"; import { settingsField } from "./settingsFieldSchemas"; import type { FieldDocs } from "./fieldDocs"; @@ -73,6 +75,7 @@ export type Site = { siteTitle: string; siteDescription: string; headerTitle: string; + wordmarkLead?: string; homeTagline: string; socialLinks?: SocialLink[]; groups: ChannelGroup[]; @@ -96,6 +99,8 @@ export const SITE_FIELD_DOCS: FieldDocs<Site> = { siteTitle: "The site's title (browser tab, manifest, headings).", siteDescription: "One-line description (meta description, manifest).", headerTitle: "The title shown in the site header.", + wordmarkLead: + 'The heavy first part of the header wordmark: the subject\'s name, e.g. `"Jer"` for `"Jeralyzer"`; the rest is set light. Kept only when it is a proper prefix of `headerTitle` (case-sensitive, shorter than it); anything else is dropped, and a site without one sets its whole title heavy. Never guessed from the title.', homeTagline: "Tagline under the home page title. Empty = none.", socialLinks: "Per-site social links. ABSENT means inherit the global default (`settings.json` `socialLinks`); an array — even an empty one — overrides it. Each link's SVG must be safe to inline or the save is refused.", @@ -108,7 +113,7 @@ export const SITE_FIELD_DOCS: FieldDocs<Site> = { cloudflareProject: "Cloudflare Pages project name this site deploys to (`wrangler pages deploy out --project-name <cloudflareProject>`). Trimmed; blank = none.", accent: - 'Per-site brand accent, `"#rrggbb"`. Overrides the family brass on this site\'s public build. Absent = inherit the family brass. Any other spelling is dropped.', + 'Per-site brand accent: a named accent id (`signal`, `brass`, `vermilion`, `violet`, `sakura`, `blue`, `green`) or a custom `"#rrggbb"`. It is the site\'s default accent — a reader can pick another. Absent = `signal`, the family default. A custom hex is darkened or lightened per base until it reaches 4.5:1. The public `/site.json` always carries a hex: an id is published as its on-dark value. Any other spelling is dropped.', siteUrl: "Absolute public URL of this site's deployment, e.g. `https://jeralyzer.pages.dev` (trimmed, trailing slashes removed; anything not absolute http(s) is dropped). Drives the cross-site footer: a site with no siteUrl is omitted from every other site's list.", relatedSites: @@ -223,6 +228,10 @@ export const siteFieldsSchema = z.object({ d.siteDescription, ), headerTitle: settingsField(stringOr(SITE_DEFAULT_TITLE)).describe(d.headerTitle), + // Checked against `headerTitle` in the object step below. + wordmarkLead: settingsField((v): string | undefined => + typeof v === "string" && v.trim() ? v.trim() : undefined, + ).describe(d.wordmarkLead), homeTagline: settingsField(stringOr("")).describe(d.homeTagline), // Key present (array) = override; absent = inherit the global default. socialLinks: settingsField((v): SocialLink[] | undefined => @@ -235,7 +244,7 @@ export const siteFieldsSchema = z.object({ cloudflareProject: settingsField((v): string | undefined => typeof v === "string" && v.trim() ? v.trim() : undefined, ).describe(d.cloudflareProject), - accent: settingsField(parseAccent).describe(d.accent), + accent: settingsField(parseAccentSetting).describe(d.accent), siteUrl: settingsField(parseSiteUrl).describe(d.siteUrl), relatedSites: settingsField(parseRelatedSites).describe(d.relatedSites), pwa: settingsField((v): boolean => v === true).describe(d.pwa), @@ -249,7 +258,8 @@ export const siteFieldsSchema = z.object({ hubUrl: settingsField(parseSiteUrl).describe(d.hubUrl), }); -// The whole schema: the per-key object, then the two sibling-dependent fields. +// The whole schema: the per-key object, then the three sibling-dependent +// fields. // // THE OBJECT STEP ALSO RE-EMITS EVERY KEY, in order. zod 4 omits a key that was // absent from the input when its transform returns `undefined`, so the per-key @@ -261,6 +271,9 @@ export const siteSchema = siteFieldsSchema.transform((s): Site => { const resolved: Partial<Record<keyof Site, unknown>> = { ...s, defaultGroupId: resolveDefaultGroupId(s.defaultGroupId, groups), + // A lead that is not a proper prefix of the header title is dropped (the + // wordmark then sets the whole title heavy; lib/brand.ts splitWordmark). + wordmarkLead: wordmarkLeadFor(s.headerTitle, s.wordmarkLead), // Drop a membership's groupId that names no configured group (it folds to // the default at render time via resolveChannelGroupId); keep a valid one // so the editor round-trips it. @@ -299,7 +312,8 @@ export function siteToDisk(site: Site): Site { (c) => !c.groupId || groups.some((g) => g.id === c.groupId), ); const relatedSites = parseRelatedSites(site.relatedSites); - const accent = parseAccent(site.accent); + const accent = parseAccentSetting(site.accent); + const wordmarkLead = wordmarkLeadFor(site.headerTitle, site.wordmarkLead); const siteUrl = parseSiteUrl(site.siteUrl); const hubUrl = parseSiteUrl(site.hubUrl); const archiveMaxBytes = archiveMaxBytesOf(site.archiveMaxBytes); @@ -308,6 +322,7 @@ export function siteToDisk(site: Site): Site { siteTitle: site.siteTitle, siteDescription: site.siteDescription, headerTitle: site.headerTitle, + ...(wordmarkLead ? { wordmarkLead } : {}), homeTagline: site.homeTagline, // undefined socialLinks = inherit the global default; only an explicit // override (an array, even empty) is persisted.