authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-07-16 10:47:36+02:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-07-16 08:47:36+00:00
log54f167e2e0b857a60946590bfa983fb93b4f138a
tree95cfdfddf14aa7b55c5e0c01aeaedfcd59492251
parentd114e2772d059c04460a7e0c25e19ac0c2468f14
signature Signed by PGP key B5690EEEBB952194

Normalize over-indented markdown list items (#4020)

Co-authored-by: codex <codex@users.noreply.github.com>

3 files changed, 247 insertions(+), 5 deletions(-)

apps/web/src/components/ChatMarkdown.tsx+22-5
......@@ -32,7 +32,7 @@ import React, {
3232 useState,
3333 type ReactNode,
3434} from "react";
35import type { Components } from "react-markdown";
35import type { Components, Options as ReactMarkdownOptions } from "react-markdown";
3636import ReactMarkdown from "react-markdown";
3737import { defaultUrlTransform } from "react-markdown";
3838import rehypeRaw from "rehype-raw";
......@@ -60,6 +60,7 @@ import {
6060 serializeTableElementToCsv,
6161 serializeTableElementToMarkdown,
6262} from "../markdown-clipboard";
63import { remarkNormalizeListItemIndentation } from "../markdown-list-indentation";
6364import {
6465 normalizeMarkdownLinkDestination,
6566 resolveMarkdownFileLinkMeta,
......@@ -164,6 +165,24 @@ const CHAT_MARKDOWN_SANITIZE_SCHEMA = {
164165 },
165166} satisfies Parameters<typeof rehypeSanitize>[0];
166167
168const CHAT_MARKDOWN_REMARK_PLUGINS = [
169 remarkGfm,
170 remarkNormalizeListItemIndentation,
171 remarkPreserveCodeMeta,
172] satisfies NonNullable<ReactMarkdownOptions["remarkPlugins"]>;
173
174const CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS = [
175 remarkGfm,
176 remarkNormalizeListItemIndentation,
177 remarkBreaks,
178 remarkPreserveCodeMeta,
179] satisfies NonNullable<ReactMarkdownOptions["remarkPlugins"]>;
180
181const CHAT_MARKDOWN_REHYPE_PLUGINS = [
182 rehypeRaw,
183 [rehypeSanitize, CHAT_MARKDOWN_SANITIZE_SCHEMA],
184] satisfies NonNullable<ReactMarkdownOptions["rehypePlugins"]>;
185
167186function extractFenceLanguage(className: string | undefined): string {
168187 const match = className?.match(CODE_FENCE_LANGUAGE_REGEX);
169188 const raw = match?.[1] ?? "text";
......@@ -1545,11 +1564,9 @@ function ChatMarkdown({
15451564 >
15461565 <ReactMarkdown
15471566 remarkPlugins={
1548 lineBreaks
1549 ? [remarkGfm, remarkBreaks, remarkPreserveCodeMeta]
1550 : [remarkGfm, remarkPreserveCodeMeta]
1567 lineBreaks ? CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS : CHAT_MARKDOWN_REMARK_PLUGINS
15511568 }
1552 rehypePlugins={[rehypeRaw, [rehypeSanitize, CHAT_MARKDOWN_SANITIZE_SCHEMA]]}
1569 rehypePlugins={CHAT_MARKDOWN_REHYPE_PLUGINS}
15531570 components={markdownComponents}
15541571 urlTransform={markdownUrlTransform}
15551572 >
apps/web/src/markdown-list-indentation.test.tsx created+82
......@@ -0,0 +1,82 @@
1import { renderToStaticMarkup } from "react-dom/server";
2import ReactMarkdown from "react-markdown";
3import remarkGfm from "remark-gfm";
4import { describe, expect, it } from "vite-plus/test";
5
6import { remarkNormalizeListItemIndentation } from "./markdown-list-indentation";
7
8function renderMarkdown(markdown: string): string {
9 return renderToStaticMarkup(
10 <ReactMarkdown remarkPlugins={[remarkGfm, remarkNormalizeListItemIndentation]}>
11 {markdown}
12 </ReactMarkdown>,
13 );
14}
15
16describe("remarkNormalizeListItemIndentation", () => {
17 it("renders same-line over-indented list content as list text", () => {
18 const html = renderMarkdown(`why did you do this?
19
20- for (const step of rest.steps) {
21- if (step.request.body) {
22- step.request.body = "<redacted>";
23- }
24- }`);
25
26 expect(html).not.toContain("<pre>");
27 expect(html).toContain("<li>for (const step of rest.steps) {</li>");
28 expect(html).toContain("<li>if (step.request.body) {</li>");
29 expect(html).toContain("<li>step.request.body = &quot;&lt;redacted&gt;&quot;;</li>");
30 });
31
32 it("parses inline markdown in recovered list content", () => {
33 const html = renderMarkdown(
34 "- **important** [docs](https://example.com) use `inline code`, not ~~plain text~~",
35 );
36
37 expect(html).toContain("<strong>important</strong>");
38 expect(html).toContain('<a href="https://example.com">docs</a>');
39 expect(html).toContain("<code>inline code</code>");
40 expect(html).toContain("<del>plain text</del>");
41 expect(html).not.toContain("**important**");
42 });
43
44 it("preserves every recovered block separated by blank lines", () => {
45 const html = renderMarkdown(`- **first block**
46
47 [second block](https://example.com)`);
48
49 expect(html).toContain("<strong>first block</strong>");
50 expect(html).toContain('<a href="https://example.com">second block</a>');
51 });
52
53 it("recursively normalizes lists in recovered tail blocks", () => {
54 const html = renderMarkdown(`- first block
55
56 - nested block`);
57
58 expect(html).not.toContain("<pre>");
59 expect(html).toContain("<li>nested block</li>");
60 });
61
62 it("preserves fenced code blocks within list items", () => {
63 const html = renderMarkdown(`- \`\`\`ts
64 const value = 1;
65 \`\`\``);
66
67 expect(html).toContain('<pre><code class="language-ts">const value = 1;');
68 });
69
70 it("preserves indented code blocks that start below a list marker", () => {
71 const html = renderMarkdown(`-
72 const value = 1;`);
73
74 expect(html).toContain("<pre><code>const value = 1;");
75 });
76
77 it("preserves same-line code blocks without excess indentation", () => {
78 const html = renderMarkdown("- const value = 1;");
79
80 expect(html).toContain("<pre><code>const value = 1;");
81 });
82});
apps/web/src/markdown-list-indentation.ts created+143
......@@ -0,0 +1,143 @@
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;