mirror of
https://github.com/outline/outline.git
synced 2026-05-13 13:50:22 -05:00
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:
@@ -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");
|
||||
});
|
||||
@@ -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",
|
||||
},
|
||||
],
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user