fix: Dropped content in Markdown parser with mixed checklist content (#11994)

* fix: Dropped content in Markdown parser with mixed checklist content

* fix: Treat non-checkbox items as unchecked in mixed checkbox lists

When a bullet list contains a mix of checkbox and regular items, the
markdown-it checkbox rule converts the list to a checkbox_list but
leaves non-checkbox items as list_item tokens. Since the Prosemirror
schema requires checkbox_item+ children, these invalid list_item nodes
cause the entire list to be silently dropped — explaining the content
truncation reported in #11988.

Convert remaining list_item tokens that are direct children of a
checkbox_list into unchecked checkbox_item tokens. Uses a level stack
to avoid converting nested bullet/ordered list items.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor: Move checkbox tests to collocated checkboxes.test.ts

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Tom Moor
2026-04-10 08:07:28 -04:00
committed by GitHub
parent 15524cdd08
commit 79df2f2dc8
3 changed files with 129 additions and 7 deletions
+67
View File
@@ -0,0 +1,67 @@
import { parser, serializer } from "@server/editor";
interface ProsemirrorNode {
type: string;
content?: ProsemirrorNode[];
attrs?: Record<string, unknown>;
}
it("preserves mixed checkbox and regular items in a list", () => {
const markdown = `- [x] Checked item
- Regular item
- [ ] Unchecked item`;
const ast = parser.parse(markdown);
const json = ast?.toJSON();
const checkboxList = json?.content?.find(
(node: ProsemirrorNode) => node.type === "checkbox_list"
);
expect(checkboxList).toBeDefined();
expect(checkboxList?.content).toHaveLength(3);
expect(checkboxList?.content[0].type).toBe("checkbox_item");
expect(checkboxList?.content[1].type).toBe("checkbox_item");
expect(checkboxList?.content[2].type).toBe("checkbox_item");
});
it("round-trips mixed checkbox lists through serializer", () => {
const markdown = `- [x] Checked
- Plain text
- [ ] Unchecked`;
const ast = parser.parse(markdown);
const output = serializer.serialize(ast);
// All items should survive the round-trip
expect(output).toContain("Checked");
expect(output).toContain("Plain text");
expect(output).toContain("Unchecked");
});
it("does not convert nested bullet list items inside checkbox lists", () => {
const markdown = `- [x] Parent checkbox
- Nested bullet item
- Another nested item
- [ ] Second checkbox`;
const ast = parser.parse(markdown);
const json = ast?.toJSON();
const checkboxList = json?.content?.find(
(node: ProsemirrorNode) => node.type === "checkbox_list"
);
expect(checkboxList).toBeDefined();
expect(checkboxList?.content).toHaveLength(2);
expect(checkboxList?.content[0].type).toBe("checkbox_item");
expect(checkboxList?.content[1].type).toBe("checkbox_item");
// Nested list should remain a bullet_list, not a checkbox_list
const nestedContent = checkboxList?.content[0].content;
const nestedList = nestedContent?.find(
(node: ProsemirrorNode) => node.type === "bullet_list"
);
expect(nestedList).toBeDefined();
expect(nestedList?.content?.[0].type).toBe("list_item");
});
+39 -7
View File
@@ -18,11 +18,21 @@ test("parses lowercase alpha lists", () => {
attrs: { listStyle: "lower-alpha", order: 1 },
content: [
{
content: [{ content: [{ text: "First item", type: "text" }], type: "paragraph" }],
content: [
{
content: [{ text: "First item", type: "text" }],
type: "paragraph",
},
],
type: "list_item",
},
{
content: [{ content: [{ text: "Second item", type: "text" }], type: "paragraph" }],
content: [
{
content: [{ text: "Second item", type: "text" }],
type: "paragraph",
},
],
type: "list_item",
},
],
@@ -42,11 +52,21 @@ test("parses uppercase alpha lists", () => {
attrs: { listStyle: "upper-alpha", order: 1 },
content: [
{
content: [{ content: [{ text: "First item", type: "text" }], type: "paragraph" }],
content: [
{
content: [{ text: "First item", type: "text" }],
type: "paragraph",
},
],
type: "list_item",
},
{
content: [{ content: [{ text: "Second item", type: "text" }], type: "paragraph" }],
content: [
{
content: [{ text: "Second item", type: "text" }],
type: "paragraph",
},
],
type: "list_item",
},
],
@@ -68,7 +88,9 @@ b. Do that.`;
const json = ast?.toJSON();
// Find the ordered_list in the result
const orderedList = json?.content?.find((node: any) => node.type === "ordered_list");
const orderedList = json?.content?.find(
(node: { type: string }) => node.type === "ordered_list"
);
expect(orderedList).toBeDefined();
expect(orderedList?.attrs.listStyle).toBe("lower-alpha");
@@ -85,11 +107,21 @@ test("preserves numeric lists", () => {
attrs: { listStyle: "number", order: 1 },
content: [
{
content: [{ content: [{ text: "First item", type: "text" }], type: "paragraph" }],
content: [
{
content: [{ text: "First item", type: "text" }],
type: "paragraph",
},
],
type: "list_item",
},
{
content: [{ content: [{ text: "Second item", type: "text" }], type: "paragraph" }],
content: [
{
content: [{ text: "Second item", type: "text" }],
type: "paragraph",
},
],
type: "list_item",
},
],
+23
View File
@@ -106,6 +106,29 @@ export default function markdownItCheckbox(md: MarkdownIt): void {
}
}
// Second pass: convert any remaining direct child list_item tokens inside
// a checkbox_list to checkbox_item so they aren't silently dropped by the
// Prosemirror schema which requires checkbox_item+ children.
const checkboxListOpenLevels: number[] = [];
for (let i = 0; i < tokens.length; i++) {
const token = tokens[i];
if (token.type === "checkbox_list_open") {
checkboxListOpenLevels.push(token.level);
} else if (token.type === "checkbox_list_close") {
checkboxListOpenLevels.pop();
} else if (checkboxListOpenLevels.length > 0) {
const checkboxListOpenLevel =
checkboxListOpenLevels[checkboxListOpenLevels.length - 1];
const isDirectChild = token.level === checkboxListOpenLevel + 1;
if (isDirectChild && token.type === "list_item_open") {
token.type = "checkbox_item_open";
} else if (isDirectChild && token.type === "list_item_close") {
token.type = "checkbox_item_close";
}
}
}
return false;
});
}