Skip to content

Commit fc1480e

Browse files
authored
fix(super-converter): normalize single-paragraph BIBLIOGRAPHY/INDEX/TOA field content (SD-3005) (#3538)
1 parent cae408b commit fc1480e

39 files changed

Lines changed: 1128 additions & 417 deletions
Lines changed: 3 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -1,79 +1,8 @@
11
/**
22
* Bibliography Processing Module
33
*
4-
* Handles bibliography field containers by converting child paragraphs to flow blocks.
5-
* Follows the same pattern as document-index.ts.
4+
* Bibliography field containers convert their child paragraphs to flow blocks
5+
* via the shared paragraph-container handler.
66
*/
77

8-
import type { PMNode, NodeHandlerContext } from '../types.js';
9-
import { createSectionBreakBlock, hasIntrinsicBoundarySignals, shouldRequirePageBoundary } from '../sections/index.js';
10-
11-
const getChildren = (node: PMNode): PMNode[] => {
12-
if (Array.isArray(node.content)) return node.content;
13-
const content = node.content as { forEach?: (cb: (child: PMNode) => void) => void } | undefined;
14-
if (content && typeof content.forEach === 'function') {
15-
const children: PMNode[] = [];
16-
content.forEach((child) => children.push(child));
17-
return children;
18-
}
19-
return [];
20-
};
21-
22-
export function handleBibliographyNode(node: PMNode, context: NodeHandlerContext): void {
23-
const children = getChildren(node);
24-
if (children.length === 0) return;
25-
26-
const {
27-
blocks,
28-
recordBlockKind,
29-
nextBlockId,
30-
positions,
31-
trackedChangesConfig,
32-
bookmarks,
33-
hyperlinkConfig,
34-
sectionState,
35-
converters,
36-
themeColors,
37-
enableComments,
38-
} = context;
39-
40-
const paragraphToFlowBlocks = converters.paragraphToFlowBlocks;
41-
42-
children.forEach((child) => {
43-
if (child.type !== 'paragraph') return;
44-
45-
if ((sectionState?.ranges?.length ?? 0) > 0) {
46-
const nextSection = sectionState!.ranges[sectionState!.currentSectionIndex + 1];
47-
if (nextSection && sectionState!.currentParagraphIndex === nextSection.startParagraphIndex) {
48-
const currentSection = sectionState!.ranges[sectionState!.currentSectionIndex];
49-
const requiresPageBoundary =
50-
shouldRequirePageBoundary(currentSection, nextSection) || hasIntrinsicBoundarySignals(nextSection);
51-
const extraAttrs = requiresPageBoundary ? { requirePageBoundary: true } : undefined;
52-
const sectionBreak = createSectionBreakBlock(nextSection, nextBlockId, extraAttrs);
53-
blocks.push(sectionBreak);
54-
recordBlockKind?.(sectionBreak.kind);
55-
sectionState!.currentSectionIndex++;
56-
}
57-
}
58-
59-
const paragraphBlocks = paragraphToFlowBlocks({
60-
para: child,
61-
nextBlockId,
62-
positions,
63-
trackedChangesConfig,
64-
bookmarks,
65-
hyperlinkConfig,
66-
themeColors,
67-
converterContext: context.converterContext,
68-
enableComments,
69-
converters,
70-
});
71-
72-
paragraphBlocks.forEach((block) => {
73-
blocks.push(block);
74-
recordBlockKind?.(block.kind);
75-
});
76-
77-
sectionState!.currentParagraphIndex++;
78-
});
79-
}
8+
export { handleParagraphContainerNode as handleBibliographyNode } from './paragraph-container.js';
Lines changed: 4 additions & 93 deletions
Original file line numberDiff line numberDiff line change
@@ -1,98 +1,9 @@
11
/**
22
* Document Index Processing Module
33
*
4-
* Handles index field containers and keeps section break accounting aligned
5-
* with the paragraph flow inside the index.
4+
* Index field containers convert their child paragraphs to flow blocks via the
5+
* shared paragraph-container handler, which keeps section-break accounting
6+
* aligned with the paragraph flow inside the index.
67
*/
78

8-
import type { PMNode, NodeHandlerContext } from '../types.js';
9-
import { createSectionBreakBlock, hasIntrinsicBoundarySignals, shouldRequirePageBoundary } from '../sections/index.js';
10-
11-
/**
12-
* Extracts child nodes from an index node.
13-
*
14-
* Handles both array-based content (plain objects) and ProseMirror Fragment-like
15-
* content (which uses forEach instead of array iteration).
16-
*
17-
* @param node - The index node to extract children from
18-
* @returns Array of child nodes, or empty array if no children
19-
*/
20-
const getIndexChildren = (node: PMNode): PMNode[] => {
21-
if (Array.isArray(node.content)) return node.content;
22-
const content = node.content as { forEach?: (cb: (child: PMNode) => void) => void } | undefined;
23-
if (content && typeof content.forEach === 'function') {
24-
const children: PMNode[] = [];
25-
content.forEach((child) => {
26-
children.push(child);
27-
});
28-
return children;
29-
}
30-
return [];
31-
};
32-
33-
/**
34-
* Handle index nodes by converting child paragraphs to flow blocks.
35-
*
36-
* @param node - Index node to process
37-
* @param context - Shared handler context
38-
*/
39-
export function handleIndexNode(node: PMNode, context: NodeHandlerContext): void {
40-
const children = getIndexChildren(node);
41-
if (children.length === 0) return;
42-
43-
const {
44-
blocks,
45-
recordBlockKind,
46-
nextBlockId,
47-
positions,
48-
trackedChangesConfig,
49-
bookmarks,
50-
hyperlinkConfig,
51-
sectionState,
52-
converters,
53-
themeColors,
54-
enableComments,
55-
} = context;
56-
57-
const paragraphToFlowBlocks = converters.paragraphToFlowBlocks;
58-
59-
children.forEach((child) => {
60-
if (child.type !== 'paragraph') {
61-
return;
62-
}
63-
64-
if ((sectionState?.ranges?.length ?? 0) > 0) {
65-
const nextSection = sectionState!.ranges[sectionState!.currentSectionIndex + 1];
66-
if (nextSection && sectionState!.currentParagraphIndex === nextSection.startParagraphIndex) {
67-
const currentSection = sectionState!.ranges[sectionState!.currentSectionIndex];
68-
const requiresPageBoundary =
69-
shouldRequirePageBoundary(currentSection, nextSection) || hasIntrinsicBoundarySignals(nextSection);
70-
const extraAttrs = requiresPageBoundary ? { requirePageBoundary: true } : undefined;
71-
const sectionBreak = createSectionBreakBlock(nextSection, nextBlockId, extraAttrs);
72-
blocks.push(sectionBreak);
73-
recordBlockKind?.(sectionBreak.kind);
74-
sectionState!.currentSectionIndex++;
75-
}
76-
}
77-
78-
const paragraphBlocks = paragraphToFlowBlocks({
79-
para: child,
80-
nextBlockId,
81-
positions,
82-
trackedChangesConfig,
83-
bookmarks,
84-
hyperlinkConfig,
85-
themeColors,
86-
converterContext: context.converterContext,
87-
enableComments: enableComments,
88-
converters,
89-
});
90-
91-
paragraphBlocks.forEach((block) => {
92-
blocks.push(block);
93-
recordBlockKind?.(block.kind);
94-
});
95-
96-
sectionState!.currentParagraphIndex++;
97-
});
98-
}
9+
export { handleParagraphContainerNode as handleIndexNode } from './paragraph-container.js';

packages/super-editor/src/editors/v1/core/layout-adapter/sdt/document-part-object.test.ts

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -702,5 +702,84 @@ describe('document-part-object', () => {
702702
expect(callArgs[2]).toMatchObject({ sectionState: state });
703703
});
704704
});
705+
706+
// ==================== SD-3005: block field children ====================
707+
describe('block field children (SD-3005)', () => {
708+
beforeEach(() => {
709+
vi.mocked(metadataModule.getDocPartGallery).mockReturnValue('Bibliographies');
710+
vi.mocked(metadataModule.getDocPartObjectId).mockReturnValue('bib-1');
711+
vi.mocked(metadataModule.getNodeInstruction).mockReturnValue(undefined);
712+
vi.mocked(metadataModule.resolveNodeSdtMetadata).mockReturnValue({ type: 'docPartObject' });
713+
});
714+
715+
const bibliography = (): PMNode =>
716+
({
717+
type: 'bibliography',
718+
attrs: { instruction: 'BIBLIOGRAPHY' },
719+
content: [
720+
{ type: 'paragraph', content: [{ type: 'text', text: 'Entry One' }] },
721+
{ type: 'paragraph', content: [{ type: 'text', text: 'Entry Two' }] },
722+
],
723+
}) as unknown as PMNode;
724+
725+
// Minimal section state with a far-away boundary so no break is emitted —
726+
// these tests only assert rendering + the paragraph-index counter.
727+
const withSectionState = () => {
728+
mockContext.sectionState = {
729+
ranges: [{ sectionIndex: 0, startParagraphIndex: 0, endParagraphIndex: 99 }],
730+
currentSectionIndex: 0,
731+
currentParagraphIndex: 0,
732+
} as unknown as NonNullable<NodeHandlerContext['sectionState']>;
733+
};
734+
735+
it('renders a direct bibliography child (heading + both entries become blocks)', () => {
736+
withSectionState();
737+
const node: PMNode = {
738+
type: 'documentPartObject',
739+
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Bibliography' }] }, bibliography()],
740+
};
741+
742+
handleDocumentPartObjectNode(node, mockContext);
743+
744+
// heading paragraph + 2 bibliography entry paragraphs
745+
expect(mockParagraphConverter).toHaveBeenCalledTimes(3);
746+
expect(mockContext.blocks).toHaveLength(3);
747+
});
748+
749+
it('advances currentParagraphIndex through a bibliography child to match findParagraphsWithSectPr', () => {
750+
// findParagraphsWithSectPr recurses `bibliography`, so the handler must
751+
// advance the counter per entry or section breaks downstream drift.
752+
withSectionState();
753+
const node: PMNode = {
754+
type: 'documentPartObject',
755+
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Bibliography' }] }, bibliography()],
756+
};
757+
758+
handleDocumentPartObjectNode(node, mockContext);
759+
760+
// heading (1) + Entry One (1) + Entry Two (1) = 3 paragraphs counted
761+
expect(mockContext.sectionState!.currentParagraphIndex).toBe(3);
762+
});
763+
764+
it('renders a structuredContentBlock-wrapped bibliography without advancing the counter', () => {
765+
// findParagraphsWithSectPr does NOT recurse structuredContentBlock, so its
766+
// inner paragraphs render but must not advance currentParagraphIndex.
767+
withSectionState();
768+
const node: PMNode = {
769+
type: 'documentPartObject',
770+
content: [
771+
{ type: 'paragraph', content: [{ type: 'text', text: 'Bibliography' }] },
772+
{ type: 'structuredContentBlock', attrs: {}, content: [bibliography()] },
773+
],
774+
};
775+
776+
handleDocumentPartObjectNode(node, mockContext);
777+
778+
// both entries still render
779+
expect(mockParagraphConverter).toHaveBeenCalledTimes(3); // heading + 2 entries
780+
// but only the heading advanced the counter (scb is not recursed by analysis)
781+
expect(mockContext.sectionState!.currentParagraphIndex).toBe(1);
782+
});
783+
});
705784
});
706785
});

