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
Original file line number Diff line number Diff line change
@@ -1,79 +1,8 @@
/**
* Bibliography Processing Module
*
* Handles bibliography field containers by converting child paragraphs to flow blocks.
* Follows the same pattern as document-index.ts.
* Bibliography field containers convert their child paragraphs to flow blocks
* via the shared paragraph-container handler.
*/

import type { PMNode, NodeHandlerContext } from '../types.js';
import { createSectionBreakBlock, hasIntrinsicBoundarySignals, shouldRequirePageBoundary } from '../sections/index.js';

const getChildren = (node: PMNode): PMNode[] => {
if (Array.isArray(node.content)) return node.content;
const content = node.content as { forEach?: (cb: (child: PMNode) => void) => void } | undefined;
if (content && typeof content.forEach === 'function') {
const children: PMNode[] = [];
content.forEach((child) => children.push(child));
return children;
}
return [];
};

export function handleBibliographyNode(node: PMNode, context: NodeHandlerContext): void {
const children = getChildren(node);
if (children.length === 0) return;

const {
blocks,
recordBlockKind,
nextBlockId,
positions,
trackedChangesConfig,
bookmarks,
hyperlinkConfig,
sectionState,
converters,
themeColors,
enableComments,
} = context;

const paragraphToFlowBlocks = converters.paragraphToFlowBlocks;

children.forEach((child) => {
if (child.type !== 'paragraph') return;

if ((sectionState?.ranges?.length ?? 0) > 0) {
const nextSection = sectionState!.ranges[sectionState!.currentSectionIndex + 1];
if (nextSection && sectionState!.currentParagraphIndex === nextSection.startParagraphIndex) {
const currentSection = sectionState!.ranges[sectionState!.currentSectionIndex];
const requiresPageBoundary =
shouldRequirePageBoundary(currentSection, nextSection) || hasIntrinsicBoundarySignals(nextSection);
const extraAttrs = requiresPageBoundary ? { requirePageBoundary: true } : undefined;
const sectionBreak = createSectionBreakBlock(nextSection, nextBlockId, extraAttrs);
blocks.push(sectionBreak);
recordBlockKind?.(sectionBreak.kind);
sectionState!.currentSectionIndex++;
}
}

const paragraphBlocks = paragraphToFlowBlocks({
para: child,
nextBlockId,
positions,
trackedChangesConfig,
bookmarks,
hyperlinkConfig,
themeColors,
converterContext: context.converterContext,
enableComments,
converters,
});

paragraphBlocks.forEach((block) => {
blocks.push(block);
recordBlockKind?.(block.kind);
});

sectionState!.currentParagraphIndex++;
});
}
export { handleParagraphContainerNode as handleBibliographyNode } from './paragraph-container.js';
Original file line number Diff line number Diff line change
@@ -1,98 +1,9 @@
/**
* Document Index Processing Module
*
* Handles index field containers and keeps section break accounting aligned
* with the paragraph flow inside the index.
* Index field containers convert their child paragraphs to flow blocks via the
* shared paragraph-container handler, which keeps section-break accounting
* aligned with the paragraph flow inside the index.
*/

import type { PMNode, NodeHandlerContext } from '../types.js';
import { createSectionBreakBlock, hasIntrinsicBoundarySignals, shouldRequirePageBoundary } from '../sections/index.js';

/**
* Extracts child nodes from an index node.
*
* Handles both array-based content (plain objects) and ProseMirror Fragment-like
* content (which uses forEach instead of array iteration).
*
* @param node - The index node to extract children from
* @returns Array of child nodes, or empty array if no children
*/
const getIndexChildren = (node: PMNode): PMNode[] => {
if (Array.isArray(node.content)) return node.content;
const content = node.content as { forEach?: (cb: (child: PMNode) => void) => void } | undefined;
if (content && typeof content.forEach === 'function') {
const children: PMNode[] = [];
content.forEach((child) => {
children.push(child);
});
return children;
}
return [];
};

