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
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>
219 lines
8.3 KiB
TypeScript
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;
|
|
}
|