interface MarkdownPosition { readonly start?: { readonly line?: number; readonly offset?: number; };} interface MarkdownAstNode { readonly type: string; readonly value?: unknown; readonly position?: MarkdownPosition; children?: MarkdownAstNode[];} interface MarkdownFile { readonly value?: unknown;} interface MarkdownParser { parse(markdown: string): unknown;} interface RecoveredMarkdown { readonly blocks: MarkdownAstNode[]; readonly source: string;} const INLINE_PARSE_PREFIX = "t3-markdown-inline-prefix:"; function isSameLineOverIndentedCode( node: MarkdownAstNode, parent: MarkdownAstNode | undefined, markdown: string,): boolean { if ( node.type !== "code" || parent?.type !== "listItem" || typeof node.value !== "string" || !/^[\t ]/.test(node.value) ) { return false; } const nodeStart = node.position?.start; const parentStart = parent.position?.start; if ( nodeStart?.line === undefined || nodeStart.offset === undefined || parentStart?.line === undefined || nodeStart.line !== parentStart.line ) { return false; } const sourceCharacter = markdown[nodeStart.offset]; return sourceCharacter !== "`" && sourceCharacter !== "~";} function parseRecoveredMarkdown(value: string, parser: MarkdownParser): RecoveredMarkdown { // A text prefix forces block-looking input into a paragraph while preserving // the processor's configured inline extensions (for example, GFM syntax). // Later root children are kept as blocks so blank-line-separated content is // never discarded. const source = `${INLINE_PARSE_PREFIX}${value}`; const document = parser.parse(source) as MarkdownAstNode; const blocks = document.children; const paragraph = blocks?.[0]; const children = paragraph?.type === "paragraph" ? paragraph.children : undefined; const first = children?.[0]; if ( !blocks || !children || first?.type !== "text" || typeof first.value !== "string" || !first.value.startsWith(INLINE_PARSE_PREFIX) ) { return { blocks: [{ type: "text", value }], source }; } const firstValue = first.value.slice(INLINE_PARSE_PREFIX.length); return { blocks: [ { ...paragraph, type: "paragraph", children: [...(firstValue ? [{ ...first, value: firstValue }] : []), ...children.slice(1)], }, ...blocks.slice(1), ], source, };} function blocksFromIndentedCode(node: MarkdownAstNode, parser: MarkdownParser): RecoveredMarkdown { const value = typeof node.value === "string" ? node.value.trim() : ""; const recovered = parseRecoveredMarkdown(value, parser); const first = recovered.blocks[0]; return { ...recovered, blocks: first && node.position ? [{ ...first, position: node.position }, ...recovered.blocks.slice(1)] : recovered.blocks, };} /** * CommonMark treats four or more spaces after a list marker as an indented * code block. In chat output, excessive spacing is commonly accidental * alignment such as `- text`, which otherwise produces a full code card * for every bullet. Only normalize blocks that retain excess indentation and * start on the marker's own line; explicit fences and conventional indented * blocks remain code. */function attachListItemIndentationNormalizer(this: MarkdownParser) { return (tree: MarkdownAstNode, file: MarkdownFile) => { if (typeof file.value !== "string") { return; } const markdown = file.value; const visit = (node: MarkdownAstNode, source: string) => { if (!node.children) { return; } node.children = node.children.flatMap((child) => { if (isSameLineOverIndentedCode(child, node, source)) { const recovered = blocksFromIndentedCode(child, this); for (const block of recovered.blocks) { visit(block, recovered.source); } return recovered.blocks; } visit(child, source); return [child]; }); }; visit(tree, markdown); };} export const remarkNormalizeListItemIndentation = attachListItemIndentationNormalizer;
interface MarkdownPosition {
readonly start?: {
readonly line?: number;
readonly offset?: number;
};
}
interface MarkdownAstNode {
readonly type: string;
readonly value?: unknown;
readonly position?: MarkdownPosition;
children?: MarkdownAstNode[];
}
interface MarkdownFile {
readonly value?: unknown;
}
interface MarkdownParser {
parse(markdown: string): unknown;
}
interface RecoveredMarkdown {
readonly blocks: MarkdownAstNode[];
readonly source: string;
}
const INLINE_PARSE_PREFIX = "t3-markdown-inline-prefix:";
function isSameLineOverIndentedCode(
node: MarkdownAstNode,
parent: MarkdownAstNode | undefined,
markdown: string,
): boolean {
if (
node.type !== "code" ||
parent?.type !== "listItem" ||
typeof node.value !== "string" ||
!/^[\t ]/.test(node.value)
) {
return false;
}
const nodeStart = node.position?.start;
const parentStart = parent.position?.start;
if (
nodeStart?.line === undefined ||
nodeStart.offset === undefined ||
parentStart?.line === undefined ||
nodeStart.line !== parentStart.line
) {
return false;
}
const sourceCharacter = markdown[nodeStart.offset];
return sourceCharacter !== "`" && sourceCharacter !== "~";
}
function parseRecoveredMarkdown(value: string, parser: MarkdownParser): RecoveredMarkdown {
// A text prefix forces block-looking input into a paragraph while preserving
// the processor's configured inline extensions (for example, GFM syntax).
// Later root children are kept as blocks so blank-line-separated content is
// never discarded.
const source = `${INLINE_PARSE_PREFIX}${value}`;
const document = parser.parse(source) as MarkdownAstNode;
const blocks = document.children;
const paragraph = blocks?.[0];
const children = paragraph?.type === "paragraph" ? paragraph.children : undefined;
const first = children?.[0];
if (
!blocks ||
!children ||
first?.type !== "text" ||
typeof first.value !== "string" ||
!first.value.startsWith(INLINE_PARSE_PREFIX)
) {
return { blocks: [{ type: "text", value }], source };
}
const firstValue = first.value.slice(INLINE_PARSE_PREFIX.length);
return {
blocks: [
{
...paragraph,
type: "paragraph",
children: [...(firstValue ? [{ ...first, value: firstValue }] : []), ...children.slice(1)],
},
...blocks.slice(1),
],
source,
};
}
function blocksFromIndentedCode(node: MarkdownAstNode, parser: MarkdownParser): RecoveredMarkdown {
const value = typeof node.value === "string" ? node.value.trim() : "";
const recovered = parseRecoveredMarkdown(value, parser);
const first = recovered.blocks[0];
return {
...recovered,
blocks:
first && node.position
? [{ ...first, position: node.position }, ...recovered.blocks.slice(1)]
: recovered.blocks,
};
}
/**
* CommonMark treats four or more spaces after a list marker as an indented
* code block. In chat output, excessive spacing is commonly accidental
* alignment such as `- text`, which otherwise produces a full code card
* for every bullet. Only normalize blocks that retain excess indentation and
* start on the marker's own line; explicit fences and conventional indented
* blocks remain code.
*/
function attachListItemIndentationNormalizer(this: MarkdownParser) {
return (tree: MarkdownAstNode, file: MarkdownFile) => {
if (typeof file.value !== "string") {
return;
}
const markdown = file.value;
const visit = (node: MarkdownAstNode, source: string) => {
if (!node.children) {
return;
}
node.children = node.children.flatMap((child) => {
if (isSameLineOverIndentedCode(child, node, source)) {
const recovered = blocksFromIndentedCode(child, this);
for (const block of recovered.blocks) {
visit(block, recovered.source);
}
return recovered.blocks;
}
visit(child, source);
return [child];
});
};
visit(tree, markdown);
};
}
export const remarkNormalizeListItemIndentation = attachListItemIndentationNormalizer;