1interface MarkdownPosition {
2 readonly start?: {
3 readonly line?: number;
4 readonly offset?: number;
5 };
6}
7
8interface MarkdownAstNode {
9 readonly type: string;
10 readonly value?: unknown;
11 readonly position?: MarkdownPosition;
12 children?: MarkdownAstNode[];
13}
14
15interface MarkdownFile {
16 readonly value?: unknown;
17}
18
19interface MarkdownParser {
20 parse(markdown: string): unknown;
21}
22
23interface RecoveredMarkdown {
24 readonly blocks: MarkdownAstNode[];
25 readonly source: string;
26}
27
28const INLINE_PARSE_PREFIX = "t3-markdown-inline-prefix:";
29
30function isSameLineOverIndentedCode(
31 node: MarkdownAstNode,
32 parent: MarkdownAstNode | undefined,
33 markdown: string,
34): boolean {
35 if (
36 node.type !== "code" ||
37 parent?.type !== "listItem" ||
38 typeof node.value !== "string" ||
39 !/^[\t ]/.test(node.value)
40 ) {
41 return false;
42 }
43
44 const nodeStart = node.position?.start;
45 const parentStart = parent.position?.start;
46 if (
47 nodeStart?.line === undefined ||
48 nodeStart.offset === undefined ||
49 parentStart?.line === undefined ||
50 nodeStart.line !== parentStart.line
51 ) {
52 return false;
53 }
54
55 const sourceCharacter = markdown[nodeStart.offset];
56 return sourceCharacter !== "`" && sourceCharacter !== "~";
57}
58
59function parseRecoveredMarkdown(value: string, parser: MarkdownParser): RecoveredMarkdown {
60 // A text prefix forces block-looking input into a paragraph while preserving
61 // the processor's configured inline extensions (for example, GFM syntax).
62 // Later root children are kept as blocks so blank-line-separated content is
63 // never discarded.
64 const source = `${INLINE_PARSE_PREFIX}${value}`;
65 const document = parser.parse(source) as MarkdownAstNode;
66 const blocks = document.children;
67 const paragraph = blocks?.[0];
68 const children = paragraph?.type === "paragraph" ? paragraph.children : undefined;
69 const first = children?.[0];
70 if (
71 !blocks ||
72 !children ||
73 first?.type !== "text" ||
74 typeof first.value !== "string" ||
75 !first.value.startsWith(INLINE_PARSE_PREFIX)
76 ) {
77 return { blocks: [{ type: "text", value }], source };
78 }
79
80 const firstValue = first.value.slice(INLINE_PARSE_PREFIX.length);
81 return {
82 blocks: [
83 {
84 ...paragraph,
85 type: "paragraph",
86 children: [...(firstValue ? [{ ...first, value: firstValue }] : []), ...children.slice(1)],
87 },
88 ...blocks.slice(1),
89 ],
90 source,
91 };
92}
93
94function blocksFromIndentedCode(node: MarkdownAstNode, parser: MarkdownParser): RecoveredMarkdown {
95 const value = typeof node.value === "string" ? node.value.trim() : "";
96 const recovered = parseRecoveredMarkdown(value, parser);
97 const first = recovered.blocks[0];
98 return {
99 ...recovered,
100 blocks:
101 first && node.position
102 ? [{ ...first, position: node.position }, ...recovered.blocks.slice(1)]
103 : recovered.blocks,
104 };
105}
106
107/**
108 * CommonMark treats four or more spaces after a list marker as an indented
109 * code block. In chat output, excessive spacing is commonly accidental
110 * alignment such as `- text`, which otherwise produces a full code card
111 * for every bullet. Only normalize blocks that retain excess indentation and
112 * start on the marker's own line; explicit fences and conventional indented
113 * blocks remain code.
114 */
115function attachListItemIndentationNormalizer(this: MarkdownParser) {
116 return (tree: MarkdownAstNode, file: MarkdownFile) => {
117 if (typeof file.value !== "string") {
118 return;
119 }
120 const markdown = file.value;
121
122 const visit = (node: MarkdownAstNode, source: string) => {
123 if (!node.children) {
124 return;
125 }
126 node.children = node.children.flatMap((child) => {
127 if (isSameLineOverIndentedCode(child, node, source)) {
128 const recovered = blocksFromIndentedCode(child, this);
129 for (const block of recovered.blocks) {
130 visit(block, recovered.source);
131 }
132 return recovered.blocks;
133 }
134 visit(child, source);
135 return [child];
136 });
137 };
138
139 visit(tree, markdown);
140 };
141}
142
143export const remarkNormalizeListItemIndentation = attachListItemIndentationNormalizer;