commit 6f589af28d110b17ec1682df3e276dfdfcad925c
parent 8e0faacf63276a0d4cab03223f2486581ef9a73f
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Tue, 19 May 2026 23:07:59 -0400
Release/deploy UI
Diffstat:
12 files changed, 424 insertions(+), 17 deletions(-)
diff --git a/common/lib/changelog.ts b/common/lib/changelog.ts
@@ -4,6 +4,10 @@ const DATED_HEADING = /^##\s+[^\n]*?(\d{4}-\d{2}-\d{2})/m;
const ANY_H2 = /^##\s+[^\n]+$/gm;
+const SEMVER_VERSION = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/;
+const RELEASED_HEADING = /^##\s+\[(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)\]/m;
+const UNRELEASED_HEADING = /^##\s+\[Unreleased\]\s*$/im;
+
export function getLatestChangelogDate(source: string): string | null {
const match = source.match(DATED_HEADING);
return match ? match[1] : null;
@@ -18,3 +22,66 @@ export function sliceTopEntries(source: string, limit: number): string {
if (indexes.length <= limit) return source;
return source.slice(0, indexes[limit]).trimEnd() + "\n";
}
+
+export function getLatestReleasedVersion(source: string): string | null {
+ const match = source.match(RELEASED_HEADING);
+ return match ? match[1] : null;
+}
+
+export function suggestNextVersion(current: string | null): string {
+ if (!current) return "0.1.0";
+ const core = current.split("-")[0];
+ const parts = core.split(".").map((p) => Number.parseInt(p, 10));
+ if (parts.length !== 3 || parts.some(Number.isNaN)) return "0.1.0";
+ const [major, minor, patch] = parts;
+ return `${major}.${minor}.${patch + 1}`;
+}
+
+export function extractUnreleasedSection(source: string): string {
+ const start = source.search(UNRELEASED_HEADING);
+ if (start === -1) return "";
+ const afterHeading = source.indexOf("\n", start);
+ if (afterHeading === -1) return "";
+ const rest = source.slice(afterHeading + 1);
+ const next = rest.search(/^##\s+/m);
+ const body = next === -1 ? rest : rest.slice(0, next);
+ return body.trim();
+}
+
+export class CutReleaseError extends Error {
+ constructor(message: string) {
+ super(message);
+ this.name = "CutReleaseError";
+ }
+}
+
+export function cutRelease(
+ source: string,
+ version: string,
+ dateISO: string,
+): string {
+ if (!SEMVER_VERSION.test(version)) {
+ throw new CutReleaseError(
+ `Version "${version}" is not a valid semver (expected X.Y.Z or X.Y.Z-prerelease).`,
+ );
+ }
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(dateISO)) {
+ throw new CutReleaseError(`Date "${dateISO}" is not in YYYY-MM-DD form.`);
+ }
+ const match = UNRELEASED_HEADING.exec(source);
+ if (!match) {
+ throw new CutReleaseError(
+ "Could not find a `## [Unreleased]` heading to cut from.",
+ );
+ }
+ const unreleased = extractUnreleasedSection(source);
+ if (!unreleased) {
+ throw new CutReleaseError("Nothing pending to release under [Unreleased].");
+ }
+ const headingStart = match.index;
+ const headingEnd = headingStart + match[0].length;
+ const before = source.slice(0, headingStart);
+ const after = source.slice(headingEnd);
+ const replacement = `## [Unreleased]\n\n## [${version}] - ${dateISO}`;
+ return `${before}${replacement}${after}`;
+}
diff --git a/editor/CHANGELOG.md b/editor/CHANGELOG.md
@@ -3,6 +3,8 @@
## [Unreleased]
### Added
+- New `/deploy` page that previews `export/CHANGELOG.md` pending changes, offers a "Cut release" form (turns `## [Unreleased]` into a dated semver heading) and a "Deploy" button (runs `pnpm run deploy` in `export/` with live log streaming).
+- "Cut release" form at the bottom of the `/changelog` page for the editor's own changelog.
- New `/changelog` page that renders the editor's `CHANGELOG.md`. Linked from the sidebar nav.
- "Recent changes" card on the dashboard showing the most recent changelog entry, with a "View all" link to the full changelog.
- Subtle blue dot on the sidebar **Changelog** nav item when a newer dated entry is detected and hasn't been viewed yet. The state is per-browser (localStorage) and clears when the changelog page is opened.
@@ -11,4 +13,8 @@
- Sortable column headers on the `/channels` table. Clicking any column sorts client-side; clicking the active column flips direction. Default rendering is unsorted (input order preserved); sort state does not persist across navigation.
### Changed
+- "Recent changes" card on the dashboard now clamps to a fixed height with a gradient fade at the bottom; the entire card is a link to `/changelog`.
- `/channels` rows are now dimmed when a channel is excluded from Sync all, matching the existing treatment for build exclusion.
+
+### Fixed
+- Editor's `/changelog` page no longer renders unstyled. The `@source` directive in `editor/app/globals.css` pointed at a non-existent path, so Tailwind never scanned the shared `<Changelog>` component for class usage.
diff --git a/editor/app/changelog/page.tsx b/editor/app/changelog/page.tsx
@@ -2,9 +2,16 @@ import { readFileSync } from "node:fs";
import path from "node:path";
import type { Metadata } from "next";
import { Changelog } from "yt-dlp-transcript-common/components/Changelog";
-import { getLatestChangelogDate } from "yt-dlp-transcript-common/lib/changelog";
+import {
+ extractUnreleasedSection,
+ getLatestChangelogDate,
+ getLatestReleasedVersion,
+ suggestNextVersion,
+} from "yt-dlp-transcript-common/lib/changelog";
import { MarkChangelogSeen } from "../components/MarkChangelogSeen";
+import { CutReleaseForm } from "../deploy/components/CutReleaseForm";
+export const dynamic = "force-dynamic";
export const metadata: Metadata = { title: "Changelog" };
function loadChangelog(): string {
@@ -17,10 +24,29 @@ function loadChangelog(): string {
export default function ChangelogPage() {
const source = loadChangelog();
const latestDate = getLatestChangelogDate(source);
+ const lastVersion = getLatestReleasedVersion(source);
+ const suggestedVersion = suggestNextVersion(lastVersion);
+ const unreleased = extractUnreleasedSection(source);
+ const hasUnreleased = unreleased.length > 0;
return (
- <div className="flex flex-col gap-4 max-w-3xl">
+ <div className="flex flex-col gap-6 max-w-3xl">
<h1 className="text-2xl font-semibold">Changelog</h1>
<Changelog source={source} />
+ <section className="flex flex-col gap-3 border-t border-zinc-200 dark:border-zinc-800 pt-6">
+ <div>
+ <h2 className="text-lg font-semibold">Cut release</h2>
+ <p className="text-sm text-zinc-500">
+ Turns the current <code>## [Unreleased]</code> heading into a
+ dated semver heading.
+ </p>
+ </div>
+ <CutReleaseForm
+ workspace="editor"
+ lastVersion={lastVersion}
+ suggestedVersion={suggestedVersion}
+ hasUnreleased={hasUnreleased}
+ />
+ </section>
<MarkChangelogSeen latestDate={latestDate} />
</div>
);
diff --git a/editor/app/deploy/components/CutReleaseForm.tsx b/editor/app/deploy/components/CutReleaseForm.tsx
@@ -0,0 +1,80 @@
+"use client";
+
+import { useActionState } from "react";
+import {
+ cutReleaseAction,
+ type CutReleaseState,
+} from "../cutReleaseAction";
+
+type Props = {
+ workspace: "editor" | "export";
+ lastVersion: string | null;
+ suggestedVersion: string;
+ hasUnreleased: boolean;
+};
+
+export function CutReleaseForm({
+ workspace,
+ lastVersion,
+ suggestedVersion,
+ hasUnreleased,
+}: Props) {
+ const [state, formAction, pending] = useActionState<
+ CutReleaseState | undefined,
+ FormData
+ >(cutReleaseAction, undefined);
+
+ return (
+ <form action={formAction} className="flex flex-col gap-2">
+ <div className="text-sm text-zinc-500">
+ {lastVersion
+ ? `Last released: ${lastVersion}`
+ : "No releases yet."}
+ </div>
+ <div className="flex flex-wrap items-center gap-2">
+ <input type="hidden" name="workspace" value={workspace} />
+ <label className="sr-only" htmlFor={`version-${workspace}`}>
+ New version
+ </label>
+ <input
+ id={`version-${workspace}`}
+ name="version"
+ type="text"
+ placeholder={suggestedVersion}
+ defaultValue={suggestedVersion}
+ disabled={!hasUnreleased || pending}
+ className="w-40 px-3 py-2 rounded-md border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-900 text-sm font-mono"
+ aria-describedby={`hint-${workspace}`}
+ />
+ <button
+ type="submit"
+ disabled={!hasUnreleased || pending}
+ className="px-3 py-2 rounded-md text-sm bg-zinc-900 text-zinc-50 dark:bg-zinc-100 dark:text-zinc-900 hover:opacity-90 disabled:opacity-40 disabled:cursor-not-allowed"
+ >
+ {pending ? "Cutting…" : "Cut release"}
+ </button>
+ </div>
+ <div id={`hint-${workspace}`} className="text-xs text-zinc-500">
+ {hasUnreleased
+ ? "Date stamps to today automatically."
+ : "Nothing pending under [Unreleased] yet."}
+ </div>
+ {state && !state.ok && (
+ <div
+ role="alert"
+ className="text-sm text-rose-700 dark:text-rose-300 bg-rose-50 dark:bg-rose-950/40 border border-rose-200 dark:border-rose-900 rounded-md px-3 py-2"
+ >
+ {state.error}
+ </div>
+ )}
+ {state && state.ok && (
+ <div
+ role="status"
+ className="text-sm text-emerald-700 dark:text-emerald-300"
+ >
+ Cut release {state.version}.
+ </div>
+ )}
+ </form>
+ );
+}
diff --git a/editor/app/deploy/components/DeployButton.tsx b/editor/app/deploy/components/DeployButton.tsx
@@ -0,0 +1,17 @@
+"use client";
+
+import { StreamActionLog } from "yt-dlp-transcript-common/components/StreamActionLog";
+import { cancelJobAction } from "../../jobs/actions";
+import { deployExportAction } from "../deployAction";
+
+export function DeployButton() {
+ return (
+ <StreamActionLog
+ trigger={() => deployExportAction()}
+ cancelAction={cancelJobAction}
+ buttonLabel="Deploy"
+ runningLabel="Deploying…"
+ label="Deploy export"
+ />
+ );
+}
diff --git a/editor/app/deploy/cutReleaseAction.ts b/editor/app/deploy/cutReleaseAction.ts
@@ -0,0 +1,74 @@
+"use server";
+
+import fs from "node:fs";
+import path from "node:path";
+import { revalidatePath } from "next/cache";
+import { getPaths } from "yt-dlp-transcript-common/lib/paths";
+import {
+ cutRelease,
+ CutReleaseError,
+} from "yt-dlp-transcript-common/lib/changelog";
+
+export type CutReleaseState =
+ | { ok: true; version: string }
+ | { ok: false; error: string };
+
+function changelogPathFor(workspace: "editor" | "export"): string {
+ const paths = getPaths();
+ if (workspace === "export") return path.join(paths.exportDir, "CHANGELOG.md");
+ return path.join(paths.monorepoRoot, "editor", "CHANGELOG.md");
+}
+
+function todayISO(): string {
+ const now = new Date();
+ const y = now.getFullYear().toString().padStart(4, "0");
+ const m = (now.getMonth() + 1).toString().padStart(2, "0");
+ const d = now.getDate().toString().padStart(2, "0");
+ return `${y}-${m}-${d}`;
+}
+
+export async function cutReleaseAction(
+ _prev: CutReleaseState | undefined,
+ formData: FormData,
+): Promise<CutReleaseState> {
+ const workspaceRaw = formData.get("workspace");
+ const versionRaw = formData.get("version");
+ const workspace =
+ workspaceRaw === "editor" || workspaceRaw === "export"
+ ? workspaceRaw
+ : null;
+ const version =
+ typeof versionRaw === "string" ? versionRaw.trim() : "";
+ if (!workspace) {
+ return { ok: false, error: "Invalid workspace." };
+ }
+ if (!version) {
+ return { ok: false, error: "Version is required." };
+ }
+ const filePath = changelogPathFor(workspace);
+ let source: string;
+ try {
+ source = await fs.promises.readFile(filePath, "utf8");
+ } catch (err) {
+ return {
+ ok: false,
+ error: `Could not read ${filePath}: ${(err as Error).message}`,
+ };
+ }
+ let next: string;
+ try {
+ next = cutRelease(source, version, todayISO());
+ } catch (err) {
+ if (err instanceof CutReleaseError) {
+ return { ok: false, error: err.message };
+ }
+ throw err;
+ }
+ const tmp = `${filePath}.tmp-${process.pid}`;
+ await fs.promises.writeFile(tmp, next);
+ await fs.promises.rename(tmp, filePath);
+ revalidatePath("/");
+ revalidatePath("/changelog");
+ revalidatePath("/deploy");
+ return { ok: true, version };
+}
diff --git a/editor/app/deploy/deployAction.ts b/editor/app/deploy/deployAction.ts
@@ -0,0 +1,27 @@
+"use server";
+
+import { getPaths } from "yt-dlp-transcript-common/lib/paths";
+import {
+ runManagedCommand,
+ type StreamActionResult,
+} from "yt-dlp-transcript-common/jobs/streamCommand";
+
+const DEPLOY_QUEUE = "deploy";
+
+export async function deployExportAction(): Promise<StreamActionResult> {
+ const paths = getPaths();
+ return runManagedCommand({
+ kind: "deploy-export",
+ queueKey: DEPLOY_QUEUE,
+ paths,
+ cwd: paths.exportDir,
+ command: "pnpm",
+ args: ["run", "deploy"],
+ env: {
+ ...process.env,
+ NODE_ENV: "production",
+ TRANSCRIPTS_DIR: paths.transcriptsDir,
+ EXPORT_PUBLIC_DIR: paths.exportPublicDir,
+ },
+ });
+}
diff --git a/editor/app/deploy/page.tsx b/editor/app/deploy/page.tsx
@@ -0,0 +1,106 @@
+import { readFileSync, statSync } from "node:fs";
+import path from "node:path";
+import type { Metadata } from "next";
+import { Changelog } from "yt-dlp-transcript-common/components/Changelog";
+import {
+ extractUnreleasedSection,
+ getLatestReleasedVersion,
+ suggestNextVersion,
+} from "yt-dlp-transcript-common/lib/changelog";
+import { getPaths } from "yt-dlp-transcript-common/lib/paths";
+import { CutReleaseForm } from "./components/CutReleaseForm";
+import { DeployButton } from "./components/DeployButton";
+
+export const dynamic = "force-dynamic";
+export const metadata: Metadata = { title: "Deploy" };
+
+function loadExportChangelog(): string | null {
+ try {
+ return readFileSync(
+ path.join(getPaths().exportDir, "CHANGELOG.md"),
+ "utf8",
+ );
+ } catch {
+ return null;
+ }
+}
+
+function getLastDeployedAt(): string | null {
+ try {
+ const indexPath = path.join(getPaths().exportDir, "out", "index.html");
+ return statSync(indexPath).mtime.toISOString();
+ } catch {
+ return null;
+ }
+}
+
+export default function DeployPage() {
+ const source = loadExportChangelog();
+ const lastVersion = source ? getLatestReleasedVersion(source) : null;
+ const suggestedVersion = suggestNextVersion(lastVersion);
+ const unreleased = source ? extractUnreleasedSection(source) : "";
+ const hasUnreleased = unreleased.length > 0;
+ const lastDeployedAt = getLastDeployedAt();
+
+ return (
+ <div className="flex flex-col gap-8 max-w-3xl">
+ <h1 className="text-2xl font-semibold">Deploy</h1>
+
+ <section className="flex flex-col gap-3">
+ <div>
+ <h2 className="text-lg font-semibold">Pending export changes</h2>
+ <p className="text-sm text-zinc-500">
+ Bullets currently sitting under <code>## [Unreleased]</code> in{" "}
+ <code>export/CHANGELOG.md</code>. These become the body of the
+ next cut release.
+ </p>
+ </div>
+ {hasUnreleased && source ? (
+ <div className="rounded-md border border-zinc-200 dark:border-zinc-800 bg-white dark:bg-zinc-900 px-4 py-3">
+ <Changelog source={source} limit={1} />
+ </div>
+ ) : (
+ <div className="text-sm italic text-zinc-500">
+ No pending changes yet.
+ </div>
+ )}
+ </section>
+
+ <section className="flex flex-col gap-3 border-t border-zinc-200 dark:border-zinc-800 pt-6">
+ <div>
+ <h2 className="text-lg font-semibold">Cut release</h2>
+ <p className="text-sm text-zinc-500">
+ Turns the current <code>## [Unreleased]</code> heading into a
+ dated semver heading and inserts a fresh empty{" "}
+ <code>## [Unreleased]</code> above it.
+ </p>
+ </div>
+ <CutReleaseForm
+ workspace="export"
+ lastVersion={lastVersion}
+ suggestedVersion={suggestedVersion}
+ hasUnreleased={hasUnreleased}
+ />
+ </section>
+
+ <section className="flex flex-col gap-3 border-t border-zinc-200 dark:border-zinc-800 pt-6">
+ <div>
+ <h2 className="text-lg font-semibold">Deploy static export</h2>
+ <p className="text-sm text-zinc-500">
+ Runs <code>pnpm run deploy</code> in <code>export/</code>,
+ publishing the most recently built static site at{" "}
+ <code>export/out/</code>. Build first if needed (
+ <code>Build → Build static export</code>).
+ </p>
+ <p className="text-sm text-zinc-500 mt-1">
+ Last deployed:{" "}
+ {lastDeployedAt
+ ? new Date(lastDeployedAt).toLocaleString()
+ : "never"}
+ </p>
+ </div>
+ <DeployButton />
+ </section>
+ </div>
+ );
+}
diff --git a/editor/app/globals.css b/editor/app/globals.css
@@ -1,5 +1,5 @@
@import "tailwindcss";
-@source "../../node_modules/common/components";
+@source "../../common/components";
:root {
--background: #ffffff;
diff --git a/editor/app/layout.tsx b/editor/app/layout.tsx
@@ -34,6 +34,7 @@ const NAV_LINKS: NavLink[] = [
{ href: "/jobs", label: "Jobs", badgeKey: "jobs" },
{ href: "/jobs/active", label: "Active", badgeKey: "jobs" },
{ href: "/build", label: "Build" },
+ { href: "/deploy", label: "Deploy" },
{ href: "/settings", label: "Settings" },
{ href: "/changelog", label: "Changelog", badgeKey: "changelog" },
];
diff --git a/editor/app/page.tsx b/editor/app/page.tsx
@@ -71,20 +71,22 @@ export default async function Dashboard() {
</dl>
{changelogSource && (
- <section className="rounded-md border border-zinc-200 dark:border-zinc-800 bg-white dark:bg-zinc-900 px-4 py-3">
- <div className="flex items-baseline justify-between gap-3 mb-1">
- <h2 className="text-xs uppercase tracking-wide text-zinc-500">
- Recent changes
- </h2>
- <Link
- href="/changelog"
- className="text-xs text-zinc-500 underline hover:text-zinc-900 dark:hover:text-zinc-100"
- >
- View all →
- </Link>
+ <Link
+ href="/changelog"
+ aria-label="View full changelog"
+ className="block rounded-md border border-zinc-200 dark:border-zinc-800 bg-white dark:bg-zinc-900 px-4 py-3 hover:border-zinc-400 dark:hover:border-zinc-600"
+ >
+ <h2 className="text-xs uppercase tracking-wide text-zinc-500 mb-1">
+ Recent changes
+ </h2>
+ <div className="relative max-h-48 overflow-hidden">
+ <Changelog source={changelogSource} limit={1} />
+ <div
+ aria-hidden="true"
+ className="pointer-events-none absolute inset-x-0 bottom-0 h-16 bg-gradient-to-t from-white dark:from-zinc-900 to-transparent"
+ />
</div>
- <Changelog source={changelogSource} limit={1} />
- </section>
+ </Link>
)}
{channels.length > 0 && (
diff --git a/export/package.json b/export/package.json
@@ -11,7 +11,8 @@
"start": "serve out",
"lint": "eslint",
"e2e": "playwright test",
- "e2e:ui": "playwright test --ui"
+ "e2e:ui": "playwright test --ui",
+ "deploy": "pnpm dlx wrangler pages deploy out"
},
"dependencies": {
"@tanstack/react-query": "^5.99.1",