diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a09078b..e7babb6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,6 +21,19 @@ jobs: - run: npm test - run: npm run build + docs-drift: + name: Docs drift check + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: actions/setup-node@v4 + with: + node-version: "20" + - run: node scripts/gen-docs-manifest.mjs + - run: node scripts/check-docs-drift.mjs + python: name: Python tests runs-on: ubuntu-latest diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 914970a..c8bdf91 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -142,6 +142,8 @@ jobs: runs-on: ubuntu-latest permissions: contents: write + env: + LANDING_DISPATCH_TOKEN: ${{ secrets.LANDING_DISPATCH_TOKEN }} steps: - uses: actions/download-artifact@v4 with: @@ -156,3 +158,11 @@ jobs: with: files: dist/* generate_release_notes: true + - name: Rebuild the landing site so the changelog picks up this release + if: ${{ env.LANDING_DISPATCH_TOKEN != '' }} + run: | + curl -sf -X POST \ + -H "Authorization: Bearer $LANDING_DISPATCH_TOKEN" \ + -H "Accept: application/vnd.github+json" \ + https://api.github.com/repos/nmbrthirteen/podcli-landing/dispatches \ + -d '{"event_type":"release"}' diff --git a/.gitignore b/.gitignore index 30bc139..6312001 100644 --- a/.gitignore +++ b/.gitignore @@ -58,3 +58,6 @@ cli/internal/backend/files/ cli/internal/podstack/commands/ cli/podcli podcli-clips/ + +# generated by scripts/gen-docs-manifest.mjs +docs-manifest.json diff --git a/README.md b/README.md index f9d7b3b..95549b0 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@

podcli.com · + Docs · Quick start · MCP · Features @@ -32,6 +33,8 @@ podcli process episode.mp4 One command transcribes, picks the best moments, crops to the face, and burns captions in. Nothing leaves your machine. +Full documentation lives at [podcli.com/docs](https://podcli.com/docs). The docs are open source, edit them at [nmbrthirteen/podcli-docs](https://github.com/nmbrthirteen/podcli-docs). + --- ## What It Does @@ -148,8 +151,8 @@ Both halves share the same **knowledge base** (`.podcli/knowledge/`) — your sh - **Clip history** — tracks everything to avoid duplicates - **Preset system** — save named configurations per show - **Content studio** — generate titles, descriptions, tags, and hashtags in the web UI; regenerate any section with your own guidance, or ask for anything custom -- **MCP server** — 17 tools for Claude Desktop / Claude Code integration -- **Web UI** — single-page flow at `localhost:3847` +- **MCP server** — 22 tools for Claude Desktop / Claude Code integration +- **Web UI** — multi-page studio at `localhost:3847` - **CLI** — one-command processing: `podcli process episode.mp4` --- diff --git a/scripts/check-docs-drift.mjs b/scripts/check-docs-drift.mjs new file mode 100644 index 0000000..29fdab2 --- /dev/null +++ b/scripts/check-docs-drift.mjs @@ -0,0 +1,43 @@ +import { readFileSync } from "fs"; +import { join, dirname } from "path"; +import { fileURLToPath } from "url"; + +const root = join(dirname(fileURLToPath(import.meta.url)), ".."); +const read = (p) => readFileSync(join(root, p), "utf8"); + +let manifest; +try { + manifest = JSON.parse(read("docs-manifest.json")); +} catch { + console.error("docs-manifest.json not found. Run: node scripts/gen-docs-manifest.mjs"); + process.exit(1); +} + +const readme = read("README.md"); +const errors = []; +const warnings = []; + +if (/single-page/i.test(readme) && manifest.studioRouteCount > 1) { + errors.push(`README calls the studio "single-page" but it has ${manifest.studioRouteCount} routes.`); +} + +const toolClaims = [...new Set([...readme.matchAll(/(\d+)\s+(?:MCP\s+)?tools/gi)].map((m) => Number(m[1])))]; +for (const claim of toolClaims) { + if (claim !== manifest.mcpToolCount) { + warnings.push(`README mentions "${claim} tools" but src registers ${manifest.mcpToolCount} (server.tool calls).`); + } +} + +if (manifest.packageVersion && manifest.version && !manifest.version.endsWith(manifest.packageVersion)) { + warnings.push(`package.json version ${manifest.packageVersion} lags the latest tag ${manifest.version}.`); +} + +for (const w of warnings) console.warn("warning:", w); + +if (errors.length) { + console.error("Docs drift detected:"); + for (const e of errors) console.error(" -", e); + process.exit(1); +} + +console.log(`Docs in sync: ${manifest.mcpToolCount} tools, ${manifest.studioRouteCount} studio routes, ${manifest.version}.`); diff --git a/scripts/gen-docs-manifest.mjs b/scripts/gen-docs-manifest.mjs new file mode 100644 index 0000000..71017b0 --- /dev/null +++ b/scripts/gen-docs-manifest.mjs @@ -0,0 +1,40 @@ +import { readFileSync, writeFileSync, readdirSync } from "fs"; +import { execSync } from "child_process"; +import { join, dirname } from "path"; +import { fileURLToPath } from "url"; + +const root = join(dirname(fileURLToPath(import.meta.url)), ".."); +const read = (p) => readFileSync(join(root, p), "utf8"); + +function countTools(dir) { + let n = 0; + for (const entry of readdirSync(join(root, dir), { withFileTypes: true })) { + const rel = join(dir, entry.name); + if (entry.isDirectory()) n += countTools(rel); + else if (entry.name.endsWith(".ts")) n += (read(rel).match(/server\.tool\(/g) || []).length; + } + return n; +} + +const mainTsx = read("src/ui/client/main.tsx"); +const studioRoutes = [...mainTsx.matchAll(/ !/Navigate/.test(m[0])) + .map((m) => ({ path: m[1], component: m[2] })); + +let version = ""; +try { + version = execSync("git describe --tags --abbrev=0", { cwd: root }).toString().trim(); +} catch { + version = JSON.parse(read("package.json")).version || ""; +} + +const manifest = { + version, + packageVersion: JSON.parse(read("package.json")).version || "", + mcpToolCount: countTools("src"), + studioRouteCount: studioRoutes.length, + studioRoutes, +}; + +writeFileSync(join(root, "docs-manifest.json"), JSON.stringify(manifest, null, 2) + "\n"); +console.log("wrote docs-manifest.json:", JSON.stringify(manifest)); diff --git a/src/services/clips-history.ts b/src/services/clips-history.ts index d110706..c673573 100644 --- a/src/services/clips-history.ts +++ b/src/services/clips-history.ts @@ -4,6 +4,7 @@ import { basename, join } from "path"; import { v4 as uuidv4 } from "uuid"; import { paths } from "../config/paths.js"; import { sliceTranscript } from "../utils/transcript.js"; +import { isDemoMode, demoClips } from "../ui/demo-fixtures.js"; import type { BatchClipsResult, ClipHistoryEntry, Format, WordTimestamp } from "../models/index.js"; type BatchResultRow = BatchClipsResult["results"][number]; @@ -31,6 +32,7 @@ export class ClipsHistory { } async load(): Promise { + if (isDemoMode()) return demoClips(); try { if (!existsSync(this.historyPath)) return []; const raw = await readFile(this.historyPath, "utf-8"); @@ -41,6 +43,7 @@ export class ClipsHistory { } private async save(entries: ClipHistoryEntry[]): Promise { + if (isDemoMode()) return; // demo fixtures are read-only; never persist to clips.json await this.ensureDir(); // Write to a temp file and atomically rename so a crash or a concurrent // reader never sees a half-written clips.json. @@ -181,6 +184,8 @@ export class ClipsHistory { async remove(idOrPrefix: string): Promise { const id = await this.resolveId(idOrPrefix); if (!id) return null; + // Demo entries are read-only fixtures — never delete their (shipped) artifacts. + if (isDemoMode()) return (await this.findById(id)) ?? null; const entry = await this.mutate((entries) => { const idx = entries.findIndex((e) => e.id === id); if (idx < 0) return null; diff --git a/src/ui/client/ClipDetail.tsx b/src/ui/client/ClipDetail.tsx index 6dd5474..784ecc5 100644 --- a/src/ui/client/ClipDetail.tsx +++ b/src/ui/client/ClipDetail.tsx @@ -90,6 +90,7 @@ export default function ClipDetail() { const tc = clip.thumbnail_config || {}; const dirty = title !== clip.title || captionStyle !== clip.caption_style; const previewUrl = `/api/clips/${clip.id}/preview?t=${bust}`; + const posterUrl = tc.preview_path ? img(tc.preview_path, bust) : undefined; const patch = (body: any) => api(`/clips/${clip.id}`, { method: "PATCH", body: JSON.stringify(body) }); @@ -231,7 +232,7 @@ export default function ClipDetail() {

- {clip.output_path ? (playerTime.current = t)} /> :
No rendered output
} + {clip.output_path ? (playerTime.current = t)} /> :
No rendered output
}
{fmt(clip.start_second)}-{fmt(clip.end_second)} · {clip.duration}s diff --git a/src/ui/client/ClipPlayer.tsx b/src/ui/client/ClipPlayer.tsx index 12304f7..8f312ed 100644 --- a/src/ui/client/ClipPlayer.tsx +++ b/src/ui/client/ClipPlayer.tsx @@ -4,7 +4,7 @@ import { PlayIcon, PauseIcon } from "./icons"; const fmt = (s: number) => (Number.isFinite(s) ? fmtTime(s) : "0:00"); -export default function ClipPlayer({ src, onTime }: { src: string; onTime?: (t: number) => void }) { +export default function ClipPlayer({ src, poster, onTime }: { src: string; poster?: string; onTime?: (t: number) => void }) { const ref = useRef(null); const [playing, setPlaying] = useState(false); const [t, setT] = useState(0); @@ -36,6 +36,7 @@ export default function ClipPlayer({ src, onTime }: { src: string; onTime?: (t: