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, {...@@ -32,7 +32,7 @@ import React, {
32 useState,32 useState,
33 type ReactNode,33 type ReactNode,
34} from "react";34} from "react";
35import type { Components } from "react-markdown";35import type { Components, Options as ReactMarkdownOptions } from "react-markdown";
36import ReactMarkdown from "react-markdown";36import ReactMarkdown from "react-markdown";
37import { defaultUrlTransform } from "react-markdown";37import { defaultUrlTransform } from "react-markdown";
38import rehypeRaw from "rehype-raw";38import rehypeRaw from "rehype-raw";
...@@ -60,6 +60,7 @@ import {...@@ -60,6 +60,7 @@ import {
60 serializeTableElementToCsv,60 serializeTableElementToCsv,
61 serializeTableElementToMarkdown,61 serializeTableElementToMarkdown,
62} from "../markdown-clipboard";62} from "../markdown-clipboard";
63import { remarkNormalizeListItemIndentation } from "../markdown-list-indentation";
63import {64import {
64 normalizeMarkdownLinkDestination,65 normalizeMarkdownLinkDestination,
65 resolveMarkdownFileLinkMeta,66 resolveMarkdownFileLinkMeta,
...@@ -164,6 +165,24 @@ const CHAT_MARKDOWN_SANITIZE_SCHEMA = {...@@ -164,6 +165,24 @@ const CHAT_MARKDOWN_SANITIZE_SCHEMA = {
164 },165 },
165} satisfies Parameters<typeof rehypeSanitize>[0];166} 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
167function extractFenceLanguage(className: string | undefined): string {186function extractFenceLanguage(className: string | undefined): string {
168 const match = className?.match(CODE_FENCE_LANGUAGE_REGEX);187 const match = className?.match(CODE_FENCE_LANGUAGE_REGEX);
169 const raw = match?.[1] ?? "text";188 const raw = match?.[1] ?? "text";
...@@ -1545,11 +1564,9 @@ function ChatMarkdown({...@@ -1545,11 +1564,9 @@ function ChatMarkdown({
1545 >1564 >
1546 <ReactMarkdown1565 <ReactMarkdown
1547 remarkPlugins={1566 remarkPlugins={
1548 lineBreaks1567 lineBreaks ? CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS : CHAT_MARKDOWN_REMARK_PLUGINS
1549 ? [remarkGfm, remarkBreaks, remarkPreserveCodeMeta]
1550 : [remarkGfm, remarkPreserveCodeMeta]
1551 }1568 }
1552 rehypePlugins={[rehypeRaw, [rehypeSanitize, CHAT_MARKDOWN_SANITIZE_SCHEMA]]}1569 rehypePlugins={CHAT_MARKDOWN_REHYPE_PLUGINS}
1553 components={markdownComponents}1570 components={markdownComponents}
1554 urlTransform={markdownUrlTransform}1571 urlTransform={markdownUrlTransform}
1555 >1572 >
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;