pi-interview 0.6.1 → 0.6.2

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
@@ -139,26 +139,26 @@ await interview({
139
139
  | `id` | string | Unique identifier |
140
140
  | `type` | string | `single`, `multi`, `text`, `image`, or `info` |
141
141
  | `question` | string | Question text |
142
- | `options` | string[] or object[] | Choices (required for single/multi). Can be strings or `{ label, code? }` objects |
142
+ | `options` | string[] or object[] | Choices (required for single/multi). Can be strings or `{ label, content? }` objects |
143
143
  | `recommended` | string or string[] | Shows "Recommended" badge and pre-selects option(s) |
144
144
  | `conviction` | string | `"strong"` or `"slight"`. Slight opts out of pre-selection. Requires `recommended` |
145
145
  | `weight` | string | `"critical"` (prominent card) or `"minor"` (compact card) |
146
146
  | `context` | string | Help text shown below question |
147
- | `codeBlock` | object | Code block displayed below question text |
147
+ | `content` | object | Content block displayed below question text (`lang: "md"|"markdown"` previews Markdown by default) |
148
148
  | `media` | object or object[] | Media content: image, chart, mermaid, table, or html |
149
149
 
150
- ### Code Blocks
150
+ ### Content Blocks
151
151
 
152
- Questions and options can include code blocks for displaying code snippets, diffs, and file references.
152
+ Questions and options can include `content` blocks for code snippets, diffs, and Markdown.
153
153
 
154
- **Question-level code block** (displayed above options):
154
+ **Question-level code content** (displayed above options):
155
155
  ```json
156
156
  {
157
157
  "id": "review",
158
158
  "type": "single",
159
159
  "question": "Review this implementation",
160
- "codeBlock": {
161
- "code": "function add(a, b) {\n return a + b;\n}",
160
+ "content": {
161
+ "source": "function add(a, b) {\n return a + b;\n}",
162
162
  "lang": "ts",
163
163
  "file": "src/math.ts",
164
164
  "lines": "10-12",
@@ -168,44 +168,59 @@ Questions and options can include code blocks for displaying code snippets, diff
168
168
  }
169
169
  ```
170
170
 
171
- **Options with code blocks**:
171
+ **Options with content blocks**:
172
172
  ```json
173
173
  {
174
174
  "options": [
175
175
  {
176
176
  "label": "Use async/await",
177
- "code": { "code": "const data = await fetch(url);", "lang": "ts" }
177
+ "content": { "source": "const data = await fetch(url);", "lang": "ts" }
178
178
  },
179
179
  {
180
180
  "label": "Use promises",
181
- "code": { "code": "fetch(url).then(data => ...);", "lang": "ts" }
181
+ "content": { "source": "fetch(url).then(data => ...);", "lang": "ts" }
182
182
  },
183
183
  "Keep current implementation"
184
184
  ]
185
185
  }
186
186
  ```
187
187
 
188
- **Diff display** (set `lang: "diff"`):
188
+ **Diff display** (`lang: "diff"`):
189
189
  ```json
190
190
  {
191
- "codeBlock": {
192
- "code": "--- a/file.ts\n+++ b/file.ts\n@@ -1,3 +1,4 @@\n const x = 1;\n+const y = 2;\n const z = 3;",
191
+ "content": {
192
+ "source": "--- a/file.ts\n+++ b/file.ts\n@@ -1,3 +1,4 @@\n const x = 1;\n+const y = 2;\n const z = 3;",
193
193
  "lang": "diff",
194
194
  "file": "src/file.ts"
195
195
  }
196
196
  }
197
197
  ```
198
198
 
199
- | CodeBlock Field | Type | Description |
200
- |-----------------|------|-------------|
201
- | `code` | string | The code content (required) |
202
- | `lang` | string | Language for display (e.g., "ts", "diff") |
203
- | `file` | string | File path to display in header |
204
- | `lines` | string | Line range to display (e.g., "10-25") |
205
- | `highlights` | number[] | Line numbers to highlight |
206
- | `title` | string | Optional title above code |
199
+ **Markdown preview by default** (`lang: "md"` or `"markdown"`):
200
+ ```json
201
+ {
202
+ "content": {
203
+ "source": "# Release notes\n\n- Added preview mode\n- Fixed wrapping",
204
+ "lang": "md"
205
+ }
206
+ }
207
+ ```
207
208
 
208
- Line numbers are shown when `file` or `lines` is specified. Diff syntax (`+`/`-` lines) is automatically styled when `lang` is "diff".
209
+ Set `showSource: true` on Markdown content to show raw Markdown instead of preview.
210
+
211
+ | Content Field | Type | Description |
212
+ |---------------|------|-------------|
213
+ | `source` | string | Content text (required) |
214
+ | `lang` | string | Language hint (e.g., `ts`, `diff`, `md`) |
215
+ | `file` | string | File path shown in the header |
216
+ | `lines` | string | Line range shown in the header (code content only) |
217
+ | `highlights` | number[] | Line highlights (code content only) |
218
+ | `title` | string | Optional title above content |
219
+ | `showSource` | boolean | Markdown only: `true` forces raw source instead of preview |
220
+
221
+ Rules:
222
+ - `lang: "md"` or `"markdown"`: preview by default, `showSource: true` shows raw source.
223
+ - Any other `lang`: renders as raw source; `showSource` is not allowed.
209
224
 
210
225
  ### Info Panels
211
226
 
package/form/script.js CHANGED
@@ -380,12 +380,16 @@
380
380
  el.textContent = text || "";
381
381
  }
382
382
 
383
- function renderLightMarkdown(text) {
384
- if (!text) return "";
385
- let html = text
383
+ function escapeHtml(text) {
384
+ return String(text || "")
386
385
  .replace(/&/g, "&")
387
386
  .replace(/</g, "&lt;")
388
387
  .replace(/>/g, "&gt;");
388
+ }
389
+
390
+ function renderLightMarkdown(text) {
391
+ if (!text) return "";
392
+ let html = escapeHtml(text);
389
393
  html = html.replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>");
390
394
  html = html.replace(/`([^`]+)`/g, "<code>$1</code>");
391
395
  html = html.replace(/\n/g, "<br>");
@@ -393,6 +397,118 @@
393
397
  return html;
394
398
  }
395
399
 
400
+ function isMarkdownLang(lang) {
401
+ if (typeof lang !== "string") return false;
402
+ const normalized = lang.trim().toLowerCase();
403
+ return normalized === "md" || normalized === "markdown";
404
+ }
405
+
406
+ function renderMarkdownPreviewFallback(markdown) {
407
+ const lines = String(markdown || "").replace(/\r\n?/g, "\n").split("\n");
408
+ const html = [];
409
+ const paragraph = [];
410
+ let listType = null;
411
+ let inFence = false;
412
+ let fenceLang = "";
413
+ let fenceLines = [];
414
+
415
+ const flushParagraph = () => {
416
+ if (paragraph.length === 0) return;
417
+ html.push(`<p>${renderLightMarkdown(paragraph.join(" "))}</p>`);
418
+ paragraph.length = 0;
419
+ };
420
+
421
+ const closeList = () => {
422
+ if (!listType) return;
423
+ html.push(listType === "ol" ? "</ol>" : "</ul>");
424
+ listType = null;
425
+ };
426
+
427
+ for (const rawLine of lines) {
428
+ const line = rawLine ?? "";
429
+
430
+ if (inFence) {
431
+ if (/^```/.test(line.trim())) {
432
+ html.push(`<pre class="markdown-fence"><code${fenceLang ? ` data-lang="${escapeHtml(fenceLang)}"` : ""}>${escapeHtml(fenceLines.join("\n"))}</code></pre>`);
433
+ inFence = false;
434
+ fenceLang = "";
435
+ fenceLines = [];
436
+ } else {
437
+ fenceLines.push(line);
438
+ }
439
+ continue;
440
+ }
441
+
442
+ const fenceStart = line.match(/^```\s*([^\s`]*)\s*$/);
443
+ if (fenceStart) {
444
+ flushParagraph();
445
+ closeList();
446
+ inFence = true;
447
+ fenceLang = fenceStart[1] || "";
448
+ fenceLines = [];
449
+ continue;
450
+ }
451
+
452
+ if (!line.trim()) {
453
+ flushParagraph();
454
+ closeList();
455
+ continue;
456
+ }
457
+
458
+ const headingMatch = line.match(/^\s{0,3}(#{1,6})\s+(.+)$/);
459
+ if (headingMatch) {
460
+ flushParagraph();
461
+ closeList();
462
+ const level = headingMatch[1].length;
463
+ html.push(`<h${level}>${renderLightMarkdown(headingMatch[2].trim())}</h${level}>`);
464
+ continue;
465
+ }
466
+
467
+ const quoteMatch = line.match(/^>\s?(.*)$/);
468
+ if (quoteMatch) {
469
+ flushParagraph();
470
+ closeList();
471
+ html.push(`<blockquote><p>${renderLightMarkdown(quoteMatch[1])}</p></blockquote>`);
472
+ continue;
473
+ }
474
+
475
+ const orderedMatch = line.match(/^\s*\d+\.\s+(.+)$/);
476
+ if (orderedMatch) {
477
+ flushParagraph();
478
+ if (listType !== "ol") {
479
+ closeList();
480
+ html.push("<ol>");
481
+ listType = "ol";
482
+ }
483
+ html.push(`<li>${renderLightMarkdown(orderedMatch[1])}</li>`);
484
+ continue;
485
+ }
486
+
487
+ const unorderedMatch = line.match(/^\s*[-*]\s+(.+)$/);
488
+ if (unorderedMatch) {
489
+ flushParagraph();
490
+ if (listType !== "ul") {
491
+ closeList();
492
+ html.push("<ul>");
493
+ listType = "ul";
494
+ }
495
+ html.push(`<li>${renderLightMarkdown(unorderedMatch[1])}</li>`);
496
+ continue;
497
+ }
498
+
499
+ closeList();
500
+ paragraph.push(line.trim());
501
+ }
502
+
503
+ if (inFence) {
504
+ html.push(`<pre class="markdown-fence"><code${fenceLang ? ` data-lang="${escapeHtml(fenceLang)}"` : ""}>${escapeHtml(fenceLines.join("\n"))}</code></pre>`);
505
+ }
506
+
507
+ flushParagraph();
508
+ closeList();
509
+ return html.join("\n");
510
+ }
511
+
396
512
  function getOptionLabel(option) {
397
513
  return typeof option === "string" ? option : option.label;
398
514
  }
