@khanglvm/relay 0.13.5 → 0.13.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -88,7 +88,7 @@ Node ≥ 18; Chart.js / Mermaid / Graphviz are vendored and lazy-loaded offline.
88
88
  | | |
89
89
  |---|---|
90
90
  | `rly help` | every command at a glance |
91
- | `rly view <file.md> …` | open a quick read-only board that renders local markdown file(s) — library-free; great for plans, READMEs, reports |
91
+ | `rly view <file.md> …` | open a quick read-only board that renders local markdown file(s), data files, or PDFs — library-free; great for plans, READMEs, reports, quotes |
92
92
  | `rly install --target <agent>` | write relay's rules into an agent's instruction file — `claude` `codex` `cursor` `copilot` `kiro` `windsurf` `cline` `gemini` `opencode` `droid` `agents`; `--all`, `--scope`, `--print`, `--list` |
93
93
  | `rly upgrade` | update the CLI **and** refresh the skill in one step (safe around open boards; `--dry-run`, `--cli-only`, `--skill-only`) |
94
94
  | `rly mcp` | run relay as an MCP App server so boards render **inline** in the chat — **stdio** for local desktop hosts (Claude Desktop, Codex), or `rly mcp --http` (Streamable HTTP) for web/mobile/remote; `rly mcp config` / `rly mcp install --target claude\|codex` to register it |
@@ -164,6 +164,8 @@ npm test # zero-dep smoke tests (spawns real servers, fake-submits)
164
164
  - **`video` block** — YouTube/Vimeo embeds, a direct media URL, or a local
165
165
  video file streamed from the server with HTTP Range (seekable), never
166
166
  embedded in the payload.
167
+ - **`pdf` block** — render local `.pdf` files or PDF URLs inline. Local PDFs
168
+ stream from the board server and are never embedded in the page payload.
167
169
  - **Durable drafts / rescue** — every autosave mirrors to `localStorage`; a
168
170
  board whose connection drops blocks further input instead of losing it, and
169
171
  `rly rescue <id>` re-serves on the same port so an open tab reconnects.
package/docs/AGENT.md CHANGED
@@ -151,6 +151,7 @@ Read a markdown file (no questions; library-free renderer; submit reads "Done"):
151
151
  rly view PLAN.md # one file
152
152
  rly view README.md CHANGELOG.md # several, each under a filename heading
153
153
  rly view data.csv # .csv/.tsv/.json → a sortable, filterable table
