Files
jobtrackingapp/job-tracker-ui/src/components/Markdown.tsx
T
cesnimda bb0c0feb4c
CI and Deploy / test (push) Failing after 1m54s
CI and Deploy / deploy (push) Has been skipped
feat(ai): AI Workspace panel on every job application
Phase 5 frontend. A new "AI Workspace" tab in the job details dialog hosts the
five suggestion modules (Job Analysis, Career Match, Cover Letter with tone,
Interview Prep, Application Review) with a generate flow, a dependency-free
markdown renderer for results, and a history sidebar (reuse / compare / copy /
delete). Everything is suggestion-only — copy to keep; nothing auto-applies.

- aiWorkspace.ts (types + API), components/AiWorkspacePanel.tsx,
  components/Markdown.tsx (no HTML injection surface — renders React nodes)
- mounted as the last tab in JobDetailsDialog (index-safe, no reindexing)
- 3 tests (generate flow, cover-letter mode, markdown); tsc + build clean

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 15:23:23 +02:00

68 lines
2.6 KiB
TypeScript

import React from "react";
import { Box, Link, Typography } from "@mui/material";
// Tiny, dependency-free markdown renderer for AI suggestions. Handles the subset the prompts emit:
// #/##/### headings, **bold**, *italic*, [text](url), and - / * bullet lists. Everything is rendered
// as React elements (no dangerouslySetInnerHTML), so there is no HTML-injection surface.
export default function Markdown({ text }: { text: string }) {
const blocks: React.ReactNode[] = [];
const lines = (text ?? "").replace(/\r\n/g, "\n").split("\n");
let list: string[] = [];
const flushList = (key: string) => {
if (list.length === 0) return;
blocks.push(
<Box key={key} component="ul" sx={{ pl: 3, my: 0.5 }}>
{list.map((li, i) => <li key={i}><Typography component="span" variant="body2">{inline(li)}</Typography></li>)}
</Box>,
);
list = [];
};
lines.forEach((raw, i) => {
const line = raw.trimEnd();
const bullet = line.match(/^\s*[-*]\s+(.*)$/);
if (bullet) { list.push(bullet[1]); return; }
flushList(`ul-${i}`);
if (!line.trim()) return;
const heading = line.match(/^(#{1,3})\s+(.*)$/);
if (heading) {
const level = heading[1].length;
blocks.push(
<Typography key={i} variant={level === 1 ? "subtitle1" : "subtitle2"} sx={{ fontWeight: 800, mt: 1, mb: 0.25 }}>
{inline(heading[2])}
</Typography>,
);
return;
}
// A line that is only **bold** reads as a section heading in these prompts.
const boldOnly = line.match(/^\*\*(.+)\*\*:?$/);
if (boldOnly) {
blocks.push(<Typography key={i} variant="subtitle2" sx={{ fontWeight: 800, mt: 1, mb: 0.25 }}>{boldOnly[1]}</Typography>);
return;
}
blocks.push(<Typography key={i} variant="body2" sx={{ mb: 0.5 }}>{inline(line)}</Typography>);
});
flushList("ul-end");
return <Box>{blocks}</Box>;
}
// Inline **bold**, *italic*, [text](url) → React nodes.
function inline(text: string): React.ReactNode[] {
const tokens: React.ReactNode[] = [];
const rx = /\*\*(.+?)\*\*|\*(.+?)\*|\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g;
let last = 0;
let m: RegExpExecArray | null;
let k = 0;
while ((m = rx.exec(text)) !== null) {
if (m.index > last) tokens.push(text.slice(last, m.index));
if (m[1] !== undefined) tokens.push(<strong key={k++}>{m[1]}</strong>);
else if (m[2] !== undefined) tokens.push(<em key={k++}>{m[2]}</em>);
else if (m[3] !== undefined) tokens.push(<Link key={k++} href={m[4]} target="_blank" rel="noopener noreferrer">{m[3]}</Link>);
last = rx.lastIndex;
}
if (last < text.length) tokens.push(text.slice(last));
return tokens;
}