Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,9 +43,23 @@ lib/
bun dev # Dev server at localhost:3000
bun run build # Production build
bun run new-slideshow <id> <title> # Scaffold new slideshow
bun run export <id> [output.pdf] # Export slideshow to PDF
bun run export <id> [output.pdf] [--url <origin>] # Export slideshow to PDF (headless Chrome CLI)
```

## Exporting to PDF

`bun run export` drives headless Chrome through its command line only (`--dump-dom`, `--screenshot`, `--print-to-pdf`), never through a DevTools connection, so it works where remote debugging is disabled.

- `?slide=N&export=1` renders step N without the navigation bar and mounts only that slide (no neighbors); the viewer's `<main>` carries `data-total-steps` and `data-current-step`.
- Chrome captures a page when its `load` event fires. With `&settle=<ms>` (0 included), export mode holds that event until the slide has been in the DOM for that many milliseconds of real time, so live embeds have that long to render (`lib/export-hold.ts`: an inline script in `<head>` opens the hold, and the viewer releases it once mounted). A hold that a release ends marks `<html>` with `data-export-settled`. Chrome's `--timeout` ends a hold that outlasts it, unmarked, so a capture cannot wait forever.
- Each capture also dumps the DOM, and the script rejects a capture that does not show the requested step with the settled mark (a slide that never mounted, or one the timeout cut off); the step is retried.
- Without `--url`, the script runs `next build`, serves the build on a free port and captures it, so unmerged changes export too. With `--url https://www.policyengine.org`, it captures the deployed site.
- `--size 1440x810 --scale 2` suits decks laid out for a laptop: the content fills the page and text stays sharp. The default is `1920x1080` at scale 1.
- Private decks: set `NEXT_PUBLIC_PRIVATE_PASSWORD`. The script passes it in the URL fragment (`#password=...`), which `PasswordGate` accepts and the browser never sends to the server. A local build also needs the variable at build time, as any build of a private deck does.
- `EXPORT_WAIT_MS` (default 8000, 0 to 60000) is the settle time; raise it for slow embeds or long intro animations. `EXPORT_CONCURRENCY` (default 3) sets how many slides are captured at once. `CHROME_PATH` overrides the Chrome binary.
- A watchdog stops a Chrome that hangs. A step that fails in the parallel pass is retried afterwards on its own with twice the time, up to three attempts in all. A failure, Ctrl-C or SIGTERM stops the build, every Chrome the script started and the server, and removes the screenshots. Error messages never include a URL fragment, so a deck password stays out of logs.
- The script checks that the PDF has one page per step before it reports success.

## Adding a new slideshow

1. `bun run new-slideshow my-talk "My talk title"`
Expand Down
5 changes: 5 additions & 0 deletions app/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { Metadata } from 'next';
import './globals.css';
import { EXPORT_HOLD_SCRIPT } from '@/lib/export-hold';