154
+ rly view report.pdf # .pdf → inline PDF viewer
154
155
  ```
155
156
 
156
157
  Show a git diff in one step (sugar — runs git diff, opens a diff board):
@@ -405,6 +406,10 @@ Rules of thumb:
405
406
  { "type": "video", "src": "https://youtu.be/dQw4w9WgXcQ", "title": "Demo walkthrough" }
406
407
  { "type": "video", "src": "recordings/demo.mp4", "title": "Local capture", "height": 360 }
407
408
 
409
+ // PDF — an http(s) PDF URL or a local .pdf file rendered inline. Local PDFs
410
+ // stream from the server and are never embedded in the page payload.
411
+ { "type": "pdf", "src": "reports/quote.pdf", "title": "Quote", "height": 900 }
412
+
408
413
  // HTML — sandboxed iframe; default height 360
409
414
  { "type": "html", "html": "<h1>Hello</h1>", "height": 360 }
410
415
  { "type": "html", "htmlFile": "viz.html", "height": 400 }
@@ -469,6 +474,7 @@ the question-types section above.
469
474
  | `code` | code snippets, config examples, command output — syntax-highlighted + line-numbered; load from a file with `codeFile` |
470
475
  | `diff` | proposed code changes / before-after — a unified diff rendered as a colored git-style comparison (no git needed) |
471
476
  | `video` | demos, screen recordings, walkthroughs — YouTube/Vimeo embeds, a media URL, or a local video file (streamed) |
477
+ | `pdf` | quotes, reports, exports, forms — local `.pdf` files or PDF URLs rendered inline; local files stream from the board server |
472
478
  | `image` | screenshots, mockup exports, photos — local files embed and work offline |
473
479
  | `palette` | color palettes / themes — swatch cards with hover-hex + click-to-copy; pair with a `color` question to let the user pick |
474
480
  | `kpi` | big-number metric cards (`items:[{label,value,delta?,dir?,sub?}]`) with up/down/flat-tinted deltas — at-a-glance numbers without a chart |
@@ -646,6 +652,25 @@ cancelled — with the full result JSON piped to stdin and `RLY_BOARD_ID`,
646
652
  a `wait` that obtains a terminal result. Write a file your harness watches,
647
653
  hit a webhook — whatever wakes you.
648
654
 
655
+ ### Codex browser-board pattern
656
+
657
+ Codex does not have a normal, user-facing "wake this agent turn from a browser
658
+ submit" command. If the Codex turn stops waiting, a later board submit can leave
659
+ the user needing to prompt manually. So when an agent is running in Codex and
660
+ uses the browser board path, keep the waiter in the foreground until the user
661
+ submits:
662
+
663
+ ```sh
664
+ rly ask --file spec.json --detach
665
+ rly wait b-xxxxx --timeout 1800 --while-active --idle-grace 300
666
+ ```
667
+
668
+ If `rly wait` exits with `wait-timeout`, immediately run `rly result <boardId>`.
669
+ If it is still open and the user may continue, run `rly wait` again. Do not use
670
+ `--on-result` as the primary Codex return path; it can write files or hit
671
+ webhooks, but normal Codex CLI sessions do not expose a portable inbound API
672
+ that wakes the current agent turn.
673
+
649
674
  ## Editable diagrams — let the user redraw your mermaid
650
675
 
651
676
  Add `"editable": true` to any mermaid block. The user gets an "Edit diagram"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@khanglvm/relay",
3
- "version": "0.13.5",
3
+ "version": "0.13.7",
4
4
  "description": "Question boards with rich blocks (markdown, charts, mermaid, tables, code, diffs, video, sandboxed HTML), clickable local file-links, and element-level annotations for AI coding agents (Claude Code, Codex, …): ask users structured questions, present interactive visuals, collect inline comments, read answers as JSON — in a local browser board OR rendered INLINE inside the Claude & Codex apps as an MCP App (SEP-1865).",
5
5
  "keywords": [
6
6
  "ai-agents",
@@ -86,6 +86,7 @@ priority call in a `rank` question — not in paragraphs. Unsure which exists? R
86
86
  | `image` | screenshots / mockups / renders — zoom+pan; `pins:true` → click-to-drop point comments |
87
87
  | `compare` | a before/after pair — draggable divider |
88
88
  | `video` | a demo / screen recording / walkthrough |
89
+ | `pdf` | a quote / report / exported document that should render inline |
89
90
  | `palette` | color schemes — swatch cards, hover-hex, click-to-copy |
90
91
  | `typography` | type choices — specimens at given size/weight/font |
91
92
  | `html` | anything bespoke — custom widgets, pixel-perfect mockups |
@@ -145,6 +146,22 @@ Push-wake instead of polling: add --on-result '<shell cmd>' to ask/show/reopen
145
146
  (or --notify-cmd on wait) - the command runs the moment the board finishes,
146
147
  with the full result JSON on stdin and RLY_BOARD_ID/RLY_STATUS/RLY_URL in env.
147
148
 
149
+ **Codex browser-board pattern.** Codex does not have a normal, user-facing
150
+ "wake this agent turn from a browser submit" command. If the Codex turn stops
151
+ waiting, a later board submit can leave the user needing to prompt manually.
152
+ So in Codex, keep the waiter in the foreground until the user submits:
153
+
154
+ ```sh
155
+ rly ask --file spec.json --detach
156
+ rly wait b-xxxxx --timeout 1800 --while-active --idle-grace 300
157
+ ```
158
+
159
+ If `rly wait` exits with `wait-timeout`, immediately run `rly result <boardId>`.
160
+ If it is still open and the user may continue, run `rly wait` again. Do not use
161
+ `--on-result` as the primary Codex return path; it can write files or hit
162
+ webhooks, but normal Codex CLI sessions do not expose a portable inbound API
163
+ that wakes the current agent turn.
164
+
148
165
  Blocking mode (`rly ask --file spec.json --timeout 1800`, no --detach) is fine
149
166
  ONLY when your shell tool has no execution time limit.
150
167
 
@@ -270,6 +287,8 @@ single/multi question.
270
287
  { "type": "video", "src": "recordings/demo.mp4", "title": "Local capture", "height": 360 }
271
288
  // ^ YouTube/Vimeo URL embeds a player; an http(s) media URL or a local video
272
289
  // file (mp4/webm/ogv/mov/mkv/m4v) plays inline (local files stream, not embedded).
290
+ { "type": "pdf", "src": "reports/quote.pdf", "title": "Quote", "height": 900 }
291
+ // ^ local .pdf files and http(s) PDF URLs render inline; local files stream, not embedded.
273
292
  { "type": "html", "html": "<p>hi</p>", "height": 360 }
274
293
  { "type": "html", "htmlFile": "viz.html", "height": 400 }
275
294
  { "type": "image", "src": "screenshot.png" } // local file, URL, or data URI
package/src/cli.js CHANGED
@@ -4,7 +4,7 @@ import os from 'node:os';
4
4
  import { spawn, spawnSync } from 'node:child_process';
5
5
  import { fileURLToPath } from 'node:url';
6
6
  import { CliError, sleep, pollFor } from './util.js';
7
- import { normalizeSpec, questionFromInline, SPEC_SCHEMA } from './spec.js';
7
+ import { assertSpecReady, normalizeSpec, questionFromInline, SPEC_SCHEMA } from './spec.js';
8
8
  import {
9
9
  createBoard,
10
10
  loadBoard,
@@ -197,6 +197,7 @@ function readFileOrThrow(p) {
197
197
  }
198
198
 
199
199
  async function runOrDetach(record, args) {
200
+ await assertSpecReady(record.spec);
200
201
  const timeoutSec = args.timeout !== undefined ? Math.max(0, Number.parseInt(args.timeout, 10) || 0) : 1800;
201
202
  const port = args.port !== undefined ? Number.parseInt(args.port, 10) || 0 : 0;
202
203
  const open = args.open !== false;
@@ -244,6 +245,7 @@ async function runOrDetach(record, args) {
244
245
  async function cmdAsk(args, mode) {
245
246
  const raw = await resolveSpecInput(args, mode);
246
247
  const spec = normalizeSpec(raw);
248
+ await assertSpecReady(spec);
247
249
  const record = createBoard(spec);
248
250
  return runOrDetach(record, args);
249
251
  }
@@ -275,17 +277,17 @@ async function cmdDiff(rest) {
275
277
  }
276
278
  const title = args.title || ('git diff' + (gitArgs.length ? ' ' + gitArgs.join(' ') : ''));
277
279
  const spec = normalizeSpec({ title, blocks: [{ type: 'diff', diff, view: args.split ? 'split' : 'unified' }] });
280
+ await assertSpecReady(spec);
278
281
  const record = createBoard(spec);
279
282
  return runOrDetach(record, args);
280
283
  }
281
284
 
282
- // `rly view <file.md> [more.md …]` — quick read-only board that renders one or
283
- // more markdown files (README, plan, report) with the built-in no-library
284
- // renderer. Each file becomes a markdown block; with 2+ files a small filename
285
- // heading separates them. Sugar over `rly show` with markdown mdFile blocks.
285
+ // `rly view <file.md> [more.md …]` — quick read-only board that renders files
286
+ // with purpose-built blocks (markdown, data tables, PDF). With 2+ files a small
287
+ // filename heading separates them. Sugar over `rly show`.
286
288
  async function cmdView(args) {
287
289
  const files = args._;
288
- if (!files.length) throw new CliError('usage: rly view <file.md|.csv|.json> [more …] [--title T] [--detach]');
290
+ if (!files.length) throw new CliError('usage: rly view <file.md|.csv|.json|.pdf> [more …] [--title T] [--detach]');
289
291
  const multi = files.length > 1;
290
292
  const DATA_EXT = new Set(['csv', 'tsv', 'json']);
291
293
  const blocks = [];
@@ -293,12 +295,14 @@ async function cmdView(args) {
293
295
  if (multi) blocks.push({ type: 'markdown', md: `## ${path.basename(f)}` });
