diff --git a/runtime/engines/reveal.ts b/runtime/engines/reveal.ts
index 96dcfd2..f218ee1 100644
--- a/runtime/engines/reveal.ts
+++ b/runtime/engines/reveal.ts
@@ -1,11 +1,117 @@
-import { mkdir, readFile, writeFile } from 'node:fs/promises';
-import { dirname, join } from 'node:path';
+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';
-const REVEAL_PREFIX = '/vendor/reveal';
+// Relative (no leading slash) so the emitted HTML works both as served
+// from the local review server AND as a standalone file:// open after
+// publish_deck multi-file mode. The dev server's /vendor/reveal/* route
+// still matches because the browser resolves the relative path against
+// the page URL, which is the server root '/'.
+const REVEAL_PREFIX = 'vendor/reveal';
const __dirname = dirname(fileURLToPath(import.meta.url));
+/**
+ * Names at the deck root that are deckmark/agent internals, NOT user assets.
+ * Everything else (images/, fonts/, etc.) gets copied into build/ so the
+ * rendered deck can reference assets via relative paths like
+ * `
` and the local server can serve them.
+ *
+ * `vendor` is excluded so a user-provided `vendor/` folder cannot collide
+ * with — or overwrite — the official reveal.js dist that publish_deck
+ * (multi-file mode) places at `/vendor/reveal/`.
+ */
+const EXCLUDED_FROM_ASSET_SYNC = new Set([
+ // The content markdown is excluded dynamically by basename(opts.contentPath)
+ // — it may be named anything (slides.md, talk.md, etc.), and the MCP layer
+ // lets callers override it. 'content.md' stays here as a belt-and-suspenders
+ // default for the common case.
+ 'content.md',
+ 'deckmark.config.json',
+ 'annotations',
+ 'build',
+ 'published',
+ 'vendor',
+ 'node_modules',
+ '.git',
+ '.github',
+ '.gitignore',
+ '.claude-plugin',
+ '.mcp.json',
+ 'AGENTS.md',
+ 'CLAUDE.md',
+ 'GEMINI.md',
+ '.codex',
+ '.cursor'
+]);
+
+/**
+ * cp() filter that rejects symlinks at any depth. Required because
+ * `cp(..., { recursive: true })` without this would copy nested symlinks
+ * verbatim (e.g. `images/secret -> /etc/passwd`); the static server then
+ * follows them via stat()/readFile(), exposing arbitrary local files.
+ *
+ * `lstat` (not `stat`) is mandatory here — `stat` follows symlinks and
+ * would report the target's type, defeating the check.
+ */
+async function rejectSymlink(src: string): Promise {
+ try {
+ const st = await lstat(src);
+ return !st.isSymbolicLink();
+ } catch {
+ return false;
+ }
+}
+
+/**
+ * Mirror non-internal files from the deck folder into build/ so the rendered
+ * HTML can reference user assets (images, fonts, etc.) via stable relative
+ * paths. Re-running overwrites matching files in build/. In the normal build
+ * flow, removed assets do not linger because `buildDeck` clears the output
+ * directory before syncing user assets.
+ *
+ * Symlinks are skipped at every depth (top-level entries + the `filter`
+ * passed to cp()) to keep links like `images/secret -> /etc/passwd` out of
+ * the published output. The static server has its own containment check,
+ * but this is defense in depth.
+ */
+async function syncUserAssetsToBuild(
+ deckDir: string,
+ buildDir: string,
+ contentBase: string
+): Promise {
+ let entries: Dirent[];
+ try {
+ entries = await readdir(deckDir, { withFileTypes: true });
+ } catch {
+ return;
+ }
+ // Compare resolved absolute paths — not basenames — so a deck that happens
+ // to contain a folder whose name matches the buildDir basename (e.g. an
+ // `output/` assets folder when outDir=/tmp/out/output/) doesn't get
+ // accidentally skipped. Only the literal buildDir is excluded.
+ const buildResolved = resolve(buildDir);
+ for (const e of entries) {
+ // Hidden / dotfiles never sync.
+ if (e.name.startsWith('.')) continue;
+ if (EXCLUDED_FROM_ASSET_SYNC.has(e.name)) continue;
+ // Skip the content markdown (any filename — caller passes its basename).
+ if (e.name === contentBase) continue;
+ // Don't sync the build dir itself, even if it sits inside deckDir.
+ if (resolve(join(deckDir, e.name)) === buildResolved) continue;
+ // Top-level .html and .tgz files are likely publish artifacts; skip.
+ if (!e.isDirectory() && (e.name.endsWith('.html') || e.name.endsWith('.tgz'))) continue;
+ // Fast path: skip top-level symlinks before recursing.
+ if (e.isSymbolicLink()) continue;
+ const src = join(deckDir, e.name);
+ const dst = join(buildDir, e.name);
+ // `filter` is applied to every src path cp() visits during recursion,
+ // so nested symlinks are rejected too — not just top-level ones.
+ await cp(src, dst, { recursive: true, force: true, filter: rejectSymlink });
+ }
+}
+
export type DeckStyle = 'professional' | 'academic' | 'fashion' | 'technical' | 'fun';
export type DeckMode = 'light' | 'dark';
export type DeckMotion = 'slide-transitions' | 'fragment-reveals' | 'auto-animate';
@@ -133,7 +239,65 @@ ${sections.join('\n')}