From 38f9c6e48c447ba906adbdd3f047f080cac3ee1a Mon Sep 17 00:00:00 2001 From: Kathryn Isabelle Lawrence Date: Wed, 1 Jul 2026 13:29:45 -0700 Subject: [PATCH] add most robust dedent util --- .../src/components/code-block/code-block.tsx | 4 +- .../src/components/code-group/code-group.tsx | 8 +- packages/components/src/utils/dedent.ts | 137 ++++++++++++++++++ packages/components/src/utils/shiki/lib.ts | 3 +- 4 files changed, 147 insertions(+), 5 deletions(-) create mode 100644 packages/components/src/utils/dedent.ts diff --git a/packages/components/src/components/code-block/code-block.tsx b/packages/components/src/components/code-block/code-block.tsx index 61ac6772..047d1e0c 100644 --- a/packages/components/src/components/code-block/code-block.tsx +++ b/packages/components/src/components/code-block/code-block.tsx @@ -2,8 +2,8 @@ import type { ReactNode, RefObject } from "react"; import { Classes } from "@/constants/selectors"; import { cn } from "@/utils/cn"; -import { getNodeText } from "@/utils/get-node-text"; import type { CodeBlockTheme, CodeStyling } from "@/utils/shiki/code-styling"; +import { getCodeString } from "@/utils/shiki/lib"; import { BaseCodeBlock } from "./base-code-block"; import { CodeHeader } from "./code-header"; @@ -98,7 +98,7 @@ const CodeBlock = function CodeBlock(params: CodeBlockProps) { copyButtonProps, } = params; - const codeString = getNodeText(children); + const codeString = getCodeString(children, className, true); const hasGrayBackgroundContainer = !!filename || !!icon; return ( diff --git a/packages/components/src/components/code-group/code-group.tsx b/packages/components/src/components/code-group/code-group.tsx index 6f285ccd..c41ce8ad 100644 --- a/packages/components/src/components/code-group/code-group.tsx +++ b/packages/components/src/components/code-group/code-group.tsx @@ -18,8 +18,8 @@ import { import { Icon as ComponentIcon } from "@/components/icon"; import { Classes } from "@/constants/selectors"; import { cn } from "@/utils/cn"; -import { getNodeText } from "@/utils/get-node-text"; import type { CodeBlockTheme, CodeStyling } from "@/utils/shiki/code-styling"; +import { getCodeString } from "@/utils/shiki/lib"; import { LanguageDropdown } from "./language-dropdown"; @@ -220,7 +220,11 @@ const CodeGroup = ({ {feedbackButton && feedbackButton} {askAiButton && askAiButton} diff --git a/packages/components/src/utils/dedent.ts b/packages/components/src/utils/dedent.ts new file mode 100644 index 00000000..08d4d6d2 --- /dev/null +++ b/packages/components/src/utils/dedent.ts @@ -0,0 +1,137 @@ +// Strips the indentation that leaks in from surrounding JSX/MDX when code is +// passed to a code block as a template literal, e.g. +// +// +// {`import { +// a, +// } from 'pkg';`} +// +// +// In a template literal the first line sits flush against the opening backtick +// while every continuation line carries the JSX indentation. We therefore +// compute the shared indent from the continuation lines only (ignoring blank +// lines) and strip it from them, leaving the first line as-is. Leading and +// trailing blank lines are trimmed. +// +// This is a no-op for correctly-formatted code, which always has at least one +// continuation line back at the base column (a closing brace, a top-level +// statement, etc.), making the shared continuation indent 0. Only blocks where +// every continuation line is indented — the JSX-pollution signature — are +// changed. Indentation is measured in spaces to avoid corrupting mixed +// tab/space input. +// +// Limitation: when the first line is flush against the backtick AND is itself a +// parent of indentation-significant content (e.g. a top-level yaml key whose +// children never return to column 0), the intended first level of nesting is +// indistinguishable from JSX pollution and gets stripped. For such content, +// author with a leading newline so every line carries the shared indent. +// +// Stories for manual verification — paste any of these into +// code-block.stories.tsx and view in Storybook. Each should render with clean, +// author-intended indentation (except the documented limitation case). +// +// // Issue #226: flush first line, brace-terminated continuation lines. +// export const MDXStringChildrenIndentation: Story = { +// render: () => ( +// +// {`import { +// a, +// b, +// } from 'pkg'; +// +// async function main() { +// console.log('hello'); +// }`} +// +// ), +// }; +// +// // Leading-newline env vars (no closing bracket). +// export const MDXStringChildrenLeadingNewline: Story = { +// render: () => ( +// +// {` +// CLICKHOUSE_USERNAME=main +// CLICKHOUSE_FRAGMENT= +// CLICKHOUSE_IP=123.456.78.90 +// `} +// +// ), +// }; +// +// // Leading-newline yaml — relative nesting is preserved. +// export const MDXStringChildrenYaml: Story = { +// render: () => ( +// +// {` +// applications: +// myapp: +// source: +// root: "/" +// `} +// +// ), +// }; +// +// // Deeply nested — the full indentation ladder is preserved. +// export const MDXStringChildrenDeeplyNested: Story = { +// render: () => ( +// +// {`function outer() { +// function middle() { +// function inner() { +// return 42; +// } +// return inner(); +// } +// return middle(); +// }`} +// +// ), +// }; +// +// // Limitation: a flush first line that is itself a parent of +// // indentation-significant content loses its first nesting level (myapp ends +// // up flush with applications). Author with a leading newline (see the yaml +// // story above) to avoid this. +// export const MDXStringChildrenFlushParentLimitation: Story = { +// render: () => ( +// +// {`applications: +// myapp: +// source: +// root: "/"`} +// +// ), +// }; +const LEADING_SPACES_REGEX = /^[ ]*/; + +export function dedent(code: string): string { + const lines = code.split("\n"); + if (lines.length <= 1) { + return code; + } + + const continuationIndents = lines + .slice(1) + .filter((line) => line.trim() !== "") + .map((line) => line.match(LEADING_SPACES_REGEX)?.[0].length ?? 0); + + const minIndent = continuationIndents.length + ? Math.min(...continuationIndents) + : 0; + + const dedented = + minIndent > 0 + ? lines.map((line, i) => (i === 0 ? line : line.slice(minIndent))) + : lines.slice(); + + while (dedented.length && dedented[0].trim() === "") { + dedented.shift(); + } + while (dedented.length && dedented.at(-1)?.trim() === "") { + dedented.pop(); + } + + return dedented.join("\n"); +} diff --git a/packages/components/src/utils/shiki/lib.ts b/packages/components/src/utils/shiki/lib.ts index 99c9bab0..1b5c57e6 100644 --- a/packages/components/src/utils/shiki/lib.ts +++ b/packages/components/src/utils/shiki/lib.ts @@ -1,5 +1,6 @@ import { type ReactNode, useMemo } from "react"; +import { dedent } from "@/utils/dedent"; import { getNodeText } from "@/utils/get-node-text"; import { SHIKI_CLASSNAME } from "@/utils/shiki/constants"; @@ -50,7 +51,7 @@ function getCodeString( const codeString = getNodeText(children); - return codeString; + return dedent(codeString); } function calculateCodeLinesFromHtml(html: string | undefined): number {