feat(web): notes render as markdown — markdown-it+DOMPurify pipeline, rich paste
Objective 6 (web half). Storage/sync stay raw markdown (KOReader shows literal source, accepted). Notes-grade syntax: headings, emphasis/ strikethrough, code, links, lists, blockquotes, GFM tables, reference links, footnotes (markdown-it-footnote); images/raw HTML/math excluded (html:false posture + FORBID img/style/form + default safe-scheme URI check). Display sites: annotations-drawer note rows and highlight note lines render pre-sanitized HTML (x-html); render-time sanitize only. Rich paste: both note textareas intercept paste; a text/html clipboard flavor converts via turndown (gfm tables incl. a headingless-table rule emitting pipe syntax; images blanked) and inserts at the caret; plain pastics fall through to the default paste unchanged. Verified live in Brave: bold/italic/strike/code/lists/quote/table/ footnote-ref/reference-link render; <script> renders inert literal text with no alert; javascript: hrefs absent from the DOM; paste of rich HTML lands markdown at the caret; plain pastes untouched.
This commit is contained in:
+8
-1
@@ -15,13 +15,20 @@
|
|||||||
"@bookhoard/foliate-js": "git+https://git.linuxhg.com/Bookhoard/foliate-js.git#b7597d6",
|
"@bookhoard/foliate-js": "git+https://git.linuxhg.com/Bookhoard/foliate-js.git#b7597d6",
|
||||||
"alpinejs": "^3.15.8",
|
"alpinejs": "^3.15.8",
|
||||||
"chart.js": "^4.5.1",
|
"chart.js": "^4.5.1",
|
||||||
|
"dompurify": "^3.4.16",
|
||||||
"highlight.js": "^11.11.1",
|
"highlight.js": "^11.11.1",
|
||||||
"htmx.org": "^2.0.8",
|
"htmx.org": "^2.0.8",
|
||||||
"lunr": "^2.3.9"
|
"lunr": "^2.3.9",
|
||||||
|
"markdown-it": "^15.0.2",
|
||||||
|
"markdown-it-footnote": "^4.0.0",
|
||||||
|
"turndown": "^7.2.4",
|
||||||
|
"turndown-plugin-gfm": "^1.0.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/forms": "^0.5.11",
|
"@tailwindcss/forms": "^0.5.11",
|
||||||
"@tailwindcss/typography": "^0.5.19",
|
"@tailwindcss/typography": "^0.5.19",
|
||||||
|
"@types/markdown-it": "^14.2.0",
|
||||||
|
"@types/turndown": "^5.0.6",
|
||||||
"autoprefixer": "^10.4.27",
|
"autoprefixer": "^10.4.27",
|
||||||
"tailwindcss": "^3.4.19",
|
"tailwindcss": "^3.4.19",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
|
|||||||
+11
-7
@@ -178,7 +178,9 @@ templ Reader(user User, metadata ReaderMetadata) {
|
|||||||
rows="3"
|
rows="3"
|
||||||
class="reader-note-input"
|
class="reader-note-input"
|
||||||
placeholder="Note…"
|
placeholder="Note…"
|
||||||
|
@paste="richNotePaste"
|
||||||
></textarea>
|
></textarea>
|
||||||
|
<p class="text-xs mt-0.5" style="color: var(--text-secondary)">Markdown supported — rich-text paste converts to markdown</p>
|
||||||
<div
|
<div
|
||||||
class="text-xs mt-0.5 text-right"
|
class="text-xs mt-0.5 text-right"
|
||||||
x-show="selectionPopover.note.length > 0"
|
x-show="selectionPopover.note.length > 0"
|
||||||
@@ -634,7 +636,7 @@ templ ReaderAnnotationsDrawer() {
|
|||||||
class="flex-1 min-w-0 block py-2 px-2 rounded hover:bg-gray-700"
|
class="flex-1 min-w-0 block py-2 px-2 rounded hover:bg-gray-700"
|
||||||
>
|
>
|
||||||
<span class="block text-sm truncate" :style="'border-left: 3px solid ' + hl.color + '; padding-left: 0.5rem;'" x-text="hl.text"></span>
|
<span class="block text-sm truncate" :style="'border-left: 3px solid ' + hl.color + '; padding-left: 0.5rem;'" x-text="hl.text"></span>
|
||||||
<span class="block text-xs mt-0.5 truncate pl-2" style="color: var(--text-secondary)" x-show="hl.note" x-text="'📝 ' + hl.note"></span>
|
<span class="block text-xs mt-0.5 truncate pl-2" style="color: var(--text-secondary)" x-show="hl.note" x-html="'📝 ' + hl.noteHtml"></span>
|
||||||
</a>
|
</a>
|
||||||
<button
|
<button
|
||||||
@click="deleteHighlightById(hl.id)"
|
@click="deleteHighlightById(hl.id)"
|
||||||
@@ -657,7 +659,9 @@ templ ReaderAnnotationsDrawer() {
|
|||||||
rows="2"
|
rows="2"
|
||||||
class="reader-note-input"
|
class="reader-note-input"
|
||||||
placeholder="Add a note at the current position…"
|
placeholder="Add a note at the current position…"
|
||||||
|
@paste="richNotePaste"
|
||||||
></textarea>
|
></textarea>
|
||||||
|
<p class="text-xs mt-0.5" style="color: var(--text-secondary)">Markdown supported — rich-text paste converts to markdown</p>
|
||||||
<div
|
<div
|
||||||
class="text-xs mt-0.5 text-right"
|
class="text-xs mt-0.5 text-right"
|
||||||
x-show="newNoteText.length > 0"
|
x-show="newNoteText.length > 0"
|
||||||
@@ -672,12 +676,12 @@ templ ReaderAnnotationsDrawer() {
|
|||||||
+ Add Note
|
+ Add Note
|
||||||
</button>
|
</button>
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<template x-for="note in noteItems" :key="note.id">
|
<template x-for="note in noteItems" :key="note.id">
|
||||||
<div class="reader-hl-row group">
|
<div class="reader-hl-row group">
|
||||||
<a href="#" @click.prevent="closeDrawers()" class="flex-1 min-w-0 block py-2 px-2 rounded hover:bg-gray-700">
|
<a href="#" @click.prevent="closeDrawers()" class="flex-1 min-w-0 block py-2 px-2 rounded hover:bg-gray-700">
|
||||||
<span class="block text-sm truncate" x-text="note.content"></span>
|
<span class="block text-sm truncate reader-note-rich" x-html="note.contentHtml"></span>
|
||||||
<span class="block text-xs mt-0.5 truncate" style="color: var(--text-secondary)" x-text="note.positionLabel"></span>
|
<span class="block text-xs mt-0.5 truncate" style="color: var(--text-secondary)" x-text="note.positionLabel"></span>
|
||||||
</a>
|
</a>
|
||||||
<button
|
<button
|
||||||
@click="deleteNoteById(note.id)"
|
@click="deleteNoteById(note.id)"
|
||||||
class="p-2 rounded hover:bg-red-900/60 opacity-0 group-hover:opacity-100 transition-opacity"
|
class="p-2 rounded hover:bg-red-900/60 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
// Notes render as markdown: storage and every sync payload carry the raw
|
||||||
|
// source string (KOReader devices show it literally — accepted), and every
|
||||||
|
// DISPLAY site renders it through this pipeline. Raw HTML is disabled at the
|
||||||
|
// parser level (markdown-it's default) and DOMPurify strips whatever the
|
||||||
|
// enabled syntax still produced (link hrefs are scheme-whitelisted below),
|
||||||
|
// so a malicious note can render nothing more than styled text.
|
||||||
|
import MarkdownIt from "markdown-it";
|
||||||
|
import footnote from "markdown-it-footnote";
|
||||||
|
import DOMPurify from "dompurify";
|
||||||
|
import TurndownService from "turndown";
|
||||||
|
import { gfm, tables } from "turndown-plugin-gfm";
|
||||||
|
|
||||||
|
const md = new MarkdownIt({ html: false, linkify: false });
|
||||||
|
md.use(footnote);
|
||||||
|
|
||||||
|
export function renderNoteHtml(source: string): string {
|
||||||
|
if (!source) return "";
|
||||||
|
const raw = md.render(source);
|
||||||
|
// Disallowing img/input/style/form by tag and keeping DOMPurify's default
|
||||||
|
// safe-URI scheme check (http/https/mailto + same-page anchors only)
|
||||||
|
// closes the link-injection hole, which is the one markdown pivot into
|
||||||
|
// script execution.
|
||||||
|
const clean = DOMPurify.sanitize(raw, {
|
||||||
|
ALLOWED_ATTR: ["href", "class", "id", "role"],
|
||||||
|
FORBID_TAGS: ["img", "input", "style", "form"],
|
||||||
|
});
|
||||||
|
return clean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compact rows may show a plain line instead of rich markup.
|
||||||
|
export function stripNote(source: string): string {
|
||||||
|
if (!source) return "";
|
||||||
|
const holder = document.createElement("div");
|
||||||
|
holder.innerHTML = renderNoteHtml(source);
|
||||||
|
return (holder.textContent || "").replace(/\s+/g, " ").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----- rich paste: HTML flavor -> markdown source -----
|
||||||
|
const turndown = new TurndownService({
|
||||||
|
// Keep the converted output inside our supported syntax: headings,
|
||||||
|
// emphasis, lists, code, quotes, links, tables. Everything else degrades.
|
||||||
|
headingStyle: "atx",
|
||||||
|
codeBlockStyle: "fenced",
|
||||||
|
bulletListMarker: "-",
|
||||||
|
hr: "---",
|
||||||
|
});
|
||||||
|
turndown.use(gfm);
|
||||||
|
turndown.use(tables);
|
||||||
|
// Images are explicitly out of scope for notes: blank them at convert time
|
||||||
|
// (remove() alone can leave the node's alt-text shape behind).
|
||||||
|
turndown.addRule("noImages", { filter: "img", replacement: () => "" });
|
||||||
|
// The gfm tables service only converts tables with a heading row and keeps
|
||||||
|
// headingless ones as raw HTML — which our no-raw-HTML parser would then
|
||||||
|
// show as literal tags. Convert those to pipe syntax with a blank header.
|
||||||
|
turndown.addRule("tablesNoHeading", {
|
||||||
|
filter: (node: Node) => {
|
||||||
|
const el = node as HTMLElement;
|
||||||
|
if (el.nodeName !== "TABLE" || !el.querySelector) return false;
|
||||||
|
return !el.querySelector("th") && el.querySelectorAll("tr").length > 0;
|
||||||
|
},
|
||||||
|
replacement: (_content: string, node: Node) => {
|
||||||
|
const table = node as HTMLTableElement;
|
||||||
|
const rows = Array.from(table.querySelectorAll("tr")).map((tr) =>
|
||||||
|
Array.from(tr.querySelectorAll("td, th")).map((cell) =>
|
||||||
|
(cell.textContent ?? "").replace(/\s+/g, " ").trim().replace(/\|/g, "\\|"),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const cols = Math.max(1, ...rows.map((r) => r.length));
|
||||||
|
const pad = (r: string[]) => {
|
||||||
|
const copy = [...r];
|
||||||
|
while (copy.length < cols) copy.push("");
|
||||||
|
return copy.map((c) => c || " ");
|
||||||
|
};
|
||||||
|
const head = pad(rows[0] ?? []);
|
||||||
|
const lines = [
|
||||||
|
"| " + head.join(" | ") + " |",
|
||||||
|
"|" + Array.from({ length: cols }, () => " --- ").join("|") + "|",
|
||||||
|
...rows.slice(1).map((r) => "| " + pad(r).join(" | ") + " |"),
|
||||||
|
];
|
||||||
|
return "\n\n" + lines.join("\n") + "\n\n";
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export function htmlToMarkdown(html: string): string {
|
||||||
|
return turndown.turndown(html);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reads the paste event's clipboard and returns markdown converted from the
|
||||||
|
* rich (text/html) flavor, or null when the clipboard holds no usable rich
|
||||||
|
* content (plain pastes must fall through to the browser default). */
|
||||||
|
export function richPasteToMarkdown(event: ClipboardEvent): string | null {
|
||||||
|
const data = event.clipboardData;
|
||||||
|
if (!data) return null;
|
||||||
|
const html = data.getData("text/html");
|
||||||
|
if (!html || !html.trim()) return null;
|
||||||
|
// A rich flavor that is just an HTML wrapper of plain text (some apps
|
||||||
|
// emit <p>...</p> wrappers for everything Chrome copies) still converts
|
||||||
|
// harmlessly to the same text, so no content sniffing is needed here.
|
||||||
|
const markdown = htmlToMarkdown(html);
|
||||||
|
if (!markdown || !markdown.trim()) return null;
|
||||||
|
return markdown;
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import { Alpine } from "../alpine";
|
|||||||
import { loadSettings, saveSettings } from "./settings-manager";
|
import { loadSettings, saveSettings } from "./settings-manager";
|
||||||
import { getToken } from "../storage";
|
import { getToken } from "../storage";
|
||||||
import { showToast } from "../toast";
|
import { showToast } from "../toast";
|
||||||
|
import { renderNoteHtml, richPasteToMarkdown } from "../markdown";
|
||||||
import {
|
import {
|
||||||
extractPdfPages,
|
extractPdfPages,
|
||||||
searchPdfPages,
|
searchPdfPages,
|
||||||
@@ -435,6 +436,7 @@ document.addEventListener("alpine:init", () => {
|
|||||||
id: string;
|
id: string;
|
||||||
text: string;
|
text: string;
|
||||||
note: string;
|
note: string;
|
||||||
|
noteHtml: string;
|
||||||
color: string;
|
color: string;
|
||||||
cfi: string;
|
cfi: string;
|
||||||
cfiEnd: string;
|
cfiEnd: string;
|
||||||
@@ -443,7 +445,7 @@ document.addEventListener("alpine:init", () => {
|
|||||||
pdfPage: number;
|
pdfPage: number;
|
||||||
pdfRects: number[][];
|
pdfRects: number[][];
|
||||||
}[],
|
}[],
|
||||||
noteItems: [] as { id: string; content: string; positionLabel: string }[],
|
noteItems: [] as { id: string; content: string; contentHtml: string; positionLabel: string }[],
|
||||||
annotationsTab: "highlights" as string,
|
annotationsTab: "highlights" as string,
|
||||||
newNoteText: "",
|
newNoteText: "",
|
||||||
// Mirrors the server-side validator cap on note content
|
// Mirrors the server-side validator cap on note content
|
||||||
@@ -1556,6 +1558,7 @@ document.addEventListener("alpine:init", () => {
|
|||||||
id: r.id,
|
id: r.id,
|
||||||
text: r.selection_text ?? "",
|
text: r.selection_text ?? "",
|
||||||
note: r.note_text ?? "",
|
note: r.note_text ?? "",
|
||||||
|
noteHtml: renderNoteHtml(r.note_text ?? ""),
|
||||||
color: r.color ?? "#ffff00",
|
color: r.color ?? "#ffff00",
|
||||||
cfi,
|
cfi,
|
||||||
cfiEnd,
|
cfiEnd,
|
||||||
@@ -1642,6 +1645,7 @@ document.addEventListener("alpine:init", () => {
|
|||||||
this.noteItems = (rows as any[]).map((r) => ({
|
this.noteItems = (rows as any[]).map((r) => ({
|
||||||
id: r.id,
|
id: r.id,
|
||||||
content: r.content ?? "",
|
content: r.content ?? "",
|
||||||
|
contentHtml: renderNoteHtml(r.content ?? ""),
|
||||||
positionLabel: r.position ?? "",
|
positionLabel: r.position ?? "",
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
@@ -2021,6 +2025,21 @@ document.addEventListener("alpine:init", () => {
|
|||||||
noteCountLabel(text: string): string {
|
noteCountLabel(text: string): string {
|
||||||
return `${text.length.toLocaleString()}/${this.noteMaxLength.toLocaleString()}`;
|
return `${text.length.toLocaleString()}/${this.noteMaxLength.toLocaleString()}`;
|
||||||
},
|
},
|
||||||
|
// Rich clipboard (text/html) converts to markdown source at the caret;
|
||||||
|
// plain-only clipboards fall through to the browser default paste.
|
||||||
|
richNotePaste(event: ClipboardEvent) {
|
||||||
|
const markdown = richPasteToMarkdown(event);
|
||||||
|
if (markdown === null) return;
|
||||||
|
event.preventDefault();
|
||||||
|
const el = event.target as HTMLTextAreaElement | null;
|
||||||
|
if (!el) return;
|
||||||
|
const start = el.selectionStart ?? el.value.length;
|
||||||
|
const end = el.selectionEnd ?? start;
|
||||||
|
el.value = el.value.slice(0, start) + markdown + el.value.slice(end);
|
||||||
|
const caret = start + markdown.length;
|
||||||
|
el.setSelectionRange(caret, caret);
|
||||||
|
el.dispatchEvent(new Event("input", { bubbles: true }));
|
||||||
|
},
|
||||||
async addNote(content: string) {
|
async addNote(content: string) {
|
||||||
const token = getToken();
|
const token = getToken();
|
||||||
if (!token || !this.mediaItemId || !content.trim()) return;
|
if (!token || !this.mediaItemId || !content.trim()) return;
|
||||||
|
|||||||
Vendored
+2
@@ -0,0 +1,2 @@
|
|||||||
|
declare module "markdown-it-footnote";
|
||||||
|
declare module "turndown-plugin-gfm";
|
||||||
Reference in New Issue
Block a user