// 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`. 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 { /** `tag` carries the NAME, without the leading `#` — it is both what gets looked up * for a colour and what is rendered, so the renderer puts the `#` back. */ type: "text" | "bold" | "italic" | "code" | "tag"; 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" | "task"; inline?: InlineToken[]; items?: InlineToken[][]; value?: string; /** `task` only: one entry per `items` entry, parallel by position. */ tasks?: TaskMeta[]; } export interface TaskMeta { /** This item's ordinal among ALL task lines in the body, in document order. * That is the id the server and the native clients address an item by, so a * checkbox can be toggled straight from it. Counted across blocks, not within * one, and unaffected by the card truncating the body — the card only ever * drops lines from the END. */ index: number; checked: boolean; } // Order matters: code is matched before emphasis so its contents aren't re-parsed; // bold (**) before italic (*). Emphasis does not nest (v1). // // `[[wiki-links]]` used to lead this alternation. They are gone (note 2897) — this is // a capture-and-recall surface, and a linking system is organization. `[[text]]` now // renders as the literal characters someone typed, which is what it always was. // `#tag` is LAST in the alternation and that is load-bearing twice over. JS tries // alternatives left to right, so a `#tag` inside backticks is claimed by `code` first // and stays literal — matching the core, where a fenced block's contents are code. // And a tag is the one token here that is not delimiter-based, so it must not get a // chance to start inside `**bold #x**`. // // The grammar MIRRORS `line_tags` in core/src/local/derive.rs, which is the definition: // a `#` at a word boundary (the preceding character is neither a tag character nor // another `#`, so `a#b` and `##x` are not tags), a letter immediately after it, then // alphanumerics, `_` and `-`. Rust's `is_alphanumeric` is `Alphabetic | N`, hence the // property escapes rather than `\w` — and hence the `u` flag. // // A heading cannot collide with this: `parseMarkdown` requires a space after the `#`s, // which `#tag` by definition does not have. const INLINE_RE = /(`[^`]+`)|(\*\*[^*]+\*\*)|(\*[^*]+\*)|(_[^_]+_)|((? last) tokens.push({ type: "text", value: text.slice(last, m.index) }); const raw = m[0]; if (m[1]) tokens.push({ type: "code", value: raw.slice(1, -1) }); else if (m[2]) tokens.push({ type: "bold", value: raw.slice(2, -2) }); else if (m[5]) tokens.push({ type: "tag", value: raw.slice(1) }); 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; } // A checklist item: the third implementation of one grammar, alongside // core/src/local/derive.rs and src/thoughtsync/notes/checklist.py. A difference // between any two of them is a checklist that changes shape when it syncs (M304). // // `-` and `*` only, deliberately, even though the `ul` matcher below also takes `+`. // The other two implementations do not take `+`, and one grammar in three places has // to be one grammar; a `+ [ ] x` line renders as an ordinary bullet everywhere, // which is at least consistent. const TASK_RE = /^\s*[-*] +\[([ xX])\](?: +(.*))?$/; export interface TaskLine { checked: boolean; text: string; } /** One task line's parts, or null when the line is prose. * * Exported so the EDITOR's block split (notes/blocks.ts) and this read-view parser * agree by construction rather than by comment. One grammar, one matcher. */ export function parseTaskLine(line: string): TaskLine | null { const m = TASK_RE.exec(line); return m ? { checked: m[1] !== " ", text: m[2] ?? "" } : null; } /** One item as the body line that stores it, in canonical form — `- [x] `, lowercase, * and no trailing space when the item is empty so a round trip does not grow it. */ export function renderTaskLine(text: string, checked: boolean): string { const mark = checked ? "x" : " "; return text ? `- [${mark}] ${text}` : `- [${mark}]`; } export function parseMarkdown(text: string): Block[] { const lines = (text ?? "").split("\n"); const blocks: Block[] = []; let paragraph: string[] = []; let i = 0; // Runs across the whole document, not per block, because that is what the item's // id means everywhere else. let taskIndex = 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; } // Task list, BEFORE the plain bullet below — which would otherwise swallow // `- [ ] x` as an ordinary list item and leave the brackets showing. Same // ordering reason as `code` being matched before emphasis in INLINE_RE. if (parseTaskLine(line)) { flushPara(); const items: InlineToken[][] = []; const tasks: TaskMeta[] = []; while (i < lines.length) { const task = parseTaskLine(lines[i]); if (!task) break; items.push(parseInline(task.text)); tasks.push({ index: taskIndex, checked: task.checked }); taskIndex++; i++; } blocks.push({ type: "task", items, tasks }); 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; }