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>(