packages/super-editor/src/editors/v1/core/layout-adapter/sdt/document-part-object.ts

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,13 @@ import type { PMNode, NodeHandlerContext } from '../types.js';
99
import { emitPendingSectionBreakForParagraph } from '../sections/index.js';
1010
import { getDocPartGallery, getDocPartObjectId, getNodeInstruction, resolveNodeSdtMetadata } from './metadata.js';
1111
import { processTocChildren } from './toc.js';
12+
import { handleParagraphContainerNode } from './paragraph-container.js';
13+
import { handleStructuredContentBlockNode } from './structured-content-block.js';
14+
15+
// Block field children whose paragraphs `findParagraphsWithSectPr` recurses into,
16+
// so their handler must advance currentParagraphIndex in step (delegated to
17+
// handleParagraphContainerNode).
18+
const PARAGRAPH_CONTAINER_TYPES = new Set(['bibliography', 'index', 'tableOfAuthorities']);
1219

1320
/**
1421
* Handle document part object nodes (e.g., TOC galleries, page numbers).
@@ -115,9 +122,18 @@ export function handleDocumentPartObjectNode(node: PMNode, context: NodeHandlerC
115122
};
116123
const output = { blocks, recordBlockKind };
117124
processTocChildren(child.content, metadata, tocContext, output);
125+
} else if (PARAGRAPH_CONTAINER_TYPES.has(child.type)) {
126+
// SD-3005: a block field (bibliography / index / table of authorities)
127+
// generated inside this SDT. Render its entry paragraphs and advance
128+
// currentParagraphIndex per child to match findParagraphsWithSectPr,
129+
// which recurses into these node types.
130+
handleParagraphContainerNode(child, context);
131+
} else if (child.type === 'structuredContentBlock') {
132+
// SD-3005: a nested content control (often wrapping a block field).
133+
// findParagraphsWithSectPr does NOT recurse structuredContentBlock, so
134+
// its handler renders without advancing currentParagraphIndex.
135+
handleStructuredContentBlockNode(child, context);
118136
}
119137
}
120138
}
121-
// Note: Other documentPartObject types (e.g., Bibliography) are intentionally
122-
// not processed - they are ignored to maintain backward compatibility.
123139
}
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import { describe, it, expect, vi } from 'vitest';
2+
import { getParagraphContainerChildren, handleParagraphContainerNode } from './paragraph-container.js';
3+
import { handleIndexNode } from './document-index.js';
4+
import { handleBibliographyNode } from './bibliography.js';
5+
import { handleTableOfAuthoritiesNode } from './table-of-authorities.js';
6+
import type { PMNode, NodeHandlerContext } from '../types.js';
7+
8+
describe('getParagraphContainerChildren', () => {
9+
it('reads array-based content', () => {
10+
const node = { type: 'index', content: [{ type: 'paragraph' }, { type: 'paragraph' }] } as unknown as PMNode;
11+
expect(getParagraphContainerChildren(node)).toHaveLength(2);
12+
});
13+
14+
it('reads ProseMirror Fragment-like content via forEach', () => {
15+
const kids = [{ type: 'paragraph' }];
16+
const node = {
17+
type: 'index',
18+
content: { forEach: (cb: (c: unknown) => void) => kids.forEach(cb) },
19+
} as unknown as PMNode;
20+
expect(getParagraphContainerChildren(node)).toEqual(kids);
21+
});
22+
23+
it('returns an empty array when there is no content', () => {
24+
expect(getParagraphContainerChildren({ type: 'index' } as unknown as PMNode)).toEqual([]);
25+
});
26+
});
27+
28+
describe('handleParagraphContainerNode', () => {
29+
const makeContext = () => {
30+
const blocks: unknown[] = [];
31+
const paragraphToFlowBlocks = vi.fn(({ para }: { para: PMNode }) => [
32+
{ kind: 'paragraph', text: (para as { text?: string }).text },
33+
]);
34+
const context = {
35+
blocks,
36+
recordBlockKind: vi.fn(),
37+
nextBlockId: vi.fn(() => 'b1'),
38+
positions: {},
39+
trackedChangesConfig: undefined,
40+
bookmarks: undefined,
41+
hyperlinkConfig: undefined,
42+
sectionState: { ranges: [], currentSectionIndex: 0, currentParagraphIndex: 0 },
43+
converters: { paragraphToFlowBlocks },
44+
themeColors: undefined,
45+
enableComments: false,
46+
converterContext: {},
47+
} as unknown as NodeHandlerContext;
48+
return { context, blocks, paragraphToFlowBlocks };
49+
};
50+
51+
it('converts each child paragraph to flow blocks and advances the paragraph counter', () => {
52+
const { context, blocks, paragraphToFlowBlocks } = makeContext();
53+
const node = {
54+
type: 'index',
55+
content: [
56+
{ type: 'paragraph', text: 'a' },
57+
{ type: 'paragraph', text: 'b' },
58+
{ type: 'someAtom', text: 'skip' },
59+
],
60+
} as unknown as PMNode;
61+
62+
handleParagraphContainerNode(node, context);
63+
64+
expect(paragraphToFlowBlocks).toHaveBeenCalledTimes(2);
65+
expect(blocks).toHaveLength(2);
66+
expect((context.sectionState as { currentParagraphIndex: number }).currentParagraphIndex).toBe(2);
67+
});
68+
69+
it('is the single implementation shared by the three block-field handlers', () => {
70+
expect(handleIndexNode).toBe(handleParagraphContainerNode);
71+
expect(handleBibliographyNode).toBe(handleParagraphContainerNode);
72+
expect(handleTableOfAuthoritiesNode).toBe(handleParagraphContainerNode);
73+
});
74+
});

0 commit comments

Comments
 (0)