/**
* Handle index nodes by converting child paragraphs to flow blocks.
*
* @param node - Index node to process
* @param context - Shared handler context
*/
export function handleIndexNode(node: PMNode, context: NodeHandlerContext): void {
const children = getIndexChildren(node);
if (children.length === 0) return;

const {
blocks,
recordBlockKind,
nextBlockId,
positions,
trackedChangesConfig,
bookmarks,
hyperlinkConfig,
sectionState,
converters,
themeColors,
enableComments,
} = context;

const paragraphToFlowBlocks = converters.paragraphToFlowBlocks;

children.forEach((child) => {
if (child.type !== 'paragraph') {
return;
}

if ((sectionState?.ranges?.length ?? 0) > 0) {
const nextSection = sectionState!.ranges[sectionState!.currentSectionIndex + 1];
if (nextSection && sectionState!.currentParagraphIndex === nextSection.startParagraphIndex) {
const currentSection = sectionState!.ranges[sectionState!.currentSectionIndex];
const requiresPageBoundary =
shouldRequirePageBoundary(currentSection, nextSection) || hasIntrinsicBoundarySignals(nextSection);
const extraAttrs = requiresPageBoundary ? { requirePageBoundary: true } : undefined;
const sectionBreak = createSectionBreakBlock(nextSection, nextBlockId, extraAttrs);
blocks.push(sectionBreak);
recordBlockKind?.(sectionBreak.kind);
sectionState!.currentSectionIndex++;
}
}

const paragraphBlocks = paragraphToFlowBlocks({
para: child,
nextBlockId,
positions,
trackedChangesConfig,
bookmarks,
hyperlinkConfig,
themeColors,
converterContext: context.converterContext,
enableComments: enableComments,
converters,
});

paragraphBlocks.forEach((block) => {
blocks.push(block);
recordBlockKind?.(block.kind);
});

sectionState!.currentParagraphIndex++;
});
}
export { handleParagraphContainerNode as handleIndexNode } from './paragraph-container.js';
Original file line number Diff line number Diff line change
Expand Up @@ -702,5 +702,84 @@ describe('document-part-object', () => {
expect(callArgs[2]).toMatchObject({ sectionState: state });
});
});

// ==================== SD-3005: block field children ====================
describe('block field children (SD-3005)', () => {
beforeEach(() => {
vi.mocked(metadataModule.getDocPartGallery).mockReturnValue('Bibliographies');
vi.mocked(metadataModule.getDocPartObjectId).mockReturnValue('bib-1');
vi.mocked(metadataModule.getNodeInstruction).mockReturnValue(undefined);
vi.mocked(metadataModule.resolveNodeSdtMetadata).mockReturnValue({ type: 'docPartObject' });
});

const bibliography = (): PMNode =>
({
type: 'bibliography',
attrs: { instruction: 'BIBLIOGRAPHY' },
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'Entry One' }] },
{ type: 'paragraph', content: [{ type: 'text', text: 'Entry Two' }] },
],
}) as unknown as PMNode;

// Minimal section state with a far-away boundary so no break is emitted β€”
// these tests only assert rendering + the paragraph-index counter.
const withSectionState = () => {
mockContext.sectionState = {
ranges: [{ sectionIndex: 0, startParagraphIndex: 0, endParagraphIndex: 99 }],
currentSectionIndex: 0,
currentParagraphIndex: 0,
} as unknown as NonNullable<NodeHandlerContext['sectionState']>;
};

it('renders a direct bibliography child (heading + both entries become blocks)', () => {
withSectionState();
const node: PMNode = {
type: 'documentPartObject',
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Bibliography' }] }, bibliography()],
};

handleDocumentPartObjectNode(node, mockContext);

// heading paragraph + 2 bibliography entry paragraphs
expect(mockParagraphConverter).toHaveBeenCalledTimes(3);
expect(mockContext.blocks).toHaveLength(3);
});

it('advances currentParagraphIndex through a bibliography child to match findParagraphsWithSectPr', () => {
// findParagraphsWithSectPr recurses `bibliography`, so the handler must
// advance the counter per entry or section breaks downstream drift.
withSectionState();
const node: PMNode = {
type: 'documentPartObject',
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Bibliography' }] }, bibliography()],
};

handleDocumentPartObjectNode(node, mockContext);

// heading (1) + Entry One (1) + Entry Two (1) = 3 paragraphs counted
expect(mockContext.sectionState!.currentParagraphIndex).toBe(3);
});

it('renders a structuredContentBlock-wrapped bibliography without advancing the counter', () => {
// findParagraphsWithSectPr does NOT recurse structuredContentBlock, so its
// inner paragraphs render but must not advance currentParagraphIndex.
withSectionState();
const node: PMNode = {
type: 'documentPartObject',
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'Bibliography' }] },
{ type: 'structuredContentBlock', attrs: {}, content: [bibliography()] },
],
};

handleDocumentPartObjectNode(node, mockContext);

// both entries still render
expect(mockParagraphConverter).toHaveBeenCalledTimes(3); // heading + 2 entries
// but only the heading advanced the counter (scb is not recursed by analysis)
expect(mockContext.sectionState!.currentParagraphIndex).toBe(1);
});
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,13 @@ import type { PMNode, NodeHandlerContext } from '../types.js';
import { emitPendingSectionBreakForParagraph } from '../sections/index.js';
import { getDocPartGallery, getDocPartObjectId, getNodeInstruction, resolveNodeSdtMetadata } from './metadata.js';
import { processTocChildren } from './toc.js';
import { handleParagraphContainerNode } from './paragraph-container.js';
import { handleStructuredContentBlockNode } from './structured-content-block.js';

