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:
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'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,