-
Notifications
You must be signed in to change notification settings - Fork 1.1k
fix(core): widen PT → PM list run grouping to include level > 1 of any type #1166
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
eddec97
0a43cce
5ed005d
c137f0d
442c503
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| --- | ||
| "@emdash-cms/core": patch | ||
| --- | ||
|
|
||
| Fixes `portableTextToProsemirror` flattening nested lists whose subtree mixes `listItem` types. The outer run-grouping broke on the first nested type switch (e.g. an `orderedList` child under a `bulletList` parent), so an input like `[bullet L1, number L2, bullet L1]` was emitted as three separate top-level lists instead of one bullet list with a numbered sub-list under the first item. Internal `convertList`/`convertListItem` recursion was already correct — only the outer grouping needed to be widened to include `level > 1` blocks regardless of `listItem` type. | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -35,13 +35,23 @@ export function portableTextToProsemirror(blocks: PortableTextBlock[]): ProseMir | |
|
|
||
| // Check for list items | ||
| if (isTextBlock(block) && block.listItem) { | ||
| // Collect consecutive list items | ||
| // Collect a list "run": the level=1 anchor plus everything that | ||
| // nests under it (level > 1, regardless of listItem type — a number | ||
| // child under a bullet parent is still part of the same tree). A | ||
| // level=1 block with a different listItem ends the run. Without the | ||
| // `level > 1` carve-out the run breaks on the first nested type | ||
| // switch and the descendant subtree leaks out as its own top-level | ||
| // list (e.g. `[bullet L1, number L2, bullet L1]` would render as | ||
| // three sibling lists instead of one bullet list with a numbered | ||
| // child). | ||
| const listBlocks: PortableTextTextBlock[] = []; | ||
| const listType = block.listItem; | ||
|
|
||
| while (i < blocks.length) { | ||
| const current = blocks[i]; | ||
| if (isTextBlock(current) && current.listItem === listType) { | ||
| if (!isTextBlock(current) || !current.listItem) break; | ||
| const level = current.level || 1; | ||
| if (level > 1 || current.listItem === listType) { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Subtle behavior change worth being aware of: for orphan sequences with no The input is already malformed (orphan |
||
| listBlocks.push(current); | ||
| i++; | ||
| } else { | ||
|
|
@@ -230,28 +240,42 @@ function convertListItem( | |
|
|
||
| // Handle nested items | ||
| if (nestedItems.length > 0) { | ||
| // Group nested items by their list type | ||
| let j = 0; | ||
| // The shallowest level in `nestedItems` is the effective root of this | ||
| // item's nested subtree. A new sub-list only starts when we hit | ||
| // another block at that root level with a different `listItem` type; | ||
| // deeper blocks (level > minLevel) belong to the current group as | ||
| // descendants regardless of their own `listItem`. The previous | ||
| // grouping broke on any type change at any depth, so a deep mixed | ||
| // tree like `bullet L1 → number L2 → bullet L3 → number L2` would | ||
| // emit C(L3) as a sibling list under A(L1) instead of nesting it | ||
| // under B(L2), then degrade C to L2 on round-trip. | ||
| let minLevel = Infinity; | ||
| for (const ni of nestedItems) { | ||
| const level = ni.level || 2; | ||
| if (level < minLevel) minLevel = level; | ||
| } | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nit: |
||
|
|
||
| let j = 0; | ||
| while (j < nestedItems.length) { | ||
| const nestedListType = nestedItems[j].listItem || parentListType; | ||
| const anchorType: "bullet" | "number" = nestedItems[j].listItem || parentListType; | ||
| const nestedGroup: PortableTextTextBlock[] = []; | ||
|
|
||
| while ( | ||
| j < nestedItems.length && | ||
| (nestedItems[j].listItem || parentListType) === nestedListType | ||
| ) { | ||
| do { | ||
| nestedGroup.push(nestedItems[j]); | ||
| j++; | ||
| } | ||
| } while ( | ||
| j < nestedItems.length && | ||
| ((nestedItems[j].level || 2) > minLevel || | ||
| (nestedItems[j].listItem || parentListType) === anchorType) | ||
| ); | ||
|
|
||
| if (nestedGroup.length > 0) { | ||
| // Decrease level for nested conversion | ||
| const adjustedGroup = nestedGroup.map((ni) => ({ | ||
| ...ni, | ||
| level: (ni.level || 2) - 1, | ||
| })); | ||
| content.push(convertList(adjustedGroup, nestedListType)); | ||
| content.push(convertList(adjustedGroup, anchorType)); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,183 @@ | ||
| import { describe, it, expect } from "vitest"; | ||
|
|
||
| import { portableTextToProsemirror } from "../../../src/content/converters/portable-text-to-prosemirror.js"; | ||
| import { prosemirrorToPortableText } from "../../../src/content/converters/prosemirror-to-portable-text.js"; | ||
| import type { PortableTextTextBlock } from "../../../src/content/converters/types.js"; | ||
|
|
||
| type PMNode = { type: string; content?: PMNode[] }; | ||
| type PMList = { | ||
| type: "bulletList" | "orderedList"; | ||
| content: Array<{ | ||
| type: "listItem"; | ||
| content: Array<{ type: string; content?: unknown[] }>; | ||
| }>; | ||
| }; | ||
|
|
||
| function findFirstList(node: { content?: unknown[] }): PMList | null { | ||
| if (!node.content) return null; | ||
| for (const child of node.content as Array<{ type?: string }>) { | ||
| if (child.type === "bulletList" || child.type === "orderedList") return child as PMList; | ||
| } | ||
| return null; | ||
| } | ||
|
|
||
| function getParagraphText(listItem: { content?: unknown[] }): string | undefined { | ||
| if (!listItem.content) return undefined; | ||
| const para = (listItem.content as Array<{ type?: string; content?: unknown[] }>).find( | ||
| (c) => c.type === "paragraph", | ||
| ); | ||
| const text = (para?.content as Array<{ type?: string; text?: string }> | undefined)?.find( | ||
| (c) => c.type === "text", | ||
| ); | ||
| return text?.text; | ||
| } | ||
|
|
||
| function getNestedList(listItem: { content?: unknown[] }): PMList | undefined { | ||
| return (listItem.content as Array<{ type?: string }> | undefined)?.find( | ||
| (c) => c.type === "bulletList" || c.type === "orderedList", | ||
| ) as PMList | undefined; | ||
| } | ||
|
|
||
| function pt(listItem: "bullet" | "number", level: number, text: string): PortableTextTextBlock { | ||
| return { | ||
| _type: "block", | ||
| _key: `b${level}-${text}`, | ||
| style: "normal", | ||
| listItem, | ||
| level, | ||
| children: [{ _type: "span", _key: `s-${text}`, text }], | ||
| }; | ||
| } | ||
|
|
||
| describe("portableTextToProsemirror: list run grouping", () => { | ||
| it("nests level=2 bullets inside their parent listItem", () => { | ||
| const result = portableTextToProsemirror([pt("bullet", 1, "Parent"), pt("bullet", 2, "Child")]); | ||
| const list = findFirstList(result); | ||
| expect(list?.type).toBe("bulletList"); | ||
| expect(list?.content).toHaveLength(1); | ||
| expect(getParagraphText(list!.content[0]!)).toBe("Parent"); | ||
|
|
||
| const nested = getNestedList(list!.content[0]!); | ||
| expect(nested?.type).toBe("bulletList"); | ||
| expect(nested?.content).toHaveLength(1); | ||
| expect(getParagraphText(nested!.content[0]!)).toBe("Child"); | ||
| }); | ||
|
|
||
| it("keeps a numbered child nested under its bullet parent (mixed type run)", () => { | ||
| // Regression: the outer run-grouping used to break on `listItem` | ||
| // change, so this input would emit one bulletList + one orderedList | ||
| // + one bulletList at the document root instead of one bulletList | ||
| // with an orderedList nested under the first item. | ||
| const result = portableTextToProsemirror([ | ||
| pt("bullet", 1, "Parent"), | ||
| pt("number", 2, "Numbered child"), | ||
| pt("bullet", 1, "Sibling"), | ||
| ]); | ||
| const lists = (result.content as PMNode[]).filter( | ||
| (c) => c.type === "bulletList" || c.type === "orderedList", | ||
| ) as PMList[]; | ||
| expect(lists).toHaveLength(1); | ||
| expect(lists[0]!.type).toBe("bulletList"); | ||
| expect(lists[0]!.content).toHaveLength(2); | ||
| expect(getParagraphText(lists[0]!.content[0]!)).toBe("Parent"); | ||
| expect(getParagraphText(lists[0]!.content[1]!)).toBe("Sibling"); | ||
| expect(getNestedList(lists[0]!.content[0]!)?.type).toBe("orderedList"); | ||
| expect(getNestedList(lists[0]!.content[1]!)).toBeUndefined(); | ||
| }); | ||
|
|
||
| it("still ends the run on a different-type level=1 sibling", () => { | ||
| // `[bullet L1, bullet L1, number L1]` is three siblings where the | ||
| // number is a separate top-level list — keep that behavior intact. | ||
| const result = portableTextToProsemirror([ | ||
| pt("bullet", 1, "A"), | ||
| pt("bullet", 1, "B"), | ||
| pt("number", 1, "C"), | ||
| ]); | ||
| const lists = (result.content as PMNode[]).filter( | ||
| (c) => c.type === "bulletList" || c.type === "orderedList", | ||
| ) as PMList[]; | ||
| expect(lists.map((l) => l.type)).toEqual(["bulletList", "orderedList"]); | ||
| expect(lists[0]!.content).toHaveLength(2); | ||
| expect(lists[1]!.content).toHaveLength(1); | ||
| expect(getParagraphText(lists[1]!.content[0]!)).toBe("C"); | ||
| }); | ||
|
|
||
| it("handles three-level nesting with type switches", () => { | ||
| const result = portableTextToProsemirror([ | ||
| pt("bullet", 1, "L1"), | ||
| pt("number", 2, "L2"), | ||
| pt("bullet", 3, "L3"), | ||
| ]); | ||
| const l1 = findFirstList(result); | ||
| expect(l1?.type).toBe("bulletList"); | ||
| expect(getParagraphText(l1!.content[0]!)).toBe("L1"); | ||
|
|
||
| const l2 = getNestedList(l1!.content[0]!); | ||
| expect(l2?.type).toBe("orderedList"); | ||
| expect(getParagraphText(l2!.content[0]!)).toBe("L2"); | ||
|
|
||
| const l3 = getNestedList(l2!.content[0]!); | ||
| expect(l3?.type).toBe("bulletList"); | ||
| expect(getParagraphText(l3!.content[0]!)).toBe("L3"); | ||
| }); | ||
|
|
||
| it("keeps deeper nesting under its true parent for mixed-type 3-level trees", () => { | ||
| // Regression for convertListItem's nested grouping: it used to break | ||
| // the group on every `listItem` change regardless of depth, so a | ||
| // level-3 block ended up as a sibling sub-list under the level-1 | ||
| // item instead of nesting under the matching level-2 item — and the | ||
| // round-trip would degrade level-3 to level-2. | ||
| const original = [ | ||
| pt("bullet", 1, "A"), | ||
| pt("number", 2, "B"), | ||
| pt("bullet", 3, "C"), | ||
| pt("number", 2, "D"), | ||
| ]; | ||
| const pm = portableTextToProsemirror(original); | ||
|
|
||
| const outer = findFirstList(pm); | ||
| expect(outer?.type).toBe("bulletList"); | ||
| expect(outer?.content).toHaveLength(1); | ||
| expect(getParagraphText(outer!.content[0]!)).toBe("A"); | ||
|
|
||
| const numbered = getNestedList(outer!.content[0]!); | ||
| expect(numbered?.type).toBe("orderedList"); | ||
| expect(numbered?.content).toHaveLength(2); | ||
| expect(getParagraphText(numbered!.content[0]!)).toBe("B"); | ||
| expect(getParagraphText(numbered!.content[1]!)).toBe("D"); | ||
|
|
||
| const cInBullets = getNestedList(numbered!.content[0]!); | ||
| expect(cInBullets?.type).toBe("bulletList"); | ||
| expect(getParagraphText(cInBullets!.content[0]!)).toBe("C"); | ||
|
|
||
| // Round-trip must keep C at level 3, not collapse it to level 2. | ||
| const roundTripped = prosemirrorToPortableText(pm).filter( | ||
| (b): b is PortableTextTextBlock => | ||
| typeof b === "object" && b !== null && (b as { _type?: string })._type === "block", | ||
| ); | ||
| expect(roundTripped.map((b) => [b.listItem, b.level, b.children[0]?.text])).toEqual([ | ||
| ["bullet", 1, "A"], | ||
| ["number", 2, "B"], | ||
| ["bullet", 3, "C"], | ||
| ["number", 2, "D"], | ||
| ]); | ||
| }); | ||
|
|
||
| it("round-trips PT → PM → PT preserving level and listItem in a mixed-type tree", () => { | ||
| const original = [ | ||
| pt("bullet", 1, "Top"), | ||
| pt("number", 2, "Nested"), | ||
| pt("bullet", 1, "Sibling"), | ||
| ]; | ||
| const pm = portableTextToProsemirror(original); | ||
| const roundTripped = prosemirrorToPortableText(pm).filter( | ||
| (b): b is PortableTextTextBlock => | ||
| typeof b === "object" && b !== null && (b as { _type?: string })._type === "block", | ||
| ); | ||
| expect(roundTripped.map((b) => [b.listItem, b.level, b.children[0]?.text])).toEqual([ | ||
| ["bullet", 1, "Top"], | ||
| ["number", 2, "Nested"], | ||
| ["bullet", 1, "Sibling"], | ||
| ]); | ||
| }); | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Minor: this changeset claims "Internal
convertList/convertListItemrecursion was already correct — only the outer grouping needed to be widened," but the diff also rewritesconvertListItem's nested grouping (introducingminLevel/anchorTypeand the do/while). That rewrite is what the test casekeeps deeper nesting under its true parent for mixed-type 3-level treesexercises — without it,[bullet L1, number L2, bullet L3, number L2]would still placeCas a sibling sub-list underAinstead of nesting underB. Worth tweaking the changeset wording so the release notes reflect both fixes.