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 @@
-
-
-
- {{ part.text }}{{ part.text }}
-
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 @@
+
+
+
+{{ t.value }}{{ t.value }}{{ t.value }} {{ t.value }}{{ t.value }}
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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ b.value ?? "" }}
+
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; +}