294
296
  const ext = path.extname(f).slice(1).toLowerCase();
295
297
  if (DATA_EXT.has(ext)) blocks.push({ type: 'table', rowsFile: f, sortable: true, filterable: true, exportable: true });
298
+ else if (ext === 'pdf') blocks.push({ type: 'pdf', src: f, title: path.basename(f) });
296
299
  else blocks.push({ type: 'markdown', mdFile: f });
297
300
  }
298
301
  const raw = { blocks };
299
302
  raw.title = args.title || (multi ? `${files.length} files` : path.basename(files[0]));
300
303
  raw.submitLabel = args.submitLabel || 'Done';
301
304
  const spec = normalizeSpec(raw); // reads + validates each file, clear error if unreadable
305
+ await assertSpecReady(spec);
302
306
  const record = createBoard(spec);
303
307
  return runOrDetach(record, args);
304
308
  }
@@ -445,6 +449,7 @@ async function cmdUpdate(args) {
445
449
  } else {
446
450
  throw new CliError('update needs --file <spec.json>, --title, --intro, or -q "...".', 4);
447
451
  }
452
+ await assertSpecReady(spec);
448
453
 
449
454
  let res;
450
455
  try {
@@ -879,8 +884,8 @@ what you're about to output would look better than monospace text — or is a fi
879
884
  the user should view — put it in relay instead of printing it.**
880
885
 
881
886
  - **Markdown file / doc / README / plan / report** → \`rly view file.md\` (renders it
882
- in the browser, no library; \`.csv\`/\`.json\` → a sortable table). Never \`cat\` a
883
- doc into the terminal for the user to read.
887
+ in the browser, no library; \`.csv\`/\`.json\` → a sortable table; \`.pdf\` → an
888
+ inline PDF viewer). Never \`cat\` a doc into the terminal for the user to read.
884
889
  - **Image / screenshot / mockup / diagram / chart / table** → a relay board with an
885
890
  \`image\` block (or \`mermaid\`/\`graphviz\`/\`chart\`/\`table\`/\`kpi\`/\`compare\`). The
886
891
  terminal can't show pixels; relay can — with click-to-zoom and comment-in-place.
@@ -889,6 +894,15 @@ the user should view — put it in relay instead of printing it.**
889
894
  \`AskUserQuestion\` / ask-user tool. Build a spec and run
890
895
  \`rly ask --file spec.json --detach\`, then \`rly wait <boardId>\`. **Holds in plan
891
896
  mode too**; reserve \`ExitPlanMode\` for the final plan-approval gate only.
897
+ - **Codex-specific browser-board pattern:** Codex does not have a normal,
898
+ user-facing "wake this agent turn from a browser submit" command. In Codex,
899
+ keep the waiter in the foreground until the user submits:
900
+ \`rly ask --file spec.json --detach\` then
901
+ \`rly wait <boardId> --timeout 1800 --while-active --idle-grace 300\`.
902
+ If wait exits with \`wait-timeout\`, immediately run \`rly result <boardId>\`;
903
+ if it is still open and the user may continue, run \`rly wait\` again. Do not
904
+ use \`--on-result\` as the primary Codex return path; normal Codex CLI sessions
905
+ do not expose a portable inbound API that wakes the current agent turn.
892
906
  - **A plan, structure, architecture, data, or prototype** → a relay board with
893
907
  diagram/chart/table/code/image/html blocks — never ASCII diagrams or walls of prose.
894
908
  - **"Show me the diff / git diff / these changes"** → \`rly diff\` (runs git diff →
@@ -899,7 +913,7 @@ the user should view — put it in relay instead of printing it.**
899
913
  - **There's a purpose-built component for most content — use the MOST SPECIFIC one,
900
914
  never plain prose when a block fits.** Blocks: \`table\` (sortable/filterable/CSV,
901
915
  load from .csv/.json), \`chart\`, \`kpi\` (stat cards), \`mermaid\`/\`graphviz\`/\`plantuml\`,
902
- \`code\`, \`diff\`, \`image\` (+\`pins\`), \`compare\` (before/after), \`video\`, \`palette\`,
916
+ \`code\`, \`diff\`, \`image\` (+\`pins\`), \`compare\` (before/after), \`video\`, \`pdf\`, \`palette\`,
903
917
  \`typography\`, \`html\`. Question types: \`single\`/\`multi\`/\`yesno\`/\`scale\`/\`color\`/
904
918
  \`text\`/\`textarea\` plus \`rank\` (prioritize), \`allocate\` (split a budget), \`checklist\`
905
919
  (per-item sign-off). For a business user, reach for \`kpi\`+\`chart\`+\`table\` and
@@ -1385,7 +1399,8 @@ USAGE
1385
1399
  rly ask ... --on-result "<cmd>" push-wake: run <cmd> when the board finishes (result JSON on stdin)
1386
1400
  rly show --html-file viz.html visualization-only board (submit button = acknowledge)
1387
1401
  rly view <file.md> [more.md …] quick read-only board rendering markdown file(s) (no lib)
1388
- (.csv/.tsv/.json render as a filterable, sortable table)
1402
+ (.csv/.tsv/.json render as a filterable, sortable table;
1403
+ .pdf streams in an inline PDF viewer)
1389
1404
  rly diff [git args…] run git diff and show it in a diff board (--split, --detach,
1390
1405
  --title; other args pass to git: rly diff --staged | HEAD~1 | -- path)
1391
1406
  rly wait <id> [--timeout 3600] block until board finishes, print result JSON
package/src/mcp.js CHANGED
@@ -19,7 +19,7 @@ import path from 'node:path';
19
19
  import http from 'node:http';
20
20
  import crypto from 'node:crypto';
21
21
  import { fileURLToPath } from 'node:url';
22
- import { normalizeSpec, SPEC_SCHEMA } from './spec.js';
22
+ import { assertSpecReady, normalizeSpec, SPEC_SCHEMA } from './spec.js';
23
23
  import { CliError } from './util.js';
24
24
 
25
25
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
@@ -143,7 +143,7 @@ function resourceMeta() {
143
143
  }
144
144
 
145
145
  // ---------- request routing ----------
146
- function buildResult(method, params, clientProtocol) {
146
+ async function buildResult(method, params, clientProtocol) {
147
147
  switch (method) {
148
148
  case 'initialize':
149
149
  return {
@@ -202,7 +202,7 @@ function readResource(params) {
202
202
  // ui/update-model-context as a best-effort context sync). Spec errors come back
203
203
  // as an isError tool result (not a protocol error) so the model can see and fix
204
204
  // them.
205
- function callTool(params) {
205
+ async function callTool(params) {
206
206
  const name = params && params.name;
207
207
  if (name !== 'relay_ask' && name !== 'relay_show') {
208
208
  return { content: [{ type: 'text', text: 'unknown tool: ' + name }], isError: true };
@@ -211,6 +211,7 @@ function callTool(params) {
211
211
  let spec;
212
212
  try {
213
213
  spec = normalizeSpec(args);
214
+ await assertSpecReady(spec);
214
215
  } catch (err) {
215
216
  const msg = err instanceof CliError ? err.message : String((err && err.message) || err);
216
217
  return { content: [{ type: 'text', text: 'relay: invalid board spec — ' + msg }], isError: true };
@@ -265,7 +266,7 @@ export function runMcp() {
265
266
  catch { stdoutOpen = false; }
266
267
  };
267
268
 
268
- function handleLine(line) {
269
+ async function handleLine(line) {
269
270
  let msg;
270
271
  try { msg = JSON.parse(line); } catch {
271
272
  send({ jsonrpc: '2.0', id: null, error: { code: -32700, message: 'parse error' } });
@@ -278,7 +279,7 @@ export function runMcp() {
278
279
  if (typeof m.method !== 'string') continue; // a response to us — we issue none
279
280
  let result;
280
281
  try {
281
- result = buildResult(m.method, m.params || {});
282
+ result = await buildResult(m.method, m.params || {});
282
283
  } catch (err) {
283
284
  if (isRequest) {
284
285
  send({ jsonrpc: '2.0', id: m.id, error: { code: err.code || -32603, message: err.message || String(err) } });
@@ -290,6 +291,7 @@ export function runMcp() {
290
291
  }
291
292
 
292
293
  let buf = '';
294
+ let queue = Promise.resolve();
293
295
  process.stdin.setEncoding('utf8');
294
296
  process.stdin.on('data', (chunk) => {
295
297
  buf += chunk;
@@ -298,7 +300,7 @@ export function runMcp() {
298
300
  const line = buf.slice(0, idx);
299
301
  buf = buf.slice(idx + 1);
300
302
  const trimmed = line.trim();
301
- if (trimmed) handleLine(trimmed);
303
+ if (trimmed) queue = queue.then(() => handleLine(trimmed)).catch(() => {});
302
304
  }
303
305
  });
304
306
 
@@ -406,7 +408,7 @@ export function runMcpHttp({ port = DEFAULT_HTTP_PORT, host = '127.0.0.1', token
406
408
  let body = '';
407
409
  let aborted = false;
408
410
  req.on('data', (c) => { body += c; if (body.length > MAX_BODY) { aborted = true; req.destroy(); } });
409
- req.on('end', () => {
411
+ req.on('end', async () => {
410
412
  if (aborted) { res.writeHead(413, base); return res.end(); }
411
413
  let msg;
412
414
  try { msg = JSON.parse(body); } catch {
@@ -421,7 +423,7 @@ export function runMcpHttp({ port = DEFAULT_HTTP_PORT, host = '127.0.0.1', token
421
423
  if (m.method === 'initialize') isInit = true;
422
424
  const isRequest = m.id !== undefined && m.id !== null;
423
425
  try {
424
- const result = buildResult(m.method, m.params || {});
426
+ const result = await buildResult(m.method, m.params || {});
425
427
  if (isRequest) responses.push({ jsonrpc: '2.0', id: m.id, result });
426
428
  } catch (err) {
427
429
  if (isRequest) responses.push({ jsonrpc: '2.0', id: m.id, error: { code: err.code || -32603, message: err.message || String(err) } });
package/src/server.js CHANGED
@@ -7,6 +7,7 @@ import { spawn } from 'node:child_process';
7
7
  import { fileURLToPath } from 'node:url';
8
8
  import { loadBoard, saveBoard, saveRunning, removeRunning, loadPref, savePref } from './store.js';
9
9
  import { openUrl } from './open.js';
10
+ import { assertSpecReady } from './spec.js';
10
11
 
11
12
  const UI_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), 'ui');
12
13
  const PKG_ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
@@ -40,7 +41,9 @@ function readUi(name) {
40
41
 
41
42
  // Strips block bodies for the client payload: html blocks ship only metadata
42
43
  // (their bodies are served via /html/b/<id>), embedded images ship only
43
- // metadata (bytes served via /img/b/<id>), everything else ships as-is.
44
+ // metadata (bytes served via /img/b/<id>), streamed local media ships only
45
+ // metadata (bytes served via /video/b/<id> or /pdf/b/<id>), everything else
46
+ // ships as-is.
44
47
  function clientBlock(b) {
45
48
  // Cross-block fields preserved when we ship metadata-only (ref = reference-link
46
49
  // target name; pins = image coordinate comments). The default `return b` path
@@ -58,6 +61,9 @@ function clientBlock(b) {
58
61
  if (b && b.type === 'video' && typeof b.file === 'string') {
59
62
  return { id: b.id, type: 'video', title: b.title, height: b.height, mime: b.mime, hasFile: true, ...extra };
60
63
  }
64
+ if (b && b.type === 'pdf' && typeof b.file === 'string') {
65
+ return { id: b.id, type: 'pdf', title: b.title, height: b.height, mime: b.mime, hasFile: true, ...extra };
66
+ }
61
67
  return b;
62
68
  }
63
69
 
@@ -587,6 +593,11 @@ export async function runBoard({ id, port = 0, open = true, timeoutSec = 1800, q
587
593
  !Array.isArray(next.questions) || !Array.isArray(next.blocks)) {
588
594
  return sendJson(res, 400, { error: 'spec must be an object with questions[] and blocks[] arrays' });
589
595
  }
596
+ try {
597
+ await assertSpecReady(next);
598
+ } catch (err) {
599
+ return sendJson(res, 400, { error: err && err.message ? err.message : String(err) });
600
+ }
590
601
  record.spec = next;
591
602
  rev++;
592
603
  saveBoard(record);
@@ -619,6 +630,12 @@ export async function runBoard({ id, port = 0, open = true, timeoutSec = 1800, q
619
630
  const block = findBlock(record.spec, blockId, 'video');
620
631
  if (!block || typeof block.file !== 'string') return sendJson(res, 404, { error: `no local video block "${blockId}"` });
621
632
  streamFile(req, res, block.file, block.mime || 'application/octet-stream');
633
+ } else if ((req.method === 'GET' || req.method === 'HEAD') && pathname.startsWith('/pdf/b/')) {
634
+ // Local PDF bytes, Range-streamed for the browser's built-in PDF viewer.
635
+ const blockId = decodeURIComponent(pathname.slice('/pdf/b/'.length));
636
+ const block = findBlock(record.spec, blockId, 'pdf');
637
+ if (!block || typeof block.file !== 'string') return sendJson(res, 404, { error: `no local pdf block "${blockId}"` });
638
+ streamFile(req, res, block.file, block.mime || 'application/pdf');
622
639
  } else if (req.method === 'GET' && pathname === '/html/board') {
623
640
  // Legacy alias → the board's first html block.
624
641
  const block = firstBoardHtml(record.spec);
package/src/spec.js CHANGED
@@ -1,5 +1,7 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
+ import vm from 'node:vm';
4
+ import { fileURLToPath } from 'node:url';
3
5
  import { CliError } from './util.js';
4
6
 
5
7
  export const TYPES = ['single', 'multi', 'yesno', 'text', 'textarea', 'scale', 'color', 'rank', 'checklist', 'allocate'];
@@ -39,7 +41,7 @@ const HTML_HEIGHT = { min: 100, max: 2400, boardDefault: 400, questionDefault: 3
39
41
 
40
42
  // Block heights clamp to the same window; defaults vary per block type.
41
43
  const BLOCK_HEIGHT = { min: 100, max: 2400 };
42
- export const BLOCK_TYPES = ['markdown', 'mermaid', 'graphviz', 'plantuml', 'chart', 'table', 'code', 'diff', 'video', 'html', 'image', 'palette', 'kpi', 'typography', 'compare'];
44
+ export const BLOCK_TYPES = ['markdown', 'mermaid', 'graphviz', 'plantuml', 'chart', 'table', 'code', 'diff', 'video', 'pdf', 'html', 'image', 'palette', 'kpi', 'typography', 'compare'];
43
45
  const CHART_KINDS = ['bar', 'line', 'pie', 'doughnut', 'radar', 'scatter'];
44
46
 
45
47
  // code/diff blocks may load their text from a local file (like htmlFile). Caps
@@ -55,6 +57,9 @@ const VIDEO_MIMES = {
55
57
  };
56
58
  const VIDEO_MAX_BYTES = 512 * 1024 * 1024;
57
59
 
60
+ const PDF_MIME = 'application/pdf';
61
+ const PDF_MAX_BYTES = 512 * 1024 * 1024;
62
+
58
63
  // image blocks: local files are embedded as data URIs at spec time (the page
59
64
  // then loads them via /img/b/<id>), so boards stay self-contained offline.
60
65
  const IMAGE_MIMES = {
@@ -62,6 +67,9 @@ const IMAGE_MIMES = {
62
67
  webp: 'image/webp', svg: 'image/svg+xml', avif: 'image/avif', bmp: 'image/bmp',
63
68
  };
64
69
  const IMAGE_MAX_BYTES = 8 * 1024 * 1024;
70
+ const VENDOR_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'vendor');
71
+
72
+ let mermaidValidatorPromise = null;
65
73
 
66
74
  const asStr = (v) => (typeof v === 'string' ? v : v == null ? '' : String(v));
67
75
 
@@ -219,6 +227,99 @@ function parseVideoEmbed(src) {
219
227
  return null;
220
228
  }
221
229
 
230
+ function compactError(err) {
231
+ const raw = err && (err.str || err.message) ? (err.str || err.message) : String(err || 'unknown error');
232
+ const msg = String(raw).replace(/\s+/g, ' ').trim();
233
+ return msg.length > 500 ? msg.slice(0, 497) + '...' : msg;
234
+ }
235
+
236
+ async function mermaidValidator() {
237
+ if (mermaidValidatorPromise) return mermaidValidatorPromise;
238
+ mermaidValidatorPromise = (async () => {
239
+ const sourcePath = path.join(VENDOR_DIR, 'mermaid.min.js');
240
+ let source;
241
+ try {
242
+ source = fs.readFileSync(sourcePath, 'utf8');
243
+ } catch {
244
+ throw new CliError('mermaid blocks need the vendored parser (vendor/mermaid.min.js is missing).');
245
+ }
246
+ const quietConsole = { log() {}, info() {}, warn() {}, error() {}, debug() {} };
247
+ const context = {
248
+ console: quietConsole,
249
+ setTimeout,
250
+ clearTimeout,
251
+ };
252
+ context.globalThis = context;
253
+ context.window = { addEventListener() {} };
254
+ vm.createContext(context);
255
+ try {
256
+ vm.runInContext(source, context, { filename: sourcePath });
257
+ } catch (err) {
258
+ throw new CliError(`could not load mermaid parser: ${compactError(err)}`);
259
+ }
260
+ if (!context.mermaid || typeof context.mermaid.parse !== 'function') {
261
+ throw new CliError('could not load mermaid parser: vendor/mermaid.min.js did not expose mermaid.parse.');
262
+ }
263
+ try {
264
+ context.mermaid.initialize({
265
+ startOnLoad: false,
266
+ securityLevel: 'strict',
267
+ suppressErrorRendering: true,
268
+ });
269
+ } catch {
270
+ // Some Mermaid versions dislike repeated initialize calls; parse below is
271
+ // the real readiness gate.
272
+ }
273
+ return context.mermaid;
274
+ })();
275
+ return mermaidValidatorPromise;
276
+ }
277
+
278
+ async function assertMermaidSyntax(code, where) {
279
+ const mermaid = await mermaidValidator();
280
+ try {
281
+ const parsed = mermaid.parse(code);
282
+ if (parsed && typeof parsed.then === 'function') await parsed;
283
+ } catch (err) {
284
+ throw new CliError(`${where}: invalid mermaid syntax — ${compactError(err)}`);
285
+ }
286
+ }
287
+
288
+ function* allBlocks(spec) {
289
+ const boardBlocks = Array.isArray(spec.blocks) ? spec.blocks : [];
290
+ for (let i = 0; i < boardBlocks.length; i++) {
291
+ yield { block: boardBlocks[i], where: `board.blocks[${i}]${boardBlocks[i]?.id ? ` (${boardBlocks[i].id})` : ''}` };
292
+ }
293
+ const questions = Array.isArray(spec.questions) ? spec.questions : [];
294
+ for (let qi = 0; qi < questions.length; qi++) {
295
+ const q = questions[qi] || {};
296
+ const qBlocks = Array.isArray(q.blocks) ? q.blocks : [];
297
+ for (let bi = 0; bi < qBlocks.length; bi++) {
298
+ yield { block: qBlocks[bi], where: `questions[${qi}].blocks[${bi}]${qBlocks[bi]?.id ? ` (${qBlocks[bi].id})` : ''}` };
299
+ }
300
+ const opts = Array.isArray(q.options) ? q.options : [];
301
+ for (let oi = 0; oi < opts.length; oi++) {
302
+ const oBlocks = Array.isArray(opts[oi]?.blocks) ? opts[oi].blocks : [];
303
+ for (let bi = 0; bi < oBlocks.length; bi++) {
304
+ yield {
305
+ block: oBlocks[bi],
306
+ where: `questions[${qi}].options[${oi}].blocks[${bi}]${oBlocks[bi]?.id ? ` (${oBlocks[bi].id})` : ''}`,
307
+ };
308
+ }
309
+ }
310
+ }
311
+ }
312
+
313
+ export async function assertSpecReady(spec) {
314
+ if (!spec || typeof spec !== 'object') return spec;
315
+ if (spec.__relayReady === true) return spec;
316
+ for (const { block, where } of allBlocks(spec)) {
317
+ if (block && block.type === 'mermaid') await assertMermaidSyntax(block.code || '', where);
318
+ }
319
+ Object.defineProperty(spec, '__relayReady', { value: true, enumerable: false, configurable: true });
320
+ return spec;
321
+ }
322
+
222
323
  // Normalizes one block object. `id` is the already-assigned block id.
223
324
  // Returns the normalized block (with a guaranteed string `type` + `id`).
224
325
  function normalizeBlock(rawBlock, id, cwd, where) {
@@ -343,6 +444,37 @@ function normalizeBlock(rawBlock, id, cwd, where) {
343
444
  return block;
344
445
  }
345
446
 
447
+ if (type === 'pdf') {
448
+ const src = asStr(rawBlock.src ?? rawBlock.file ?? rawBlock.url).trim();
449
+ if (!src) throw new CliError(`${where}: pdf block needs a "src" (http(s) PDF URL or local .pdf file path).`);
450
+ const block = { id, type: 'pdf' };
451
+ if (rawBlock.title !== undefined) block.title = asStr(rawBlock.title);
452
+ if (rawBlock.alt !== undefined && block.title === undefined) block.title = asStr(rawBlock.alt);
453
+ if (hasHeight) block.height = clampInt(rawBlock.height, BLOCK_HEIGHT.min, BLOCK_HEIGHT.max, undefined);
454
+ if (/^https?:/i.test(src)) {
455
+ block.src = src;
456
+ block.mime = PDF_MIME;
457
+ if (!block.title) block.title = path.basename(src.split(/[?#]/)[0]) || 'PDF';
458
+ return block;
459
+ }
460
+ const p = path.resolve(cwd, src);
461
+ const ext = path.extname(p).slice(1).toLowerCase();
462
+ if (ext !== 'pdf') throw new CliError(`${where}: unsupported pdf extension ".${ext}" — use a local .pdf file or an http(s) PDF URL.`);
463
+ let stat;
464
+ try {
465
+ stat = fs.statSync(p);
466
+ } catch {
467
+ throw new CliError(`${where}: cannot read pdf "${src}" (resolved: ${p})`);
468
+ }
469
+ if (stat.size > PDF_MAX_BYTES) {
470
+ throw new CliError(`${where}: pdf "${src}" is ${(stat.size / 1024 / 1024).toFixed(0)}MB — max ${PDF_MAX_BYTES / 1024 / 1024}MB.`);
471
+ }
472
+ block.file = p;
473
+ block.mime = PDF_MIME;
474
+ if (!block.title) block.title = path.basename(p);
475
+ return block;
476
+ }
477
+
346
478
  if (type === 'chart') {
347
479
  const hasConfig = rawBlock.config && typeof rawBlock.config === 'object' && !Array.isArray(rawBlock.config);
348
480
  const hasShorthand =
package/src/ui/blocks.css CHANGED
@@ -278,6 +278,14 @@
278
278
  .blk-video-embed { aspect-ratio: 16 / 9; height: auto; }
279
279
  .blk-videocap { margin-top: 8px; font-size: 0.84rem; color: var(--muted); text-align: center; }
280
280
 
281
+ /* ---------- PDF (local stream / direct URL) ---------- */
282
+ .blk-pdfwrap { margin: 0; }
283
+ .blk-pdf {
284
+ display: block; width: 100%; height: min(900px, 78vh); min-height: 420px;
285
+ border: 0; border-radius: 10px; background: var(--card);
286
+ }
287
+ .blk-pdfwrap.blk-full .blk-pdf { height: calc(100vh - 92px); min-height: 0; }
288
+
281
289
  /* ---------- table ---------- */
282
290
  .blk-table {
283
291
  width: 100%;
package/src/ui/blocks.js CHANGED
@@ -692,6 +692,27 @@
692
692
  return wrap;
693
693
  }
694
694
 
695
+ // ---------- PDF (local stream or direct URL) ----------
696
+ function renderPdf(block, ctx, blockId) {
697
+ const wrap = el('div', { class: 'blk-pdfwrap' });
698
+ const src = block.hasFile ? '/pdf/b/' + encodeURIComponent(blockId) : block.src;
699
+ const title = block.title || 'PDF';
700
+ const frame = el('iframe', {
701
+ class: 'blk-pdf',
702
+ src,
703
+ title,
704
+ loading: 'lazy',
705
+ });
706
+ if (block.height != null) frame.style.height = clampHeight(block.height, 900) + 'px';
707
+ frame.addEventListener('error', () => {
708
+ wrap.replaceChildren(el('div', { class: 'blk-error' }, 'PDF failed to load'));
709
+ });
710
+ wrap.append(frame);
711
+ if (block.title) wrap.append(el('div', { class: 'blk-videocap' }, block.title));
712
+ attachViewer(wrap, { zoomEl: null, label: 'pdf', comment: wholeBlockComment(ctx, blockId, 'pdf') });
713
+ return wrap;
714
+ }
715
+
695
716
  // ---------- table ----------
696
717
  function normalizeColumns(columns) {
697
718
  return (columns || []).map((c, idx) => {
@@ -2199,6 +2220,10 @@
2199
2220
  inner = renderVideo(block, ctx, blockId);
2200
2221
  wrapper.append(inner);
2201
2222
  break;
2223
+ case 'pdf':
2224
+ inner = renderPdf(block, ctx, blockId);
2225
+ wrapper.append(inner);
2226
+ break;
2202
2227
  case 'chart':
2203
2228
  inner = renderChart(block, ctx, blockId);
2204
2229
  wrapper.append(inner);