Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/core-list-nesting-fix.md
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.

Copy link
Copy Markdown
Contributor

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/convertListItem recursion was already correct — only the outer grouping needed to be widened," but the diff also rewrites convertListItem's nested grouping (introducing minLevel/anchorType and the do/while). That rewrite is what the test case keeps deeper nesting under its true parent for mixed-type 3-level trees exercises — without it, [bullet L1, number L2, bullet L3, number L2] would still place C as a sibling sub-list under A instead of nesting under B. Worth tweaking the changeset wording so the release notes reflect both fixes.

Original file line number Diff line number Diff line change
Expand Up @@ -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) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Subtle behavior change worth being aware of: for orphan sequences with no level === 1 anchor, this now absorbs mixed-type level > 1 blocks into the first run instead of starting a new run on type change. e.g. [number L3, bullet L2] used to produce two separate top-level lists; with this change it produces a single orderedList containing both items (the bullet L2's listItem type is effectively discarded by convertList's orphan branch, which calls convertListItem(item, [], parentListType)).

The input is already malformed (orphan level > 1 with no L1 anchor), so this is unlikely to matter in practice, but it's not covered by the existing "orphan level > 1 blocks ... are still rendered as root items" claim in the PR body — the rendering is preserved, but the type of the wrapping list now follows the first orphan instead of each block's own listItem. Worth either calling out explicitly or adding a test that pins down the intended behavior here.

listBlocks.push(current);
i++;
} else {
Expand Down Expand Up @@ -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;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: let minLevel = Infinity; for (...) { ... } works, but since nestedItems is non-empty here (guarded by if (nestedItems.length > 0) two lines up) and every nested item has level >= 2 by construction (the outer collector at line 205 requires level > 1), Math.min(...nestedItems.map(ni => ni.level || 2)) would be equivalent and slightly more idiomatic. Not worth blocking on — the explicit loop is fine and arguably clearer about the || 2 fallback.


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));
}
}
}
Expand Down
183 changes: 183 additions & 0 deletions packages/core/tests/unit/converters/list-nesting.test.ts
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"],
]);
});
});
Loading