Skip to content
Open
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
18 changes: 11 additions & 7 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,18 @@
"module": "./build/index.mjs",
"types": "./build/index.d.ts",
"scripts": {
"prepare": "husky",
"build": "tsup",
"prebuild": "npm run clean",
"build": "npm run clean && tsup",
"clean": "rm -rf ./build",
"test": "jest --coverage --verbose",
"test:watch": "jest -w",
"lint": "eslint \"src/**/*.ts\"",
"format": "prettier --write ."
"test": "jest",
"test:all": "node test/run-all-tests.js",
"test:unit": "jest",
"test:integration": "node test/integration/test-math-issue-case.js",
"test:features": "node test/features/test-math-rendering.js && node test/features/test-nested-numbered-lists.js && node test/features/test-mdx-spacing.js",
"lint": "eslint . --ext .ts,.tsx,.js,.jsx",
"lint:fix": "eslint . --ext .ts,.tsx,.js,.jsx --fix",
"format": "prettier --write .",
"format:check": "prettier --check .",
"prepare": "husky install"
},
"peerDependencies": {
"@notionhq/client": "^2.0.0"
Expand Down
3 changes: 2 additions & 1 deletion src/core/renderer/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -319,9 +319,10 @@ export abstract class BaseRendererPlugin implements ProcessorChainNode {
item.equation &&
this.context.transformers.annotations.equation
) {
// Use the equation.expression as the text for the annotation transformer
text = await this.context.transformers.annotations.equation.transform(
{
text,
text: item.equation.expression,
metadata,
manifest: this.context.manifest,
},
Expand Down
62 changes: 62 additions & 0 deletions src/plugins/renderer/mdx/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,4 +53,66 @@ export class MDXRenderer extends BaseRendererPlugin {
this.addVariable(name, resolver);
});
}

// Public method to render an array of blocks as Markdown, grouping lists
public async renderBlocksAsMarkdown(
blocks: any[],
metadata: any = {},
): Promise<string> {
const results: string[] = [];
let i = 0;
while (i < blocks.length) {
const block = blocks[i];
// Group consecutive numbered_list_items
if (block.type === 'numbered_list_item') {
const group: any[] = [];
let j = i;
while (j < blocks.length && blocks[j].type === 'numbered_list_item') {
group.push(blocks[j]);
j++;
}
// Render the group as a single list
const lines: string[] = [];
for (let idx = 0; idx < group.length; idx++) {
const item = group[idx];
const text = await this.processBlock(item, {
...metadata,
listLevel: 0,
currentNumber: idx + 1,
});
lines.push(text);
}
results.push(lines.join('\n'));
i = j;
continue;
}
// Group consecutive bulleted_list_items
if (block.type === 'bulleted_list_item') {
const group: any[] = [];
let j = i;
while (j < blocks.length && blocks[j].type === 'bulleted_list_item') {
group.push(blocks[j]);
j++;
}
// Render the group as a single list
const lines: string[] = [];
for (let idx = 0; idx < group.length; idx++) {
const item = group[idx];
const text = await this.processBlock(item, {
...metadata,
listLevel: 0,
});
lines.push(text);
}
results.push(lines.join('\n'));
i = j;
continue;
}
// Otherwise, render the block normally
const text = await this.processBlock(block, metadata);
results.push(text);
i++;
}
return results.join('\n');
}
}
246 changes: 188 additions & 58 deletions src/plugins/renderer/mdx/transformers/blocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,76 +134,136 @@ export const blockTransformers: Partial<

