diff --git a/frontend/src/components/LinkedText.vue b/frontend/src/components/LinkedText.vue deleted file mode 100644 index ce4cd11..0000000 --- a/frontend/src/components/LinkedText.vue +++ /dev/null @@ -1,63 +0,0 @@ - - - diff --git a/frontend/src/components/MarkdownInline.vue b/frontend/src/components/MarkdownInline.vue new file mode 100644 index 0000000..762db0a --- /dev/null +++ b/frontend/src/components/MarkdownInline.vue @@ -0,0 +1,47 @@ + + + + diff --git a/frontend/src/components/MarkdownText.vue b/frontend/src/components/MarkdownText.vue new file mode 100644 index 0000000..30719fe --- /dev/null +++ b/frontend/src/components/MarkdownText.vue @@ -0,0 +1,36 @@ + + + diff --git a/frontend/src/components/NoteCard.vue b/frontend/src/components/NoteCard.vue index 692fc2b..f292255 100644 --- a/frontend/src/components/NoteCard.vue +++ b/frontend/src/components/NoteCard.vue @@ -11,7 +11,7 @@ import { } from "../notes/colors"; import type { Note } from "../stores/notes"; import Icon from "./Icon.vue"; -import LinkedText from "./LinkedText.vue"; +import MarkdownText from "./MarkdownText.vue"; import NoteChecklist from "./NoteChecklist.vue"; import { formatReminder, isOverdue } from "../notes/datetime"; @@ -175,7 +175,7 @@ onBeforeUnmount(() => document.removeEventListener("mousedown", onDocMousedown)) {{ note.title }}
- +

Empty note diff --git a/frontend/src/notes/markdown.ts b/frontend/src/notes/markdown.ts new file mode 100644 index 0000000..3908a60 --- /dev/null +++ b/frontend/src/notes/markdown.ts @@ -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; +}