diff --git a/src/lib/collaboration.ts b/src/lib/collaboration.ts index 452e340..75a592b 100644 --- a/src/lib/collaboration.ts +++ b/src/lib/collaboration.ts @@ -2,10 +2,9 @@ import { HocuspocusProvider } from "@hocuspocus/provider"; import { TiptapTransformer } from "@hocuspocus/transformer"; import * as Y from "yjs"; import WebSocket from "ws"; -import { marked } from "marked"; -import { generateJSON } from "@tiptap/html"; import { JSDOM } from "jsdom"; import { tiptapExtensions } from "./tiptap-extensions.js"; +import { markdownToTiptapJson } from "./markdown-to-json.js"; // Setup DOM environment for Tiptap HTML parsing in Node.js const dom = new JSDOM(""); @@ -29,11 +28,9 @@ export async function updatePageContentRealtime( `Token prefix: ${collabToken ? collabToken.substring(0, 5) : "NONE"}...`, ); - // 1. Convert Markdown to HTML - const html = await marked.parse(markdownContent); - - // 2. Convert HTML to ProseMirror JSON - const tiptapJson = generateJSON(html, tiptapExtensions); + // 1. Convert Markdown to ProseMirror JSON + // (handles GFM task lists; see markdown-to-json.ts) + const tiptapJson = await markdownToTiptapJson(markdownContent); // 3. Setup Hocuspocus Provider const ydoc = new Y.Doc(); diff --git a/src/lib/markdown-to-json.ts b/src/lib/markdown-to-json.ts index d1cfda9..8cf74f9 100644 --- a/src/lib/markdown-to-json.ts +++ b/src/lib/markdown-to-json.ts @@ -16,10 +16,47 @@ if (typeof global.document === "undefined") { global.Element = dom.window.Element; } +/** + * Convert marked's GFM task-list HTML into the markup TipTap's TaskList/TaskItem + * extensions expect. + * + * marked renders `- [ ] foo` as ``, + * but TipTap's parseHTML rules only match `ul[data-type="taskList"]` and + * `li[data-type="taskItem"]` (with the checked state held in `data-checked`). + * Without this normalization the `` is dropped and the list degrades to a + * plain bulletList. We walk each checkbox, tag its ancestor `
  • ` and list, and + * remove the now-redundant input. + */ +function normalizeTaskLists(html: string): string { + const dom = new JSDOM(`${html}`); + const doc = dom.window.document; + + const checkboxes = doc.querySelectorAll('input[type="checkbox"]'); + checkboxes.forEach((input) => { + const li = input.closest("li"); + if (!li) return; + + const checked = + (input as HTMLInputElement).checked || input.hasAttribute("checked"); + li.setAttribute("data-type", "taskItem"); + li.setAttribute("data-checked", checked ? "true" : "false"); + + const list = li.closest("ul, ol"); + if (list) { + list.setAttribute("data-type", "taskList"); + } + + input.remove(); + }); + + return doc.body.innerHTML; +} + /** * Convert markdown string to TipTap-compatible ProseMirror JSON */ export async function markdownToTiptapJson(markdown: string): Promise { const html = await marked.parse(markdown); - return generateJSON(html, tiptapExtensions); + const normalized = normalizeTaskLists(html); + return generateJSON(normalized, tiptapExtensions); } diff --git a/src/lib/tiptap-extensions.ts b/src/lib/tiptap-extensions.ts index 380818c..ca15212 100644 --- a/src/lib/tiptap-extensions.ts +++ b/src/lib/tiptap-extensions.ts @@ -2,6 +2,7 @@ import StarterKit from "@tiptap/starter-kit"; import Image from "@tiptap/extension-image"; import Link from "@tiptap/extension-link"; import { TableKit } from "@tiptap/extension-table"; +import { TaskList, TaskItem } from "@tiptap/extension-list"; // Define extensions compatible with standard Markdown features // We use the default Tiptap extensions to handle basic content @@ -19,4 +20,10 @@ export const tiptapExtensions = [ openOnClick: false, }), TableKit, + // Task lists (GFM checkboxes: `- [ ]` / `- [x]`). + // marked emits `