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
21 changes: 21 additions & 0 deletions .changeset/purple-grapes-leave.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
---
"@biomejs/biome": patch
---

Fixed [#9099](https://github.com/biomejs/biome/issues/9099): the HTML formatter collapsing non-text children (inline elements, Svelte expressions, comments) onto a single line when the source had them on separate lines. Biome now preserves the user's intended line breaks for exclusively non-text children.

For example, the following Svelte snippet is now preserved instead of being collapsed to `<div>{name}<!-- comment --></div>`:

```svelte
<div>
{name}<!-- comment -->
</div>
```

Similarly, HTML elements like `<span>` inside a `<div>` are now preserved when written on their own line:

```html
<div>
<span>text</span>
</div>
```
16 changes: 16 additions & 0 deletions .changeset/warm-roses-dream.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
"@biomejs/biome": patch
---

Fixed [#9450](https://github.com/biomejs/biome/issues/9450): the HTML formatter now correctly preserves multiline formatting for nested `<template>` elements (e.g. `<template #body>`) when the source has children on separate lines. Previously, the children were collapsed onto a single line.

```diff
<template>
<UModal>
- <template #body> <p>content</p> </template>
+ <template #body>
+ <p>content</p>
+ </template>
</UModal>
</template>
```
25 changes: 22 additions & 3 deletions crates/biome_html_formatter/src/html/auxiliary/element.rs
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,6 @@ impl FormatHtmlElement {
// third one is either `HtmlRoot` or another `HtmlElement`
.nth(2)
.is_some_and(|ancestor| HtmlRoot::can_cast(ancestor.kind()));
// If `<template>` is at the root level, force multiline formatting of its children.
let is_template_element = get_tag_name_text(&tag_name)
.is_some_and(|tt| tt.to_ascii_lowercase_cow() == "template");

Expand Down Expand Up @@ -175,8 +174,28 @@ impl FormatHtmlElement {
.ok()
.is_some_and(|tok| tok.has_leading_whitespace_or_newline());

let forces_break_children =
should_force_break_content || (is_root_element_list && is_template_element);
// Check if there is a newline between the opening tag and the first child.
// This is distinct from `content_has_leading_whitespace` which also matches spaces.
let content_has_leading_newline = opening_element
.r_angle_token()
.ok()
.is_some_and(|tok| tok.trailing_trivia().pieces().any(|p| p.is_newline()))
|| children
.syntax()
.first_token()
.is_some_and(|tok| tok.leading_trivia().pieces().any(|p| p.is_newline()));

let forces_break_children = should_force_break_content
// If `<template>` is at the root level, always force multiline formatting of its children.
|| (is_root_element_list && is_template_element)
// Nested `<template>` elements with a leading newline
// and direct element children should also force multiline, since `<template>` acts
// as a structural container in frameworks like Vue/Svelte.
// Without a leading newline (e.g. `<template #body><p>foo</p></template>`),
// the children stay inline.
|| (is_template_element
&& content_has_leading_newline
&& has_non_text_child(&children));
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// "Borrowing" in this context refers to tokens in nodes that would normally be
// formatted by that node's formatter, but are instead formatted by a sibling
Expand Down
36 changes: 34 additions & 2 deletions crates/biome_html_formatter/src/html/auxiliary/opening_element.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,11 @@ use crate::{
prelude::*,
utils::{css_display::get_css_display_from_tag, metadata::should_lowercase_html_tag},
};
use biome_formatter::{FormatRuleWithOptions, GroupId, write};
use biome_html_syntax::{HtmlOpeningElement, HtmlOpeningElementFields, HtmlSyntaxToken};
use biome_formatter::{FormatRuleWithOptions, GroupId, trivia::format_dangling_comments, write};
use biome_html_syntax::{
HtmlElement, HtmlOpeningElement, HtmlOpeningElementFields, HtmlSyntaxToken,
};
use biome_rowan::AstNode;
#[derive(Debug, Clone, Default)]
pub(crate) struct FormatHtmlOpeningElement {
/// Whether or not the r_angle is borrowed by the children of the element (aka [`HtmlElementList`][HtmlElementList]). See also: [`FormatHtmlElementList`][FormatHtmlElementList]
Expand Down Expand Up @@ -47,6 +50,35 @@ impl FormatRuleWithOptions<HtmlOpeningElement> for FormatHtmlOpeningElement {
}

impl FormatNodeRule<HtmlOpeningElement> for FormatHtmlOpeningElement {
fn fmt_dangling_comments(
&self,
node: &HtmlOpeningElement,
f: &mut HtmlFormatter,
) -> FormatResult<()> {
if !f.comments().has_dangling_comments(node.syntax()) {
return Ok(());
}

// Check if the source had newlines around the dangling comments.
// If so, use hard_block_indent to preserve the multiline layout.
// This handles: <div>\n <!-- comment -->\n</div>
let has_source_newline = node
.parent::<HtmlElement>()
.and_then(|el| el.closing_element().ok())
.and_then(|c| c.l_angle_token().ok())
.is_some_and(|tok| tok.has_leading_whitespace_or_newline());
Comment thread
dyc3 marked this conversation as resolved.

if has_source_newline {
format_dangling_comments(node.syntax())
.with_block_indent()
.fmt(f)
} else {
format_dangling_comments(node.syntax())
.with_soft_block_indent()
.fmt(f)
}
}

fn fmt_fields(&self, node: &HtmlOpeningElement, f: &mut HtmlFormatter) -> FormatResult<()> {
let HtmlOpeningElementFields {
l_angle_token,
Expand Down
35 changes: 31 additions & 4 deletions crates/biome_html_formatter/src/html/lists/element_list.rs
Original file line number Diff line number Diff line change
Expand Up @@ -313,6 +313,13 @@ struct ChildrenMeta {
/// `true` if children contains a block-like element
has_block_element: bool,

/// `true` if children contains any non-text child (element, expression, etc.)
has_non_text_child: bool,

/// `true` if children contains text (Word) content
Comment thread
dyc3 marked this conversation as resolved.
/// Comments are not included.
has_word: bool,
Comment thread
coderabbitai[bot] marked this conversation as resolved.

/// `true` if there are multiple non-text children
multiple_block_elements: bool,
}
Expand All @@ -326,6 +333,7 @@ impl FormatHtmlElementList {
for child in children {
match child {
HtmlChild::NonText(element) => {
meta.has_non_text_child = true;
// Check if this is a block element
let display = get_element_css_display(element);
if display.is_block_like() {
Expand All @@ -334,8 +342,15 @@ impl FormatHtmlElementList {
}
}
HtmlChild::Verbatim(_) => {
meta.has_non_text_child = true;
block_element_count += 1;
Comment thread
dyc3 marked this conversation as resolved.
}
HtmlChild::Comment(_) => {
meta.has_non_text_child = true;
}
HtmlChild::Word(_) => {
meta.has_word = true;
}
_ => {}
}
}
Expand Down Expand Up @@ -400,12 +415,24 @@ impl FormatHtmlElementList {
}
}

// Force multiline if there was a leading newline AND there are block elements
// This respects the user's intent to break when they have:
// Force multiline if there was a leading newline AND:
// - There are block elements (original behavior), OR
// - Children are exclusively non-text (no mixed text content)
//
// The first case handles block elements mixed with text:
// <div>a<div>b</div>c</div>
//
// The second case preserves the user's intent to break non-text children:
// <div>
// <span>...</span>
// </div>
// <div>
// <div>...</div>
// {expression}
// </div>
if had_leading_newline && children_meta.has_block_element {
if had_leading_newline
&& (children_meta.has_block_element
|| (children_meta.has_non_text_child && !children_meta.has_word))
{
force_multiline = true;
}

Expand Down
34 changes: 24 additions & 10 deletions crates/biome_html_formatter/tests/quick_test.rs
Original file line number Diff line number Diff line change
@@ -1,23 +1,17 @@
use biome_configuration::{Configuration, HtmlConfiguration, html::HtmlFormatterConfiguration};
use biome_fs::{BiomePath, MemoryFileSystem};
use biome_service::settings::ModuleGraphResolutionKind;
use biome_service::workspace::{
ChangeFileParams, FileContent, FormatFileParams, GetFormatterIRParams, OpenFileParams,
OpenProjectParams, server,
OpenProjectParams, UpdateSettingsParams, server,
};
use std::sync::Arc;

#[ignore]
#[test]
// use this test check if your snippet prints as you wish, without using a snapshot
fn quick_test() {
let src = r#"
{#snippet ff.call()}
{page.value}
{second.value}
<div>
<span></span>
</div>
{/snippet}
"#;
let src = r#""#;
let fs = MemoryFileSystem::default();
let workspace = server(Arc::new(fs), None);

Expand All @@ -28,6 +22,26 @@ fn quick_test() {
})
.unwrap();

workspace
.update_settings(UpdateSettingsParams {
project_key: project.project_key,
configuration: Configuration {
html: Some(HtmlConfiguration {
experimental_full_support_enabled: Some(true.into()),
formatter: Some(HtmlFormatterConfiguration {
enabled: Some(true.into()),
..Default::default()
}),
..Default::default()
}),
..Default::default()
},
workspace_directory: None,
extended_configurations: Default::default(),
module_graph_resolution_kind: ModuleGraphResolutionKind::None,
})
.unwrap();
Comment thread
dyc3 marked this conversation as resolved.

let path = BiomePath::new("test.html");
workspace
.open_file(OpenFileParams {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ info: interpolation/interpolation.html
# Formatted

```html
<div>{{ $interpolation }}</div>
<div>
{{ $interpolation }}
</div>

```
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,9 @@ info: svelte/html.svelte
# Formatted

```svelte
<article>{@html content}</article>
<article>
{@html content}
</article>
Comment thread
dyc3 marked this conversation as resolved.

{@html '<div>'}content{@html '</div>'}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<!-- Newlines around expression should be preserved -->
<div>
{name}
</div>

<!-- No newlines = stays inline -->
<div>{name}</div>

<!-- Newlines around expression + comment should be preserved -->
<div>
{name}<!-- comment -->
</div>

<!-- No newlines around expression + comment = stays inline -->
<div>{name}<!-- comment --></div>

<!-- Multiple expressions with newlines -->
<div>
{foo}
{bar}
</div>
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
---
source: crates/biome_formatter_test/src/snapshot_builder.rs
info: svelte/preserve_newline_nontext_children.svelte
---

# Input

```svelte
<!-- Newlines around expression should be preserved -->
<div>
{name}
</div>

<!-- No newlines = stays inline -->
<div>{name}</div>

<!-- Newlines around expression + comment should be preserved -->
<div>
{name}<!-- comment -->
</div>

<!-- No newlines around expression + comment = stays inline -->
<div>{name}<!-- comment --></div>

<!-- Multiple expressions with newlines -->
<div>
{foo}
{bar}
</div>

```


# Formatted

```svelte
<!-- Newlines around expression should be preserved -->
<div>
{name}
</div>

<!-- No newlines = stays inline -->
<div>{name}</div>

<!-- Newlines around expression + comment should be preserved -->
<div>
{name}<!-- comment -->
</div>

<!-- No newlines around expression + comment = stays inline -->
<div>{name}<!-- comment --></div>

<!-- Multiple expressions with newlines -->
<div>
{foo}
{bar}
</div>

```
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<template>
<UModal>
<!--
because the children of this inner template already has a newline,
the multiline formatting should be preserved
-->
<template #body>
<p>{{ store.options.text }}</p>
</template>
</UModal>
</template>

<template>
<UModal>
<!--
because the children of this inner template does not have a newline,
the children should NOT be multiline formatted
-->
<template #body><p>foo</p></template>
</UModal>
</template>
Loading