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/README.md +91 -10
- package/form/index.html +6 -3
- package/form/script.js +311 -32
- package/form/styles.css +318 -57
- package/form/themes/default-dark.css +7 -1
- package/form/themes/default-light.css +7 -0
- package/form/themes/tufte-dark.css +10 -4
- package/form/themes/tufte-light.css +10 -4
- package/index.ts +118 -12
- package/package.json +7 -1
- package/schema.ts +108 -5
- package/server.ts +238 -12
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
|
|
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>${
|
|
539
|
+
.map((v) => `<li>${savedAnswerItemHtml(String(v), q)}</li>`)
|
|
424
540
|
.join("")}</ul></div>`;
|
|
425
541
|
} else {
|
|
426
|
-
answerHtml = `<div class="saved-answer">${
|
|
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="
|
|
444
|
-
<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,
|