Archilyzer · Source

archilyzer

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

commit 7ffb4d45f2335320dacbd9f75761d9adb424169f
parent 1fde61eb37430c87ac858c99408edec91d8c757c
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Mon, 22 Jun 2026 19:58:03 -0400

Merge feat/homepage-hub: Archilyzer homepage hub (SSG)

Diffstat:
M.gitignore | 5+++++
Acommon/bin/compose-homepage.ts | 70++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcommon/components/charts/ChartCard.tsx | 9+++++++--
Mcommon/components/charts/ChartConfigEditor.tsx | 1+
Acommon/components/charts/channelSites.tsx | 31+++++++++++++++++++++++++++++++
Mcommon/controller/buildStats.ts | 0
Mcommon/lib/chartAggregate.ts | 60++++++++++++++++++++++++++++++++++++++++++++++++------------
Mcommon/lib/chartConfig.ts | 62+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mcommon/lib/chartShare.ts | 1+
Mcommon/lib/chartsStore.ts | 38++++++++++++++++++++++++++++++++++++++
Acommon/lib/homepage.ts | 175+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acommon/lib/homepageConstants.ts | 23+++++++++++++++++++++++
Acommon/lib/homepagePages.ts | 124+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcommon/lib/paths.ts | 17++++++++++++++++-
Meditor/CHANGELOG.md | 1+
Aeditor/app/homepage/HomepageConfigForm.tsx | 85+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aeditor/app/homepage/PageEditor.tsx | 157+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aeditor/app/homepage/actions.ts | 120+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aeditor/app/homepage/page.tsx | 68++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aeditor/app/homepage/pages/[slug]/page.tsx | 41+++++++++++++++++++++++++++++++++++++++++
Aeditor/app/homepage/pages/new/page.tsx | 21+++++++++++++++++++++
Meditor/app/layout.tsx | 1+
Meditor/package.json | 1+
Ahomepage/CHANGELOG.md | 12++++++++++++
Ahomepage/app/[slug]/page.tsx | 49+++++++++++++++++++++++++++++++++++++++++++++++++
Ahomepage/app/components/Footer.tsx | 35+++++++++++++++++++++++++++++++++++
Ahomepage/app/components/Header.tsx | 51+++++++++++++++++++++++++++++++++++++++++++++++++++
Ahomepage/app/components/PageBody.tsx | 85+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ahomepage/app/globals.css | 53+++++++++++++++++++++++++++++++++++++++++++++++++++++
Ahomepage/app/layout.tsx | 48++++++++++++++++++++++++++++++++++++++++++++++++
Ahomepage/app/lib/homepage.ts | 13+++++++++++++
Ahomepage/app/page.tsx | 42++++++++++++++++++++++++++++++++++++++++++
Ahomepage/app/stats/HomepageChartsClient.tsx | 70++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ahomepage/app/stats/page.tsx | 36++++++++++++++++++++++++++++++++++++
Ahomepage/eslint.config.mjs | 18++++++++++++++++++
Ahomepage/next.config.ts | 11+++++++++++
Ahomepage/package.json | 39+++++++++++++++++++++++++++++++++++++++
Ahomepage/postcss.config.mjs | 7+++++++
Ahomepage/serve.json | 13+++++++++++++
Ahomepage/tsconfig.json | 20++++++++++++++++++++
Mpackage.json | 4++++
Mpnpm-lock.yaml | 59+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpnpm-workspace.yaml | 1+
Mscripts/worktree.mjs | 3+++
44 files changed, 1764 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,175 @@ +import fs from "node:fs"; +import { getPaths, type Paths } from "./paths"; +import { + getSettings, + normalizeSocialSvg, + parseSocialLinks, + type SiteSettings, + type SocialLink, +} from "./settings"; +import { + isValidHomepageSlug, + type HomepageNavItem, +} from "./homepageConstants"; + +// Re-export the pure constants/types so server callers can keep importing them +// from "lib/homepage"; client components must import from "lib/homepageConstants" +// directly to stay fs-free. +export { + HOMEPAGE_INDEX_SLUG, + isValidHomepageSlug, + type HomepageNavItem, +} from "./homepageConstants"; + +// 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. + +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; +}; + +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/homepageConstants.ts b/common/lib/homepageConstants.ts @@ -0,0 +1,23 @@ +// Pure (fs-free) homepage constants, types, and validators — safe to import from +// CLIENT components. The filesystem-backed config/page IO lives in homepage.ts +// and homepagePages.ts (server only); importing those into a client bundle would +// drag `node:fs` in and break the Turbopack client build. + +// The canonical home page slug: its body renders at "/", never at "/<slug>". +export const HOMEPAGE_INDEX_SLUG = "index"; + +// Page/nav slug grammar: a lowercase slug, safe as a file and URL segment. +// Mirrors SITE_ID_RE in site.ts (kept inline here so this module pulls no fs). +const HOMEPAGE_SLUG_RE = /^[a-z0-9][a-z0-9-]*$/; + +export function isValidHomepageSlug(slug: unknown): slug is string { + return typeof slug === "string" && HOMEPAGE_SLUG_RE.test(slug); +} + +// One nav/page entry. The Markdown body is stored separately (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; +}; diff --git a/common/lib/homepagePages.ts b/common/lib/homepagePages.ts @@ -0,0 +1,124 @@ +import fs from "node:fs"; +import path from "node:path"; +import { getPaths, type Paths } from "./paths"; +import { HOMEPAGE_INDEX_SLUG, 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; +}; + +// Built-in fallback doc. `output: "export"` rejects the `[slug]` dynamic route +// when generateStaticParams() is empty, which happens for a hub with no docs +// pages OR one with only a home (`index`) page. To keep the route valid we +// always build at least this one doc; it's superseded the moment any real non- +// index page is authored. See homepageDocSlugs / getHomepageDoc below. +export const DEFAULT_HOMEPAGE_DOC: HomepagePage = { + slug: "about", + body: + "# About\n\nThis is the Archilyzer hub. Edit this page or add new ones in the " + + "editor under **Manage → Homepage**.\n", +}; + +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); +} + +// Real, authored non-index doc slugs (the ones that get a /<slug> route). +function realDocSlugs(paths: Paths): string[] { + return listHomepagePageSlugs(paths).filter((s) => s !== HOMEPAGE_INDEX_SLUG); +} + +// Doc slugs to route/build: the authored non-index pages, or the single built-in +// fallback when there are none (so the static `[slug]` route always has ≥1 +// param). Use for generateStaticParams and the hub nav. +export function homepageDocSlugs(paths: Paths = getPaths()): string[] { + const real = realDocSlugs(paths); + return real.length ? real : [DEFAULT_HOMEPAGE_DOC.slug]; +} + +// Resolve a doc for rendering: the authored file if present, else the built-in +// fallback when its slug is requested and no real docs exist. Null otherwise. +export function getHomepageDoc( + slug: string, + paths: Paths = getPaths(), +): HomepagePage | null { + const real = getHomepagePage(slug, paths); + if (real) return real; + if (slug === DEFAULT_HOMEPAGE_DOC.slug && realDocSlugs(paths).length === 0) { + return DEFAULT_HOMEPAGE_DOC; + } + return 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/homepageConstants"; +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,49 @@ +import type { Metadata } from "next"; +import { notFound } from "next/navigation"; +import { getPaths } from "yt-dlp-transcript-common/lib/paths"; +import { + getHomepageDoc, + homepageDocSlugs, +} 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. homepageDocSlugs() always returns ≥1 slug (a built-in +// fallback when no docs are authored) so the export build never sees an empty +// dynamic route. +export const dynamicParams = false; + +export function generateStaticParams(): { slug: string }[] { + return homepageDocSlugs(getPaths()).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; + const page = getHomepageDoc(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,51 @@ +import Link from "next/link"; +import { getPaths } from "yt-dlp-transcript-common/lib/paths"; +import { homepageDocSlugs } from "yt-dlp-transcript-common/lib/homepagePages"; +import { currentHomepage } from "../lib/homepage"; + +function titleCase(slug: string): string { + return slug.replace(/-/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()); +} + +// Top nav for the hub. The header title links home; doc links come from the +// built doc routes (homepageDocSlugs — the same set the [slug] route builds, +// including the built-in fallback when nothing is authored), labeled via the +// config nav, with a trailing always-on "Stats" link to the cross-site board. +export default function Header() { + const config = currentHomepage(); + const labelFor = new Map(config.nav.map((n) => [n.slug, n.label])); + const links = homepageDocSlugs(getPaths()).map((slug) => ({ + slug, + label: labelFor.get(slug) ?? titleCase(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;