pi-interview 0.4.5 → 0.5.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/server.ts CHANGED
@@ -1,12 +1,12 @@
1
1
  import http, { type IncomingMessage, type ServerResponse } from "node:http";
2
2
  import { randomUUID } from "node:crypto";
3
3
  import { tmpdir, homedir } from "node:os";
4
- import { join, dirname, basename } from "node:path";
4
+ import { join, dirname, basename, resolve } from "node:path";
5
5
  import { readFileSync, existsSync, mkdirSync, readdirSync, unlinkSync, renameSync, writeFileSync } from "node:fs";
6
- import { mkdir, writeFile } from "node:fs/promises";
6
+ import { mkdir, writeFile, copyFile } from "node:fs/promises";
7
7
  import { execSync } from "node:child_process";
8
8
  import { fileURLToPath } from "node:url";
9
- import type { Question, QuestionsFile } from "./schema.js";
9
+ import type { Question, QuestionsFile, MediaBlock } from "./schema.js";
10
10
 
11
11
  function getGitBranch(cwd: string): string | null {
12
12
  try {
@@ -400,15 +400,131 @@ function escapeHtml(str: string): string {
400
400
  .replace(/"/g, """);
401
401
  }
402
402
 
403
+ function renderMediaCaptionHtml(media: MediaBlock): string {
404
+ if (!media.caption) return "";
405
+ return `<div class="media-caption">${escapeHtml(media.caption)}</div>`;
406
+ }
407
+
408
+ function renderMediaBlockHtml(media: MediaBlock): string {
409
+ const caption = renderMediaCaptionHtml(media);
410
+
411
+ switch (media.type) {
412
+ case "image":
413
+ return `<figure class="media-block media-image">
414
+ <img src="${escapeHtml(media.src || "")}" alt="${escapeHtml(media.alt || "")}">
415
+ ${caption}</figure>`;
416
+ case "table": {
417
+ if (!media.table) return "";
418
+ const highlights = new Set(media.table.highlights || []);
419
+ const headers = media.table.headers.map(h => `<th>${escapeHtml(h)}</th>`).join("");
420
+ const rows = media.table.rows.map((row, i) => {
421
+ const cls = highlights.has(i) ? ' class="highlighted-row"' : "";
422
+ const cells = row.map(c => `<td>${escapeHtml(c)}</td>`).join("");
423
+ return `<tr${cls}>${cells}</tr>`;
424
+ }).join("\n");
425
+ return `<div class="media-block media-table"><div class="media-table-scroll">
426
+ <table class="data-table"><thead><tr>${headers}</tr></thead>
427
+ <tbody>${rows}</tbody></table></div>${caption}</div>`;
428
+ }
429
+ case "mermaid":
430
+ return `<div class="media-block media-mermaid">
431
+ <pre class="mermaid">${escapeHtml(media.mermaid || "")}</pre>${caption}</div>`;
432
+ case "chart":
433
+ return `<div class="media-block media-chart">
434
+ <div class="media-chart-static">[Chart: ${escapeHtml(media.chart?.type || "unknown")}]</div>
435
+ ${caption}</div>`;
436
+ case "html":
437
+ return `<div class="media-block media-html">${media.html || ""}${caption}</div>`;
438
+ default:
439
+ return "";
440
+ }
441
+ }
442
+
443
+ function renderMediaListHtml(media: MediaBlock | MediaBlock[] | undefined): string {
444
+ if (!media) return "";
445
+ const list = Array.isArray(media) ? media : [media];
446
+ return list.map(renderMediaBlockHtml).join("\n");
447
+ }
448
+
449
+ function recommendedIndicatorHtml(q: Question): string {
450
+ if (!q.recommended) return "";
451
+ return '<span class="recommended-pill">Recommended</span>';
452
+ }
453
+
454
+ function savedAnswerItemHtml(text: string, q: Question): string {
455
+ const recs = Array.isArray(q.recommended)
456
+ ? q.recommended
457
+ : q.recommended ? [q.recommended] : [];
458
+ const indicator = recs.includes(text) ? " " + recommendedIndicatorHtml(q) : "";
459
+ return escapeHtml(text) + indicator;
460
+ }
461
+
462
+ function weightClasses(q: Question): string {
463
+ const classes = ["saved-question"];
464
+ if (q.type === "info") classes.push("info-panel");
465
+ if (q.weight === "critical") classes.push("weight-critical");
466
+ if (q.weight === "minor") classes.push("weight-minor");
467
+ return classes.join(" ");
468
+ }
469
+
470
+ async function copyMediaImages(questionsList: Question[], imagesDir: string, cwd: string): Promise<Question[]> {
471
+ const toCopy: Array<{ src: string; dest: string }> = [];
472
+ const rewritten = questionsList.map(q => {
473
+ if (!q.media) return q;
474
+ const mediaList = Array.isArray(q.media) ? q.media : [q.media];
475
+ let changed = false;
476
+ const newMedia = mediaList.map(m => {
477
+ if (m.type !== "image" || !m.src) return m;
478
+ if (m.src.startsWith("http://") || m.src.startsWith("https://") || m.src.startsWith("data:")) return m;
479
+ const resolved = resolve(
480
+ m.src.startsWith("~") ? join(homedir(), m.src.slice(1))
481
+ : m.src.startsWith("/") ? m.src
482
+ : join(cwd, m.src)
483
+ );
484
+ if (!existsSync(resolved)) return m;
485
+ const filename = basename(resolved);
486
+ toCopy.push({ src: resolved, dest: join(imagesDir, filename) });
487
+ changed = true;
488
+ return { ...m, src: "images/" + filename };
489
+ });
490
+ if (!changed) return q;
491
+ return { ...q, media: Array.isArray(q.media) ? newMedia : newMedia[0] };
492
+ });
493
+ if (toCopy.length > 0) {
494
+ await mkdir(imagesDir, { recursive: true });
495
+ await Promise.all(toCopy.map(f => copyFile(f.src, f.dest)));
496
+ }
497
+ return rewritten;
498
+ }
499
+
403
500
  function renderQuestionsHtml(questionsList: Question[], answers: ResponseItem[]): string {
404
501
  const answerMap = new Map(answers.map((a) => [a.id, a]));
502
+ let questionNum = 0;
405
503
  return questionsList
406
- .map((q, i) => {
504
+ .map((q) => {
505
+ const showNumber = q.type !== "info";
506
+ if (showNumber) questionNum++;
507
+ const numPrefix = showNumber ? `${questionNum}. ` : "";
508
+ const mediaHtml = renderMediaListHtml(q.media);
509
+
510
+ if (q.type === "info") {
511
+ const codeHtml = q.codeBlock
512
+ ? `<pre class="saved-code"><code>${escapeHtml(q.codeBlock.code)}</code></pre>`
513
+ : "";
514
+ return `
515
+ <div class="${weightClasses(q)}">
516
+ <h2>${escapeHtml(q.question)}</h2>
517
+ ${q.context ? `<p class="question-context">${escapeHtml(q.context)}</p>` : ""}
518
+ ${codeHtml}
519
+ ${mediaHtml}
520
+ </div>
521
+ `;
522
+ }
523
+
407
524
  const ans = answerMap.get(q.id);
408
525
  const value = ans?.value;
409
526
  const attachments = ans?.attachments ?? [];
410
527
 
411
- // Format answer based on question type
412
528
  let answerHtml: string;
413
529
  if (!value || (Array.isArray(value) && value.length === 0)) {
414
530
  answerHtml = '<div class="saved-answer empty">(no answer)</div>';
@@ -420,18 +536,16 @@ function renderQuestionsHtml(questionsList: Question[], answers: ResponseItem[])
420
536
  } else if (q.type === "multi") {
421
537
  const items = Array.isArray(value) ? value : [value];
422
538
  answerHtml = `<div class="saved-answer"><ul>${items
423
- .map((v) => `<li>${escapeHtml(String(v))}</li>`)
539
+ .map((v) => `<li>${savedAnswerItemHtml(String(v), q)}</li>`)
424
540
  .join("")}</ul></div>`;
425
541
  } else {
426
- answerHtml = `<div class="saved-answer">${escapeHtml(String(value))}</div>`;
542
+ answerHtml = `<div class="saved-answer">${savedAnswerItemHtml(String(value), q)}</div>`;
427
543
  }
428
544
 
429
- // Render code block if present
430
545
  const codeHtml = q.codeBlock
431
546
  ? `<pre class="saved-code"><code>${escapeHtml(q.codeBlock.code)}</code></pre>`
432
547
  : "";
433
548
 
434
- // Render attachments if any
435
549
  const attachHtml =
436
550
  attachments.length > 0
437
551
  ? `<div class="saved-attachments">${attachments
@@ -439,10 +553,16 @@ function renderQuestionsHtml(questionsList: Question[], answers: ResponseItem[])
439
553
  .join("")}</div>`
440
554
  : "";
441
555
 
556
+ const contextHtml = q.context
557
+ ? `<p class="question-context">${escapeHtml(q.context)}</p>`
558
+ : "";
559
+
442
560
  return `
443
- <div class="saved-question">
444
- <h2>${i + 1}. ${escapeHtml(q.question)}</h2>
561
+ <div class="${weightClasses(q)}">
562
+ <h2>${numPrefix}${escapeHtml(q.question)}</h2>
563
+ ${contextHtml}
445
564
  ${codeHtml}
565
+ ${mediaHtml}
446
566
  ${answerHtml}
447
567
  ${attachHtml}
448
568
  </div>
@@ -533,6 +653,32 @@ const SAVED_VIEW_STYLES = `
533
653
  border-radius: var(--radius);
534
654
  border: 1px solid var(--border-muted);
535
655
  }
656
+ .saved-question.info-panel h2 {
657
+ color: var(--fg-muted);
658
+ }
659
+ .saved-question.weight-critical {
660
+ border-left: 5px solid var(--accent);
661
+ background: color-mix(in srgb, var(--accent) 4%, var(--bg-elevated));
662
+ }
663
+ .saved-question.weight-minor {
664
+ padding: 12px;
665
+ }
666
+ .saved-question.weight-minor h2 {
667
+ font-size: 13px;
668
+ }
669
+ .recommended-pill {
670
+ display: inline-flex;
671
+ align-items: center;
672
+ padding: 1px 6px;
673
+ margin-left: 6px;
674
+ border-radius: 8px;
675
+ font-size: 9px;
676
+ font-weight: 600;
677
+ text-transform: uppercase;
678
+ letter-spacing: 0.05em;
679
+ background: color-mix(in srgb, var(--accent) 15%, transparent);
680
+ color: var(--accent);
681
+ }
536
682
  `;
537
683
 
538
684
  function generateSavedHtml(options: {
@@ -606,6 +752,26 @@ export async function startInterviewServer(
606
752
  questionById.set(question.id, question);
607
753
  }
608
754
 
755
+ function getMediaList(q: Question): MediaBlock[] {
756
+ if (!q.media) return [];
757
+ return Array.isArray(q.media) ? q.media : [q.media];
758
+ }
759
+
760
+ const needsChartJs = questions.questions.some(q =>
761
+ getMediaList(q).some(m => m.type === "chart")
762
+ );
763
+ const needsMermaid = questions.questions.some(q =>
764
+ getMediaList(q).some(m => m.type === "mermaid")
765
+ );
766
+
767
+ let cdnScripts = "";
768
+ if (needsChartJs) {
769
+ cdnScripts += '<script src="https://cdn.jsdelivr.net/npm/chart.js@4/dist/chart.umd.min.js"></script>\n';
770
+ }
771
+ if (needsMermaid) {
772
+ cdnScripts += '<script src="https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.min.js"></script>\n';
773
+ }
774
+
609
775
  const themeConfig = options.theme ?? {};
610
776
  const resolvedThemeName =
611
777
  themeConfig.name && BUILTIN_THEMES.has(themeConfig.name) ? themeConfig.name : "default";
@@ -694,6 +860,7 @@ export async function startInterviewServer(
694
860
  autoSaveOnSubmit: options.autoSaveOnSubmit ?? true,
695
861
  });
696
862
  const html = TEMPLATE
863
+ .replace("<!-- __CDN_SCRIPTS__ -->", cdnScripts)
697
864
  .replace("/* __INTERVIEW_DATA_PLACEHOLDER__ */", inlineData)
698
865
  .replace(/__SESSION_TOKEN__/g, sessionToken);
699
866
  res.writeHead(200, {
@@ -760,6 +927,56 @@ export async function startInterviewServer(
760
927
  return;
761
928
  }
762
929
 
930
+ if (method === "GET" && url.pathname === "/media") {
931
+ if (!validateTokenQuery(url, sessionToken, res)) return;
932
+ const filePath = url.searchParams.get("path");
933
+ if (!filePath) {
934
+ sendText(res, 400, "Missing path parameter");
935
+ return;
936
+ }
937
+
938
+ const home = homedir();
939
+ const resolved = resolve(
940
+ filePath.startsWith("~")
941
+ ? join(home, filePath.slice(1))
942
+ : filePath.startsWith("/")
943
+ ? filePath
944
+ : join(cwd, filePath)
945
+ );
946
+
947
+ const allowed = [cwd, home, tmpdir()];
948
+ const isAllowed = allowed.some(dir => resolved === dir || resolved.startsWith(dir + "/"));
949
+ if (!isAllowed) {
950
+ sendText(res, 403, "Path not allowed");
951
+ return;
952
+ }
953
+
954
+ if (!existsSync(resolved)) {
955
+ sendText(res, 404, "File not found");
956
+ return;
957
+ }
958
+
959
+ const ext = resolved.split(".").pop()?.toLowerCase();
960
+ const mimeTypes: Record<string, string> = {
961
+ png: "image/png",
962
+ jpg: "image/jpeg",
963
+ jpeg: "image/jpeg",
964
+ gif: "image/gif",
965
+ webp: "image/webp",
966
+ svg: "image/svg+xml",
967
+ };
968
+
969
+ const contentType = mimeTypes[ext || ""] || "application/octet-stream";
970
+ const data = readFileSync(resolved);
971
+ res.writeHead(200, {
972
+ "Content-Type": contentType,
973
+ "Cache-Control": "private, max-age=300",
974
+ "Content-Length": data.length,
975
+ });
976
+ res.end(data);
977
+ return;
978
+ }
979
+
763
980
  if (method === "POST" && url.pathname === "/heartbeat") {
764
981
  const body = await parseJSONBody(req).catch(() => null);
765
982
  if (!body) {
@@ -1023,6 +1240,15 @@ export async function startInterviewServer(
1023
1240
  }
1024
1241
  }
1025
1242
 
1243
+ // Copy local media images to snapshot and rewrite paths
1244
+ const rewrittenQuestions = await copyMediaImages(
1245
+ questions.questions, imagesPath, cwd
1246
+ );
1247
+ const snapshotQuestions: QuestionsFile = {
1248
+ ...questions,
1249
+ questions: rewrittenQuestions,
1250
+ };
1251
+
1026
1252
  // Generate HTML with embedded data
1027
1253
  const meta: SavedInterviewMeta = {
1028
1254
  savedAt: new Date().toISOString(),
@@ -1031,7 +1257,7 @@ export async function startInterviewServer(
1031
1257
  };
1032
1258
  const themeCss = themeMode === "light" ? themeLightCss : themeDarkCss;
1033
1259
  const html = generateSavedHtml({
1034
- questions,
1260
+ questions: snapshotQuestions,
1035
1261
  answers: savedResponses,
1036
1262
  meta,
1037
1263
  baseStyles: STYLES,