commit a7b97f3252ce9d255657c9066dc66b6ade2e6e94
parent 1fde61eb37430c87ac858c99408edec91d8c757c
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Mon, 22 Jun 2026 17:45:49 -0400
Add Archilyzer homepage hub (SSG) with editor config + cross-site charts
A fourth workspace package, `homepage`, builds a single instance-level
static site (output: "export") that sits above the per-content export
sites: info/docs Markdown pages plus a cross-site stats dashboard
charting downloads/transcriptions across every content site.
- common: lib/homepage.ts + lib/homepagePages.ts (singleton config +
Markdown page store under sites/_homepage), bin/compose-homepage.ts,
a wholePoolStatsDir option on buildStats, and groupBy "site" threaded
into aggregate() via a ChannelSitesProvider context (many-to-many:
a video fans out to each site exposing its channel).
- homepage/: SSG Next.js app — home + [slug] Markdown routes
(markdown-to-jsx) and a /stats ChartsDashboard.
- editor: Manage -> Homepage page (branding form + Markdown page CRUD
with live preview).
- wiring: workspace entry, dev/build/start/deploy:homepage scripts,
HOMEPAGE_* worktree ports, .gitignore for generated public data,
changelog entries.
Verified: 4-package typecheck + lint, site-grouping unit test, and a
full SSG build smoke (home/docs/stats render correctly).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Diffstat:
43 files changed, 1701 insertions(+), 16 deletions(-)
diff --git a/.gitignore b/.gitignore
@@ -58,6 +58,11 @@ yarn-error.log*
/export/.export-index/
/transcripts/index.mdb/
+# homepage (hub) generated public data (regenerate with `pnpm build:homepage`)
+/homepage/public/stats/
+/homepage/public/channel-sites.json
+/homepage/public/chart-templates.json
+
# editor e2e fixtures and ephemeral state
/editor/test-transcripts/
/editor/test-settings.json
diff --git a/common/bin/compose-homepage.ts b/common/bin/compose-homepage.ts
@@ -0,0 +1,70 @@
+#!/usr/bin/env tsx
+// Composes the served `public/` dir for the Archilyzer hub (homepage package)
+// ahead of `next build`. Unlike compose-site.ts this is a SINGLE, pool-wide
+// build — there is no SITE_ID and no per-site filtering. It produces only the
+// two data files the hub's cross-site charts need:
+//
+// public/stats/{manifest,page-NNNN}.json <- whole-pool stats (every channel)
+// public/channel-sites.json <- channel slug -> [siteId, ...]
+//
+// The hub's Markdown pages and chart-templates are read straight from the data
+// dir by the homepage app at build (listHomepagePages / readHomepageTemplates),
+// so they are not staged here. Requires build:index to have populated the cues
+// LMDB first (the homepage prebuild chains it), same as the export pipeline.
+
+import path from "node:path";
+import { mkdir, rename, writeFile } from "node:fs/promises";
+import { getPaths } from "../lib/paths";
+import { buildStats } from "../controller/buildStats";
+import { listSites } from "../lib/site";
+
+// Where the homepage Next.js app serves static assets from. Overridable for e2e
+// test isolation, mirroring EXPORT_PUBLIC_DIR.
+function homepagePublicDir(monorepoRoot: string): string {
+ return (
+ process.env.HOMEPAGE_PUBLIC_DIR ??
+ path.join(monorepoRoot, "homepage", "public")
+ );
+}
+
+async function writeJsonAtomic(filePath: string, value: unknown): Promise<void> {
+ const tmp = `${filePath}.tmp-${process.pid}`;
+ await writeFile(tmp, JSON.stringify(value));
+ await rename(tmp, filePath);
+}
+
+async function main(): Promise<void> {
+ const paths = getPaths();
+ const publicDir = homepagePublicDir(paths.monorepoRoot);
+ const statsDir = path.join(publicDir, "stats");
+ await mkdir(statsDir, { recursive: true });
+
+ // Whole-pool stats dataset (every non-excluded channel) for the cross-site
+ // charts. buildStats also refreshes the per-site bundles as a side effect,
+ // which is harmless.
+ await buildStats({ paths, wholePoolStatsDir: statsDir });
+
+ // channel slug -> the ids of the content sites that expose it. Drives
+ // `groupBy: "site"` in the hub's dashboard (see channelSites.tsx). A channel
+ // on multiple sites maps to all of them; a pool-only channel is simply absent.
+ const sites = listSites(paths);
+ const channelSites: Record<string, string[]> = {};
+ for (const site of sites) {
+ for (const c of site.channels) {
+ (channelSites[c.slug] ??= []).push(site.siteId);
+ }
+ }
+ await writeJsonAtomic(
+ path.join(publicDir, "channel-sites.json"),
+ channelSites,
+ );
+
+ console.log(
+ `compose-homepage: ${Object.keys(channelSites).length} channel(s) mapped across ${sites.length} site(s) into ${publicDir}.`,
+ );
+}
+
+main().catch((err) => {
+ console.error(err);
+ process.exit(1);
+});
diff --git a/common/components/charts/ChartCard.tsx b/common/components/charts/ChartCard.tsx
@@ -6,6 +6,7 @@ import type { VideoStat } from "../../lib/stats";
import type { ChartConfig, ChartFilters } from "../../lib/chartConfig";
import { aggregate, mergeFilters } from "../../lib/chartAggregate";
import { ChartView } from "./ChartView";
+import { useChannelSites } from "./channelSites";
import { useSearchSeries } from "./useSearchSeries";
import { copyPngToClipboard, exportCsv, exportPng } from "./chartExport";
@@ -37,11 +38,15 @@ export function ChartCard({
[config, dashboardFilters],
);
+ // Channel→sites map for groupBy "site" (null outside the hub). See
+ // channelSites.tsx.
+ const channelSites = useChannelSites();
+
// Both data sources are hooked unconditionally (rules of hooks); the search
// hook is inert unless this chart has a search block.
const metaData = useMemo(
- () => (config.search ? null : aggregate(stats, merged)),
- [stats, merged, config.search],
+ () => (config.search ? null : aggregate(stats, merged, { channelSites })),
+ [stats, merged, config.search, channelSites],
);
const searchState = useSearchSeries(
merged,
diff --git a/common/components/charts/ChartConfigEditor.tsx b/common/components/charts/ChartConfigEditor.tsx
@@ -115,6 +115,7 @@ export function ChartConfigEditor({
<option value="language">Language</option>
<option value="mediaType">Media type</option>
<option value="status">Status</option>
+ <option value="site">Site</option>
</select>
</label>
diff --git a/common/components/charts/channelSites.tsx b/common/components/charts/channelSites.tsx
@@ -0,0 +1,31 @@
+"use client";
+
+import { createContext, useContext } from "react";
+
+// Supplies the channel→sites map (channel slug → the ids of the content sites
+// that expose it) to the chart renderers, so `groupBy: "site"` can fan a video
+// out to each of its sites. The Archilyzer hub (homepage package) wraps its
+// dashboard in this provider; the editor and per-site export apps don't, so the
+// context defaults to null and "site" grouping degrades to channel grouping
+// there. See chartAggregate.ts (AggregateContext / seriesKeysForStat).
+export type ChannelSitesMap = Record<string, string[]>;
+
+const ChannelSitesContext = createContext<ChannelSitesMap | null>(null);
+
+export function ChannelSitesProvider({
+ value,
+ children,
+}: {
+ value: ChannelSitesMap | null;
+ children: React.ReactNode;
+}) {
+ return (
+ <ChannelSitesContext.Provider value={value}>
+ {children}
+ </ChannelSitesContext.Provider>
+ );
+}
+
+export function useChannelSites(): ChannelSitesMap | null {
+ return useContext(ChannelSitesContext);
+}
diff --git a/common/controller/buildStats.ts b/common/controller/buildStats.ts
Binary files differ.
diff --git a/common/lib/chartAggregate.ts b/common/lib/chartAggregate.ts
@@ -21,6 +21,14 @@ export type ChartData = {
series: ChartSeries[];
};
+// Extra context the pure aggregation can't derive from VideoStat alone. Today
+// only `channelSites` (channel slug → the site ids that expose that channel),
+// supplied by the Archilyzer hub so `groupBy: "site"` can fan a video out to
+// each of its sites. Optional everywhere else.
+export type AggregateContext = {
+ channelSites?: Record<string, string[]> | null;
+};
+
const SERIES_ALL = "all";
const EMPTY = "(none)";
@@ -189,11 +197,33 @@ export function seriesKeyForStat(stat: VideoStat, groupBy: SeriesGroupBy): strin
return stat.mediaType;
case "status":
return stat.status;
+ // "site" is many-to-many and needs the channel→sites map, which this
+ // single-key helper has no access to (it's used by the search path). Degrade
+ // to the channel slug so search-derived "site" charts still produce a series;
+ // metadata charts go through seriesKeysForStat below for the real fan-out.
+ case "site":
+ return stat.channelSlug;
case "none":
return SERIES_ALL;
}
}
+// The series keys a stat contributes to. Usually one; `groupBy: "site"` can be
+// many (a channel exposed by several sites) or none (a channel on no site, e.g.
+// pool-only content) — the latter falls into the EMPTY bucket so it still counts
+// in combined totals. All other groupings delegate to seriesKeyForStat.
+export function seriesKeysForStat(
+ stat: VideoStat,
+ groupBy: SeriesGroupBy,
+ ctx?: AggregateContext,
+): string[] {
+ if (groupBy === "site") {
+ const ids = ctx?.channelSites?.[stat.channelSlug];
+ return ids && ids.length ? ids : [EMPTY];
+ }
+ return [seriesKeyForStat(stat, groupBy)];
+}
+
function reduce(values: number[], y: YMetric): number {
if (y.agg === "count") return values.length;
if (values.length === 0) return 0;
@@ -219,6 +249,7 @@ function reduce(values: number[], y: YMetric): number {
export function aggregate(
stats: readonly VideoStat[],
config: ChartConfig,
+ ctx?: AggregateContext,
): ChartData {
const filtered = applyFilters(stats, config.filters);
@@ -228,21 +259,26 @@ export function aggregate(
Map<string, { values: number[]; sort: number | string }>
>();
for (const stat of filtered) {
- const sKey = seriesKeyForStat(stat, config.groupBy);
const mv = metricValue(stat, config.y);
if (config.y.agg !== "count" && mv === null) continue; // skip missing metric
- let xMap = buckets.get(sKey);
- if (!xMap) {
- xMap = new Map();
- buckets.set(sKey, xMap);
- }
- for (const { label, sort } of xLabelsForStat(stat, config.x)) {
- let cell = xMap.get(label);
- if (!cell) {
- cell = { values: [], sort };
- xMap.set(label, cell);
+ const labels = xLabelsForStat(stat, config.x);
+ if (labels.length === 0) continue;
+ // A stat can contribute to several series (groupBy "site" fan-out); add it
+ // to each so a video shared across sites counts once per site.
+ for (const sKey of seriesKeysForStat(stat, config.groupBy, ctx)) {
+ let xMap = buckets.get(sKey);
+ if (!xMap) {
+ xMap = new Map();
+ buckets.set(sKey, xMap);
+ }
+ for (const { label, sort } of labels) {
+ let cell = xMap.get(label);
+ if (!cell) {
+ cell = { values: [], sort };
+ xMap.set(label, cell);
+ }
+ if (mv !== null) cell.values.push(mv);
}
- if (mv !== null) cell.values.push(mv);
}
}
diff --git a/common/lib/chartConfig.ts b/common/lib/chartConfig.ts
@@ -52,7 +52,12 @@ export type SeriesGroupBy =
| "platform"
| "language"
| "mediaType"
- | "status";
+ | "status"
+ // One series per content site. Many-to-many: a video's channel can belong to
+ // several sites, so it contributes to each. Only meaningful where a channel→
+ // sites map is supplied (the Archilyzer hub passes one via ChannelSitesContext);
+ // elsewhere it degrades to grouping by channel slug. See chartAggregate.ts.
+ | "site";
export type ChartFilters = {
dateFrom?: string; // YYYYMMDD inclusive
@@ -481,3 +486,58 @@ export function defaultTemplates(): ChartTemplates {
defaultDashboard: defaultDashboard(),
};
}
+
+// ─── Archilyzer hub (homepage) default dashboard ───
+// The cross-site board for the marketing/hub site. Leads with a per-site
+// breakdown of content acquired over time (groupBy "site", resolved through the
+// channel→sites map the hub supplies), plus combined whole-pool totals so "both"
+// views are present out of the box. Bins on the acquisition date fields.
+export function defaultHomepageDashboard(): Dashboard {
+ return {
+ version: 1,
+ title: "Across all sites",
+ filters: {},
+ charts: [
+ newChart({
+ id: "hub-downloaded-by-site",
+ title: "Downloaded over time, by site",
+ type: "area",
+ x: { kind: "time", bin: "month", field: "downloadedDate" },
+ y: { agg: "count" },
+ groupBy: "site",
+ cumulative: true,
+ }),
+ newChart({
+ id: "hub-transcribed-by-site",
+ title: "Transcribed over time, by site",
+ type: "area",
+ x: { kind: "time", bin: "month", field: "transcribedDate" },
+ y: { agg: "count" },
+ groupBy: "site",
+ cumulative: true,
+ }),
+ newChart({
+ id: "hub-downloaded-total",
+ title: "Downloaded per month (all sites)",
+ type: "bar",
+ x: { kind: "time", bin: "month", field: "downloadedDate" },
+ y: { agg: "count" },
+ }),
+ newChart({
+ id: "hub-library-growth",
+ title: "Library growth (added, all sites)",
+ type: "area",
+ x: { kind: "time", bin: "month", field: "downloadedDate" },
+ y: { agg: "count" },
+ cumulative: true,
+ }),
+ ],
+ };
+}
+
+export function defaultHomepageTemplates(): ChartTemplates {
+ return {
+ version: 1,
+ defaultDashboard: defaultHomepageDashboard(),
+ };
+}
diff --git a/common/lib/chartShare.ts b/common/lib/chartShare.ts
@@ -40,6 +40,7 @@ const GROUPS: SeriesGroupBy[] = [
"language",
"mediaType",
"status",
+ "site",
];
type SC = Record<string, unknown>;
diff --git a/common/lib/chartsStore.ts b/common/lib/chartsStore.ts
@@ -9,6 +9,7 @@ import type { Paths } from "./paths";
import { siteChartTemplatesFile, siteIndexDir } from "./site";
import {
defaultTemplates,
+ defaultHomepageTemplates,
type ChartTemplates,
type Dashboard,
} from "./chartConfig";
@@ -65,3 +66,40 @@ export function syncTemplatesToExport(paths: Paths, siteId: string): void {
const templates = readTemplates(paths, siteId);
writeJsonAtomic(siteTemplatesStagingPath(paths, siteId), templates);
}
+
+// ─── Archilyzer hub (homepage) chart templates ───
+// The hub owns one cross-site dashboard at homepageChartTemplatesFile
+// (sites/_homepage/chart-templates.json). Falls back to the code-level hub
+// default (per-site acquisition charts) when unauthored. Read server-side by the
+// homepage app and the editor's hub charts authoring; no staging/composition
+// step — the homepage app reads it directly at build.
+
+function coerceHomepage(raw: unknown): ChartTemplates {
+ if (!raw || typeof raw !== "object") return defaultHomepageTemplates();
+ const r = raw as Partial<ChartTemplates>;
+ const base = defaultHomepageTemplates();
+ return {
+ version: 1,
+ defaultDashboard:
+ r.defaultDashboard && Array.isArray(r.defaultDashboard.charts)
+ ? (r.defaultDashboard as Dashboard)
+ : base.defaultDashboard,
+ };
+}
+
+export function readHomepageTemplates(paths: Paths): ChartTemplates {
+ try {
+ return coerceHomepage(
+ JSON.parse(readFileSync(paths.homepageChartTemplatesFile, "utf8")),
+ );
+ } catch {
+ return defaultHomepageTemplates();
+ }
+}
+
+export function writeHomepageTemplates(
+ paths: Paths,
+ templates: ChartTemplates,
+): void {
+ writeJsonAtomic(paths.homepageChartTemplatesFile, templates);
+}
diff --git a/common/lib/homepage.ts b/common/lib/homepage.ts
@@ -0,0 +1,181 @@
+import fs from "node:fs";
+import { getPaths, type Paths } from "./paths";
+import {
+ getSettings,
+ normalizeSocialSvg,
+ parseSocialLinks,
+ type SiteSettings,
+ type SocialLink,
+} from "./settings";
+import { SITE_ID_RE } from "./site";
+
+// The Archilyzer hub/homepage is a SINGLE, instance-level marketing + docs site
+// (the `homepage` SSG package) that sits above the per-content sites. Unlike a
+// Site, it has no channel membership and no grouping layout — it's just branding,
+// social links, an ordered nav of Markdown pages (bodies live as files under
+// homepagePagesDir, see homepagePages.ts) and an optional cross-site chart
+// dashboard (chart-templates.json, same shape sites use). Config lives at
+// homepageConfigFile (sites/_homepage/homepage.json); operational config stays
+// global in settings.json.
+
+// One nav/page entry. `slug` is the page id (and URL segment); the Markdown body
+// is stored separately at homepagePagesDir/<slug>.md (see homepagePages.ts). The
+// nav array is the single source of truth for page ORDER and link LABEL.
+export type HomepageNavItem = {
+ slug: string;
+ label: string;
+ order?: number;
+};
+
+export type HomepageConfig = {
+ siteTitle: string;
+ siteDescription: string;
+ headerTitle: string;
+ homeTagline: string;
+ // Per-hub social links. `undefined` = inherit the global default from
+ // SiteSettings.socialLinks; an array (even empty) overrides it. Same semantics
+ // as Site.socialLinks — resolve with resolveHomepageSocialLinks() at render.
+ socialLinks?: SocialLink[];
+ // Ordered nav of Markdown pages. The home route ("/") renders the page with
+ // slug "index" when present; every other slug renders at "/<slug>".
+ nav: HomepageNavItem[];
+ // Absolute public URL of the deployed hub, e.g. "https://archilyzer.com".
+ siteUrl?: string;
+ // Cloudflare Pages project the hub deploys to.
+ cloudflareProject?: string;
+};
+
+// The canonical home page slug. The home route renders this page's body (if it
+// exists) and it is never given its own "/<slug>" route.
+export const HOMEPAGE_INDEX_SLUG = "index";
+
+// Page slugs share the site-id grammar (lowercase slug, safe as a file/URL name).
+export function isValidHomepageSlug(slug: unknown): slug is string {
+ return typeof slug === "string" && SITE_ID_RE.test(slug);
+}
+
+function defaults(): HomepageConfig {
+ return {
+ siteTitle: "Archilyzer",
+ siteDescription: "Searchable video-transcript archives.",
+ headerTitle: "Archilyzer",
+ homeTagline: "",
+ nav: [],
+ };
+}
+
+export function parseHomepageNav(input: unknown): HomepageNavItem[] {
+ if (!Array.isArray(input)) return [];
+ const out: HomepageNavItem[] = [];
+ const seen = new Set<string>();
+ for (const raw of input) {
+ if (!raw || typeof raw !== "object") continue;
+ const r = raw as Record<string, unknown>;
+ const slug = typeof r.slug === "string" ? r.slug.trim() : "";
+ if (!isValidHomepageSlug(slug) || seen.has(slug)) continue;
+ seen.add(slug);
+ const label =
+ typeof r.label === "string" && r.label.trim() ? r.label.trim() : slug;
+ const item: HomepageNavItem = { slug, label };
+ if (typeof r.order === "number" && Number.isFinite(r.order)) {
+ item.order = Math.floor(r.order);
+ }
+ out.push(item);
+ }
+ // Stable display order: explicit `order` first (ascending), then config order.
+ return out
+ .map((item, i) => ({ item, i }))
+ .sort((a, b) => {
+ const ao = a.item.order ?? Number.MAX_SAFE_INTEGER;
+ const bo = b.item.order ?? Number.MAX_SAFE_INTEGER;
+ return ao - bo || a.i - b.i;
+ })
+ .map(({ item }) => item);
+}
+
+// Normalize a raw siteUrl into a trimmed absolute http(s) URL with no trailing
+// slash, or undefined. Mirrors parseSiteUrl in site.ts.
+export function parseHomepageUrl(input: unknown): string | undefined {
+ if (typeof input !== "string") return undefined;
+ const trimmed = input.trim().replace(/\/+$/, "");
+ if (!/^https?:\/\/\S+/i.test(trimmed)) return undefined;
+ return trimmed;
+}
+
+export function parseHomepageConfig(raw: unknown): HomepageConfig {
+ const base = defaults();
+ const r =
+ raw && typeof raw === "object" ? (raw as Record<string, unknown>) : {};
+ const str = (key: keyof HomepageConfig, fallback: string): string =>
+ typeof r[key] === "string" ? (r[key] as string) : fallback;
+ return {
+ siteTitle: str("siteTitle", base.siteTitle),
+ siteDescription: str("siteDescription", base.siteDescription),
+ headerTitle: str("headerTitle", base.headerTitle),
+ homeTagline: str("homeTagline", base.homeTagline),
+ socialLinks: Array.isArray(r.socialLinks)
+ ? parseSocialLinks(r.socialLinks)
+ : undefined,
+ nav: parseHomepageNav(r.nav),
+ siteUrl: parseHomepageUrl(r.siteUrl),
+ cloudflareProject:
+ typeof r.cloudflareProject === "string" && r.cloudflareProject.trim()
+ ? r.cloudflareProject.trim()
+ : undefined,
+ };
+}
+
+export function getHomepageConfig(paths: Paths = getPaths()): HomepageConfig {
+ let parsed: unknown = {};
+ try {
+ parsed = JSON.parse(fs.readFileSync(paths.homepageConfigFile, "utf8"));
+ } catch {
+ parsed = {};
+ }
+ return parseHomepageConfig(parsed);
+}
+
+// The effective social links for the hub: its own override when present, else
+// the global default. Mirrors resolveSocialLinks for Sites.
+export function resolveHomepageSocialLinks(
+ config: HomepageConfig,
+ settings: SiteSettings = getSettings(),
+): SocialLink[] {
+ return config.socialLinks ?? settings.socialLinks;
+}
+
+export async function writeHomepageConfig(
+ config: HomepageConfig,
+ paths: Paths = getPaths(),
+): Promise<void> {
+ 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`);
+ }
+ socialLinks.push({ ...link, svg });
+ }
+ }
+ const merged: HomepageConfig = {
+ siteTitle: config.siteTitle,
+ siteDescription: config.siteDescription,
+ headerTitle: config.headerTitle,
+ homeTagline: config.homeTagline,
+ ...(socialLinks !== undefined ? { socialLinks } : {}),
+ nav: parseHomepageNav(config.nav),
+ ...(parseHomepageUrl(config.siteUrl)
+ ? { siteUrl: parseHomepageUrl(config.siteUrl) }
+ : {}),
+ ...(config.cloudflareProject && config.cloudflareProject.trim()
+ ? { cloudflareProject: config.cloudflareProject.trim() }
+ : {}),
+ };
+ await fs.promises.mkdir(paths.homepageDir, { recursive: true });
+ const file = paths.homepageConfigFile;
+ const tmp = `${file}.tmp-${process.pid}`;
+ await fs.promises.writeFile(tmp, JSON.stringify(merged, null, 2) + "\n");
+ await fs.promises.rename(tmp, file);
+}
diff --git a/common/lib/homepagePages.ts b/common/lib/homepagePages.ts
@@ -0,0 +1,85 @@
+import fs from "node:fs";
+import path from "node:path";
+import { getPaths, type Paths } from "./paths";
+import { isValidHomepageSlug } from "./homepage";
+
+// On-disk store for the hub's Markdown page bodies, one file per page at
+// homepagePagesDir/<slug>.md. Page ORDER and link LABEL live in the homepage
+// config's `nav` array (see homepage.ts) — this module owns only the prose, so
+// the editor can CRUD page content and render it with markdown-to-jsx at build.
+// Bodies are plain Markdown (markdown-to-jsx renders Markdown + HTML-like tags;
+// it does NOT compile real MDX imports), which is exactly what we want for the
+// SSG hub.
+
+const PAGE_EXT = ".md";
+
+export type HomepagePage = {
+ slug: string;
+ body: string;
+};
+
+function pageFile(paths: Paths, slug: string): string {
+ return path.join(paths.homepagePagesDir, `${slug}${PAGE_EXT}`);
+}
+
+// All page slugs that have a body file on disk, sorted for stable iteration.
+// Invalid filenames (not <validSlug>.md) are ignored.
+export function listHomepagePageSlugs(paths: Paths = getPaths()): string[] {
+ let entries: fs.Dirent[];
+ try {
+ entries = fs.readdirSync(paths.homepagePagesDir, { withFileTypes: true });
+ } catch {
+ return [];
+ }
+ const slugs: string[] = [];
+ for (const e of entries) {
+ if (!e.isFile() || !e.name.endsWith(PAGE_EXT)) continue;
+ const slug = e.name.slice(0, -PAGE_EXT.length);
+ if (isValidHomepageSlug(slug)) slugs.push(slug);
+ }
+ return slugs.sort((a, b) => a.localeCompare(b));
+}
+
+export function getHomepagePage(
+ slug: string,
+ paths: Paths = getPaths(),
+): HomepagePage | null {
+ if (!isValidHomepageSlug(slug)) return null;
+ try {
+ const body = fs.readFileSync(pageFile(paths, slug), "utf8");
+ return { slug, body };
+ } catch {
+ return null;
+ }
+}
+
+export function listHomepagePages(paths: Paths = getPaths()): HomepagePage[] {
+ return listHomepagePageSlugs(paths)
+ .map((slug) => getHomepagePage(slug, paths))
+ .filter((p): p is HomepagePage => p !== null);
+}
+
+export async function writeHomepagePage(
+ slug: string,
+ body: string,
+ paths: Paths = getPaths(),
+): Promise<void> {
+ if (!isValidHomepageSlug(slug)) {
+ throw new Error(`Invalid homepage page slug: ${String(slug)}`);
+ }
+ await fs.promises.mkdir(paths.homepagePagesDir, { recursive: true });
+ const file = pageFile(paths, slug);
+ const tmp = `${file}.tmp-${process.pid}`;
+ await fs.promises.writeFile(tmp, body);
+ await fs.promises.rename(tmp, file);
+}
+
+export async function deleteHomepagePage(
+ slug: string,
+ paths: Paths = getPaths(),
+): Promise<void> {
+ if (!isValidHomepageSlug(slug)) {
+ throw new Error(`Invalid homepage page slug: ${String(slug)}`);
+ }
+ await fs.promises.rm(pageFile(paths, slug), { force: true });
+}
diff --git a/common/lib/paths.ts b/common/lib/paths.ts
@@ -10,6 +10,15 @@ export type Paths = {
// See common/lib/site.ts. A "site" is a selection + presentation layer over the
// single global channel pool; channel downloads are never duplicated per site.
sitesDir: string;
+ // The Archilyzer hub/homepage (the `homepage` SSG package): a single, instance-
+ // level marketing/docs site, NOT a content site. Its config + Markdown pages +
+ // chart dashboard live under sitesDir/_homepage. The leading underscore is
+ // intentionally not a valid siteId (SITE_ID_RE), so listSiteIds() ignores it.
+ // See common/lib/homepage.ts and common/lib/homepagePages.ts.
+ homepageDir: string;
+ homepageConfigFile: string;
+ homepagePagesDir: string;
+ homepageChartTemplatesFile: string;
jobsDir: string;
// Scratch area for remote-worker transcription requests: uploaded audio and
// the produced transcript.json live under workerScratchDir/<remoteJobId>/ and
@@ -83,11 +92,17 @@ export function getPaths(): Paths {
process.env.EXPORT_INDEX_DIR ??
path.join(path.dirname(exportPublicDir), ".export-index");
const exportSharedDir = path.join(exportIndexDir, "shared");
+ const sitesDir = process.env.SITES_DIR ?? path.join(transcriptsDir, "sites");
+ const homepageDir = path.join(sitesDir, "_homepage");
cached = {
monorepoRoot,
transcriptsDir,
channelsDir: path.join(transcriptsDir, "channels"),
- sitesDir: process.env.SITES_DIR ?? path.join(transcriptsDir, "sites"),
+ sitesDir,
+ homepageDir,
+ homepageConfigFile: path.join(homepageDir, "homepage.json"),
+ homepagePagesDir: path.join(homepageDir, "pages"),
+ homepageChartTemplatesFile: path.join(homepageDir, "chart-templates.json"),
jobsDir: path.join(transcriptsDir, ".jobs"),
workerScratchDir: path.join(transcriptsDir, ".worker-scratch"),
schedulerStateFile: path.join(transcriptsDir, ".scheduler", "state.json"),
diff --git a/editor/CHANGELOG.md b/editor/CHANGELOG.md
@@ -1,6 +1,7 @@
# Changelog
## [Unreleased]
+- **New Archilyzer homepage (hub): a standalone marketing/docs site, configurable here.** A fourth workspace package, `homepage`, builds a single instance-level static site (`output: "export"`) that sits above the per-content export sites — for info/docs pages and cross-site charts that don't belong on any one content site. It's managed from the new **Manage → Homepage** page: edit branding (title/header/description/tagline/public URL/Cloudflare project) and author **Markdown pages** (a slug + nav label + body, with a live markdown-to-jsx preview), which the hub renders server-side at build (the `index` page is the home body; every other slug gets a `/<slug>` route). Page content lives in the data dir (`sites/_homepage/`, a reserved id `listSiteIds()` ignores), so copy changes need no code deploy. The hub's **/stats** dashboard charts downloads/transcriptions completed across **all** content sites, leading with a per-site breakdown — the chart engine gains a **Site** grouping option (`groupBy: "site"`) that fans each video out to every site exposing its channel, resolved through a channel→sites map the hub supplies; combined whole-pool totals remain one series. Build with `pnpm build:homepage` (a `compose-homepage` step stages whole-pool stats + the channel→sites map ahead of `next build`). See `common/lib/{homepage,homepagePages}.ts`, `common/bin/compose-homepage.ts`, `common/components/charts/channelSites.tsx`, the `homepage/` package, and `editor/app/homepage/*`.
- **"Cut release" can now create the release commit for you.** After turning `## [Unreleased]` into a dated semver heading, cutting a release used to leave the changelog edit sitting in your working tree to `git commit` by hand. A **Commit changelog** checkbox now sits next to the **Cut release** button (on `/changelog` for the editor and `/deploy` for the export), checked by default — leave it on and the cut is followed by a path-limited `git commit` of just that one CHANGELOG.md, with the message `Release <workspace> <version>` (e.g. `Release export 0.4.1`). To keep the release commit clean it commits *only* the changelog: if the working tree has any *other* uncommitted change, the cut is refused up front (nothing is written) with an error telling you to commit or stash those first — a dirty changelog itself is fine, so uncommitted `[Unreleased]` bullets get folded into the release commit. Uncheck the box to cut without committing, exactly as before. See `editor/app/deploy/cutReleaseAction.ts`, `editor/app/deploy/components/CutReleaseForm.tsx`, and the new `common/lib/git.ts`.
- **"Stop & keep progress" no longer mislabels the paused video as a failed transcription.** Using **Stop & keep progress** on a busy parakeet worker (or any partial-capable engine) sends the engine a graceful SIGTERM so it stops after the current window and the video resumes next run. But if the engine took longer than execa's 5-second force-kill window to exit — which a parakeet window routinely does, since finishing/stitching one ~480s window outlasts 5s — execa force-SIGKILLed it and the resulting "Command was killed with SIGTERM … forcefully terminated after 5000 milliseconds" error escaped the pause handling: it was treated as a genuine transcription failure and the video was written to the channel's `failed-transcriptions` file *permanently* (so even though its completed windows were cached for resume, it was skipped as "failed" on every later run). The transcribe path now recognizes that a force-killed **requested pause** is still a pause, not a failure — it returns the `paused` outcome (a skip, not a failure), so nothing lands in `failed-transcriptions` and the next "Transcribe missing" resumes it from the cached windows. Hard **Cancel** and **Drain** were never affected (their abort signal already classifies the kill as a skip). A video wrongly blacklisted by the old behavior won't auto-prune (it has real audio) — clear it with the channel's **Clear failed transcriptions** action to retry. See `common/controller/transcribeOne.ts`.
- **New Auto-queue: automatically transcribe (and download) across all channels by a configurable priority policy, instead of running one channel batch at a time.** Previously the only way to process pending work was to manually fire a per-channel batch (e.g. *Transcribe missing* on one channel), and since every transcription job serialized on a single queue, a batch ran to completion before any other channel got a turn — there was no way to say "do cornbreadman first, then fall back to hasanabi." The new **Auto-queue** page (under Pool → Auto-queue) adds two always-on runners, **auto-transcribe** and **auto-download**, each driven by a **policy tree**: order rules top-to-bottom for **strict** priority, or wrap rules in a group set to **round-robin** or **weighted-fair** (smooth weighted round-robin) to *alternate* between rulesets. A rule (leaf) matches a **channel**, a whole **platform**, or **all** channels, optionally narrowed to a snapshot **bucket** (e.g. prioritize `failedListed` retries over fresh `downloadedNoTranscript`), and any rule or group can carry a **max-workers** cap (a saturated subtree falls through to the next-priority sibling, like an HTB ceil). The highest-priority channel with available work claims the **next freed worker slot** — non-destructive, so a higher-priority video never kills an in-flight transcription, it just wins the next slot; when a channel's work runs out the runner falls back automatically. Transcription concurrency is bounded by the worker pool's eligible slots (so policy decides *which* video runs, the pool decides *how many*); downloads have no pool, so the runner gates to **one download per platform at a time**, matching the per-platform serial queue's politeness. Each runner is a real, drainable/cancellable job (visible on the Jobs pages), and the Auto-queue page shows live per-rule pending counts and a recent-pick log. Independent of the sync **Schedule** (which only decides *when* to re-fetch a channel) — manual batches keep working alongside it. Policies live in `settings.json` under `autoQueue` (defensively sanitized like `syncScheduler`); fairness cursors persist in `transcripts/.auto-queue/state.json`. See `common/jobs/autoQueuePolicy.ts` (pure selection engine + unit tests), `common/controller/autoRunner.ts`, `common/controller/transcribeOneFromQueue.ts` (shared per-video gating, also used by the existing whisper batch), and `editor/app/auto-queue/*`.
diff --git a/editor/app/homepage/HomepageConfigForm.tsx b/editor/app/homepage/HomepageConfigForm.tsx
@@ -0,0 +1,85 @@
+"use client";
+
+import { useActionState } from "react";
+import type { HomepageConfig } from "yt-dlp-transcript-common/lib/homepage";
+import { saveHomepageConfigAction, type SaveResult } from "./actions";
+
+const input =
+ "rounded-md border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-900 px-3 py-1.5 text-sm";
+const label = "flex flex-col gap-1 text-sm font-medium";
+
+export function HomepageConfigForm({ config }: { config: HomepageConfig }) {
+ const [state, formAction, pending] = useActionState<
+ SaveResult | undefined,
+ FormData
+ >(saveHomepageConfigAction, undefined);
+
+ return (
+ <form action={formAction} className="flex flex-col gap-3 max-w-xl">
+ <label className={label}>
+ Site title
+ <input className={input} name="siteTitle" defaultValue={config.siteTitle} />
+ </label>
+ <label className={label}>
+ Header title
+ <input
+ className={input}
+ name="headerTitle"
+ defaultValue={config.headerTitle}
+ />
+ </label>
+ <label className={label}>
+ Description
+ <input
+ className={input}
+ name="siteDescription"
+ defaultValue={config.siteDescription}
+ />
+ </label>
+ <label className={label}>
+ Home tagline
+ <input
+ className={input}
+ name="homeTagline"
+ defaultValue={config.homeTagline}
+ />
+ </label>
+ <label className={label}>
+ Public URL
+ <input
+ className={input}
+ name="siteUrl"
+ placeholder="https://archilyzer.com"
+ defaultValue={config.siteUrl ?? ""}
+ />
+ </label>
+ <label className={label}>
+ Cloudflare Pages project
+ <input
+ className={input}
+ name="cloudflareProject"
+ defaultValue={config.cloudflareProject ?? ""}
+ />
+ </label>
+ <div className="flex items-center gap-3">
+ <button
+ type="submit"
+ disabled={pending}
+ className="rounded-md bg-blue-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50"
+ >
+ {pending ? "Saving…" : "Save config"}
+ </button>
+ {state && !state.ok && (
+ <span className="text-sm text-red-600 dark:text-red-400">
+ {state.error}
+ </span>
+ )}
+ {state && state.ok && (
+ <span className="text-sm text-green-600 dark:text-green-400">
+ Saved.
+ </span>
+ )}
+ </div>
+ </form>
+ );
+}
diff --git a/editor/app/homepage/PageEditor.tsx b/editor/app/homepage/PageEditor.tsx
@@ -0,0 +1,157 @@
+"use client";
+
+import { useActionState, useState } from "react";
+import { useRouter } from "next/navigation";
+import Markdown from "markdown-to-jsx";
+import {
+ HOMEPAGE_INDEX_SLUG,
+ type HomepageNavItem,
+} from "yt-dlp-transcript-common/lib/homepage";
+import {
+ saveHomepagePageAction,
+ deleteHomepagePageAction,
+ type PageSaveResult,
+} from "./actions";
+
+const input =
+ "rounded-md border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-900 px-3 py-1.5 text-sm";
+const label = "flex flex-col gap-1 text-sm font-medium";
+
+// Create (no `existing`) or edit (with `existing`) one Markdown page. The slug is
+// immutable once created; the body is edited with a live markdown-to-jsx preview
+// so authors see what the static hub will render.
+export function PageEditor({
+ existing,
+ nav,
+}: {
+ existing?: { slug: string; body: string };
+ nav: HomepageNavItem[];
+}) {
+ const router = useRouter();
+ const navItem = existing
+ ? nav.find((n) => n.slug === existing.slug)
+ : undefined;
+ const [body, setBody] = useState(existing?.body ?? "");
+ const [state, formAction, pending] = useActionState<
+ PageSaveResult | undefined,
+ FormData
+ >(async (prev, formData) => {
+ const result = await saveHomepagePageAction(prev, formData);
+ if (result.ok && !existing) router.push(`/homepage/pages/${result.slug}`);
+ return result;
+ }, undefined);
+
+ const onDelete = async () => {
+ if (!existing) return;
+ if (!confirm(`Delete page "${existing.slug}"?`)) return;
+ const result = await deleteHomepagePageAction(existing.slug);
+ if (result.ok) router.push("/homepage");
+ else alert(result.error);
+ };
+
+ return (
+ <form action={formAction} className="flex flex-col gap-3">
+ <div className="grid gap-3 sm:grid-cols-3 max-w-2xl">
+ <label className={label}>
+ Slug
+ {existing ? (
+ <>
+ <input
+ type="hidden"
+ name="slug"
+ value={existing.slug}
+ readOnly
+ />
+ <span className={`${input} text-zinc-500`}>{existing.slug}</span>
+ </>
+ ) : (
+ <input
+ className={input}
+ name="slug"
+ placeholder="getting-started"
+ required
+ />
+ )}
+ {!existing && (
+ <span className="text-xs font-normal text-zinc-500">
+ Use <code>{HOMEPAGE_INDEX_SLUG}</code> for the home page body.
+ </span>
+ )}
+ </label>
+ <label className={label}>
+ Nav label
+ <input
+ className={input}
+ name="label"
+ defaultValue={navItem?.label ?? ""}
+ placeholder="Getting started"
+ />
+ </label>
+ <label className={label}>
+ Order
+ <input
+ className={input}
+ name="order"
+ inputMode="numeric"
+ defaultValue={navItem?.order ?? ""}
+ placeholder="(optional)"
+ />
+ </label>
+ </div>
+
+ <div className="grid gap-3 lg:grid-cols-2">
+ <label className={label}>
+ Markdown
+ <textarea
+ name="body"
+ value={body}
+ onChange={(e) => setBody(e.target.value)}
+ rows={20}
+ className={`${input} font-mono`}
+ />
+ </label>
+ <div className="flex flex-col gap-1 text-sm font-medium">
+ Preview
+ <div className="rounded-md border border-zinc-200 dark:border-zinc-800 bg-white dark:bg-zinc-900 px-3 py-2 overflow-auto min-h-[10rem]">
+ {body.trim() ? (
+ <Markdown options={{ forceBlock: true }}>{body}</Markdown>
+ ) : (
+ <span className="text-zinc-400 font-normal">
+ Nothing to preview yet.
+ </span>
+ )}
+ </div>
+ </div>
+ </div>
+
+ <div className="flex items-center gap-3">
+ <button
+ type="submit"
+ disabled={pending}
+ className="rounded-md bg-blue-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50"
+ >
+ {pending ? "Saving…" : existing ? "Save page" : "Create page"}
+ </button>
+ {existing && (
+ <button
+ type="button"
+ onClick={onDelete}
+ className="rounded-md border border-red-300 dark:border-red-800 px-3 py-1.5 text-sm text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-950"
+ >
+ Delete
+ </button>
+ )}
+ {state && !state.ok && (
+ <span className="text-sm text-red-600 dark:text-red-400">
+ {state.error}
+ </span>
+ )}
+ {state && state.ok && (
+ <span className="text-sm text-green-600 dark:text-green-400">
+ Saved.
+ </span>
+ )}
+ </div>
+ </form>
+ );
+}
diff --git a/editor/app/homepage/actions.ts b/editor/app/homepage/actions.ts
@@ -0,0 +1,120 @@
+"use server";
+
+import { revalidatePath } from "next/cache";
+import { getPaths } from "yt-dlp-transcript-common/lib/paths";
+import {
+ getHomepageConfig,
+ writeHomepageConfig,
+ parseHomepageUrl,
+ isValidHomepageSlug,
+ type HomepageConfig,
+ type HomepageNavItem,
+} from "yt-dlp-transcript-common/lib/homepage";
+import {
+ writeHomepagePage,
+ deleteHomepagePage,
+} from "yt-dlp-transcript-common/lib/homepagePages";
+
+export type SaveResult = { ok: true } | { ok: false; error: string };
+
+// Branding/config. Page nav is owned by the page actions below, so this preserves
+// the existing nav and social-link override and only writes branding fields.
+export async function saveHomepageConfigAction(
+ _prev: SaveResult | undefined,
+ formData: FormData,
+): Promise<SaveResult> {
+ const paths = getPaths();
+ const current = getHomepageConfig(paths);
+ const siteTitle = String(formData.get("siteTitle") ?? "").trim();
+ const headerTitle = String(formData.get("headerTitle") ?? "").trim();
+ if (!siteTitle) return { ok: false, error: "Site title is required" };
+ if (!headerTitle) return { ok: false, error: "Header title is required" };
+ const siteUrlRaw = String(formData.get("siteUrl") ?? "").trim();
+ if (siteUrlRaw && !parseHomepageUrl(siteUrlRaw)) {
+ return {
+ ok: false,
+ error: "Public URL must be an absolute http(s):// URL, or left blank.",
+ };
+ }
+ const cloudflareProject = String(
+ formData.get("cloudflareProject") ?? "",
+ ).trim();
+ const next: HomepageConfig = {
+ ...current,
+ siteTitle,
+ headerTitle,
+ siteDescription: String(formData.get("siteDescription") ?? "").trim(),
+ homeTagline: String(formData.get("homeTagline") ?? "").trim(),
+ siteUrl: parseHomepageUrl(siteUrlRaw),
+ cloudflareProject: cloudflareProject || undefined,
+ };
+ try {
+ await writeHomepageConfig(next, paths);
+ } catch (err) {
+ return { ok: false, error: err instanceof Error ? err.message : String(err) };
+ }
+ revalidatePath("/homepage");
+ return { ok: true };
+}
+
+// Create/update a Markdown page: writes the body file AND upserts the nav entry
+// (label/order) so the page shows up in the hub's nav. Returns the slug so the
+// client can redirect on create.
+export type PageSaveResult =
+ | { ok: true; slug: string }
+ | { ok: false; error: string };
+
+export async function saveHomepagePageAction(
+ _prev: PageSaveResult | undefined,
+ formData: FormData,
+): Promise<PageSaveResult> {
+ const paths = getPaths();
+ const slug = String(formData.get("slug") ?? "").trim();
+ if (!isValidHomepageSlug(slug)) {
+ return {
+ ok: false,
+ error: "Slug must be lowercase letters, numbers, and hyphens.",
+ };
+ }
+ const label = String(formData.get("label") ?? "").trim() || slug;
+ const orderRaw = String(formData.get("order") ?? "").trim();
+ const order = orderRaw ? Number(orderRaw) : undefined;
+ if (orderRaw && !Number.isFinite(order)) {
+ return { ok: false, error: "Order must be a number, or left blank." };
+ }
+ const body = String(formData.get("body") ?? "");
+ try {
+ await writeHomepagePage(slug, body, paths);
+ const current = getHomepageConfig(paths);
+ const nav: HomepageNavItem[] = current.nav.filter((n) => n.slug !== slug);
+ nav.push({
+ slug,
+ label,
+ ...(order != null && Number.isFinite(order) ? { order } : {}),
+ });
+ await writeHomepageConfig({ ...current, nav }, paths);
+ } catch (err) {
+ return { ok: false, error: err instanceof Error ? err.message : String(err) };
+ }
+ revalidatePath("/homepage");
+ revalidatePath(`/homepage/pages/${slug}`);
+ return { ok: true, slug };
+}
+
+export async function deleteHomepagePageAction(
+ slug: string,
+): Promise<SaveResult> {
+ const paths = getPaths();
+ try {
+ await deleteHomepagePage(slug, paths);
+ const current = getHomepageConfig(paths);
+ await writeHomepageConfig(
+ { ...current, nav: current.nav.filter((n) => n.slug !== slug) },
+ paths,
+ );
+ } catch (err) {
+ return { ok: false, error: err instanceof Error ? err.message : String(err) };
+ }
+ revalidatePath("/homepage");
+ return { ok: true };
+}
diff --git a/editor/app/homepage/page.tsx b/editor/app/homepage/page.tsx
@@ -0,0 +1,68 @@
+import type { Metadata } from "next";
+import Link from "next/link";
+import { getPaths } from "yt-dlp-transcript-common/lib/paths";
+import { getHomepageConfig } from "yt-dlp-transcript-common/lib/homepage";
+import { listHomepagePageSlugs } from "yt-dlp-transcript-common/lib/homepagePages";
+import { HomepageConfigForm } from "./HomepageConfigForm";
+
+export const dynamic = "force-dynamic";
+export const metadata: Metadata = { title: "Homepage" };
+
+export default function HomepageAdminPage() {
+ const paths = getPaths();
+ const config = getHomepageConfig(paths);
+ const slugs = listHomepagePageSlugs(paths);
+ const navBySlug = new Map(config.nav.map((n) => [n.slug, n]));
+
+ return (
+ <div className="flex flex-col gap-8">
+ <div>
+ <h1 className="text-2xl font-semibold">Homepage (hub)</h1>
+ <p className="mt-1 text-sm text-zinc-500">
+ The Archilyzer marketing/docs site, built by the{" "}
+ <code>homepage</code> package. Branding and Markdown pages are edited
+ here; its cross-site stats charts build from the whole channel pool.
+ </p>
+ </div>
+
+ <section className="flex flex-col gap-3">
+ <h2 className="text-lg font-semibold">Config</h2>
+ <HomepageConfigForm config={config} />
+ </section>
+
+ <section className="flex flex-col gap-3">
+ <div className="flex items-center justify-between">
+ <h2 className="text-lg font-semibold">Pages</h2>
+ <Link
+ href="/homepage/pages/new"
+ className="rounded-md border border-zinc-300 dark:border-zinc-700 px-3 py-1.5 text-sm hover:bg-zinc-100 dark:hover:bg-zinc-800"
+ >
+ New page
+ </Link>
+ </div>
+ {slugs.length === 0 ? (
+ <p className="text-sm text-zinc-500">
+ No pages yet. Create one — use the slug <code>index</code> for the
+ home page body.
+ </p>
+ ) : (
+ <ul className="flex flex-col gap-1 list-none">
+ {slugs.map((slug) => (
+ <li key={slug}>
+ <Link
+ href={`/homepage/pages/${slug}`}
+ className="flex items-baseline gap-2 rounded-md border border-zinc-200 dark:border-zinc-800 px-3 py-2 text-sm hover:bg-zinc-50 dark:hover:bg-zinc-800"
+ >
+ <span className="font-mono">{slug}</span>
+ <span className="text-zinc-500">
+ {navBySlug.get(slug)?.label ?? ""}
+ </span>
+ </Link>
+ </li>
+ ))}
+ </ul>
+ )}
+ </section>
+ </div>
+ );
+}
diff --git a/editor/app/homepage/pages/[slug]/page.tsx b/editor/app/homepage/pages/[slug]/page.tsx
@@ -0,0 +1,41 @@
+import type { Metadata } from "next";
+import Link from "next/link";
+import { notFound } from "next/navigation";
+import { getPaths } from "yt-dlp-transcript-common/lib/paths";
+import { getHomepageConfig } from "yt-dlp-transcript-common/lib/homepage";
+import { getHomepagePage } from "yt-dlp-transcript-common/lib/homepagePages";
+import { PageEditor } from "../../PageEditor";
+
+export const dynamic = "force-dynamic";
+
+export async function generateMetadata({
+ params,
+}: {
+ params: Promise<{ slug: string }>;
+}): Promise<Metadata> {
+ const { slug } = await params;
+ return { title: `Edit ${slug}` };
+}
+
+export default async function EditHomepagePage({
+ params,
+}: {
+ params: Promise<{ slug: string }>;
+}) {
+ const { slug } = await params;
+ const paths = getPaths();
+ const page = getHomepagePage(slug, paths);
+ if (!page) notFound();
+ const config = getHomepageConfig(paths);
+ return (
+ <div className="flex flex-col gap-4">
+ <Link href="/homepage" className="text-sm text-zinc-500 hover:underline">
+ ← Homepage
+ </Link>
+ <h1 className="text-2xl font-semibold">
+ Edit <span className="font-mono">{slug}</span>
+ </h1>
+ <PageEditor existing={page} nav={config.nav} />
+ </div>
+ );
+}
diff --git a/editor/app/homepage/pages/new/page.tsx b/editor/app/homepage/pages/new/page.tsx
@@ -0,0 +1,21 @@
+import type { Metadata } from "next";
+import Link from "next/link";
+import { getPaths } from "yt-dlp-transcript-common/lib/paths";
+import { getHomepageConfig } from "yt-dlp-transcript-common/lib/homepage";
+import { PageEditor } from "../../PageEditor";
+
+export const dynamic = "force-dynamic";
+export const metadata: Metadata = { title: "New page" };
+
+export default function NewHomepagePage() {
+ const config = getHomepageConfig(getPaths());
+ return (
+ <div className="flex flex-col gap-4">
+ <Link href="/homepage" className="text-sm text-zinc-500 hover:underline">
+ ← Homepage
+ </Link>
+ <h1 className="text-2xl font-semibold">New page</h1>
+ <PageEditor nav={config.nav} />
+ </div>
+ );
+}
diff --git a/editor/app/layout.tsx b/editor/app/layout.tsx
@@ -65,6 +65,7 @@ const NAV_GROUPS: NavGroup[] = [
label: "Manage",
links: [
{ href: "/sites", label: "Sites" },
+ { href: "/homepage", label: "Homepage" },
{ href: "/settings", label: "Settings" },
{ href: "/changelog", label: "Changelog", badgeKey: "changelog" },
],
diff --git a/editor/package.json b/editor/package.json
@@ -15,6 +15,7 @@
},
"dependencies": {
"@sindresorhus/slugify": "^3.0.0",
+ "markdown-to-jsx": "^7.7.4",
"next": "16.2.3",
"react": "19.2.4",
"react-dom": "19.2.4",
diff --git a/homepage/CHANGELOG.md b/homepage/CHANGELOG.md
@@ -0,0 +1,12 @@
+# Homepage Changelog
+
+## 2026-06-22
+
+- New `homepage` package: the Archilyzer hub — a standalone Next.js static
+ (`output: "export"`) marketing/docs site, separate from the per-content export
+ sites.
+- Markdown info/docs pages, authored in the editor and rendered with
+ markdown-to-jsx at build (`app/[slug]`, home renders the `index` page).
+- Cross-site stats dashboard (`/stats`) charting downloads/transcriptions across
+ every content site in the instance, with a per-site breakdown (`groupBy:
+ "site"`) plus combined totals.
diff --git a/homepage/app/[slug]/page.tsx b/homepage/app/[slug]/page.tsx
@@ -0,0 +1,51 @@
+import type { Metadata } from "next";
+import { notFound } from "next/navigation";
+import { getPaths } from "yt-dlp-transcript-common/lib/paths";
+import { HOMEPAGE_INDEX_SLUG } from "yt-dlp-transcript-common/lib/homepage";
+import {
+ getHomepagePage,
+ listHomepagePageSlugs,
+} from "yt-dlp-transcript-common/lib/homepagePages";
+import { currentHomepage } from "../lib/homepage";
+import PageBody from "../components/PageBody";
+
+// Every Markdown page except "index" (which is the home route) gets a static
+// "/<slug>" route. With output: "export" the full set is enumerated here at
+// build; unknown slugs 404.
+export const dynamicParams = false;
+
+export function generateStaticParams(): { slug: string }[] {
+ return listHomepagePageSlugs(getPaths())
+ .filter((slug) => slug !== HOMEPAGE_INDEX_SLUG)
+ .map((slug) => ({ slug }));
+}
+
+function labelFor(slug: string): string {
+ const item = currentHomepage().nav.find((n) => n.slug === slug);
+ return item?.label ?? slug;
+}
+
+export async function generateMetadata({
+ params,
+}: {
+ params: Promise<{ slug: string }>;
+}): Promise<Metadata> {
+ const { slug } = await params;
+ return { title: labelFor(slug) };
+}
+
+export default async function HomepageDocPage({
+ params,
+}: {
+ params: Promise<{ slug: string }>;
+}) {
+ const { slug } = await params;
+ if (slug === HOMEPAGE_INDEX_SLUG) notFound();
+ const page = getHomepagePage(slug, getPaths());
+ if (!page) notFound();
+ return (
+ <div className="flex flex-col gap-2">
+ <PageBody body={page.body} />
+ </div>
+ );
+}
diff --git a/homepage/app/components/Footer.tsx b/homepage/app/components/Footer.tsx
@@ -0,0 +1,35 @@
+import { getSettings } from "yt-dlp-transcript-common/lib/settings";
+import { resolveHomepageSocialLinks } from "yt-dlp-transcript-common/lib/homepage";
+import { currentHomepage } from "../lib/homepage";
+
+// Hub footer: just social links (its own override or the global default). The
+// per-content-site "related sites" footer lives in the export app; the hub IS
+// the cross-site index, so it doesn't repeat that list here.
+export default function Footer() {
+ const config = currentHomepage();
+ const socialLinks = resolveHomepageSocialLinks(config, getSettings());
+ return (
+ <footer className="mt-auto border-t border-zinc-200 dark:border-zinc-800 bg-white/40 dark:bg-zinc-900/40">
+ <div className="max-w-6xl mx-auto px-4 py-4 flex items-center justify-between gap-4 text-sm text-zinc-500">
+ <span>{config.siteTitle}</span>
+ {socialLinks.length > 0 && (
+ <ul className="flex items-center gap-3 list-none">
+ {socialLinks.map((link, i) => (
+ <li key={`${link.url}-${i}`}>
+ <a
+ href={link.url}
+ title={link.label}
+ aria-label={link.label}
+ target="_blank"
+ rel="noopener noreferrer"
+ className="inline-block w-5 h-5 text-zinc-500 hover:text-zinc-900 dark:hover:text-zinc-100 [&_svg]:w-full [&_svg]:h-full"
+ dangerouslySetInnerHTML={{ __html: link.svg }}
+ />
+ </li>
+ ))}
+ </ul>
+ )}
+ </div>
+ </footer>
+ );
+}
diff --git a/homepage/app/components/Header.tsx b/homepage/app/components/Header.tsx
@@ -0,0 +1,42 @@
+import Link from "next/link";
+import { HOMEPAGE_INDEX_SLUG } from "yt-dlp-transcript-common/lib/homepage";
+import { currentHomepage } from "../lib/homepage";
+
+// Top nav for the hub. The header title links home; nav links come from the
+// config's ordered `nav` array (the "index" page is the home route, so it's not
+// repeated as a nav link), with a trailing always-on "Stats" link to the
+// cross-site dashboard.
+export default function Header() {
+ const config = currentHomepage();
+ const links = config.nav.filter((n) => n.slug !== HOMEPAGE_INDEX_SLUG);
+ return (
+ <header className="border-b border-zinc-200 dark:border-zinc-800 bg-white/70 dark:bg-zinc-900/70 backdrop-blur sticky top-0 z-20">
+ <div className="max-w-6xl mx-auto px-4 py-3 flex items-center justify-between gap-3">
+ <div className="flex items-center gap-5 min-w-0">
+ <Link href="/" className="font-semibold tracking-tight shrink-0">
+ {config.headerTitle}
+ </Link>
+ <nav className="flex items-center gap-4 text-sm font-medium">
+ {links.map((n) => (
+ <Link
+ key={n.slug}
+ href={`/${n.slug}`}
+ className="text-zinc-900 dark:text-zinc-100 hover:text-blue-600 dark:hover:text-blue-400"
+ >
+ {n.label}
+ </Link>
+ ))}
+ </nav>
+ </div>
+ <nav className="text-sm shrink-0">
+ <Link
+ href="/stats"
+ className="text-zinc-500 hover:text-zinc-900 dark:hover:text-zinc-100"
+ >
+ Stats
+ </Link>
+ </nav>
+ </div>
+ </header>
+ );
+}
diff --git a/homepage/app/components/PageBody.tsx b/homepage/app/components/PageBody.tsx
@@ -0,0 +1,85 @@
+import Markdown from "markdown-to-jsx";
+
+// Renders a hub page's Markdown body to React, server-side (SSG). markdown-to-jsx
+// renders Markdown plus HTML-like tags via `overrides` — no MDX compile step, no
+// imports — which is all the hub's docs/info pages need. The override table here
+// is the docs-page analogue of common/components/Changelog.tsx (which keeps its
+// own changelog-specific h1/h2 anchor behavior).
+
+const OVERRIDES = {
+ h1: {
+ props: {
+ className:
+ "mt-8 mb-4 text-2xl font-semibold tracking-tight text-zinc-900 dark:text-zinc-100 first:mt-0",
+ },
+ },
+ h2: {
+ props: {
+ className:
+ "mt-8 mb-3 text-lg font-semibold text-zinc-900 dark:text-zinc-100",
+ },
+ },
+ h3: {
+ props: {
+ className:
+ "mt-6 mb-2 text-base font-semibold text-zinc-900 dark:text-zinc-100",
+ },
+ },
+ p: {
+ props: { className: "my-3 text-zinc-700 dark:text-zinc-300 leading-relaxed" },
+ },
+ ul: {
+ props: {
+ className:
+ "list-disc pl-6 my-3 space-y-1 text-zinc-700 dark:text-zinc-300 marker:text-zinc-400",
+ },
+ },
+ ol: {
+ props: {
+ className:
+ "list-decimal pl-6 my-3 space-y-1 text-zinc-700 dark:text-zinc-300 marker:text-zinc-400",
+ },
+ },
+ li: { props: { className: "leading-relaxed" } },
+ strong: {
+ props: { className: "font-semibold text-zinc-900 dark:text-zinc-100" },
+ },
+ em: { props: { className: "italic" } },
+ code: {
+ props: {
+ className:
+ "px-1 py-0.5 rounded bg-zinc-100 dark:bg-zinc-800 font-mono text-[0.85em]",
+ },
+ },
+ pre: {
+ props: {
+ className:
+ "my-4 p-3 rounded-md bg-zinc-100 dark:bg-zinc-900 overflow-x-auto text-sm font-mono",
+ },
+ },
+ a: {
+ props: {
+ className:
+ "text-blue-600 dark:text-blue-400 underline decoration-zinc-400 hover:decoration-blue-600 dark:hover:decoration-blue-400",
+ },
+ },
+ hr: {
+ props: { className: "my-8 border-t border-zinc-200 dark:border-zinc-800" },
+ },
+ blockquote: {
+ props: {
+ className:
+ "my-4 pl-4 border-l-2 border-zinc-300 dark:border-zinc-700 text-zinc-600 dark:text-zinc-400",
+ },
+ },
+};
+
+export default function PageBody({ body }: { body: string }) {
+ return (
+ <article className="prose-none max-w-3xl">
+ <Markdown options={{ overrides: OVERRIDES, forceBlock: true }}>
+ {body}
+ </Markdown>
+ </article>
+ );
+}
diff --git a/homepage/app/globals.css b/homepage/app/globals.css
@@ -0,0 +1,53 @@
+@import "tailwindcss";
+@source "../../common/components";
+
+:root {
+ --background: #ffffff;
+ --foreground: #171717;
+ /* Semantic tokens consumed by shadcn/ui chart primitives. */
+ --border: #e4e4e7;
+ --muted: #f4f4f5;
+ --muted-foreground: #71717a;
+ /* Chart series palette. */
+ --chart-1: #2563eb;
+ --chart-2: #16a34a;
+ --chart-3: #ea580c;
+ --chart-4: #9333ea;
+ --chart-5: #db2777;
+}
+
+@theme inline {
+ --color-background: var(--background);
+ --color-foreground: var(--foreground);
+ --color-border: var(--border);
+ --color-muted: var(--muted);
+ --color-muted-foreground: var(--muted-foreground);
+ --color-chart-1: var(--chart-1);
+ --color-chart-2: var(--chart-2);
+ --color-chart-3: var(--chart-3);
+ --color-chart-4: var(--chart-4);
+ --color-chart-5: var(--chart-5);
+ --font-sans: var(--font-geist-sans);
+ --font-mono: var(--font-geist-mono);
+}
+
+@media (prefers-color-scheme: dark) {
+ :root {
+ --background: #0a0a0a;
+ --foreground: #ededed;
+ --border: #27272a;
+ --muted: #27272a;
+ --muted-foreground: #a1a1aa;
+ --chart-1: #60a5fa;
+ --chart-2: #4ade80;
+ --chart-3: #fb923c;
+ --chart-4: #c084fc;
+ --chart-5: #f472b6;
+ }
+}
+
+body {
+ background: var(--background);
+ color: var(--foreground);
+ font-family: Arial, Helvetica, sans-serif;
+}
diff --git a/homepage/app/layout.tsx b/homepage/app/layout.tsx
@@ -0,0 +1,48 @@
+import type { Metadata } from "next";
+import { Geist, Geist_Mono } from "next/font/google";
+import { currentHomepage } from "./lib/homepage";
+import Header from "./components/Header";
+import Footer from "./components/Footer";
+import "./globals.css";
+
+const geistSans = Geist({
+ variable: "--font-geist-sans",
+ subsets: ["latin"],
+});
+
+const geistMono = Geist_Mono({
+ variable: "--font-geist-mono",
+ subsets: ["latin"],
+});
+
+export function generateMetadata(): Metadata {
+ const c = currentHomepage();
+ return {
+ title: {
+ default: c.siteTitle,
+ template: `%s — ${c.siteTitle}`,
+ },
+ description: c.siteDescription,
+ };
+}
+
+export default function RootLayout({
+ children,
+}: Readonly<{
+ children: React.ReactNode;
+}>) {
+ return (
+ <html
+ lang="en"
+ className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
+ >
+ <body className="min-h-full flex flex-col bg-zinc-50 text-zinc-900 dark:bg-zinc-950 dark:text-zinc-100 font-sans">
+ <Header />
+ <main className="flex-1 w-full max-w-6xl mx-auto px-4 py-6">
+ {children}
+ </main>
+ <Footer />
+ </body>
+ </html>
+ );
+}
diff --git a/homepage/app/lib/homepage.ts b/homepage/app/lib/homepage.ts
@@ -0,0 +1,13 @@
+import { getPaths } from "yt-dlp-transcript-common/lib/paths";
+import {
+ getHomepageConfig,
+ type HomepageConfig,
+} from "yt-dlp-transcript-common/lib/homepage";
+
+// The hub builds a single, instance-level site (no SITE_ID). Config comes from
+// sites/_homepage/homepage.json; getHomepageConfig() synthesizes neutral
+// defaults when the file is absent, so `next dev`/`next build` work before the
+// hub is configured in the editor.
+export function currentHomepage(): HomepageConfig {
+ return getHomepageConfig(getPaths());
+}
diff --git a/homepage/app/page.tsx b/homepage/app/page.tsx
@@ -0,0 +1,42 @@
+import Link from "next/link";
+import { getPaths } from "yt-dlp-transcript-common/lib/paths";
+import { HOMEPAGE_INDEX_SLUG } from "yt-dlp-transcript-common/lib/homepage";
+import { getHomepagePage } from "yt-dlp-transcript-common/lib/homepagePages";
+import { currentHomepage } from "./lib/homepage";
+import PageBody from "./components/PageBody";
+
+// Home route. Shows a branding hero (title + tagline) followed by the "index"
+// Markdown page body when one is authored; otherwise a minimal default that
+// points at the docs nav and the cross-site stats dashboard.
+export default function Home() {
+ const config = currentHomepage();
+ const index = getHomepagePage(HOMEPAGE_INDEX_SLUG, getPaths());
+ return (
+ <div className="flex flex-col gap-8">
+ <section className="flex flex-col gap-3">
+ <h1 className="text-3xl font-semibold tracking-tight text-zinc-900 dark:text-zinc-100">
+ {config.siteTitle}
+ </h1>
+ {config.homeTagline ? (
+ <p className="text-lg text-zinc-600 dark:text-zinc-400">
+ {config.homeTagline}
+ </p>
+ ) : (
+ <p className="text-lg text-zinc-600 dark:text-zinc-400">
+ {config.siteDescription}
+ </p>
+ )}
+ <div>
+ <Link
+ href="/stats"
+ className="inline-block rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
+ >
+ View cross-site stats
+ </Link>
+ </div>
+ </section>
+
+ {index && <PageBody body={index.body} />}
+ </div>
+ );
+}
diff --git a/homepage/app/stats/HomepageChartsClient.tsx b/homepage/app/stats/HomepageChartsClient.tsx
@@ -0,0 +1,70 @@
+"use client";
+
+import { QueryProvider } from "yt-dlp-transcript-common/components/QueryProvider";
+import { useStats } from "yt-dlp-transcript-common/components/statsCache";
+import { ChartsDashboard } from "yt-dlp-transcript-common/components/charts/ChartsDashboard";
+import {
+ ChannelSitesProvider,
+ type ChannelSitesMap,
+} from "yt-dlp-transcript-common/components/charts/channelSites";
+import {
+ PRESET_GROUPS,
+ type Dashboard,
+} from "yt-dlp-transcript-common/lib/chartConfig";
+
+// The hub has no transcript/search index, so the search-derived gallery group
+// (which needs one) is dropped. Everything else (overview + content-added) works
+// off the whole-pool stats dataset alone.
+const HUB_GALLERY = PRESET_GROUPS.filter((g) => g.id !== "search");
+
+function Inner({ initialDashboard }: { initialDashboard: Dashboard }) {
+ const { stats, ready, loadedPages, pageCount, manifest, error } = useStats();
+
+ if (error) {
+ return (
+ <p className="text-sm text-red-600 dark:text-red-400">
+ Failed to load the stats dataset ({error.message}).
+ </p>
+ );
+ }
+ if (!ready) {
+ return (
+ <p className="text-sm text-zinc-500">
+ Loading stats…{" "}
+ {pageCount > 0 ? `(${loadedPages}/${pageCount} pages)` : ""}
+ </p>
+ );
+ }
+
+ const channels = (manifest?.channels ?? []).map((c) => ({
+ slug: c.slug,
+ name: c.name,
+ }));
+
+ return (
+ <ChartsDashboard
+ initialDashboard={initialDashboard}
+ gallery={HUB_GALLERY}
+ stats={stats}
+ summaries={[]}
+ channels={channels}
+ syncUrl
+ />
+ );
+}
+
+export function HomepageChartsClient({
+ initialDashboard,
+ channelSites,
+}: {
+ initialDashboard: Dashboard;
+ channelSites: ChannelSitesMap;
+}) {
+ return (
+ <QueryProvider>
+ <ChannelSitesProvider value={channelSites}>
+ <Inner initialDashboard={initialDashboard} />
+ </ChannelSitesProvider>
+ </QueryProvider>
+ );
+}
diff --git a/homepage/app/stats/page.tsx b/homepage/app/stats/page.tsx
@@ -0,0 +1,36 @@
+import type { Metadata } from "next";
+import { getPaths } from "yt-dlp-transcript-common/lib/paths";
+import { readHomepageTemplates } from "yt-dlp-transcript-common/lib/chartsStore";
+import { listSites } from "yt-dlp-transcript-common/lib/site";
+import type { ChannelSitesMap } from "yt-dlp-transcript-common/components/charts/channelSites";
+import { HomepageChartsClient } from "./HomepageChartsClient";
+
+export const metadata: Metadata = { title: "Stats" };
+
+// Cross-site charts dashboard. The dataset (whole-pool stats) is fetched
+// client-side from /stats/ (staged by compose-homepage); the channel→sites map
+// for `groupBy: "site"` is computed here at build from the live site configs and
+// passed down to the chart context.
+export default function StatsPage() {
+ const paths = getPaths();
+ const templates = readHomepageTemplates(paths);
+ const channelSites: ChannelSitesMap = {};
+ for (const site of listSites(paths)) {
+ for (const c of site.channels) {
+ (channelSites[c.slug] ??= []).push(site.siteId);
+ }
+ }
+ return (
+ <div className="flex flex-col gap-4">
+ <h1 className="text-2xl font-semibold">Across all sites</h1>
+ <p className="text-sm text-zinc-500">
+ Downloads and transcriptions completed across every content site in this
+ instance.
+ </p>
+ <HomepageChartsClient
+ initialDashboard={templates.defaultDashboard}
+ channelSites={channelSites}
+ />
+ </div>
+ );
+}
diff --git a/homepage/eslint.config.mjs b/homepage/eslint.config.mjs
@@ -0,0 +1,18 @@
+import { defineConfig, globalIgnores } from "eslint/config";
+import nextVitals from "eslint-config-next/core-web-vitals";
+import nextTs from "eslint-config-next/typescript";
+
+const eslintConfig = defineConfig([
+ ...nextVitals,
+ ...nextTs,
+ // Override default ignores of eslint-config-next.
+ globalIgnores([
+ // Default ignores of eslint-config-next:
+ ".next/**",
+ "out/**",
+ "build/**",
+ "next-env.d.ts",
+ ]),
+]);
+
+export default eslintConfig;
diff --git a/homepage/next.config.ts b/homepage/next.config.ts
@@ -0,0 +1,11 @@
+import type { NextConfig } from "next";
+
+const nextConfig: NextConfig = {
+ output: "export",
+ trailingSlash: true,
+ images: { unoptimized: true },
+ staticPageGenerationTimeout: 600, // Increase timeout to 600 seconds
+ transpilePackages: ["yt-dlp-transcript-common"],
+};
+
+export default nextConfig;
diff --git a/homepage/package.json b/homepage/package.json
@@ -0,0 +1,39 @@
+{
+ "name": "homepage",
+ "version": "0.1.0",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "dev": "next dev --port ${HOMEPAGE_DEV_PORT:-3030}",
+ "build:index": "NODE_OPTIONS=--max-old-space-size=8192 tsx ../common/bin/build-index.ts",
+ "compose": "tsx ../common/bin/compose-homepage.ts",
+ "prebuild": "pnpm run build:index",
+ "build": "pnpm run compose && next build",
+ "start": "serve out -l ${HOMEPAGE_PORT:-3031}",
+ "lint": "eslint",
+ "e2e": "playwright test",
+ "e2e:ui": "playwright test --ui",
+ "deploy": "pnpm dlx wrangler pages deploy out"
+ },
+ "dependencies": {
+ "@tanstack/react-query": "^5.99.1",
+ "markdown-to-jsx": "^7.7.4",
+ "next": "16.2.3",
+ "react": "19.2.4",
+ "react-dom": "19.2.4",
+ "serve": "^14.2.6",
+ "yt-dlp-transcript-common": "workspace:*"
+ },
+ "devDependencies": {
+ "@playwright/test": "^1.59.1",
+ "@tailwindcss/postcss": "^4.2.2",
+ "@types/node": "^20.19.39",
+ "@types/react": "^19.2.14",
+ "@types/react-dom": "^19.2.3",
+ "eslint": "^9.39.4",
+ "eslint-config-next": "16.2.3",
+ "tailwindcss": "^4.2.2",
+ "tsx": "^4.21.0",
+ "typescript": "^5.9.3"
+ }
+}
diff --git a/homepage/postcss.config.mjs b/homepage/postcss.config.mjs
@@ -0,0 +1,7 @@
+const config = {
+ plugins: {
+ "@tailwindcss/postcss": {},
+ },
+};
+
+export default config;
diff --git a/homepage/serve.json b/homepage/serve.json
@@ -0,0 +1,13 @@
+{
+ "headers": [
+ {
+ "source": "**/*.json",
+ "headers": [
+ {
+ "key": "Cache-Control",
+ "value": "public, max-age=3600"
+ }
+ ]
+ }
+ ]
+}
diff --git a/homepage/tsconfig.json b/homepage/tsconfig.json
@@ -0,0 +1,20 @@
+{
+ "extends": "../tsconfig.base.json",
+ "compilerOptions": {
+ "jsx": "preserve",
+ "plugins": [
+ {
+ "name": "next"
+ }
+ ]
+ },
+ "include": [
+ "next-env.d.ts",
+ "**/*.ts",
+ "**/*.tsx",
+ ".next/types/**/*.ts",
+ ".next/dev/types/**/*.ts",
+ "**/*.mts"
+ ],
+ "exclude": ["node_modules"]
+}
diff --git a/package.json b/package.json
@@ -7,10 +7,14 @@
"build:index": "pnpm --filter yt-dlp-transcript-common exec tsx bin/build-index.ts",
"sync:tick": "pnpm --filter yt-dlp-transcript-common exec tsx bin/sync-tick.ts",
"build:export": "pnpm --filter export run build",
+ "build:homepage": "pnpm --filter homepage run build",
"build": "pnpm --filter export run build",
"start:export": "node scripts/worktree.mjs run -- pnpm --filter export run start",
+ "start:homepage": "node scripts/worktree.mjs run -- pnpm --filter homepage run start",
"dev:editor": "node scripts/worktree.mjs run -- pnpm --filter editor run dev",
"dev:export": "node scripts/worktree.mjs run -- pnpm --filter export run dev",
+ "dev:homepage": "node scripts/worktree.mjs run -- pnpm --filter homepage run dev",
+ "deploy:homepage": "pnpm --filter homepage run deploy",
"e2e": "node scripts/worktree.mjs run -- pnpm --filter editor run e2e",
"wt": "node scripts/worktree.mjs",
"e2e:sharded": "docker build -f Dockerfile.test -t yt-dlp-transcript-browser-e2e . && node scripts/run-sharded-e2e.mjs",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
@@ -87,6 +87,9 @@ importers:
'@sindresorhus/slugify':
specifier: ^3.0.0
version: 3.0.0
+ markdown-to-jsx:
+ specifier: ^7.7.4
+ version: 7.7.17(react@19.2.4)
next:
specifier: 16.2.3
version: 16.2.3(@babel/core@7.29.0)(@playwright/test@1.59.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
@@ -183,6 +186,61 @@ importers:
specifier: ^5.9.3
version: 5.9.3
+ homepage:
+ dependencies:
+ '@tanstack/react-query':
+ specifier: ^5.99.1
+ version: 5.100.5(react@19.2.4)
+ markdown-to-jsx:
+ specifier: ^7.7.4
+ version: 7.7.17(react@19.2.4)
+ next:
+ specifier: 16.2.3
+ version: 16.2.3(@babel/core@7.29.0)(@playwright/test@1.59.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
+ react:
+ specifier: 19.2.4
+ version: 19.2.4
+ react-dom:
+ specifier: 19.2.4
+ version: 19.2.4(react@19.2.4)
+ serve:
+ specifier: ^14.2.6
+ version: 14.2.6
+ yt-dlp-transcript-common:
+ specifier: workspace:*
+ version: link:../common
+ devDependencies:
+ '@playwright/test':
+ specifier: ^1.59.1
+ version: 1.59.1
+ '@tailwindcss/postcss':
+ specifier: ^4.2.2
+ version: 4.2.4
+ '@types/node':
+ specifier: ^20.19.39
+ version: 20.19.39
+ '@types/react':
+ specifier: ^19.2.14
+ version: 19.2.14
+ '@types/react-dom':
+ specifier: ^19.2.3
+ version: 19.2.3(@types/react@19.2.14)
+ eslint:
+ specifier: ^9.39.4
+ version: 9.39.4(jiti@2.6.1)
+ eslint-config-next:
+ specifier: 16.2.3
+ version: 16.2.3(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)
+ tailwindcss:
+ specifier: ^4.2.2
+ version: 4.2.4
+ tsx:
+ specifier: ^4.21.0
+ version: 4.21.0
+ typescript:
+ specifier: ^5.9.3
+ version: 5.9.3
+
packages:
'@alloc/quick-lru@5.2.0':
@@ -2528,6 +2586,7 @@ packages:
recharts@2.15.4:
resolution: {integrity: sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw==}
engines: {node: '>=14'}
+ deprecated: 1.x and 2.x branches are no longer active. Bump to Recharts v3 to receive latest features and bugfixes. See https://github.com/recharts/recharts/wiki/3.0-migration-guide
peerDependencies:
react: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
@@ -2,6 +2,7 @@ packages:
- common
- editor
- export
+ - homepage
allowBuilds:
esbuild: true
diff --git a/scripts/worktree.mjs b/scripts/worktree.mjs
@@ -18,6 +18,9 @@ const PORT_BASES = {
EXPORT_PORT: 3010, // export server launched by editor e2e
EXPORT_DEV_PORT: 3000, // export real dev
EXPORT_E2E_PORT: 3020, // export's own Playwright suite
+ HOMEPAGE_DEV_PORT: 3030, // homepage (hub) real dev
+ HOMEPAGE_PORT: 3031, // homepage static `serve out` (start:homepage)
+ HOMEPAGE_E2E_PORT: 3040, // homepage's own Playwright suite
};
const OFFSET_STEP = 100;