M6: lightweight markdown rendering on note cards
Render a small Markdown subset in the read view — headings (#..###), bullet/ordered lists, blockquote, fenced code, and inline **bold** / *italic* / `code` — alongside the existing [[wiki-links]] (task 1905). Capture stays plain text; only the card render is formatted. Hand-rolled dependency-free parser (notes/markdown.ts) rendered as Vue vnodes (MarkdownText/MarkdownInline), never v-html, so there is no HTML-injection surface — matches the no-heavy-dep ethos. Headings require '# ' (space), so a #tag (no space) stays plain text and is never mistaken for a heading; the two coexist. Replaces LinkedText (links-only) on the card; LinkedText deleted. Pure frontend — no backend/DB/migration. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
This commit is contained in:
@@ -0,0 +1,132 @@
|
||||
// A tiny, dependency-free Markdown parser for the READ view (note cards). Capture
|
||||
// stays plain text; this only formats what's shown. We render the parsed tree as
|
||||
// Vue vnodes (never v-html), so there is no HTML-injection surface. Deliberately a
|
||||
// small subset — headings (#..###), unordered/ordered lists, blockquote, fenced
|
||||
// code, and inline **bold** / *italic* / _italic_ / `code` — plus ThoughtSync's own
|
||||
// [[wiki-links]]. Note: headings need a space after `#`, so a #tag (no space) is
|
||||
// left as plain text and never mistaken for a heading.
|
||||
|
||||
export interface InlineToken {
|
||||
type: "text" | "bold" | "italic" | "code" | "link";
|
||||
value: string;
|
||||
}
|
||||
|
||||
// Flat (non-discriminated) shape on purpose — keeps template type-checking simple.
|
||||
export interface Block {
|
||||
type: "p" | "h1" | "h2" | "h3" | "quote" | "ul" | "ol" | "pre";
|
||||
inline?: InlineToken[];
|
||||
items?: InlineToken[][];
|
||||
value?: string;
|
||||
}
|
||||
|
||||
// Order matters: links + code are matched before emphasis so their contents aren't
|
||||
// re-parsed; bold (**) before italic (*). Emphasis does not nest (v1).
|
||||
const INLINE_RE = /(\[\[[^[\]]+\]\])|(`[^`]+`)|(\*\*[^*]+\*\*)|(\*[^*]+\*)|(_[^_]+_)/g;
|
||||
|
||||
export function parseInline(text: string): InlineToken[] {
|
||||
const tokens: InlineToken[] = [];
|
||||
let last = 0;
|
||||
let m: RegExpExecArray | null;
|
||||
INLINE_RE.lastIndex = 0;
|
||||
while ((m = INLINE_RE.exec(text)) !== null) {
|
||||
if (m.index > last) tokens.push({ type: "text", value: text.slice(last, m.index) });
|
||||
const raw = m[0];
|
||||
if (m[1]) tokens.push({ type: "link", value: raw.slice(2, -2).trim() });
|
||||
else if (m[2]) tokens.push({ type: "code", value: raw.slice(1, -1) });
|
||||
else if (m[3]) tokens.push({ type: "bold", value: raw.slice(2, -2) });
|
||||
else tokens.push({ type: "italic", value: raw.slice(1, -1) });
|
||||
last = m.index + raw.length;
|
||||
}
|
||||
if (last < text.length) tokens.push({ type: "text", value: text.slice(last) });
|
||||
return tokens;
|
||||
}
|
||||
|
||||
export function parseMarkdown(text: string): Block[] {
|
||||
const lines = (text ?? "").split("\n");
|
||||
const blocks: Block[] = [];
|
||||
let paragraph: string[] = [];
|
||||
let i = 0;
|
||||
|
||||
const flushPara = () => {
|
||||
if (paragraph.length) {
|
||||
blocks.push({ type: "p", inline: parseInline(paragraph.join("\n")) });
|
||||
paragraph = [];
|
||||
}
|
||||
};
|
||||
|
||||
while (i < lines.length) {
|
||||
const line = lines[i];
|
||||
|
||||
// Fenced code block: ``` … ``` (kept verbatim, not inline-parsed).
|
||||
if (line.trimStart().startsWith("```")) {
|
||||
flushPara();
|
||||
const buf: string[] = [];
|
||||
i++;
|
||||
while (i < lines.length && !lines[i].trimStart().startsWith("```")) {
|
||||
buf.push(lines[i]);
|
||||
i++;
|
||||
}
|
||||
i++; // consume the closing fence (or fall off the end)
|
||||
blocks.push({ type: "pre", value: buf.join("\n") });
|
||||
continue;
|
||||
}
|
||||
|
||||
// Heading: #, ##, ### followed by a space.
|
||||
const h = /^(#{1,3})\s+(.*)$/.exec(line);
|
||||
if (h) {
|
||||
flushPara();
|
||||
const level = h[1].length;
|
||||
blocks.push({ type: level === 1 ? "h1" : level === 2 ? "h2" : "h3", inline: parseInline(h[2]) });
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Blockquote: consecutive `>` lines merge into one quote.
|
||||
if (/^\s*>\s?/.test(line)) {
|
||||
flushPara();
|
||||
const buf: string[] = [];
|
||||
while (i < lines.length && /^\s*>\s?/.test(lines[i])) {
|
||||
buf.push(lines[i].replace(/^\s*>\s?/, ""));
|
||||
i++;
|
||||
}
|
||||
blocks.push({ type: "quote", inline: parseInline(buf.join("\n")) });
|
||||
continue;
|
||||
}
|
||||
|
||||
// Unordered list: -, *, or + then a space.
|
||||
if (/^\s*[-*+]\s+/.test(line)) {
|
||||
flushPara();
|
||||
const items: InlineToken[][] = [];
|
||||
while (i < lines.length && /^\s*[-*+]\s+/.test(lines[i])) {
|
||||
items.push(parseInline(lines[i].replace(/^\s*[-*+]\s+/, "")));
|
||||
i++;
|
||||
}
|
||||
blocks.push({ type: "ul", items });
|
||||
continue;
|
||||
}
|
||||
|
||||
// Ordered list: `1. ` etc.
|
||||
if (/^\s*\d+\.\s+/.test(line)) {
|
||||
flushPara();
|
||||
const items: InlineToken[][] = [];
|
||||
while (i < lines.length && /^\s*\d+\.\s+/.test(lines[i])) {
|
||||
items.push(parseInline(lines[i].replace(/^\s*\d+\.\s+/, "")));
|
||||
i++;
|
||||
}
|
||||
blocks.push({ type: "ol", items });
|
||||
continue;
|
||||
}
|
||||
|
||||
// Blank line ends a paragraph; other lines accumulate into one (newlines kept).
|
||||
if (line.trim() === "") {
|
||||
flushPara();
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
paragraph.push(line);
|
||||
i++;
|
||||
}
|
||||
|
||||
flushPara();
|
||||
return blocks;
|
||||
}
|
||||
Reference in New Issue
Block a user