Skip to content
Draft
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
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ user types /deckmark:use-deckmark <topic>
agent asks: mode? style? motion? audience? length?
agent + user align on storyline outline (core argument per slide)
agent: init_deck → writes content.md → build_deck
agent: start_review → "open <url>, press A to annotate, click Done"
Expand All @@ -100,7 +101,7 @@ Annotations live in `./annotations/session-<timestamp>.json` next to your deck.
| Tool | Purpose |
|---|---|
| `init_deck` | Scaffold a project (`content.md`, config, agent instructions, `.gitignore`). |
| `build_deck` | Render `content.md` to `./build/index.html` with reveal.js. Accepts `style`/`mode`/`motion`/`slideNumbers` params. |
| `build_deck` | Render `content.md` to `./build/index.html` with reveal.js. Accepts `style`/`mode`/`motion`/`slideNumbers` plus optional `customCss`/`template`/`markedPlugins` overrides. |
| `start_review` | Launch the local annotation review server, return URL + session id. |
| `wait_for_close` | Block until the user clicks "Done" in the browser, or until timeout. |
| `get_annotations` | Read annotations from disk (works even if Done wasn't clicked). |
Expand Down
32 changes: 30 additions & 2 deletions mcp/tools/build.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
// mcp/tools/build.ts
import { resolve } from 'node:path';
import { isAbsolute, relative, resolve } from 'node:path';
import {
buildDeck,
listStyles,
Expand All @@ -16,6 +16,18 @@ interface BuildInput {
mode?: DeckMode;
motion?: DeckMotion[];
slideNumbers?: boolean | 'c' | 'c/t' | 'h.v' | 'h/v';
customCss?: string;
template?: string;
markedPlugins?: string[];
}

function resolveWithinDir(dir: string, filePath: string, label: string): string {
const resolved = resolve(dir, filePath);
const rel = relative(dir, resolved);
if (rel.startsWith('..') || isAbsolute(rel)) {
throw new Error(`${label} must be relative to dir`);
}
return resolved;
}

export const buildDeckTool = {
Expand Down Expand Up @@ -52,6 +64,19 @@ export const buildDeckTool = {
],
default: false,
description: 'Show slide numbers. true → "current / total" (e.g. 3/8). false → off. Strings are passed through to reveal.js: c = current, c/t = current/total, h.v / h/v = horizontal+vertical indices.'
},
customCss: {
type: 'string',
description: 'Optional CSS file (relative to dir) appended after built-in style theme.'
},
template: {
type: 'string',
description: 'Optional HTML template file (relative to dir) using {{DECKMARK_*}} placeholders.'
},
markedPlugins: {
type: 'array',
items: { type: 'string' },
description: 'Optional local module paths (relative to dir) exporting default/register(marked) to extend markdown rendering.'
}
}
},
Expand All @@ -66,7 +91,10 @@ export const buildDeckTool = {
style: opts.style,
mode: opts.mode,
motion: opts.motion,
slideNumbers: opts.slideNumbers
slideNumbers: opts.slideNumbers,
customCssPath: opts.customCss ? resolveWithinDir(cwd, opts.customCss, 'customCss') : undefined,
templatePath: opts.template ? resolveWithinDir(cwd, opts.template, 'template') : undefined,
markedPlugins: opts.markedPlugins?.map(p => resolveWithinDir(cwd, p, 'markedPlugins'))
});
return {
out_dir: result.outDir,
Expand Down
7 changes: 6 additions & 1 deletion mcp/tools/review.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
import { resolve } from 'node:path';
import type { FastifyInstance } from 'fastify';
import { createServer } from '../../runtime/server/factory.ts';
import { createSession, readSession } from '../../runtime/store/session-store.ts';
import { createSession, readSession, closeSession } from '../../runtime/store/session-store.ts';
import { buildHash } from '../../runtime/store/build-hash.ts';

interface RunningServer {
Expand Down Expand Up @@ -34,6 +34,11 @@ export const startReviewTool = {
handler: async (input: Record<string, unknown>) => {
const opts = input as unknown as StartInput;
const deckDir = opts.dir ? resolve(process.cwd(), opts.dir) : process.cwd();
const existing = [...running.values()].filter(r => r.deckDir === deckDir);
for (const r of existing) {
try { await closeSession({ deckDir: r.deckDir, sessionId: r.sessionId }); } catch { /* ignore */ }
try { await r.app.close(); } catch { /* ignore */ }
}
Comment on lines +37 to +41
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in commit 4da9071. Before closing the old Fastify instance, closeSession is now called on the replaced session so its file is updated with closed: true. Any in-progress wait_for_close poll reading that session will detect it and return promptly instead of waiting until timeout.

const hash = await buildHash(resolve(deckDir, 'build'));
const session = await createSession({ deckDir, engine: 'reveal', buildHash: hash });
const app = await createServer({ deckDir, sessionId: session.session_id });
Expand Down
84 changes: 65 additions & 19 deletions runtime/engines/reveal.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { mkdir, readFile, writeFile, readdir, cp, lstat, rm } from 'node:fs/promises';
import type { Dirent } from 'node:fs';
import { dirname, join, basename, resolve, sep } from 'node:path';
import { fileURLToPath } from 'node:url';
import { marked } from 'marked';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { Marked } from 'marked';

// Relative (no leading slash) so the emitted HTML works both as served
// from the local review server AND as a standalone file:// open after
Expand Down Expand Up @@ -124,6 +124,12 @@ export interface BuildOpts {
motion?: DeckMotion[];
/** Show slide numbers in the corner. Pass true for "current / total", a string for custom reveal.js format. */
slideNumbers?: boolean | 'c' | 'c/t' | 'h.v' | 'h/v';
/** Optional path to extra CSS appended after the built-in deckmark theme. */
customCssPath?: string;
/** Optional path to a custom HTML template with DECKMARK_* placeholders. */
templatePath?: string;
/** Optional list of local module paths exporting default/register(marked) plugin hooks. */
markedPlugins?: string[];
}

export interface BuildResult {
Expand Down Expand Up @@ -171,6 +177,7 @@ export async function buildDeck(opts: BuildOpts): Promise<BuildResult> {
const slideNumberValue: false | string = slideNumbers === false
? false
: (slideNumbers === true ? 'c/t' : slideNumbers);
const md = await createMarkedRenderer(opts.markedPlugins);

const raw = await readFile(opts.contentPath, 'utf8');
const blocks = raw.split(/^\s*---\s*$/m).map(s => s.trim()).filter(Boolean);
Expand All @@ -180,7 +187,7 @@ export async function buildDeck(opts: BuildOpts): Promise<BuildResult> {

const slugCounts = new Map<string, number>();
const sections = blocks.map((block, i) => {
let html = marked.parse(block, { async: false }) as string;
let html = md.parse(block, { async: false }) as string;
if (fragmentReveals) {
html = applyFragmentReveals(html);
}
Expand All @@ -198,10 +205,27 @@ export async function buildDeck(opts: BuildOpts): Promise<BuildResult> {
} catch {
// theme file missing — engine still produces a valid (unstyled) deck
}
if (opts.customCssPath) {
const customCss = await readFile(opts.customCssPath, 'utf8');
deckmarkTheme += `\n${customCss}`;
}

const transition = slideTransitions ? config.transition : 'none';

const htmlDocument = `<!doctype html>
const revealInitScript = `window.__deckmarkReveal = Reveal;
Reveal.initialize({
hash: true,
controlsLayout: 'edges',
controlsBackArrows: 'faded',
transition: ${JSON.stringify(transition)},
center: false,
width: '100%',
height: '100%',
margin: 0,
autoAnimate: ${autoAnimate},
fragments: ${fragmentReveals},
slideNumber: ${JSON.stringify(slideNumberValue)}
});`;
const defaultHtmlDocument = `<!doctype html>
<html lang="en" data-mode="${mode}">
<head>
<meta charset="utf-8">
Expand All @@ -221,23 +245,21 @@ ${sections.join('\n')}
</div>
<script src="${REVEAL_PREFIX}/reveal.js"></script>
<script>
window.__deckmarkReveal = Reveal;
Reveal.initialize({
hash: true,
controlsLayout: 'edges',
controlsBackArrows: 'faded',
transition: ${JSON.stringify(transition)},
center: false,
width: '100%',
height: '100%',
margin: 0,
autoAnimate: ${autoAnimate},
fragments: ${fragmentReveals},
slideNumber: ${JSON.stringify(slideNumberValue)}
});
${revealInitScript}
</script>
</body>
</html>`;
const htmlDocument = opts.templatePath
? renderTemplate(await readFile(opts.templatePath, 'utf8'), {
DECKMARK_MODE: mode,
DECKMARK_STYLE: style,
DECKMARK_REVEAL_PREFIX: REVEAL_PREFIX,
DECKMARK_BASE_THEME: baseTheme,
DECKMARK_THEME_CSS: deckmarkTheme,
DECKMARK_SLIDES: sections.join('\n'),
DECKMARK_REVEAL_INIT: revealInitScript
})
: defaultHtmlDocument;

// Clean the build dir before each build so stale entries (especially any
// pre-existing symlinks) can't survive a rebuild. Because rm() is
Expand All @@ -259,6 +281,30 @@ ${sections.join('\n')}
`buildDeck: refusing to clean filesystem root as outDir: ${resolvedOutDir}`
);
}

function renderTemplate(template: string, vars: Record<string, string>): string {
let out = template;
for (const [key, value] of Object.entries(vars)) {
out = out.replaceAll(`{{${key}}}`, value);
}
return out;
}

async function createMarkedRenderer(pluginPaths: string[] | undefined): Promise<Marked> {
const md = new Marked();
if (!pluginPaths?.length) return md;
for (const p of pluginPaths) {
const mod = await import(pathToFileURL(resolve(p)).href);
const register = typeof mod.default === 'function'
? mod.default
: (typeof mod.register === 'function' ? mod.register : null);
if (!register) {
throw new Error(`Invalid marked plugin module at ${p}: expected default/register function`);
}
register(md);
}
return md;
}
const outDirWithSep = resolvedOutDir.endsWith(sep) ? resolvedOutDir : resolvedOutDir + sep;
if (resolvedContent === resolvedOutDir || resolvedContent.startsWith(outDirWithSep)) {
throw new Error(
Expand Down
12 changes: 12 additions & 0 deletions runtime/overlay/annotation-mode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type { AnnotationInput } from '../types/session.ts';
export function mountAnnotationMode(_root: HTMLElement): void {
let outline: HTMLDivElement | null = null;
let currentEl: Element | null = null;
let pointerDown: { x: number; y: number } | null = null;

const onMouseMove = (e: MouseEvent) => {
if (getState().mode !== 'annotating') return;
Expand All @@ -18,6 +19,12 @@ export function mountAnnotationMode(_root: HTMLElement): void {

const onClick = (e: MouseEvent) => {
if (getState().mode !== 'annotating') return;
if (e.button !== 0 || e.metaKey || e.ctrlKey || e.altKey || e.shiftKey) return;
if (pointerDown) {
const dx = e.clientX - pointerDown.x;
const dy = e.clientY - pointerDown.y;
if (Math.hypot(dx, dy) > 8) return;
}
// If a popover is already open, do not start another annotation — the
// popover singleton would close the first one and the user would lose
// their in-progress comment.
Expand All @@ -30,6 +37,9 @@ export function mountAnnotationMode(_root: HTMLElement): void {
};

// capture phase so we run before reveal.js
document.addEventListener('pointerdown', (e) => {
pointerDown = { x: e.clientX, y: e.clientY };
}, true);
document.addEventListener('mousemove', onMouseMove, true);
document.addEventListener('click', onClick, true);

Expand All @@ -45,6 +55,8 @@ export function mountAnnotationMode(_root: HTMLElement): void {
if (!el) return null;
if (el.closest('.deckmark-toolbar, .deckmark-popover, .deckmark-pin, .deckmark-done-dialog'))
return null;
if (el.closest('.controls, .progress, .slide-number, .speaker-notes, [data-prevent-swipe]'))
return null;
const section = el.closest('section');
if (!section) return null;
if (el === section) return section.firstElementChild ?? section;
Expand Down
48 changes: 33 additions & 15 deletions runtime/overlay/pin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,23 +2,16 @@ import { getState, subscribe } from './state.ts';
import type { Annotation } from '../types/session.ts';

export function mountPins(_root: HTMLElement): void {
const layer = document.createElement('div');
layer.id = 'deckmark-pins';
Object.assign(layer.style, {
position: 'absolute',
inset: '0',
pointerEvents: 'none',
zIndex: '999997'
} as Partial<CSSStyleDeclaration>);
document.body.appendChild(layer);

const render = () => {
const s = getState();
layer.innerHTML = '';
clearPinLayers();
if (s.mode === 'hidden' || !s.session) return;
const onCurrent = s.session.annotations.filter(a => a.slide.index === s.currentSlideIndex);
const currentSection = document.querySelector<HTMLElement>(`section[data-slide-index="${s.currentSlideIndex}"]`);
if (!currentSection) return;
const layer = ensureSlideLayer(currentSection);
for (let i = 0; i < onCurrent.length; i++) {
placePin(layer, onCurrent[i], i + 1);
placePin(layer, currentSection, onCurrent[i], i + 1);
}
};

Expand All @@ -28,7 +21,7 @@ export function mountPins(_root: HTMLElement): void {
render();
}

function placePin(layer: HTMLElement, a: Annotation, num: number): void {
function placePin(layer: HTMLElement, section: HTMLElement, a: Annotation, num: number): void {
let target: Element | null = null;
try {
target = document.querySelector(a.element.selector);
Expand All @@ -37,16 +30,41 @@ function placePin(layer: HTMLElement, a: Annotation, num: number): void {
}
if (!target) return; // stale selector — skip (v2 could re-anchor via bbox)
const rect = target.getBoundingClientRect();
const sectionRect = section.getBoundingClientRect();
const scaleX = section.offsetWidth > 0 ? sectionRect.width / section.offsetWidth : 1;
const scaleY = section.offsetHeight > 0 ? sectionRect.height / section.offsetHeight : 1;
const unscaledX = Number.isFinite(scaleX) && scaleX > 0 ? (rect.left - sectionRect.left) / scaleX : rect.left - sectionRect.left;
const unscaledY = Number.isFinite(scaleY) && scaleY > 0 ? (rect.top - sectionRect.top) / scaleY : rect.top - sectionRect.top;
const pin = document.createElement('div');
pin.className = 'deckmark-pin';
pin.dataset.status = a.status;
pin.textContent = String(num);
pin.title = a.comment;
pin.style.left = `${window.scrollX + rect.left - 12}px`;
pin.style.top = `${window.scrollY + rect.top - 12}px`;
pin.style.left = `${unscaledX - 12}px`;
pin.style.top = `${unscaledY - 12}px`;
pin.style.pointerEvents = 'auto';
pin.addEventListener('click', () => {
alert(`Annotation #${num}\n\n${a.comment}`);
});
layer.appendChild(pin);
}

function clearPinLayers(): void {
document.querySelectorAll<HTMLElement>('.deckmark-pin-layer').forEach(layer => layer.remove());
}

function ensureSlideLayer(section: HTMLElement): HTMLElement {
if (getComputedStyle(section).position === 'static') {
section.style.position = 'relative';
}
const layer = document.createElement('div');
layer.className = 'deckmark-pin-layer';
Object.assign(layer.style, {
position: 'absolute',
inset: '0',
pointerEvents: 'none',
zIndex: '999997'
} as Partial<CSSStyleDeclaration>);
section.appendChild(layer);
return layer;
}
14 changes: 14 additions & 0 deletions runtime/overlay/popover.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,12 @@ export function showPopover(
ta.focus();

let resolved = false;
let removeOutsideListener: (() => void) | null = null;
const cleanup = () => {
if (removeOutsideListener) {
removeOutsideListener();
removeOutsideListener = null;
}
if (activePopover === pop) activePopover = null;
pop.remove();
};
Expand Down Expand Up @@ -72,6 +77,15 @@ export function showPopover(
cancelBtn.addEventListener('click', cancel);
cancelBtn.addEventListener('pointerdown', cancel);

const onOutsidePointerDown = (e: PointerEvent) => {
const targetEl = e.target as Node | null;
if (targetEl && !pop.contains(targetEl)) cancel(e);
};
document.addEventListener('pointerdown', onOutsidePointerDown, true);
removeOutsideListener = () => {
document.removeEventListener('pointerdown', onOutsidePointerDown, true);
};

ta.addEventListener('keydown', (e) => {
if (e.key === 'Escape') cancel();
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') submit();
Expand Down
Loading