Archilyzer · Source

archilyzer

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

commit 5fa76626b05a765eafdf22a2cbcba2400282654c
parent 9c4f9220d0c12ea88ac06cffaa37d2be80fcb4ac
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Thu, 24 Sep 2026 22:27:02 -0400

hub: homepage.json transcriptDownloads — the hub's modal follows the same switch

The hub has no site.json; its config is sites/_homepage/homepage.json. The
key is read only as false and written only when false (writeHomepageConfig
rebuilds the file from named fields, so the hub form on /sites carries the
checkbox, same label as the site form's). hubSite() passes it through, and
HubHome/AskHub already read currentSite(). Tests: homepage.test.ts (new,
parse + write), a hub-mode e2e (present by default, absent with the key
false; the hub config removes a leftover fixture homepage.json), and a
sites-crud round-trip of the hub form.

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

Diffstat:
Acommon/lib/homepage.test.ts | 46++++++++++++++++++++++++++++++++++++++++++++++
Mcommon/lib/homepage.ts | 7+++++++
Meditor/app/sites/components/HomepageConfigForm.tsx | 15+++++++++++++++
Meditor/app/sites/lib/homepageActions.ts | 3+++
Meditor/e2e/sites-crud.spec.ts | 40++++++++++++++++++++++++++++++++++++++++
Mexport/app/lib/site.ts | 2++
Aexport/e2e-hub/transcript-downloads.spec.ts | 70++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mexport/playwright.hub.config.ts | 6++++++
8 files changed, 189 insertions(+), 0 deletions(-)

diff --git a/common/lib/homepage.test.ts b/common/lib/homepage.test.ts @@ -0,0 +1,46 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtemp, readFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import type { Paths } from "./paths"; +import { getHomepageConfig, parseHomepageConfig, writeHomepageConfig } from "./homepage"; + +// homepage.json's `transcriptDownloads` follows site.json's: opt-out, read as +// off only on an explicit false, written only when false. + +test("transcriptDownloads is off only when explicitly false", () => { + for (const v of [undefined, true, 0, "false", null]) { + assert.equal( + parseHomepageConfig({ transcriptDownloads: v }).transcriptDownloads, + undefined, + String(v), + ); + } + assert.equal(parseHomepageConfig({ transcriptDownloads: false }).transcriptDownloads, false); + assert.equal(parseHomepageConfig({}).transcriptDownloads, undefined); +}); + +function scratchPaths(dir: string): Paths { + return { homepageConfigFile: path.join(dir, "_homepage", "homepage.json") } as Paths; +} + +test("writeHomepageConfig persists transcriptDownloads only when false, and it round-trips", async () => { + const paths = scratchPaths(await mkdtemp(path.join(os.tmpdir(), "homepage-"))); + const base = parseHomepageConfig({ siteTitle: "Hub" }); + + await writeHomepageConfig({ ...base, transcriptDownloads: false }, paths); + let disk = JSON.parse(await readFile(paths.homepageConfigFile, "utf8")); + assert.equal(disk.transcriptDownloads, false); + assert.equal(getHomepageConfig(paths).transcriptDownloads, false); + + await writeHomepageConfig({ ...base, transcriptDownloads: undefined }, paths); + disk = JSON.parse(await readFile(paths.homepageConfigFile, "utf8")); + assert.equal("transcriptDownloads" in disk, false); + assert.equal(getHomepageConfig(paths).transcriptDownloads, undefined); + + // A true is the default, so it is not written either. + await writeHomepageConfig({ ...base, transcriptDownloads: true }, paths); + disk = JSON.parse(await readFile(paths.homepageConfigFile, "utf8")); + assert.equal("transcriptDownloads" in disk, false); +}); diff --git a/common/lib/homepage.ts b/common/lib/homepage.ts @@ -32,6 +32,11 @@ export type HomepageConfig = { siteUrl?: string; // Cloudflare Pages project the hub deploys to. cloudflareProject?: string; + // The transcript modal's per-video export controls (Download menu, Copy MD, + // Copy download command) on the hub's Browse and Ask pages — the same switch + // as site.json `transcriptDownloads`. Opt-OUT: absent = on; only an explicit + // false is read, and only false is written. + transcriptDownloads?: boolean; }; // Neutral defaults for an unconfigured install. These are the PRODUCT strings @@ -75,6 +80,7 @@ export function parseHomepageConfig(raw: unknown): HomepageConfig { typeof r.cloudflareProject === "string" && r.cloudflareProject.trim() ? r.cloudflareProject.trim() : undefined, + transcriptDownloads: r.transcriptDownloads === false ? false : undefined, }; } @@ -124,6 +130,7 @@ export async function writeHomepageConfig( ...(config.cloudflareProject && config.cloudflareProject.trim() ? { cloudflareProject: config.cloudflareProject.trim() } : {}), + ...(config.transcriptDownloads === false ? { transcriptDownloads: false } : {}), }; // mkdir: the parent of homepageConfigFile IS homepageDir (lib/paths.ts). await writeJsonAtomic(paths.homepageConfigFile, merged, { mkdir: true }); diff --git a/editor/app/sites/components/HomepageConfigForm.tsx b/editor/app/sites/components/HomepageConfigForm.tsx @@ -61,6 +61,21 @@ export function HomepageConfigForm({ config }: { config: HomepageConfig }) { defaultValue={config.cloudflareProject ?? ""} /> </label> + <label className="flex items-center gap-2 text-sm"> + <input + type="checkbox" + name="transcriptDownloads" + defaultChecked={config.transcriptDownloads !== false} + className="accent-brand" + /> + Per-video transcript downloads (Download menu, Copy Markdown, Copy download + command) + </label> + <p className="-mt-2 text-xs text-muted-foreground"> + On by default: the hub&apos;s transcript viewer offers the transcript as a + file, as Markdown, and a yt-dlp command for a marked clip, for every + archive it federates. Turn off to hide all three on the hub. + </p> <div className="flex items-center gap-3"> <button type="submit" diff --git a/editor/app/sites/lib/homepageActions.ts b/editor/app/sites/lib/homepageActions.ts @@ -41,6 +41,9 @@ export async function saveHomepageConfigAction( homeTagline: String(formData.get("homeTagline") ?? "").trim(), siteUrl: parseHomepageUrl(siteUrlRaw), cloudflareProject: cloudflareProject || undefined, + // Default on, the site form's opt-out idiom: unticked → false → persisted. + transcriptDownloads: + formData.get("transcriptDownloads") === "on" ? undefined : false, }; try { await writeHomepageConfig(next, paths); diff --git a/editor/e2e/sites-crud.spec.ts b/editor/e2e/sites-crud.spec.ts @@ -345,6 +345,46 @@ test("archives + per-video transcript downloads opt-outs round-trip", async ({ await expect(downloads).toBeChecked(); }); +test("the hub form's per-video transcript downloads opt-out round-trips to homepage.json", async ({ + page, +}) => { + await resetData("empty"); + type HomepageFile = { siteTitle?: string; transcriptDownloads?: boolean }; + const file = "test-transcripts/sites/_homepage/homepage.json"; + // The hub form is the one on /sites whose submit is "Save config". + const form = page + .locator("form") + .filter({ has: page.getByRole("button", { name: "Save config" }) }); + const downloads = form.getByRole("checkbox", { + name: /per-video transcript downloads/i, + }); + const save = async () => { + await form.getByRole("button", { name: "Save config" }).click(); + await expect(form.getByText("Saved.")).toBeVisible(); + }; + + await page.goto("/sites"); + await expect(downloads).toBeChecked(); + await downloads.uncheck(); + await save(); + await expect(async () => { + const hub = await readJson<HomepageFile>(file); + expect(hub.transcriptDownloads).toBe(false); + }).toPass({ timeout: 10_000 }); + + await page.goto("/sites"); + await expect(downloads).not.toBeChecked(); + await downloads.check(); + await save(); + await expect(async () => { + const hub = await readJson<HomepageFile>(file); + expect("transcriptDownloads" in hub).toBe(false); + }).toPass({ timeout: 10_000 }); + + await page.goto("/sites"); + await expect(downloads).toBeChecked(); +}); + test("first-run migrate button appears only when no sites exist", async ({ page, }) => { diff --git a/export/app/lib/site.ts b/export/app/lib/site.ts @@ -36,6 +36,8 @@ function hubSite(): Site { ...(cfg.siteUrl ? { siteUrl: cfg.siteUrl } : {}), socialLinks: resolveHomepageSocialLinks(cfg), pwa: true, + // homepage.json's opt-out, so the hub's modal follows the same switch. + ...(cfg.transcriptDownloads === false ? { transcriptDownloads: false } : {}), }); } diff --git a/export/e2e-hub/transcript-downloads.spec.ts b/export/e2e-hub/transcript-downloads.spec.ts @@ -0,0 +1,70 @@ +import fs from "node:fs"; +import path from "node:path"; +import { expect, test, type Page } from "@playwright/test"; +import { CHANNEL_SLUG, VIDEO_TRANSCRIPT_ONLY } from "../e2e/fixtures/data"; +import { expectModalOpen, installRoutes } from "../e2e/helpers"; + +// The hub has no site.json: its switch is homepage.json `transcriptDownloads` +// (SITES_DIR/_homepage/homepage.json), read by hubSite() into currentSite(), so +// HubHome's transcript modal follows it. The fixture tree has no homepage.json +// at all (every field its default); the off case writes one for one test and +// removes it. playwright.hub.config.ts removes a leftover from a killed run. +const HOMEPAGE_FILE = path.resolve( + process.cwd(), + "e2e", + "fixtures", + "sites", + "_homepage", + "homepage.json", +); + +test.describe.configure({ mode: "serial" }); + +test.afterEach(() => { + fs.rmSync(path.dirname(HOMEPAGE_FILE), { recursive: true, force: true }); +}); + +function exportControls(page: Page) { + return [ + page.getByRole("button", { name: "Mark both clip start and end first" }), + page.getByRole("button", { name: "Download this transcript as a file" }), + page.getByRole("button", { + name: "Copy this transcript as Markdown (for AI)", + }), + ]; +} + +// A bare channel/video id is same-origin content, which the hub reads from its +// own origin — installRoutes serves it. +async function openModal(page: Page) { + await installRoutes(page); + await page.route("**/hub-sites.json", (r) => + r.fulfill({ status: 200, contentType: "application/json", body: "[]" }), + ); + await page.goto(`/?v=${CHANNEL_SLUG}/${VIDEO_TRANSCRIPT_ONLY}`); + await expectModalOpen(page); + await expect(page.locator("li", { hasText: "beta line" })).toBeVisible(); +} + +test("hub, no homepage.json key: the three export controls are present", async ({ + page, +}) => { + await openModal(page); + for (const control of exportControls(page)) { + await expect(control).toBeVisible(); + } +}); + +test("hub, homepage.json transcriptDownloads: false hides all three; Share stays", async ({ + page, +}) => { + fs.mkdirSync(path.dirname(HOMEPAGE_FILE), { recursive: true }); + fs.writeFileSync(HOMEPAGE_FILE, JSON.stringify({ transcriptDownloads: false })); + await openModal(page); + for (const control of exportControls(page)) { + await expect(control).toHaveCount(0); + } + await expect( + page.getByRole("button", { name: "Copy share link at current time" }), + ).toBeVisible(); +}); diff --git a/export/playwright.hub.config.ts b/export/playwright.hub.config.ts @@ -1,3 +1,4 @@ +import fs from "node:fs"; import path from "node:path"; import { defineConfig, devices } from "@playwright/test"; import { buildFixtureSettings } from "./e2e/fixtures/data"; @@ -17,6 +18,11 @@ const TEST_SITES_DIR = path.resolve(process.cwd(), "e2e", "fixtures", "sites"); buildFixtureSettings(TEST_SETTINGS_FILE); +// e2e-hub/transcript-downloads.spec writes SITES_DIR/_homepage/homepage.json for +// one test and removes it; the fixture tree never commits one. Remove a leftover +// from a killed run before any spec reads the hub's config. +fs.rmSync(path.join(TEST_SITES_DIR, "_homepage"), { recursive: true, force: true }); + export default defineConfig({ testDir: "./e2e-hub", timeout: 30_000,