Skip to content

Commit ba3d064

Browse files
committed
fix(packages/components/src): fix dedent and copy for template-literal code blocks
1 parent 212818e commit ba3d064

3 files changed

Lines changed: 45 additions & 15 deletions

File tree

packages/components/src/components/code-block/code-block.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@ import type { ReactNode, RefObject } from "react";
22

33
import { Classes } from "@/constants/selectors";
44
import { cn } from "@/utils/cn";
5-
import { getNodeText } from "@/utils/get-node-text";
65
import type { CodeBlockTheme, CodeStyling } from "@/utils/shiki/code-styling";
6+
import { getCodeString } from "@/utils/shiki/lib";
77

88
import { BaseCodeBlock } from "./base-code-block";
99
import { CodeHeader } from "./code-header";
@@ -98,7 +98,7 @@ const CodeBlock = function CodeBlock(params: CodeBlockProps) {
9898
copyButtonProps,
9999
} = params;
100100

101-
const codeString = getNodeText(children);
101+
const codeString = getCodeString(children, className, true);
102102
const hasGrayBackgroundContainer = !!filename || !!icon;
103103

104104
return (

packages/components/src/components/code-group/code-group.tsx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,8 @@ import {
1818
import { Icon as ComponentIcon } from "@/components/icon";
1919
import { Classes } from "@/constants/selectors";
2020
import { cn } from "@/utils/cn";
21-
import { getNodeText } from "@/utils/get-node-text";
2221
import type { CodeBlockTheme, CodeStyling } from "@/utils/shiki/code-styling";
22+
import { getCodeString } from "@/utils/shiki/lib";
2323

2424
import { LanguageDropdown } from "./language-dropdown";
2525

@@ -220,7 +220,11 @@ const CodeGroup = ({
220220
{feedbackButton && feedbackButton}
221221
<CopyToClipboardButton
222222
codeBlockTheme={codeBlockTheme}
223-
textToCopy={getNodeText(childArr[selectedIndex]?.props?.children)}
223+
textToCopy={getCodeString(
224+
childArr[selectedIndex]?.props?.children,
225+
childArr[selectedIndex]?.props?.className,
226+
true
227+
)}
224228
{...copyButtonProps}
225229
/>
226230
{askAiButton && askAiButton}

packages/components/src/utils/shiki/lib.ts

Lines changed: 37 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,12 @@ import { getNodeText } from "@/utils/get-node-text";
44
import { SHIKI_CLASSNAME } from "@/utils/shiki/constants";
55

66
const lineIndentRegex = /^( *)/;
7+
const closingStructureRegex = /^([}\])]|<\/)/;
8+
9+
function getIndent(line: string): number {
10+
const match = line.match(lineIndentRegex);
11+
return match ? match[1].length : 0;
12+
}
713

814
function findShikiClassName(children: unknown): boolean {
915
if (!children || typeof children !== "object") {
@@ -45,26 +51,46 @@ function dedentCode(code: string): string {
4551
return code;
4652
}
4753

48-
const relevantLines = lines.slice(1).filter((line) => line.trim() !== "");
54+
const relevantLines = lines.filter((line) => line.trim() !== "");
4955
if (relevantLines.length === 0) {
5056
return code;
5157
}
5258

53-
const minIndent = Math.min(
54-
...relevantLines.map((line) => {
55-
const match = line.match(lineIndentRegex);
56-
return match ? match[1].length : 0;
57-
})
58-
);
59+
const firstLine = relevantLines[0];
60+
const lastLine = relevantLines.at(-1) ?? firstLine;
61+
const firstIndent = getIndent(firstLine);
62+
const lastIndent = getIndent(lastLine);
63+
// Detects template-literal pollution: the opening backtick strips the first
64+
// line's indent, leaving it at column 0 while the rest of the body retains
65+
// JSX whitespace. The matching closing structure (}, ), ], </tag>) ends up
66+
// deeper than its opener, which is structurally invalid and a reliable signal
67+
// that the body should be dedented while the first line is left alone.
68+
const isTemplatePolluted =
69+
firstIndent < lastIndent && closingStructureRegex.test(lastLine.trim());
70+
71+
if (isTemplatePolluted) {
72+
const firstNonEmptyIndex = lines.findIndex((line) => line.trim() !== "");
73+
const tail = relevantLines.slice(1);
74+
if (tail.length === 0) {
75+
return code;
76+
}
77+
const minIndent = Math.min(...tail.map(getIndent));
78+
if (minIndent === 0) {
79+
return code;
80+
}
81+
return lines
82+
.map((line, i) =>
83+
i <= firstNonEmptyIndex ? line : line.slice(minIndent)
84+
)
85+
.join("\n");
86+
}
5987

88+
const minIndent = Math.min(...relevantLines.map(getIndent));
6089
if (minIndent === 0) {
6190
return code;
6291
}
6392

64-
return [
65-
lines[0],
66-
...lines.slice(1).map((line) => line.slice(minIndent)),
67-
].join("\n");
93+
return lines.map((line) => line.slice(minIndent)).join("\n");
6894
}
6995

7096
function getCodeString(

0 commit comments

Comments
 (0)