Archilyzer · Source

archilyzer

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

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:
Mcommon/lib/changelog.ts | 67+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Meditor/CHANGELOG.md | 6++++++
Meditor/app/changelog/page.tsx | 30++++++++++++++++++++++++++++--
Aeditor/app/deploy/components/CutReleaseForm.tsx | 80+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aeditor/app/deploy/components/DeployButton.tsx | 17+++++++++++++++++
Aeditor/app/deploy/cutReleaseAction.ts | 74++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aeditor/app/deploy/deployAction.ts | 27+++++++++++++++++++++++++++
Aeditor/app/deploy/page.tsx | 106+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Meditor/app/globals.css | 2+-
Meditor/app/layout.tsx | 1+
Meditor/app/page.tsx | 28+++++++++++++++-------------
Mexport/package.json | 3++-
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",