fix/markdown-task-list-rendering: Render markdown checklists as task lists on update_page #12

Merged
aleleba merged 1 commits from fix/markdown-task-list-rendering into main 2026-06-11 00:02:09 -06:00
3 changed files with 49 additions and 8 deletions
Showing only changes of commit 45958ec063 - Show all commits

View File

@@ -2,10 +2,9 @@ import { HocuspocusProvider } from "@hocuspocus/provider";
import { TiptapTransformer } from "@hocuspocus/transformer"; import { TiptapTransformer } from "@hocuspocus/transformer";
import * as Y from "yjs"; import * as Y from "yjs";
import WebSocket from "ws"; import WebSocket from "ws";
import { marked } from "marked";
import { generateJSON } from "@tiptap/html";
import { JSDOM } from "jsdom"; import { JSDOM } from "jsdom";
import { tiptapExtensions } from "./tiptap-extensions.js"; import { tiptapExtensions } from "./tiptap-extensions.js";
import { markdownToTiptapJson } from "./markdown-to-json.js";
// Setup DOM environment for Tiptap HTML parsing in Node.js // Setup DOM environment for Tiptap HTML parsing in Node.js
const dom = new JSDOM("<!DOCTYPE html><html><body></body></html>"); const dom = new JSDOM("<!DOCTYPE html><html><body></body></html>");
@@ -29,11 +28,9 @@ export async function updatePageContentRealtime(
`Token prefix: ${collabToken ? collabToken.substring(0, 5) : "NONE"}...`, `Token prefix: ${collabToken ? collabToken.substring(0, 5) : "NONE"}...`,
); );
// 1. Convert Markdown to HTML // 1. Convert Markdown to ProseMirror JSON
const html = await marked.parse(markdownContent); // (handles GFM task lists; see markdown-to-json.ts)
const tiptapJson = await markdownToTiptapJson(markdownContent);
// 2. Convert HTML to ProseMirror JSON
const tiptapJson = generateJSON(html, tiptapExtensions);
// 3. Setup Hocuspocus Provider // 3. Setup Hocuspocus Provider
const ydoc = new Y.Doc(); const ydoc = new Y.Doc();

View File

@@ -16,10 +16,47 @@ if (typeof global.document === "undefined") {
global.Element = dom.window.Element; 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 `<ul><li><input disabled type="checkbox"> foo</li></ul>`,
* 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 `<input>` is dropped and the list degrades to a
* plain bulletList. We walk each checkbox, tag its ancestor `<li>` and list, and
* remove the now-redundant input.
*/
function normalizeTaskLists(html: string): string {
const dom = new JSDOM(`<!DOCTYPE html><html><body>${html}</body></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 * Convert markdown string to TipTap-compatible ProseMirror JSON
*/ */
export async function markdownToTiptapJson(markdown: string): Promise<any> { export async function markdownToTiptapJson(markdown: string): Promise<any> {
const html = await marked.parse(markdown); const html = await marked.parse(markdown);
return generateJSON(html, tiptapExtensions); const normalized = normalizeTaskLists(html);
return generateJSON(normalized, tiptapExtensions);
} }

View File

@@ -2,6 +2,7 @@ import StarterKit from "@tiptap/starter-kit";
import Image from "@tiptap/extension-image"; import Image from "@tiptap/extension-image";
import Link from "@tiptap/extension-link"; import Link from "@tiptap/extension-link";
import { TableKit } from "@tiptap/extension-table"; import { TableKit } from "@tiptap/extension-table";
import { TaskList, TaskItem } from "@tiptap/extension-list";
// Define extensions compatible with standard Markdown features // Define extensions compatible with standard Markdown features
// We use the default Tiptap extensions to handle basic content // We use the default Tiptap extensions to handle basic content
@@ -19,4 +20,10 @@ export const tiptapExtensions = [
openOnClick: false, openOnClick: false,
}), }),
TableKit, TableKit,
// Task lists (GFM checkboxes: `- [ ]` / `- [x]`).
// marked emits `<ul><li><input type="checkbox">`, which is normalized to the
// `data-type="taskList"`/`data-type="taskItem"` markup these expect by
// normalizeTaskLists() in markdown-to-json.ts before generateJSON runs.
TaskList,
TaskItem.configure({ nested: true }),
]; ];