Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,16 @@ const cssProp = <div css={\`display: flex; align-items: center;\`}>Hello</div>;

const styledJsx = <style jsx>{\`display: flex; align-items: center;\`}</style>;

// 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\`
Expand Down Expand Up @@ -351,6 +361,15 @@ const styledJsx = (
\`}</style>
);

// 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;
\`;

--------------------"
`;

Expand Down Expand Up @@ -468,6 +487,16 @@ const cssProp = <div css={\`display: flex; align-items: center;\`}>Hello</div>;

const styledJsx = <style jsx>{\`display: flex; align-items: center;\`}</style>;

// 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;}\`;
Expand All @@ -488,6 +517,15 @@ const cssProp = <div css={\`display: flex; align-items: center;\`}>Hello</div>;

const styledJsx = <style jsx>{\`display: flex; align-items: center;\`}</style>;

// 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 -----------
Expand Down
10 changes: 10 additions & 0 deletions apps/oxfmt/test/cli/embedded_languages/fixtures/css.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,13 @@ const styledButton = styled(Button)`font-size:16px;color:#333;`;
const cssProp = <div css={`display: flex; align-items: center;`}>Hello</div>;

const styledJsx = <style jsx>{`display: flex; align-items: center;`}</style>;

// 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;
`;

52 changes: 47 additions & 5 deletions crates/oxc_formatter/src/print/template.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};

Expand Down Expand Up @@ -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 {
Expand All @@ -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>(
Expand Down
Loading