export const metadata: Metadata = {
metadataBase: new URL('https://policyengine.org'),
Expand All @@ -17,6 +18,10 @@ export default function RootLayout({
}) {
return (
<html lang="en">
<head>
{/* Runs before load can fire; it acts only on ?export=1&settle=<ms>. */}
<script dangerouslySetInnerHTML={{ __html: EXPORT_HOLD_SCRIPT }} />
</head>
<body>{children}</body>
</html>
);
Expand Down
153 changes: 0 additions & 153 deletions bun.lock

Large diffs are not rendered by default.

17 changes: 16 additions & 1 deletion components/core/PasswordGate.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
'use client';

import { useState } from 'react';
import { useEffect, useState } from 'react';
import { passwordFromHash } from '@/lib/slide-url';
import { releaseExportHold } from '@/lib/export-hold';

interface PasswordGateProps {
children: React.ReactNode;
Expand All @@ -13,6 +15,19 @@ export default function PasswordGate({ children }: PasswordGateProps) {

const correctPassword = process.env.NEXT_PUBLIC_PRIVATE_PASSWORD || '';

// A link can carry the password in its fragment (#password=...), which the
// browser never sends to the server. `bun run export` captures private
// decks this way. The viewer drops the fragment when it rewrites the URL.
// Without the password the form stays, so an export capture (whose hold the
// viewer would otherwise release) is let go at once and sees the form.
useEffect(() => {
if (passwordFromHash(window.location.hash) === correctPassword) {
setUnlocked(true);
} else {
releaseExportHold(0);
}
}, [correctPassword]);

if (unlocked) {
return <>{children}</>;
}
Expand Down
27 changes: 23 additions & 4 deletions components/core/SlideshowViewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ import { useState, useEffect, useMemo, Suspense } from 'react';
import { useSearchParams } from 'next/navigation';
import { SlideshowProvider, SlideshowContextValue } from './SlideshowContext';
import { SlideshowConfig, flattenSlides, getSlideComponent } from '@/lib/types';
import { buildSlideUrl, parseSlideParam } from '@/lib/slide-url';
import { buildSlideUrl, exportSettleMs, isExportMode, parseSlideParam } from '@/lib/slide-url';
import { MAX_SETTLE_MS, releaseExportHold } from '@/lib/export-hold';

interface SlideshowViewerProps {
config: SlideshowConfig;
Expand All @@ -14,6 +15,7 @@ function SlideshowViewerClient({ config }: SlideshowViewerProps) {
const searchParams = useSearchParams();
const steps = useMemo(() => flattenSlides(config.slides), [config.slides]);
const totalSteps = steps.length;
const exportMode = isExportMode(searchParams);

// Read the deep-link step once. After that, currentStep owns the URL: the
// effect below writes it with replaceState, and Next mirrors that write back
Expand Down Expand Up @@ -41,6 +43,14 @@ function SlideshowViewerClient({ config }: SlideshowViewerProps) {
window.history.replaceState(null, '', url);
}, [currentStep, config.id]);

// An export capture holds the page's load event open (lib/export-hold.ts).
// Start its settle time once the slide is in the DOM.
useEffect(() => {
if (mounted && exportMode) {
releaseExportHold(exportSettleMs(searchParams, MAX_SETTLE_MS));
}
}, [mounted, exportMode, searchParams]);

useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'ArrowRight' || e.key === ' ') {
Expand Down Expand Up @@ -92,10 +102,19 @@ function SlideshowViewerClient({ config }: SlideshowViewerProps) {

return (
<SlideshowProvider value={contextValue}>
<main className="relative cursor-pointer" onClick={handleMainClick}>
<main
className="relative cursor-pointer"
onClick={handleMainClick}
data-total-steps={totalSteps}
data-current-step={currentStep}
>
{mounted && config.slides.map((slide, slideIndex) => {
const SlideComponent = getSlideComponent(slide);
const isNeighbor = Math.abs(slideIndex - (currentStepData?.slideIndex ?? 0)) <= 1;
// Export mode captures one step per page load, so it mounts only the
// current slide: a neighbor's live embed would load in the background
// and can keep the capture from settling.
const mountRadius = exportMode ? 0 : 1;
const isNeighbor = Math.abs(slideIndex - (currentStepData?.slideIndex ?? 0)) <= mountRadius;
const isCurrent = slideIndex === currentStepData?.slideIndex;

if (!isNeighbor) {
Expand All @@ -113,7 +132,7 @@ function SlideshowViewerClient({ config }: SlideshowViewerProps) {
);
})}

{!isFullscreen && (
{!isFullscreen && !exportMode && (
<div className="fixed bottom-0 left-0 right-0 h-20 z-[100] flex items-center justify-between px-8 pointer-events-none">
<div className="flex items-center gap-3 pointer-events-auto">
<span className="text-white/60 text-xs">
Expand Down
98 changes: 98 additions & 0 deletions lib/export-hold.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { EXPORT_HOLD_SCRIPT, MAX_HOLD_MS, releaseExportHold } from "./export-hold";

// Runs the generated script, as the browser does from <head>, at `query`.
function runAt(query: string) {
window.history.replaceState(null, "", `/slides/deck${query}`);
new Function(EXPORT_HOLD_SCRIPT)();
}

const holdFrames = () => document.head.querySelectorAll("iframe[hidden]").length;

afterEach(() => {
delete window.__slideshowExportHold;
document.head.querySelectorAll("iframe").forEach((frame) => frame.remove());
document.documentElement.removeAttribute("data-export-settled");
vi.useRealTimers();
});

describe("EXPORT_HOLD_SCRIPT", () => {
it("opens a hold for export=1 with a whole-number settle, 0 included", () => {
for (const query of ["?export=1&settle=8000", "?slide=3&export=1&settle=0"]) {
runAt(query);
expect(window.__slideshowExportHold).toBeDefined();
expect(holdFrames()).toBe(1);
delete window.__slideshowExportHold;
document.head.querySelectorAll("iframe").forEach((frame) => frame.remove());
}
});

it("does nothing for any other URL", () => {
for (const query of [
"",
"?slide=3",
"?settle=8000",
"?export=1",
"?export=0&settle=8000",
"?export=true&settle=8000",
"?export=1&settle=",
"?export=1&settle=abc",
"?export=1&settle=-5",
"?export=1&settle=1.5",
"?export=1&settle=1e3",
"?export=1&settle=%208000",
]) {
runAt(query);
expect(window.__slideshowExportHold).toBeUndefined();
expect(holdFrames()).toBe(0);
}
});

it("ends the hold the settle time after a release, marking the page settled", () => {
vi.useFakeTimers();
runAt("?export=1&settle=8000");
releaseExportHold(1500);
vi.advanceTimersByTime(1499);
expect(holdFrames()).toBe(1);
expect(document.documentElement.hasAttribute("data-export-settled")).toBe(false);
vi.advanceTimersByTime(1);
expect(holdFrames()).toBe(0);
expect(document.documentElement.hasAttribute("data-export-settled")).toBe(true);
});

it("takes only the first release", () => {
vi.useFakeTimers();
runAt("?export=1&settle=8000");
releaseExportHold(1000);
releaseExportHold(0);
vi.advanceTimersByTime(999);
expect(holdFrames()).toBe(1);
vi.advanceTimersByTime(1);
expect(holdFrames()).toBe(0);
});

it("ends an unreleased hold after MAX_HOLD_MS without marking the page", () => {
vi.useFakeTimers();
runAt("?export=1&settle=8000");
vi.advanceTimersByTime(MAX_HOLD_MS - 1);
expect(holdFrames()).toBe(1);
vi.advanceTimersByTime(1);
expect(holdFrames()).toBe(0);
expect(document.documentElement.hasAttribute("data-export-settled")).toBe(false);
});

it("lets a release after the fallback do nothing", () => {
vi.useFakeTimers();
runAt("?export=1&settle=8000");
vi.advanceTimersByTime(MAX_HOLD_MS);
releaseExportHold(0);
vi.advanceTimersByTime(1);
expect(document.documentElement.hasAttribute("data-export-settled")).toBe(false);
});
});

describe("releaseExportHold", () => {
it("does nothing when no hold is open", () => {
expect(() => releaseExportHold(0)).not.toThrow();
});
});
61 changes: 61 additions & 0 deletions lib/export-hold.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
/**
* Export mode holds the page's `load` event open, because a headless browser
* captures the page at `load` and a live embed needs real time to render.
*
* EXPORT_HOLD_SCRIPT runs inline in <head>, so it runs before `load` can
* fire. On a URL with `export=1` and `settle=<ms>` (a whole number, 0
* included) it opens a document in a hidden iframe; a frame that is still
* loading keeps its parent's `load` from firing. The viewer calls
* `releaseExportHold(ms)` once the slide is mounted, and the hold ends `ms`
* later. A hold that a release ends marks <html> with `data-export-settled`,
* so a capture can tell a settled slide from one cut off by a timeout. If
* nothing releases it, the hold ends unmarked after MAX_HOLD_MS.
*/

/** Longest settle time a URL can ask for. */
export const MAX_SETTLE_MS = 60_000;

/** How long the hold lasts when the viewer never mounts to release it. */
export const MAX_HOLD_MS = 120_000;

export const EXPORT_HOLD_SCRIPT = `(function () {
var query = new URLSearchParams(location.search);
if (query.get("export") !== "1" || !/^[0-9]+$/.test(query.get("settle") || "")) return;
var frame = document.createElement("iframe");
frame.hidden = true;
frame.setAttribute("aria-hidden", "true");
frame.tabIndex = -1;
document.head.appendChild(frame);
var doc = frame.contentDocument;
if (!doc) return;
doc.open();
var ended = false;
var released = false;
function end(settled) {
if (ended) return;
ended = true;
if (settled) document.documentElement.setAttribute("data-export-settled", "");
try { doc.close(); } catch (e) {}
frame.remove();
}
var fallback = setTimeout(function () { end(false); }, ${MAX_HOLD_MS});
window.__slideshowExportHold = {
release: function (ms) {
if (released) return;
released = true;
clearTimeout(fallback);
setTimeout(function () { end(true); }, ms);
},
};
})();`;

declare global {
interface Window {
__slideshowExportHold?: { release: (ms: number) => void };
}
}

/** End the export hold `ms` from now. Does nothing when no hold is open. */
export function releaseExportHold(ms: number): void {
window.__slideshowExportHold?.release(ms);
}
76 changes: 75 additions & 1 deletion lib/slide-url.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
import { describe, it, expect } from "vitest";
import { buildSlideUrl, parseSlideParam } from "./slide-url";
import {
buildSlideUrl,
exportSettleMs,
isExportMode,
parseSlideParam,
passwordFromHash,
} from "./slide-url";

describe("buildSlideUrl", () => {
it("preserves basePath when on step 0", () => {
Expand Down Expand Up @@ -51,6 +57,74 @@ describe("buildSlideUrl", () => {
});
});

describe("isExportMode", () => {
it("is on when export=1", () => {
expect(isExportMode(new URLSearchParams("slide=4&export=1"))).toBe(true);
});

it("is off without the param or with any other value", () => {
expect(isExportMode(new URLSearchParams("slide=4"))).toBe(false);
expect(isExportMode(new URLSearchParams("export=0"))).toBe(false);
expect(isExportMode(new URLSearchParams("export=true"))).toBe(false);
});

it("survives buildSlideUrl, so the address bar keeps export mode", () => {
const url = buildSlideUrl(
"http://localhost:3000/slides/abundance-dmv?slide=2&export=1",
5,
);
expect(isExportMode(new URL(url, "http://localhost").searchParams)).toBe(
true,
);
});
});

describe("passwordFromHash", () => {
it("reads and decodes the password from the fragment", () => {
expect(passwordFromHash("#password=abc")).toBe("abc");
expect(passwordFromHash("#password=a%26b%20c%23d")).toBe("a&b c#d");
expect(passwordFromHash("password=abc")).toBe("abc");
});

it("round-trips any password that encodeURIComponent wrote", () => {
for (const password of ["", "plain", "a&b=c", "sp ace+plus", "ünï/çode#?%"]) {
const hash = `#password=${encodeURIComponent(password)}`;
expect(passwordFromHash(hash)).toBe(password);
}
});

it("returns null without a password key", () => {
expect(passwordFromHash("")).toBeNull();
expect(passwordFromHash("#")).toBeNull();
expect(passwordFromHash("#section-2")).toBeNull();
});

it("drops out of the address bar once the viewer rewrites the URL", () => {
const shown = buildSlideUrl(
"http://localhost:3000/slides/deck?slide=2&export=1#password=abc",
2,
);
expect(shown).toBe("/slides/deck?slide=2&export=1");
});
});

describe("exportSettleMs", () => {
const read = (query: string) => exportSettleMs(new URLSearchParams(query), 60_000);

it("reads a whole number of milliseconds", () => {
expect(read("export=1&settle=8000")).toBe(8000);
expect(read("settle=0")).toBe(0);
});

it("stays within 0 and the maximum for any input", () => {
expect(read("settle=999999999")).toBe(60_000);
for (const value of ["", "-5", "1.5", "abc", "1e9", " 8000", "8000ms", "Infinity"]) {
expect(read(`settle=${encodeURIComponent(value)}`)).toBe(0);
}
expect(read("export=1")).toBe(0);
});
});

describe("parseSlideParam", () => {
it("returns the step for a valid param", () => {
expect(parseSlideParam("5", 10)).toBe(5);
Expand Down
Loading
Loading