From 79df2f2dc8df3ef246b6960b1ed2c3f8ebdafef4 Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Fri, 10 Apr 2026 08:07:28 -0400 Subject: [PATCH] fix: Dropped content in Markdown parser with mixed checklist content (#11994) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 * refactor: Move checkbox tests to collocated checkboxes.test.ts Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Claude Opus 4.6 --- server/editor/checkboxes.test.ts | 67 +++++++++++++++++++++++++++++++ server/editor/index.test.ts | 46 +++++++++++++++++---- shared/editor/rules/checkboxes.ts | 23 +++++++++++ 3 files changed, 129 insertions(+), 7 deletions(-) create mode 100644 server/editor/checkboxes.test.ts diff --git a/server/editor/checkboxes.test.ts b/server/editor/checkboxes.test.ts new file mode 100644 index 0000000000..3a31ff25e2 --- /dev/null +++ b/server/editor/checkboxes.test.ts @@ -0,0 +1,67 @@ +import { parser, serializer } from "@server/editor"; + +interface ProsemirrorNode { + type: string; + content?: ProsemirrorNode[]; + attrs?: Record; +} + +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"); +}); diff --git a/server/editor/index.test.ts b/server/editor/index.test.ts index 7df6a77bbc..5cec4b3e0b 100644 --- a/server/editor/index.test.ts +++ b/server/editor/index.test.ts @@ -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", }, ], diff --git a/shared/editor/rules/checkboxes.ts b/shared/editor/rules/checkboxes.ts index f72920c1ed..153a1fefdd 100644 --- a/shared/editor/rules/checkboxes.ts +++ b/shared/editor/rules/checkboxes.ts @@ -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; }); }