fix: Deserialization of markdown checkboxes in table cells (#11217)

* Checkboxes in table cells

* test
This commit is contained in:
Tom Moor
2026-01-18 23:06:24 -05:00
committed by GitHub
parent 06e005cab9
commit f0ba8c819f
4 changed files with 205 additions and 14 deletions
@@ -662,5 +662,111 @@ describe("ProsemirrorHelper", () => {
expect(secondText.type.name).toBe("text");
expect(secondText.text).toBe("Next line");
});
it("should convert markdown with unchecked checklist items", () => {
const markdown = "- [ ] Task one\n- [ ] Task two";
const doc = ProsemirrorHelper.toProsemirror(markdown);
expect(doc.type.name).toBe("doc");
expect(doc.content.childCount).toBe(1);
const checkboxList = doc.content.child(0);
expect(checkboxList.type.name).toBe("checkbox_list");
expect(checkboxList.content.childCount).toBe(2);
// Check first item
const firstItem = checkboxList.content.child(0);
expect(firstItem.type.name).toBe("checkbox_item");
expect(firstItem.attrs.checked).toBe(false);
expect(firstItem.textContent).toBe("Task one");
// Check second item
const secondItem = checkboxList.content.child(1);
expect(secondItem.type.name).toBe("checkbox_item");
expect(secondItem.attrs.checked).toBe(false);
expect(secondItem.textContent).toBe("Task two");
});
it("should convert markdown with checked checklist items", () => {
const markdown = "- [x] Completed task\n- [X] Another completed";
const doc = ProsemirrorHelper.toProsemirror(markdown);
expect(doc.type.name).toBe("doc");
expect(doc.content.childCount).toBe(1);
const checkboxList = doc.content.child(0);
expect(checkboxList.type.name).toBe("checkbox_list");
expect(checkboxList.content.childCount).toBe(2);
// Check first item is checked
const firstItem = checkboxList.content.child(0);
expect(firstItem.type.name).toBe("checkbox_item");
expect(firstItem.attrs.checked).toBe(true);
expect(firstItem.textContent).toBe("Completed task");
// Check second item is checked (uppercase X)
const secondItem = checkboxList.content.child(1);
expect(secondItem.type.name).toBe("checkbox_item");
expect(secondItem.attrs.checked).toBe(true);
expect(secondItem.textContent).toBe("Another completed");
});
it("should convert markdown with mixed checked and unchecked items", () => {
const markdown = "- [x] Done\n- [ ] Not done\n- [x] Also done";
const doc = ProsemirrorHelper.toProsemirror(markdown);
expect(doc.type.name).toBe("doc");
expect(doc.content.childCount).toBe(1);
const checkboxList = doc.content.child(0);
expect(checkboxList.type.name).toBe("checkbox_list");
expect(checkboxList.content.childCount).toBe(3);
expect(checkboxList.content.child(0).attrs.checked).toBe(true);
expect(checkboxList.content.child(1).attrs.checked).toBe(false);
expect(checkboxList.content.child(2).attrs.checked).toBe(true);
});
it("should convert markdown table with multiple checklist items in cell separated by br", () => {
const markdown = `| Tasks |
| --- |
| [ ] First<br>[ ] Second<br>[x] Third |`;
const doc = ProsemirrorHelper.toProsemirror(markdown);
expect(doc.type.name).toBe("doc");
const table = doc.content.child(0);
expect(table.type.name).toBe("table");
const dataRow = table.content.child(1);
const cell = dataRow.content.child(0);
// Cell should contain a single checkbox_list with 3 items
const checkboxList = cell.content.child(0);
expect(checkboxList.type.name).toBe("checkbox_list");
expect(checkboxList.content.childCount).toBe(3);
// First item - unchecked
const firstItem = checkboxList.content.child(0);
expect(firstItem.type.name).toBe("checkbox_item");
expect(firstItem.attrs.checked).toBe(false);
expect(firstItem.textContent).toBe("First");
// Second item - unchecked
const secondItem = checkboxList.content.child(1);
expect(secondItem.type.name).toBe("checkbox_item");
expect(secondItem.attrs.checked).toBe(false);
expect(secondItem.textContent).toBe("Second");
// Third item - checked
const thirdItem = checkboxList.content.child(2);
expect(thirdItem.type.name).toBe("checkbox_item");
expect(thirdItem.attrs.checked).toBe(true);
expect(thirdItem.textContent).toBe("Third");
});
});
});
+1 -1
View File
@@ -87,7 +87,7 @@ export class ProsemirrorHelper {
}
/**
* Converts a plain object into a Prosemirror Node.
* Converts a plain object or Markdown string into a Prosemirror Node.
*
* @param data The ProsemirrorData object or string to parse.
* @returns The content as a Prosemirror Node
+12 -6
View File
@@ -16,10 +16,10 @@ function isParagraph(token: Token | void): boolean {
}
function isListItem(token: Token | void): boolean {
return (
!!token &&
(token.type === "list_item_open" || token.type === "checkbox_item_open")
);
// Only match list_item_open, not checkbox_item_open - items that are already
// checkbox_item_open have been processed (e.g., by the tables rule for
// checkboxes in table cells) and should not be processed again.
return !!token && token.type === "list_item_open";
}
function looksLikeChecklist(tokens: Token[], index: number) {
@@ -93,10 +93,16 @@ export default function markdownItCheckbox(md: MarkdownIt): void {
// close the list item
let j = i;
while (tokens[j].type !== "list_item_close") {
while (
tokens[j] &&
tokens[j].type !== "list_item_close" &&
tokens[j].type !== "checkbox_item_close"
) {
j++;
}
tokens[j].type = "checkbox_item_close";
if (tokens[j]) {
tokens[j].type = "checkbox_item_close";
}
}
}
+86 -7
View File
@@ -2,6 +2,9 @@ import type MarkdownIt from "markdown-it";
const BREAK_REGEX = /(?<=^|[^\\])\\n/;
const BR_TAG_REGEX = /<br\s*\/?>/gi;
// Matches checkbox syntax with optional list prefix: "- [x] Task" or "[x] Task"
// Stops at <br> or newline to handle multiple checkboxes in a cell
const CHECKBOX_REGEX = /^(?:-\s*)?\[(X|\s|_|-)\]\s([^<\n]*)?/i;
export default function markdownTables(md: MarkdownIt): void {
// insert a new rule after the "inline" rules are parsed
@@ -70,10 +73,6 @@ export default function markdownTables(md: MarkdownIt): void {
}
if (["th_open", "td_open"].includes(tokens[i].type)) {
// markdown-it table parser does not return paragraphs inside the cells
// but prosemirror requires them, so we add 'em in here.
tokens.splice(i + 1, 0, new state.Token("paragraph_open", "p", 1));
// markdown-it table parser stores alignment as html styles, convert
// to a simple string here
const tokenAttrs = tokens[i].attrs;
@@ -81,10 +80,90 @@ export default function markdownTables(md: MarkdownIt): void {
const style = tokenAttrs[0][1];
tokens[i].info = style.split(":")[1];
}
}
if (["th_close", "td_close"].includes(tokens[i].type)) {
tokens.splice(i, 0, new state.Token("paragraph_close", "p", -1));
// Find the corresponding close token
const closeType =
tokens[i].type === "th_open" ? "th_close" : "td_close";
let closeIndex = i + 2; // Start after inline token
while (
closeIndex < tokens.length &&
tokens[closeIndex].type !== closeType
) {
closeIndex++;
}
// Check if the cell content looks like a checkbox (or multiple checkboxes)
const inlineToken = tokens[i + 1];
if (inlineToken?.type !== "inline") {
// No inline content, just add paragraph wrapper
tokens.splice(
closeIndex,
0,
new state.Token("paragraph_close", "p", -1)
);
tokens.splice(i + 1, 0, new state.Token("paragraph_open", "p", 1));
continue;
}
// Split content by <br> to find all checkboxes
const parts = inlineToken.content.split(BR_TAG_REGEX);
const checkboxItems: Array<{ checked: boolean; label: string }> = [];
for (const part of parts) {
const match = part.trim().match(CHECKBOX_REGEX);
if (match) {
checkboxItems.push({
checked: match[1].toLowerCase() === "x",
label: match[2] || "",
});
}
}
if (checkboxItems.length > 0) {
// Build tokens for all checkbox items
const newTokens: InstanceType<typeof state.Token>[] = [];
// Opening: checkbox_list_open
newTokens.push(new state.Token("checkbox_list_open", "ul", 1));
// Add each checkbox item
for (const item of checkboxItems) {
const itemOpen = new state.Token("checkbox_item_open", "li", 1);
if (item.checked) {
itemOpen.attrs = [["checked", "true"]];
}
newTokens.push(itemOpen);
newTokens.push(new state.Token("paragraph_open", "p", 1));
// Create inline token for the label
const labelInline = new state.Token("inline", "", 0);
labelInline.content = item.label;
const textToken = new state.Token("text", "", 0);
textToken.content = item.label;
labelInline.children = [textToken];
newTokens.push(labelInline);
newTokens.push(new state.Token("paragraph_close", "p", -1));
newTokens.push(new state.Token("checkbox_item_close", "li", -1));
}
// Closing: checkbox_list_close
newTokens.push(new state.Token("checkbox_list_close", "ul", -1));
// Replace the inline token with our new structure
tokens.splice(i + 1, closeIndex - i - 1, ...newTokens);
} else {
// markdown-it table parser does not return paragraphs inside the cells
// but prosemirror requires them, so we add 'em in here.
// Insert closing token first (before closeIndex shifts)
tokens.splice(
closeIndex,
0,
new state.Token("paragraph_close", "p", -1)
);
// Then insert opening token
tokens.splice(i + 1, 0, new state.Token("paragraph_open", "p", 1));
}
}
}