@@ -430,22 +546,14 @@
430
546
  question.recommended = nextRecommended;
431
547
  }
432
548
 
433
- function renderCodeBlock(block) {
434
- if (!block || !block.code) return null;
549
+ function renderContentBlock(block) {
550
+ if (!block || !block.source) return null;
435
551
 
552
+ const markdownPreview = isMarkdownLang(block.lang) && block.showSource !== true;
436
553
  const container = document.createElement("div");
437
554
  container.className = "code-block";
438
-
439
- const showLineNumbers = !!block.file || !!block.lines;
440
- const isDiff = block.lang === "diff";
441
- const lines = block.code.split("\n");
442
- const highlights = new Set(block.highlights || []);
443
-
444
- // Parse starting line number from lines prop (e.g., "10-16" -> 10, "42" -> 42)
445
- let startLineNum = 1;
446
- if (block.lines) {
447
- const match = block.lines.match(/^(\d+)/);
448
- if (match) startLineNum = parseInt(match[1], 10);
555
+ if (markdownPreview) {
556
+ container.classList.add("markdown-content-block");
449
557
  }
450
558
 
451
559
  if (block.file || block.lines || block.lang || block.title) {
@@ -483,6 +591,25 @@
483
591
  container.appendChild(header);
484
592
  }
485
593
 
594
+ if (markdownPreview) {
595
+ const preview = document.createElement("div");
596
+ preview.className = "markdown-preview";
597
+ preview.innerHTML = renderMarkdownPreviewFallback(block.source);
598
+ container.appendChild(preview);
599
+ return container;
600
+ }
601
+
602
+ const showLineNumbers = !!block.file || !!block.lines;
603
+ const isDiff = block.lang === "diff";
604
+ const lines = block.source.split("\n");
605
+ const highlights = new Set(block.highlights || []);
606
+
607
+ let startLineNum = 1;
608
+ if (block.lines) {
609
+ const match = block.lines.match(/^(\d+)/);
610
+ if (match) startLineNum = parseInt(match[1], 10);
611
+ }
612
+
486
613
  const pre = document.createElement("pre");
487
614
  const code = document.createElement("code");
488
615
 
@@ -526,7 +653,7 @@
526
653
 
527
654
  code.appendChild(linesContainer);
528
655
  } else {
529
- code.textContent = block.code;
656
+ code.textContent = block.source;
530
657
  }
531
658
 
532
659
  pre.appendChild(code);
@@ -1676,11 +1803,11 @@
1676
1803
  card.appendChild(context);
1677
1804
  }
1678
1805
 
1679
- if (question.codeBlock) {
1680
- const codeBlockEl = renderCodeBlock(question.codeBlock);
1681
- if (codeBlockEl) {
1682
- codeBlockEl.classList.add("question-code-block");
1683
- card.appendChild(codeBlockEl);
1806
+ if (question.content) {
1807
+ const contentBlockEl = renderContentBlock(question.content);
1808
+ if (contentBlockEl) {
1809
+ contentBlockEl.classList.add("question-code-block");
1810
+ card.appendChild(contentBlockEl);
1684
1811
  }
1685
1812
  }
1686
1813
 
@@ -1726,11 +1853,11 @@
1726
1853
 
1727
1854
  question.options.forEach((option, optionIndex) => {
1728
1855
  const optionLabel = getOptionLabel(option);
1729
- const optionCode = isRichOption(option) ? option.code : null;
1856
+ const optionContent = isRichOption(option) ? option.content : null;
1730
1857
 
1731
1858
  const label = document.createElement("label");
1732
1859
  label.className = "option-item";
1733
- if (optionCode) {
1860
+ if (optionContent) {
1734
1861
  label.classList.add("has-code");
1735
1862
  }
1736
1863
 
@@ -1764,10 +1891,10 @@
1764
1891
  label.appendChild(input);
1765
1892
  label.appendChild(text);
1766
1893
 
1767
- if (optionCode) {
1768
- const codeBlockEl = renderCodeBlock(optionCode);
1769
- if (codeBlockEl) {
1770
- label.appendChild(codeBlockEl);
1894
+ if (optionContent) {
1895
+ const contentBlockEl = renderContentBlock(optionContent);
1896
+ if (contentBlockEl) {
1897
+ label.appendChild(contentBlockEl);
1771
1898
  }
1772
1899
  }
1773
1900
 
package/form/styles.css CHANGED
@@ -1393,6 +1393,9 @@ button {
1393
1393
  padding: 0.75rem;
1394
1394
  overflow-x: hidden;
1395
1395
  line-height: 1.5;
1396
+ white-space: pre-wrap;
1397
+ overflow-wrap: anywhere;
1398
+ word-break: break-word;
1396
1399
  }
1397
1400
 
1398
1401
  .code-block code {
@@ -1400,6 +1403,91 @@ button {
1400
1403
  font-size: var(--font-size-code);
1401
1404
  background: none;
1402
1405
  padding: 0;
1406
+ white-space: inherit;
1407
+ overflow-wrap: inherit;
1408
+ word-break: inherit;
1409
+ }
1410
+
1411
+ .markdown-content-block .code-block-header {
1412
+ border-bottom: 0;
1413
+ }
1414
+
1415
+ .markdown-preview {
1416
+ padding: 0.875rem;
1417
+ display: grid;
1418
+ gap: 0.625rem;
1419
+ color: var(--fg);
1420
+ line-height: 1.6;
1421
+ text-wrap: pretty;
1422
+ }
1423
+
1424
+ .markdown-preview > * {
1425
+ margin: 0;
1426
+ }
1427
+
1428
+ .markdown-preview h1,
1429
+ .markdown-preview h2,
1430
+ .markdown-preview h3,
1431
+ .markdown-preview h4,
1432
+ .markdown-preview h5,
1433
+ .markdown-preview h6 {
1434
+ font-family: var(--font-display);
1435
+ line-height: 1.2;
1436
+ text-wrap: balance;
1437
+ }
1438
+
1439
+ .markdown-preview h1 { font-size: 1.2rem; }
1440
+ .markdown-preview h2 { font-size: 1.1rem; }
1441
+ .markdown-preview h3 { font-size: 1rem; }
1442
+
1443
+ .markdown-preview ul,
1444
+ .markdown-preview ol {
1445
+ margin: 0;
1446
+ padding-left: 1.25rem;
1447
+ display: grid;
1448
+ gap: 0.35rem;
1449
+ }
1450
+
1451
+ .markdown-preview blockquote {
1452
+ margin: 0;
1453
+ padding-left: 0.75rem;
1454
+ border-left: 3px solid var(--accent-muted);
1455
+ color: var(--fg-muted);
1456
+ }
1457
+
1458
+ .markdown-preview pre {
1459
+ margin: 0;
1460
+ padding: 0.75rem;
1461
+ border-radius: 6px;
1462
+ background: color-mix(in srgb, var(--bg-body) 90%, transparent);
1463
+ border: 1px solid var(--border-muted);
1464
+ overflow-x: auto;
1465
+ }
1466
+
1467
+ .markdown-preview pre code {
1468
+ font-family: var(--font-mono);
1469
+ font-size: var(--font-size-code);
1470
+ white-space: pre;
1471
+ }
1472
+
1473
+ .markdown-preview code {
1474
+ font-family: var(--font-mono);
1475
+ font-size: 0.75rem;
1476
+ background: color-mix(in srgb, var(--bg-body) 80%, transparent);
1477
+ padding: 0.1rem 0.3rem;
1478
+ border-radius: 4px;
1479
+ }
1480
+
1481
+ .markdown-preview a {
1482
+ color: var(--accent);
1483
+ text-underline-offset: 2px;
1484
+ }
1485
+
1486
+ .markdown-preview p code,
1487
+ .markdown-preview li code,
1488
+ .markdown-preview blockquote code {
1489
+ white-space: normal;
1490
+ overflow-wrap: anywhere;
1403
1491
  }
1404
1492
 
1405
1493
  .code-block-lines-container {
package/index.ts CHANGED
@@ -555,14 +555,14 @@ export default function (pi: ExtensionAPI) {
555
555
  "Provides better UX than back-and-forth chat for structured input. " +
556
556
  "Image responses and attachments are returned as file paths - use read tool directly to display them. " +
557
557
  "Pass questions as inline JSON string directly (preferred) or as a path to a JSON file. " +
558
- 'Questions JSON format: { "title": "...", "description": "...", "questions": [{ "id": "q1", "type": "single|multi|text|image|info", "question": "...", "options": ["A", "B"], "codeBlock": { "code": "...", "lang": "ts" }, "media": { "type": "image|chart|mermaid|table|html", ... } }] }. ' +
559
- "Options can be strings or objects: { label: string, code?: { code, lang?, file?, lines?, highlights? } }. " +
558
+ 'Questions JSON format: { "title": "...", "description": "...", "questions": [{ "id": "q1", "type": "single|multi|text|image|info", "question": "...", "options": ["A", "B"], "content": { "source": "...", "lang": "ts" }, "media": { "type": "image|chart|mermaid|table|html", ... } }] }. ' +
559
+ "Options can be strings or objects: { label: string, content?: { source, lang?, file?, lines?, highlights?, title?, showSource? } }. " +
560
560
  "Always set recommended with context explaining your reasoning. Recommended options show a 'Recommended' badge and are pre-selected for the user. " +
561
561
  'Use conviction: "slight" when unsure (does NOT pre-select), conviction: "strong" when very confident (shows Recommended badge). ' +
562
562
  "Omit conviction for normal recommendations (pre-selects). " +
563
563
  'Use weight: "critical" for key decisions (visually prominent), weight: "minor" for low-stakes questions (compact card). ' +
564
564
  "When questions have recommendations, set description to guide review (e.g., 'Review my suggestions and adjust as needed'). " +
565
- "Questions can have a codeBlock field to display code above options. Types: single (radio), multi (checkbox), text (textarea), image (file upload), info (non-interactive). " +
565
+ "Questions can have a content field to display code or markdown above options. lang: \"md\" or \"markdown\" defaults to markdown preview unless showSource is true. Types: single (radio), multi (checkbox), text (textarea), image (file upload), info (non-interactive). " +
566
566
  'Media blocks: { type: "image", src, alt, caption }, { type: "table", table: { headers, rows, highlights }, caption }, { type: "chart", chart: { type, data, options }, caption }, { type: "mermaid", mermaid: "graph LR\\n..." }, { type: "html", html }. ' +
567
567
  "Info type is a non-interactive content panel for displaying context with media. Media position: above (default), below, side (two-column).",
568
568
  promptSnippet:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-interview",
3
- "version": "0.6.1",
3
+ "version": "0.6.2",
4
4
  "description": "Interactive interview form extension for pi coding agent",
5
5
  "author": "Nico Bailon",
6
6
  "license": "MIT",
package/schema.ts CHANGED
@@ -1,15 +1,28 @@
1
- export interface CodeBlock {
2
- code: string;
1
+ export interface BaseContentBlock {
2
+ source: string;
3
3
  lang?: string;
4
4
  file?: string;
5
+ title?: string;
6
+ }
7
+
8
+ export interface MarkdownContentBlock extends BaseContentBlock {
9
+ lang: "md" | "markdown";
10
+ showSource?: boolean;
11
+ lines?: never;
12
+ highlights?: never;
13
+ }
14
+
15
+ export interface CodeContentBlock extends BaseContentBlock {
5
16
  lines?: string;
6
17
  highlights?: number[];
7
- title?: string;
18
+ showSource?: never;
8
19
  }
9
20
 
21
+ export type ContentBlock = MarkdownContentBlock | CodeContentBlock;
22
+
10
23
  export interface RichOption {
11
24
  label: string;
12
- code?: CodeBlock;
25
+ content?: ContentBlock;
13
26
  }
14
27
 
15
28
  export type OptionValue = string | RichOption;
@@ -44,7 +57,7 @@ export interface Question {
44
57
  conviction?: "strong" | "slight";
45
58
  weight?: "critical" | "minor";
46
59
  context?: string;
47
- codeBlock?: CodeBlock;
60
+ content?: ContentBlock;
48
61
  media?: MediaBlock | MediaBlock[];
49
62
  }
50
63
 
@@ -127,34 +140,71 @@ const SCHEMA_EXAMPLE = `Expected format:
127
140
  ]
128
141
  }
129
142
  Valid types: single, multi, text, image, info
130
- Options: array of strings or objects with { label, code? }`;
143
+ Options: array of strings or objects with { label, content? }`;
144
+
145
+ function isMarkdownLang(lang: unknown): lang is "md" | "markdown" {
146
+ if (typeof lang !== "string") return false;
147
+ const normalized = lang.trim().toLowerCase();
148
+ return normalized === "md" || normalized === "markdown";
149
+ }
131
150
 
132
- function validateCodeBlock(block: unknown, context: string): CodeBlock {
151
+ function validateContentBlock(block: unknown, context: string): ContentBlock {
133
152
  if (!block || typeof block !== "object") {
134
- throw new Error(`${context}: codeBlock must be an object`);
153
+ throw new Error(`${context}: content must be an object`);
135
154
  }
136
155
  const b = block as Record<string, unknown>;
137
- if (typeof b.code !== "string") {
138
- throw new Error(`${context}: codeBlock.code must be a string`);
156
+ if (typeof b.source !== "string") {
157
+ throw new Error(`${context}: content.source must be a string`);
139
158
  }
140
159
  if (b.lang !== undefined && typeof b.lang !== "string") {
141
- throw new Error(`${context}: codeBlock.lang must be a string`);
160
+ throw new Error(`${context}: content.lang must be a string`);
142
161
  }
143
162
  if (b.file !== undefined && typeof b.file !== "string") {
144
- throw new Error(`${context}: codeBlock.file must be a string`);
163
+ throw new Error(`${context}: content.file must be a string`);
145
164
  }
146
165
  if (b.lines !== undefined && typeof b.lines !== "string") {
147
- throw new Error(`${context}: codeBlock.lines must be a string`);
166
+ throw new Error(`${context}: content.lines must be a string`);
148
167
  }
149
168
  if (b.title !== undefined && typeof b.title !== "string") {
150
- throw new Error(`${context}: codeBlock.title must be a string`);
169
+ throw new Error(`${context}: content.title must be a string`);
170
+ }
171
+ if (b.showSource !== undefined && typeof b.showSource !== "boolean") {
172
+ throw new Error(`${context}: content.showSource must be a boolean`);
151
173
  }
152
174
  if (b.highlights !== undefined) {
153
175
  if (!Array.isArray(b.highlights) || b.highlights.some((h) => typeof h !== "number")) {
154
- throw new Error(`${context}: codeBlock.highlights must be an array of numbers`);
176
+ throw new Error(`${context}: content.highlights must be an array of numbers`);
177
+ }
178
+ }
179
+
180
+ if (isMarkdownLang(b.lang)) {
181
+ if (b.lines !== undefined) {
182
+ throw new Error(`${context}: content.lines is not allowed for markdown content`);
183
+ }
184
+ if (b.highlights !== undefined) {
185
+ throw new Error(`${context}: content.highlights is not allowed for markdown content`);
155
186
  }
187
+ return {
188
+ source: b.source,
189
+ lang: b.lang.trim().toLowerCase() as "md" | "markdown",
190
+ file: b.file,
191
+ title: b.title,
192
+ showSource: b.showSource,
193
+ };
156
194
  }
157
- return b as unknown as CodeBlock;
195
+
196
+ if (b.showSource !== undefined) {
197
+ throw new Error(`${context}: content.showSource is only valid when content.lang is "md" or "markdown"`);
198
+ }
199
+
200
+ return {
201
+ source: b.source,
202
+ lang: b.lang,
203
+ file: b.file,
204
+ title: b.title,
205
+ lines: b.lines,
206
+ highlights: b.highlights as number[] | undefined,
207
+ };
158
208
  }
159
209
 
160
210
  function validateOption(option: unknown, questionId: string, index: number): OptionValue {
@@ -169,9 +219,17 @@ function validateOption(option: unknown, questionId: string, index: number): Opt
169
219
  );
170
220
  }
171
221
  if (o.code !== undefined) {
172
- validateCodeBlock(o.code, `Question "${questionId}" option "${o.label}"`);
222
+ throw new Error(
223
+ `Question "${questionId}" option "${o.label}": legacy "code" is no longer supported; use "content"`
224
+ );
173
225
  }
174
- return option as RichOption;
226
+ if (o.content !== undefined) {
227
+ return {
228
+ label: o.label,
229
+ content: validateContentBlock(o.content, `Question "${questionId}" option "${o.label}"`),
230
+ };
231
+ }
232
+ return { label: o.label };
175
233
  }
176
234
  throw new Error(
177
235
  `Question "${questionId}": option at index ${index} must be a string or object with label`
@@ -240,7 +298,7 @@ function validateBasicStructure(data: unknown): QuestionsFile {
240
298
  throw new Error(`Question "${q.id}": options must be a non-empty array`);
241
299
  }
242
300
  for (let j = 0; j < q.options.length; j++) {
243
- validateOption(q.options[j], q.id as string, j);
301
+ q.options[j] = validateOption(q.options[j], q.id as string, j);
244
302
  }
245
303
  }
246
304
 
@@ -249,7 +307,10 @@ function validateBasicStructure(data: unknown): QuestionsFile {
249
307
  }
250
308
 
251
309
  if (q.codeBlock !== undefined) {
252
- validateCodeBlock(q.codeBlock, `Question "${q.id}"`);
310
+ throw new Error(`Question "${q.id}": legacy "codeBlock" is no longer supported; use "content"`);
311
+ }
312
+ if (q.content !== undefined) {
313
+ q.content = validateContentBlock(q.content, `Question "${q.id}"`);
253
314
  }
254
315
 
255
316
  if (q.conviction !== undefined) {
package/server.ts CHANGED
@@ -437,6 +437,155 @@ function escapeHtml(str: string): string {
437
437
  .replace(/"/g, "&quot;");
438
438
  }
439
439
 
440
+ function isMarkdownLang(lang: string | undefined): boolean {
441
+ if (!lang) return false;
442
+ const normalized = lang.trim().toLowerCase();
443
+ return normalized === "md" || normalized === "markdown";
444
+ }
445
+
446
+ function renderLightMarkdownHtml(text: string): string {
447
+ let html = escapeHtml(text);
448
+ html = html.replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>");
449
+ html = html.replace(/`([^`]+)`/g, "<code>$1</code>");
450
+ html = html.replace(/\n/g, "<br>");
451
+ html = html.replace(/\s(\d+\.)\s/g, "<br>$1 ");
452
+ return html;
453
+ }
454
+
455
+ function renderMarkdownPreviewHtml(markdown: string): string {
456
+ const lines = markdown.replace(/\r\n?/g, "\n").split("\n");
457
+ const html: string[] = [];
458
+ const paragraph: string[] = [];
459
+ let listType: "ul" | "ol" | null = null;
460
+ let inFence = false;
461
+ let fenceLang = "";
462
+ let fenceLines: string[] = [];
463
+
464
+ const flushParagraph = () => {
465
+ if (paragraph.length === 0) return;
466
+ html.push(`<p>${renderLightMarkdownHtml(paragraph.join(" "))}</p>`);
467
+ paragraph.length = 0;
468
+ };
469
+
470
+ const closeList = () => {
471
+ if (!listType) return;
472
+ html.push(listType === "ol" ? "</ol>" : "</ul>");
473
+ listType = null;
474
+ };
475
+
476
+ for (const rawLine of lines) {
477
+ const line = rawLine ?? "";
478
+
479
+ if (inFence) {
480
+ if (/^```/.test(line.trim())) {
481
+ html.push(`<pre class="markdown-fence"><code${fenceLang ? ` data-lang="${escapeHtml(fenceLang)}"` : ""}>${escapeHtml(fenceLines.join("\n"))}</code></pre>`);
482
+ inFence = false;
483
+ fenceLang = "";
484
+ fenceLines = [];
485
+ } else {
486
+ fenceLines.push(line);
487
+ }
488
+ continue;
489
+ }
490
+
491
+ const fenceStart = line.match(/^```\s*([^\s`]*)\s*$/);
492
+ if (fenceStart) {
493
+ flushParagraph();
494
+ closeList();
495
+ inFence = true;
496
+ fenceLang = fenceStart[1] || "";
497
+ fenceLines = [];
498
+ continue;
499
+ }
500
+
501
+ if (!line.trim()) {
502
+ flushParagraph();
503
+ closeList();
504
+ continue;
505
+ }
506
+
507
+ const headingMatch = line.match(/^\s{0,3}(#{1,6})\s+(.+)$/);
508
+ if (headingMatch) {
509
+ flushParagraph();
510
+ closeList();
511
+ const level = headingMatch[1].length;
512
+ html.push(`<h${level}>${renderLightMarkdownHtml(headingMatch[2].trim())}</h${level}>`);
513
+ continue;
514
+ }
515
+
516
+ const quoteMatch = line.match(/^>\s?(.*)$/);
517
+ if (quoteMatch) {
518
+ flushParagraph();
519
+ closeList();
520
+ html.push(`<blockquote><p>${renderLightMarkdownHtml(quoteMatch[1])}</p></blockquote>`);
521
+ continue;
522
+ }
523
+
524
+ const orderedMatch = line.match(/^\s*\d+\.\s+(.+)$/);
525
+ if (orderedMatch) {
526
+ flushParagraph();
527
+ if (listType !== "ol") {
528
+ closeList();
529
+ html.push("<ol>");
530
+ listType = "ol";
531
+ }
532
+ html.push(`<li>${renderLightMarkdownHtml(orderedMatch[1])}</li>`);
533
+ continue;
534
+ }
535
+
536
+ const unorderedMatch = line.match(/^\s*[-*]\s+(.+)$/);
537
+ if (unorderedMatch) {
538
+ flushParagraph();
539
+ if (listType !== "ul") {
540
+ closeList();
541
+ html.push("<ul>");
542
+ listType = "ul";
543
+ }
544
+ html.push(`<li>${renderLightMarkdownHtml(unorderedMatch[1])}</li>`);
545
+ continue;
546
+ }
547
+
548
+ closeList();
549
+ paragraph.push(line.trim());
550
+ }
551
+
552
+ if (inFence) {
553
+ html.push(`<pre class="markdown-fence"><code${fenceLang ? ` data-lang="${escapeHtml(fenceLang)}"` : ""}>${escapeHtml(fenceLines.join("\n"))}</code></pre>`);
554
+ }
555
+
556
+ flushParagraph();
557
+ closeList();
558
+ return html.join("\n");
559
+ }
560
+
561
+ function renderContentBlockHtml(content: Question["content"] | undefined): string {
562
+ if (!content?.source) return "";
563
+
564
+ const markdownPreview = isMarkdownLang(content.lang) && content.showSource !== true;
565
+ const headerParts: string[] = [];
566
+ if (content.title) {
567
+ headerParts.push(`<span class="code-block-title">${escapeHtml(content.title)}</span>`);
568
+ }
569
+ if (content.file) {
570
+ headerParts.push(`<span class="code-block-file">${escapeHtml(content.file)}</span>`);
571
+ }
572
+ if (content.lines) {
573
+ headerParts.push(`<span class="code-block-lines">L${escapeHtml(content.lines)}</span>`);
574
+ }
575
+ if (content.lang && content.lang !== "diff") {
576
+ headerParts.push(`<span class="code-block-lang">${escapeHtml(content.lang)}</span>`);
577
+ }
578
+ const headerHtml = headerParts.length > 0
579
+ ? `<div class="code-block-header">${headerParts.join("")}</div>`
580
+ : "";
581
+
582
+ if (markdownPreview) {
583
+ return `<div class="code-block markdown-content-block">${headerHtml}<div class="markdown-preview">${renderMarkdownPreviewHtml(content.source)}</div></div>`;
584
+ }
585
+
586
+ return `<div class="code-block">${headerHtml}<pre class="saved-code"><code>${escapeHtml(content.source)}</code></pre></div>`;
587
+ }
588
+
440
589
  function renderMediaCaptionHtml(media: MediaBlock): string {
441
590
  if (!media.caption) return "";
442
591
  return `<div class="media-caption">${escapeHtml(media.caption)}</div>`;
@@ -545,14 +694,12 @@ function renderQuestionsHtml(questionsList: Question[], answers: ResponseItem[])
545
694
  const mediaHtml = renderMediaListHtml(q.media);
546
695
 
547
696
  if (q.type === "info") {
548
- const codeHtml = q.codeBlock
549
- ? `<pre class="saved-code"><code>${escapeHtml(q.codeBlock.code)}</code></pre>`
550
- : "";
697
+ const contentHtml = renderContentBlockHtml(q.content);
551
698
  return `
552
699
  <div class="${weightClasses(q)}">
553
700
  <h2>${escapeHtml(q.question)}</h2>
554
701
  ${q.context ? `<p class="question-context">${escapeHtml(q.context)}</p>` : ""}
555
- ${codeHtml}
702
+ ${contentHtml}
556
703
  ${mediaHtml}
557
704
  </div>
558
705
  `;
@@ -579,9 +726,7 @@ function renderQuestionsHtml(questionsList: Question[], answers: ResponseItem[])
579
726
  answerHtml = `<div class="saved-answer">${savedAnswerItemHtml(String(value), q)}</div>`;
580
727
  }
581
728
 
582
- const codeHtml = q.codeBlock
583
- ? `<pre class="saved-code"><code>${escapeHtml(q.codeBlock.code)}</code></pre>`
584
- : "";
729
+ const contentHtml = renderContentBlockHtml(q.content);
585
730
 
586
731
  const attachHtml =
587
732
  attachments.length > 0
@@ -598,7 +743,7 @@ function renderQuestionsHtml(questionsList: Question[], answers: ResponseItem[])
598
743
  <div class="${weightClasses(q)}">
599
744
  <h2>${numPrefix}${escapeHtml(q.question)}</h2>
600
745
  ${contextHtml}
601
- ${codeHtml}
746
+ ${contentHtml}
602
747
  ${mediaHtml}
603
748
  ${answerHtml}
604
749
  ${attachHtml}
@@ -660,8 +805,11 @@ const SAVED_VIEW_STYLES = `
660
805
  padding: 12px;
661
806
  background: var(--bg-body);
662
807
  border-radius: var(--radius);
663
- overflow-x: auto;
808
+ overflow-x: hidden;
664
809
  font-size: 13px;
810
+ white-space: pre-wrap;
811
+ overflow-wrap: anywhere;
812
+ word-break: break-word;
665
813
  }
666
814
  .saved-answer {
667
815
  color: var(--fg);