// Block field children whose paragraphs `findParagraphsWithSectPr` recurses into,
// so their handler must advance currentParagraphIndex in step (delegated to
// handleParagraphContainerNode).
const PARAGRAPH_CONTAINER_TYPES = new Set(['bibliography', 'index', 'tableOfAuthorities']);

/**
* Handle document part object nodes (e.g., TOC galleries, page numbers).
Expand Down Expand Up @@ -115,9 +122,18 @@ export function handleDocumentPartObjectNode(node: PMNode, context: NodeHandlerC
};
const output = { blocks, recordBlockKind };
processTocChildren(child.content, metadata, tocContext, output);
} else if (PARAGRAPH_CONTAINER_TYPES.has(child.type)) {
// SD-3005: a block field (bibliography / index / table of authorities)
// generated inside this SDT. Render its entry paragraphs and advance
// currentParagraphIndex per child to match findParagraphsWithSectPr,
// which recurses into these node types.
handleParagraphContainerNode(child, context);
} else if (child.type === 'structuredContentBlock') {
// SD-3005: a nested content control (often wrapping a block field).
// findParagraphsWithSectPr does NOT recurse structuredContentBlock, so
// its handler renders without advancing currentParagraphIndex.
handleStructuredContentBlockNode(child, context);
}
}
}
// Note: Other documentPartObject types (e.g., Bibliography) are intentionally
// not processed - they are ignored to maintain backward compatibility.
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { describe, it, expect, vi } from 'vitest';
import { getParagraphContainerChildren, handleParagraphContainerNode } from './paragraph-container.js';
import { handleIndexNode } from './document-index.js';
import { handleBibliographyNode } from './bibliography.js';
import { handleTableOfAuthoritiesNode } from './table-of-authorities.js';
import type { PMNode, NodeHandlerContext } from '../types.js';

describe('getParagraphContainerChildren', () => {
it('reads array-based content', () => {
const node = { type: 'index', content: [{ type: 'paragraph' }, { type: 'paragraph' }] } as unknown as PMNode;
expect(getParagraphContainerChildren(node)).toHaveLength(2);
});

it('reads ProseMirror Fragment-like content via forEach', () => {
const kids = [{ type: 'paragraph' }];
const node = {
type: 'index',
content: { forEach: (cb: (c: unknown) => void) => kids.forEach(cb) },
} as unknown as PMNode;
expect(getParagraphContainerChildren(node)).toEqual(kids);
});

it('returns an empty array when there is no content', () => {
expect(getParagraphContainerChildren({ type: 'index' } as unknown as PMNode)).toEqual([]);
});
});

describe('handleParagraphContainerNode', () => {
const makeContext = () => {
const blocks: unknown[] = [];
const paragraphToFlowBlocks = vi.fn(({ para }: { para: PMNode }) => [
{ kind: 'paragraph', text: (para as { text?: string }).text },
]);
const context = {
blocks,
recordBlockKind: vi.fn(),
nextBlockId: vi.fn(() => 'b1'),
positions: {},
trackedChangesConfig: undefined,
bookmarks: undefined,
hyperlinkConfig: undefined,
sectionState: { ranges: [], currentSectionIndex: 0, currentParagraphIndex: 0 },
converters: { paragraphToFlowBlocks },
themeColors: undefined,
enableComments: false,
converterContext: {},
} as unknown as NodeHandlerContext;
return { context, blocks, paragraphToFlowBlocks };
};

it('converts each child paragraph to flow blocks and advances the paragraph counter', () => {
const { context, blocks, paragraphToFlowBlocks } = makeContext();
const node = {
type: 'index',
content: [
{ type: 'paragraph', text: 'a' },
{ type: 'paragraph', text: 'b' },
{ type: 'someAtom', text: 'skip' },
],
} as unknown as PMNode;

handleParagraphContainerNode(node, context);

expect(paragraphToFlowBlocks).toHaveBeenCalledTimes(2);
expect(blocks).toHaveLength(2);
expect((context.sectionState as { currentParagraphIndex: number }).currentParagraphIndex).toBe(2);
});

it('is the single implementation shared by the three block-field handlers', () => {
expect(handleIndexNode).toBe(handleParagraphContainerNode);
expect(handleBibliographyNode).toBe(handleParagraphContainerNode);
expect(handleTableOfAuthoritiesNode).toBe(handleParagraphContainerNode);
});
});
Loading
Loading