Files
thoughtsync/frontend/src/notes/markdown.ts
T
bvandeusenandClaude Opus 5 d9e5753dc2
CI & Build / Build now, or wait for Android? (push) Successful in 2s
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Successful in 10s
CI & Build / integration (push) Successful in 16s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 2m37s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m53s
Desktop (Tauri) / Update manifest (push) Skipped
Android / Kotlin + Rust (APK) (push) Canceled after 3m25s
board: a tag in the prose is coloured where it sits, not printed twice
A tagged note was showing its tag twice — once where it was typed, once as a
chip — and the duplicate was the loud copy. Now the chip row carries only what
the body cannot say (a tag lifted off its own line, a label from the picker),
and a `#tag` left mid-sentence is tinted in place.

Which characters are a tag is asked of the CORE, the way the card already asks
it which lines are checklist items: `extract_tag_spans` keeps the spans
`extract_tags` throws away, and `body_tags` hands them to Kotlin. Offsets are
UTF-16 code units, because `AnnotatedString` and JS both index that way and a
char index lands mid-token the first time somebody writes an emoji. The web
keeps its own matcher in markdown.ts, mirroring `line_tags` case for case.

The inline ink is its own table, one Tailwind step deeper than the chip's. A
chip brings its own -100 fill and reads against that alone; inline text sits on
whatever the card is, including a gray-tagged card at neutral-200 — where the
chip's -700 measured 3.98 (green), 4.11 (orange) and 4.34 (teal), under the 4.5
body text needs. At -800/-300 every hue lands 5.63-12.01 light and 7.20-10.84
dark across every palette and generated fill.

Chips now carry the `#` on every surface. The via_tag branch that used to
decide it is gone from the card, and Android's row said no hash at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 22:20:17 -04:00

219 lines
8.3 KiB
TypeScript

// 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 =
/(`[^`]+`)|(\*\*[^*]+\*\*)|(\*[^*]+\*)|(_[^_]+_)|((?<![\p{Alphabetic}\p{N}_#-])#\p{Alphabetic}[\p{Alphabetic}\p{N}_-]*)/gu;
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: "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;
}