749a60b9fd
- renderMarkdown() accepts interactiveCheckboxes option: removes disabled="" and stamps data-task-index on each checkbox in the marked HTML output - NoteViewerView detects list notes by body content (- [ ] / - [x] pattern) and passes interactiveCheckboxes: true when rendering - onBodyChange() handles checkbox change events: toggles the matching line in the body, optimistically updates the store, then PATCHes the note - prose.css adds .prose--checklist rules for marked output: no bullet, flex row, accent-color, line-through on checked items via :has() Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
82 lines
2.7 KiB
TypeScript
82 lines
2.7 KiB
TypeScript
import { marked, type RendererThis, type Tokens } from "marked";
|
|
import DOMPurify from "dompurify";
|
|
import { linkifyTags, linkifyWikilinks } from "@/utils/tags";
|
|
|
|
function decodeEntities(text: string): string {
|
|
const textarea = document.createElement("textarea");
|
|
textarea.innerHTML = text;
|
|
return textarea.value;
|
|
}
|
|
|
|
export function slugify(text: string): string {
|
|
return text
|
|
.toLowerCase()
|
|
.replace(/<[^>]+>/g, "")
|
|
.replace(/[^\w\s-]/g, "")
|
|
.trim()
|
|
.replace(/\s+/g, "-");
|
|
}
|
|
|
|
export function stripFirstLineTags(text: string): string {
|
|
const match = text.match(/^[ \t]*((?:(?<!\w)#[\w]+(?:\/[\w]+)*[ \t]*)+)\n?/);
|
|
if (!match) return text;
|
|
// Verify every #-token is a tag, not a heading (headings have "# " with space)
|
|
const tokens = match[1].trim().split(/\s+/);
|
|
const allTags = tokens.every((t) => /^#[\w]+(?:\/[\w]+)*$/.test(t));
|
|
if (!allTags) return text;
|
|
return text.slice(match[0].length);
|
|
}
|
|
|
|
const headingRenderer = {
|
|
heading(this: RendererThis, { tokens, depth }: Tokens.Heading): string {
|
|
const text = this.parser.parseInline(tokens);
|
|
const id = slugify(text);
|
|
return `<h${depth} id="${id}">${text}</h${depth}>`;
|
|
},
|
|
};
|
|
|
|
marked.use({ renderer: headingRenderer });
|
|
|
|
const PURIFY_OPTS_FULL = {
|
|
ADD_ATTR: ["data-tag", "data-title", "data-task-index"],
|
|
FORCE_BODY: true,
|
|
};
|
|
|
|
const PURIFY_OPTS_PREVIEW = {
|
|
FORBID_TAGS: ["a", "img"],
|
|
FORCE_BODY: true,
|
|
};
|
|
|
|
export function renderMarkdown(text: string, { interactiveCheckboxes = false } = {}): string {
|
|
const stripped = stripFirstLineTags(text);
|
|
const decoded = decodeEntities(stripped);
|
|
let html = marked(decoded) as string;
|
|
|
|
if (interactiveCheckboxes) {
|
|
// marked renders task-list checkboxes as <input ... disabled="" ...>.
|
|
// Remove disabled and stamp a sequential data-task-index so the viewer
|
|
// can identify which item was toggled regardless of attribute order.
|
|
html = html.replace(/ disabled=""/g, "");
|
|
let taskIdx = 0;
|
|
html = html.replace(/<input ([^>]*type="checkbox"[^>]*)>/g, (_m, attrs) => {
|
|
return `<input data-task-index="${taskIdx++}" ${attrs}>`;
|
|
});
|
|
}
|
|
|
|
const withTags = linkifyTags(html);
|
|
const withLinks = linkifyWikilinks(withTags);
|
|
const sanitized = DOMPurify.sanitize(withLinks, PURIFY_OPTS_FULL);
|
|
// marked escapes ' to ' — replace after sanitization to ensure clean rendering
|
|
return sanitized.replace(/'/g, "'");
|
|
}
|
|
|
|
export function renderPreview(text: string): string {
|
|
const stripped = stripFirstLineTags(text);
|
|
const decoded = decodeEntities(stripped);
|
|
const html = marked(decoded) as string;
|
|
const withTags = linkifyTags(html);
|
|
const withLinks = linkifyWikilinks(withTags);
|
|
const sanitized = DOMPurify.sanitize(withLinks, PURIFY_OPTS_PREVIEW);
|
|
return sanitized.replace(/'/g, "'");
|
|
}
|