M6: lightweight markdown rendering on note cards
Render a small Markdown subset in the read view — headings (#..###), bullet/ordered lists, blockquote, fenced code, and inline **bold** / *italic* / `code` — alongside the existing [[wiki-links]] (task 1905). Capture stays plain text; only the card render is formatted. Hand-rolled dependency-free parser (notes/markdown.ts) rendered as Vue vnodes (MarkdownText/MarkdownInline), never v-html, so there is no HTML-injection surface — matches the no-heavy-dep ethos. Headings require '# ' (space), so a #tag (no space) stays plain text and is never mistaken for a heading; the two coexist. Replaces LinkedText (links-only) on the card; LinkedText deleted. Pure frontend — no backend/DB/migration. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
This commit is contained in:
@@ -1,63 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { computed } from "vue";
|
|
||||||
import { useRouter } from "vue-router";
|
|
||||||
import { useNotesStore } from "../stores/notes";
|
|
||||||
import { useTitlesStore } from "../stores/titles";
|
|
||||||
|
|
||||||
const props = defineProps<{ text: string }>();
|
|
||||||
|
|
||||||
const router = useRouter();
|
|
||||||
const notes = useNotesStore();
|
|
||||||
const titles = useTitlesStore();
|
|
||||||
|
|
||||||
interface Part {
|
|
||||||
text: string;
|
|
||||||
link: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Split the body into plain segments and [[wiki-link]] segments. Link segments
|
|
||||||
// are clickable here on the card: they resolve the title and open the target
|
|
||||||
// note (creating it first if it doesn't exist yet).
|
|
||||||
const parts = computed<Part[]>(() => {
|
|
||||||
const result: Part[] = [];
|
|
||||||
const re = /\[\[([^[\]]+)\]\]/g;
|
|
||||||
let last = 0;
|
|
||||||
let match: RegExpExecArray | null;
|
|
||||||
while ((match = re.exec(props.text)) !== null) {
|
|
||||||
if (match.index > last) result.push({ text: props.text.slice(last, match.index), link: false });
|
|
||||||
result.push({ text: match[1].trim(), link: true });
|
|
||||||
last = match.index + match[0].length;
|
|
||||||
}
|
|
||||||
if (last < props.text.length) result.push({ text: props.text.slice(last), link: false });
|
|
||||||
return result;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Reuses the command palette's open mechanism: navigate to the board with
|
|
||||||
// ?open=<id>, which BoardView watches to open the editor.
|
|
||||||
async function follow(title: string) {
|
|
||||||
await titles.load();
|
|
||||||
let hit = titles.resolve(title);
|
|
||||||
if (!hit) {
|
|
||||||
const created = await notes.createTitled(title);
|
|
||||||
await titles.reload();
|
|
||||||
hit = { id: created.id, title: created.title ?? title };
|
|
||||||
}
|
|
||||||
void router.push({ path: "/", query: { open: hit.id } });
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<span class="whitespace-pre-wrap break-words"
|
|
||||||
><template v-for="(part, i) in parts" :key="i"
|
|
||||||
><span
|
|
||||||
v-if="part.link"
|
|
||||||
role="link"
|
|
||||||
tabindex="0"
|
|
||||||
class="cursor-pointer font-medium text-brand-700 underline-offset-2 hover:underline dark:text-brand"
|
|
||||||
@click.stop="follow(part.text)"
|
|
||||||
@keydown.enter.stop.prevent="follow(part.text)"
|
|
||||||
>{{ part.text }}</span
|
|
||||||
><template v-else>{{ part.text }}</template></template
|
|
||||||
></span
|
|
||||||
>
|
|
||||||
</template>
|
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useRouter } from "vue-router";
|
||||||
|
import { useNotesStore } from "../stores/notes";
|
||||||
|
import { useTitlesStore } from "../stores/titles";
|
||||||
|
import type { InlineToken } from "../notes/markdown";
|
||||||
|
|
||||||
|
defineProps<{ tokens: InlineToken[] }>();
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const notes = useNotesStore();
|
||||||
|
const titles = useTitlesStore();
|
||||||
|
|
||||||
|
// A [[wiki-link]] on the card: resolve the title and open the target note (creating
|
||||||
|
// it first if it doesn't exist), via the board's ?open=<id> mechanism.
|
||||||
|
async function follow(title: string) {
|
||||||
|
await titles.load();
|
||||||
|
let hit = titles.resolve(title);
|
||||||
|
if (!hit) {
|
||||||
|
const created = await notes.createTitled(title);
|
||||||
|
await titles.reload();
|
||||||
|
hit = { id: created.id, title: created.title ?? title };
|
||||||
|
}
|
||||||
|
void router.push({ path: "/", query: { open: hit.id } });
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- Rendered tightly (no whitespace between tokens) so a token's own leading/trailing
|
||||||
|
spaces are preserved and no extra spaces are introduced. -->
|
||||||
|
<template
|
||||||
|
><template v-for="(t, i) in tokens" :key="i"
|
||||||
|
><span
|
||||||
|
v-if="t.type === 'link'"
|
||||||
|
role="link"
|
||||||
|
tabindex="0"
|
||||||
|
class="cursor-pointer font-medium text-brand-700 underline-offset-2 hover:underline dark:text-brand"
|
||||||
|
@click.stop="follow(t.value)"
|
||||||
|
@keydown.enter.stop.prevent="follow(t.value)"
|
||||||
|
>{{ t.value }}</span
|
||||||
|
><strong v-else-if="t.type === 'bold'" class="font-semibold">{{ t.value }}</strong
|
||||||
|
><em v-else-if="t.type === 'italic'">{{ t.value }}</em
|
||||||
|
><code
|
||||||
|
v-else-if="t.type === 'code'"
|
||||||
|
class="rounded bg-black/5 px-1 py-0.5 font-mono text-[0.85em] dark:bg-white/10"
|
||||||
|
>{{ t.value }}</code
|
||||||
|
><template v-else>{{ t.value }}</template></template
|
||||||
|
></template
|
||||||
|
>
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from "vue";
|
||||||
|
import { parseMarkdown } from "../notes/markdown";
|
||||||
|
import MarkdownInline from "./MarkdownInline.vue";
|
||||||
|
|
||||||
|
const props = defineProps<{ text: string }>();
|
||||||
|
const blocks = computed(() => parseMarkdown(props.text));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="space-y-1.5 break-words">
|
||||||
|
<template v-for="(b, i) in blocks" :key="i">
|
||||||
|
<h3 v-if="b.type === 'h1'" class="text-base font-bold"><MarkdownInline :tokens="b.inline ?? []" /></h3>
|
||||||
|
<h4 v-else-if="b.type === 'h2'" class="text-sm font-bold"><MarkdownInline :tokens="b.inline ?? []" /></h4>
|
||||||
|
<h5 v-else-if="b.type === 'h3'" class="text-sm font-semibold"><MarkdownInline :tokens="b.inline ?? []" /></h5>
|
||||||
|
<blockquote
|
||||||
|
v-else-if="b.type === 'quote'"
|
||||||
|
class="whitespace-pre-wrap border-l-2 border-neutral-300 pl-2 text-neutral-600 dark:border-neutral-600 dark:text-neutral-400"
|
||||||
|
>
|
||||||
|
<MarkdownInline :tokens="b.inline ?? []" />
|
||||||
|
</blockquote>
|
||||||
|
<ul v-else-if="b.type === 'ul'" class="list-disc space-y-0.5 pl-5">
|
||||||
|
<li v-for="(it, j) in b.items ?? []" :key="j"><MarkdownInline :tokens="it" /></li>
|
||||||
|
</ul>
|
||||||
|
<ol v-else-if="b.type === 'ol'" class="list-decimal space-y-0.5 pl-5">
|
||||||
|
<li v-for="(it, j) in b.items ?? []" :key="j"><MarkdownInline :tokens="it" /></li>
|
||||||
|
</ol>
|
||||||
|
<pre
|
||||||
|
v-else-if="b.type === 'pre'"
|
||||||
|
class="overflow-x-auto whitespace-pre-wrap rounded-md bg-black/5 p-2 font-mono text-xs dark:bg-white/10"
|
||||||
|
>{{ b.value ?? "" }}</pre
|
||||||
|
>
|
||||||
|
<p v-else class="whitespace-pre-wrap"><MarkdownInline :tokens="b.inline ?? []" /></p>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
} from "../notes/colors";
|
} from "../notes/colors";
|
||||||
import type { Note } from "../stores/notes";
|
import type { Note } from "../stores/notes";
|
||||||
import Icon from "./Icon.vue";
|
import Icon from "./Icon.vue";
|
||||||
import LinkedText from "./LinkedText.vue";
|
import MarkdownText from "./MarkdownText.vue";
|
||||||
import NoteChecklist from "./NoteChecklist.vue";
|
import NoteChecklist from "./NoteChecklist.vue";
|
||||||
import { formatReminder, isOverdue } from "../notes/datetime";
|
import { formatReminder, isOverdue } from "../notes/datetime";
|
||||||
|
|
||||||
@@ -175,7 +175,7 @@ onBeforeUnmount(() => document.removeEventListener("mousedown", onDocMousedown))
|
|||||||
{{ note.title }}
|
{{ note.title }}
|
||||||
</h3>
|
</h3>
|
||||||
<div v-if="note.body" class="text-sm text-neutral-700 dark:text-neutral-300">
|
<div v-if="note.body" class="text-sm text-neutral-700 dark:text-neutral-300">
|
||||||
<LinkedText :text="note.body" />
|
<MarkdownText :text="note.body" />
|
||||||
</div>
|
</div>
|
||||||
<p v-if="!note.title && !note.body && !note.attachments.length" class="text-sm italic text-neutral-400">
|
<p v-if="!note.title && !note.body && !note.attachments.length" class="text-sm italic text-neutral-400">
|
||||||
Empty note
|
Empty note
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user