From 8f769009785a601a68f9889a78dbb40a8807f157 Mon Sep 17 00:00:00 2001 From: leaysgur <6259812+leaysgur@users.noreply.github.com> Date: Tue, 27 Jan 2026 08:50:15 +0000 Subject: [PATCH] fix(oxfmt): Dedent xxx-in-js templates before calling prettier (#18622) Fixes #18522 --- .../embedded_languages.test.ts.snap | 38 ++++++++++++++ .../cli/embedded_languages/fixtures/css.js | 10 ++++ crates/oxc_formatter/src/print/template.rs | 52 +++++++++++++++++-- 3 files changed, 95 insertions(+), 5 deletions(-) diff --git a/apps/oxfmt/test/cli/embedded_languages/__snapshots__/embedded_languages.test.ts.snap b/apps/oxfmt/test/cli/embedded_languages/__snapshots__/embedded_languages.test.ts.snap index a1657de4c47df..3b74e81fb0a5b 100644 --- a/apps/oxfmt/test/cli/embedded_languages/__snapshots__/embedded_languages.test.ts.snap +++ b/apps/oxfmt/test/cli/embedded_languages/__snapshots__/embedded_languages.test.ts.snap @@ -294,6 +294,16 @@ const cssProp =
Hello
; const styledJsx = ; +// Multi-line templates with inherited indentation (dedent before formatting) +const documented = styled.div\` + /** + * @description This is a documented section + * @param {number} value - Some value + */ + padding: 16px; +\`; + + --- AFTER ---------- // Tagged template literals with css and styled tags const styles = css\` @@ -351,6 +361,15 @@ const styledJsx = ( \`} ); +// Multi-line templates with inherited indentation (dedent before formatting) +const documented = styled.div\` + /** + * @description This is a documented section + * @param {number} value - Some value + */ + padding: 16px; +\`; + --------------------" `; @@ -468,6 +487,16 @@ const cssProp =
Hello
; const styledJsx = ; +// Multi-line templates with inherited indentation (dedent before formatting) +const documented = styled.div\` + /** + * @description This is a documented section + * @param {number} value - Some value + */ + padding: 16px; +\`; + + --- AFTER ---------- // Tagged template literals with css and styled tags const styles = css\`.button{color:red;background:blue;padding:10px 20px;}\`; @@ -488,6 +517,15 @@ const cssProp =
Hello
; const styledJsx = ; +// Multi-line templates with inherited indentation (dedent before formatting) +const documented = styled.div\` + /** + * @description This is a documented section + * @param {number} value - Some value + */ + padding: 16px; +\`; + -------------------- --- FILE ----------- diff --git a/apps/oxfmt/test/cli/embedded_languages/fixtures/css.js b/apps/oxfmt/test/cli/embedded_languages/fixtures/css.js index 2dcdc8a86c6b9..b639ceb16721b 100644 --- a/apps/oxfmt/test/cli/embedded_languages/fixtures/css.js +++ b/apps/oxfmt/test/cli/embedded_languages/fixtures/css.js @@ -16,3 +16,13 @@ const styledButton = styled(Button)`font-size:16px;color:#333;`; const cssProp =
Hello
; const styledJsx = ; + +// Multi-line templates with inherited indentation (dedent before formatting) +const documented = styled.div` + /** + * @description This is a documented section + * @param {number} value - Some value + */ + padding: 16px; +`; + diff --git a/crates/oxc_formatter/src/print/template.rs b/crates/oxc_formatter/src/print/template.rs index 23cf5cd0156cf..fcc7d2ee4dad9 100644 --- a/crates/oxc_formatter/src/print/template.rs +++ b/crates/oxc_formatter/src/print/template.rs @@ -2,7 +2,7 @@ use unicode_width::UnicodeWidthStr; use std::cmp; -use oxc_allocator::{StringBuilder, Vec as ArenaVec}; +use oxc_allocator::{Allocator, StringBuilder, Vec as ArenaVec}; use oxc_ast::ast::*; use oxc_span::{GetSpan, Span}; @@ -777,20 +777,36 @@ fn get_tag_name<'a>(expr: &'a Expression<'a>) -> Option<&'a str> { } } +/// Format embedded language content (CSS, GraphQL, etc.) +/// inside a template literal using an external formatter (Prettier). +/// +/// NOTE: Unlike Prettier, which formats embedded languages in-process via its document IR +/// (e.g. `textToDoc()` → `indent([hardline, doc])`), +/// we communicate with the external formatter over a plain text interface. +/// +/// This means we must: +/// - Dedent the inherited JS/TS indentation before sending +/// - Reconstruct the template structure (`block_indent()`) from the formatted text +/// +/// If `format_embedded()` could return `FormatElement` (IR) directly, +/// most of work in this function would be unnecessary. fn format_embedded_template<'a>( f: &mut Formatter<'_, 'a>, language: &str, template_content: &str, ) -> bool { - // If the content is whitespace only, - // just trim it and skip calling the embedded formatter + // Whitespace-only templates become empty backticks. + // Regular template literals would preserve them as-is. if template_content.trim().is_empty() { write!(f, ["``"]); - // Return `true` (mark as formatted), - // since whitespace-only regular template literals are preserved as-is return true; } + // Strip inherited indentation. + // So the external formatter receives clean embedded language content. + // Otherwise, indentation may be duplicated on each formatting pass. + let template_content = dedent(template_content, f.context().allocator()); + let Some(Ok(formatted)) = f.context().external_callbacks().format_embedded(language, template_content) else { @@ -815,6 +831,32 @@ fn format_embedded_template<'a>( true } +/// Strip the common leading indentation from all non-empty lines in `text`. +/// Returns the original `text` unchanged if there is no common indentation. +fn dedent<'a>(text: &'a str, allocator: &'a Allocator) -> &'a str { + let min_indent = text + .split('\n') + .filter(|line| !line.trim_ascii_start().is_empty()) + .map(|line| line.bytes().take_while(u8::is_ascii_whitespace).count()) + .min() + .unwrap_or(0); + + if min_indent == 0 { + return text; + } + + let mut result = StringBuilder::with_capacity_in(text.len(), allocator); + for (i, line) in text.split('\n').enumerate() { + if i > 0 { + result.push('\n'); + } + let strip = line.bytes().take_while(u8::is_ascii_whitespace).count().min(min_indent); + result.push_str(&line[strip..]); + } + + result.into_str() +} + /// Try to format a tagged template with the embedded formatter if supported. /// Returns `true` if formatting was performed, `false` if not applicable. fn try_format_embedded_template<'a>(