bulleted_list_item: {
transform: async ({ block, utils, metadata = {} }) => {
// First, handle this block's own content
const text = await utils.transformRichText(
// @ts-ignore
block.bulleted_list_item.rich_text,
// Only render the list if this is the first in a group
if (metadata._renderedByGroup) return '';
// Find all consecutive bulleted_list_items at this level
const group = groupListItems(
block,
metadata.siblings,
'bulleted_list_item',
);
const currentLevel = metadata.listLevel || 0;
const indent = INDENT.repeat(currentLevel);

// If no children, just return formatted content
if (!block.children?.length) {
return `${indent}- ${text}`;
const lines = [];
for (const item of group) {
const text = await utils.transformRichText(
item.bulleted_list_item.rich_text,
);
let childrenContent = '';
if (item.children?.length) {
// Group nested list items by type
const nestedBullets = item.children.filter(
(b) => b.type === 'bulleted_list_item',
);
const nestedNumbers = item.children.filter(
(b) => b.type === 'numbered_list_item',
);
if (nestedBullets.length) {
childrenContent +=
'\n' +
(await blockTransformers.bulleted_list_item.transform({
block: nestedBullets[0],
utils,
metadata: {
...metadata,
listLevel: currentLevel + 1,
_renderedByGroup: false,
},
siblings: nestedBullets,
}));
}
if (nestedNumbers.length) {
childrenContent +=
'\n' +
(await blockTransformers.numbered_list_item.transform({
block: nestedNumbers[0],
utils,
metadata: {
...metadata,
listLevel: currentLevel + 1,
_renderedByGroup: false,
},
siblings: nestedNumbers,
}));
}
}
lines.push(
`${indent}- ${text}${childrenContent ? '\n' + childrenContent : ''}`,
);
}

// For blocks with children, we'll recursively handle them
const childMetadata = {
...metadata,
listLevel: currentLevel + 1, // can be anything as per your use case
};

// Process each child block directly through processBlock
const childrenContent = await Promise.all(
block.children.map((childBlock) =>
utils.processBlock(childBlock, childMetadata),
),
);

// Combine everything with proper formatting
return `${indent}- ${text}\n${childrenContent.join('\n')}\n`;
// Mark all items in this group as rendered
for (const item of group) {
item._renderedByGroup = true;
}
return lines.join('\n') + '\n';
},
},

numbered_list_item: {
transform: async ({ block, utils, metadata = {} }) => {
// Get the current nesting level
// Only render the list if this is the first in a group
if (metadata._renderedByGroup) return '';
// Find all consecutive numbered_list_items at this level
const group = groupListItems(
block,
metadata.siblings,
'numbered_list_item',
);
const currentLevel = metadata.listLevel || 0;

// The parent passes down the current number to its children
const currentNumber = metadata.currentNumber || 1;

// Create indentation based on level
const indent = INDENT.repeat(currentLevel);

// Process the item's text content
const text = await utils.transformRichText(
// @ts-ignore
block.numbered_list_item.rich_text,
);

// Format this item with proper number
const formattedItem = `${indent}${currentNumber}. ${text}`;

// If no children, just return this item
if (!block.children?.length) {
return formattedItem;
const lines = [];
for (let idx = 0; idx < group.length; idx++) {
const item = group[idx];
const text = await utils.transformRichText(
item.numbered_list_item.rich_text,
);
let childrenContent = '';
if (item.children?.length) {
// Group nested list items by type
const nestedBullets = item.children.filter(
(b) => b.type === 'bulleted_list_item',
);
const nestedNumbers = item.children.filter(
(b) => b.type === 'numbered_list_item',
);
if (nestedBullets.length) {
childrenContent +=
'\n' +
(await blockTransformers.bulleted_list_item.transform({
block: nestedBullets[0],
utils,
metadata: {
...metadata,
listLevel: currentLevel + 1,
_renderedByGroup: false,
},
siblings: nestedBullets,
}));
}
if (nestedNumbers.length) {
childrenContent +=
'\n' +
(await blockTransformers.numbered_list_item.transform({
block: nestedNumbers[0],
utils,
metadata: {
...metadata,
listLevel: currentLevel + 1,
_renderedByGroup: false,
},
siblings: nestedNumbers,
}));
}
}
lines.push(
`${indent}${idx + 1}. ${text}${childrenContent ? '\n' + childrenContent : ''}`,
);
}

// For items with children, process each child sequentially
// Each child starts with number 1 at its level
const childrenContent = [];
for (let i = 0; i < block.children.length; i++) {
const childContent = await utils.processBlock(block.children[i], {
...metadata,
listLevel: currentLevel + 1,
currentNumber: i + 1, // Pass sequential numbers to siblings
});
childrenContent.push(childContent);
// Mark all items in this group as rendered
for (const item of group) {
item._renderedByGroup = true;
}

// Combine this item with its children
return `${formattedItem}\n${childrenContent.join('\n')}\n`;
return lines.join('\n') + '\n';
},
},

Expand Down Expand Up @@ -453,7 +513,7 @@ export const blockTransformers: Partial<
transform: async ({ block }) => {
// @ts-ignore
const expression = block.equation.expression;
return `\`\`\`math\n${expression}\n\`\`\`\n\n`;
return `$$\n${expression}\n$$\n\n`;
},
},

Expand Down Expand Up @@ -645,3 +705,73 @@ export const blockTransformers: Partial<
transform: async () => '',
},
};

// NOTE: The following is a fallback to the previous per-block logic due to lack of blockTree context in MDX renderer.
// For future improvement, refactor to support grouping consecutive list items when blockTree context is available.

blockTransformers.bulleted_list_item = {
transform: async ({ block, utils, metadata = {} }) => {
const text = await utils.transformRichText(
// @ts-ignore
block.bulleted_list_item.rich_text,
);
const currentLevel = metadata.listLevel || 0;
const indent = INDENT.repeat(currentLevel);

if (!block.children?.length) {
return `${indent}- ${text}`;
}
const childMetadata = {
...metadata,
listLevel: currentLevel + 1,
};
const childrenContent = await Promise.all(
block.children.map((childBlock) =>
utils.processBlock(childBlock, childMetadata),
),
);
return `${indent}- ${text}\n${childrenContent.join('\n')}\n`;
},
};

blockTransformers.numbered_list_item = {
transform: async ({ block, utils, metadata = {} }) => {
const currentLevel = metadata.listLevel || 0;
const currentNumber = metadata.currentNumber || 1;
const indent = INDENT.repeat(currentLevel);
const text = await utils.transformRichText(
// @ts-ignore
block.numbered_list_item.rich_text,
);
const formattedItem = `${indent}${currentNumber}. ${text}`;
if (!block.children?.length) {
return formattedItem;
}
const childrenContent: string[] = [];
for (let i = 0; i < block.children.length; i++) {
const childContent = await utils.processBlock(block.children[i], {
...metadata,
listLevel: currentLevel + 1,
currentNumber: i + 1,
});
childrenContent.push(childContent);
}
return `${formattedItem}\n${childrenContent.join('\n')}\n`;
},
};

function groupListItems(block, siblings, type) {
// Find all consecutive list items of the same type at the same parent
const startIdx = siblings.findIndex((b) => b.id === block.id);
const group = [];
for (
let i = startIdx;
i < siblings.length &&
siblings[i].type === type &&
siblings[i].parent?.id === block.parent?.id;
i++
) {
group.push(siblings[i]);
}
return group;
}
Comment on lines +763 to +777
Copy link

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

Add defensive programming for robustness.

Consider adding null checks and error handling to prevent runtime errors when siblings is undefined or when block.id is not found.

 function groupListItems(block, siblings, type) {
+  if (!siblings || !Array.isArray(siblings)) {
+    return [block];
+  }
+  
   // Find all consecutive list items of the same type at the same parent
   const startIdx = siblings.findIndex((b) => b.id === block.id);
+  if (startIdx === -1) {
+    return [block];
+  }
+  
   const group = [];
   for (
     let i = startIdx;
     i < siblings.length &&
     siblings[i].type === type &&
     siblings[i].parent?.id === block.parent?.id;
     i++
   ) {
     group.push(siblings[i]);
   }
   return group;
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
function groupListItems(block, siblings, type) {
// Find all consecutive list items of the same type at the same parent
const startIdx = siblings.findIndex((b) => b.id === block.id);
const group = [];
for (
let i = startIdx;
i < siblings.length &&
siblings[i].type === type &&
siblings[i].parent?.id === block.parent?.id;
i++
) {
group.push(siblings[i]);
}
return group;
}
function groupListItems(block, siblings, type) {
if (!siblings || !Array.isArray(siblings)) {
return [block];
}
// Find all consecutive list items of the same type at the same parent
const startIdx = siblings.findIndex((b) => b.id === block.id);
if (startIdx === -1) {
return [block];
}
const group = [];
for (
let i = startIdx;
i < siblings.length &&
siblings[i].type === type &&
siblings[i].parent?.id === block.parent?.id;
i++
) {
group.push(siblings[i]);
}
return group;
}
🤖 Prompt for AI Agents
In src/plugins/renderer/mdx/transformers/blocks.ts around lines 763 to 777, the
function groupListItems assumes siblings is defined and block.id exists in
siblings, which can cause runtime errors. Add defensive checks to ensure
siblings is an array before using findIndex, and verify that startIdx is not -1
before proceeding with the loop. If these conditions are not met, return an
empty array early to prevent errors.

💡 Verification agent

🧩 Analysis chain

Verify the groupListItems function handles edge cases correctly.

The grouping logic looks sound, but please ensure it handles edge cases properly, particularly when siblings array might be undefined or when block IDs are not found.


🏁 Script executed:

#!/bin/bash
# Description: Search for usage of groupListItems to understand the calling context and verify siblings parameter handling

# Search for calls to groupListItems function
rg -A 5 -B 5 "groupListItems\(" --type typescript

# Search for metadata.siblings usage to understand how it's populated
rg -A 3 -B 3 "metadata\.siblings" --type typescript

Length of output: 175


#!/bin/bash
# Search for calls to groupListItems in .ts and .tsx files
rg -n -A5 -B5 'groupListItems\(' -g '*.ts' -g '*.tsx'

# Search for metadata.siblings usage in .ts and .tsx files
rg -n -A3 -B3 'metadata\.siblings' -g '*.ts' -g '*.tsx'

🏁 Script executed:

#!/bin/bash
# Search for usage of groupListItems in .ts and .tsx files
rg -n -A5 -B5 'groupListItems\(' -g '*.ts' -g '*.tsx'

# Search for metadata.siblings usage in .ts and .tsx files
rg -n -A3 -B3 'metadata\.siblings' -g '*.ts' -g '*.tsx'

Length of output: 4202


Handle undefined siblings and missing block IDs in groupListItems

The current implementation will throw if siblings is undefined or if block.id isn’t found (startIdx === -1). Add guards at the top of groupListItems to return an empty array in those cases.

Locations to update:

  • src/plugins/renderer/mdx/transformers/blocks.ts lines 763–777

Suggested diff:

 function groupListItems(block, siblings, type) {
-  // Find all consecutive list items of the same type at the same parent
-  const startIdx = siblings.findIndex((b) => b.id === block.id);
+  // Bail out if siblings is not an array or block id isn’t in siblings
+  if (!Array.isArray(siblings)) return [];
+  const startIdx = siblings.findIndex((b) => b.id === block.id);
+  if (startIdx < 0) return [];

   const group = [];
   for (
     let i = startIdx;
     i < siblings.length &&
     siblings[i].type === type &&
     siblings[i].parent?.id === block.parent?.id;
     i++
   ) {
     group.push(siblings[i]);
   }
   return group;
 }

This ensures we never call .findIndex or index into siblings when it’s unset or when block.id isn’t present.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
function groupListItems(block, siblings, type) {
// Find all consecutive list items of the same type at the same parent
const startIdx = siblings.findIndex((b) => b.id === block.id);
const group = [];
for (
let i = startIdx;
i < siblings.length &&
siblings[i].type === type &&
siblings[i].parent?.id === block.parent?.id;
i++
) {
group.push(siblings[i]);
}
return group;
}
function groupListItems(block, siblings, type) {
// Bail out if siblings is not an array or block id isn’t in siblings
if (!Array.isArray(siblings)) return [];
const startIdx = siblings.findIndex((b) => b.id === block.id);
if (startIdx < 0) return [];
const group = [];
for (
let i = startIdx;
i < siblings.length &&
siblings[i].type === type &&
siblings[i].parent?.id === block.parent?.id;
i++
) {
group.push(siblings[i]);
}
return group;
}
🤖 Prompt for AI Agents
In src/plugins/renderer/mdx/transformers/blocks.ts around lines 763 to 777, the
function groupListItems does not handle cases where siblings is undefined or
block.id is not found, causing potential runtime errors. Add a guard at the
start of the function to check if siblings is falsy or if findIndex returns -1
for block.id, and return an empty array immediately in those cases to prevent
errors when accessing siblings or indexing.

Loading