feat(oxfmt): support html-in-js templates closing a dynamic tag name (#28389)

Since HTML formatter supports `htm`'s `<//>` (special closing tag), we
should also support other cases like:

```js
html`
  <${For} each=${() => items}>
    <li class="item">${() => content}</li>
    <li class="item">more items</li>
  </${For}>
`;
```
This commit is contained in:
Yuji Sugiura authored and GitHub committed 2026-10-30 08:56:34 +00:00
1 parent 0a42af6086
commit 336b2c1c11
7 files changed
+184 -17

No files matched your search

+25
View File
@@ -415,3 +415,28 @@ one on the line of an element before it (`<pre><b>x</b>${`) indents from that li
Prettier 3.9.6 aligned an expression starting its line so; since 3.9.7 (prettier/prettier#19725, for idempotency) it indents it by the elements around the content instead,
which leaves `})` right of the `${` it closes.
Reformatting the expression changes no content: its source is code, not text in the element.
## dynamic-tag-name-end-tag
- Why: uniform-rule (same construct, same output: an element htm's `<//>` closes, which Prettier formats; prettier/prettier#17849)
- Pin: `conformance/fixtures/edge-cases/html-in-js/dynamic-tag-name.js`
- Conformance: `externals/webawesome/button/button.ts`
```js
// input
html`<${For} each=${() => items}><li class="item">${() => content}</li><li class="item">more items</li></${For}>`;
// ours
html`<${For} each=${() => items}
><li class="item">${() => content}</li>
<li class="item">more items</li></${For}
>`;
// prettier
html`<${For} each=${() => items}><li class="item">${() => content}</li><li class="item">more items</li></${For}>`;
```
An element whose tag name is a `${}` expression (lit's static `html`, Solid, htm) closes at an end tag named by one, as at htm's `<//>`, and is formatted the same.
Prettier gives each `${}` its own placeholder, so the end tag names another element than the start tag: its parser stops ("Unexpected closing tag") and the template stays as written.
The parser is told the placeholders, not the expressions (`oxc-htmlish-parser`'s `ParserOptions::template_placeholder`):
an end tag named by one closes the innermost element named by one, and such an element written `/>` closes there.
@@ -0,0 +1,2 @@
html`<${For} each=${() => items}><li class="item">${() => content}</li><li class="item">more items</li></${For}>`;
html`<${Layout}><${Header} title="a long title attribute" /><main class="content">main content</main><//>`;
@@ -74,12 +74,14 @@
## html-in-js
### Option 1: 173/201 (86.07%)
### Option 1: 173/202 (85.64%)
```json
{"printWidth":80}
```
- [edge-cases/html-in-js/dynamic-tag-name.js](diffs/html-in-js/edge-cases__html-in-js__dynamic-tag-name.js.md)
- apps/oxfmt/DIVERGENCES.md#dynamic-tag-name-end-tag
- [edge-cases/html-in-js/placeholder-case-in-child.js](diffs/html-in-js/edge-cases__html-in-js__placeholder-case-in-child.js.md)
- apps/oxfmt/DIVERGENCES.md#placeholder-case-in-child
- [edge-cases/html-in-js/pre-like-template-expression.js](diffs/html-in-js/edge-cases__html-in-js__pre-like-template-expression.js.md)
@@ -91,7 +93,7 @@
- [externals/webawesome/badge/badge.ts](diffs/html-in-js/externals__webawesome__badge__badge.ts.md)
- crates/oxc_formatter/DIVERGENCES.md#union-annotation-flat-retry
- [externals/webawesome/button/button.ts](diffs/html-in-js/externals__webawesome__button__button.ts.md)
- crates/oxc_formatter/DIVERGENCES.md#union-annotation-flat-retry
- crates/oxc_formatter/DIVERGENCES.md#union-annotation-flat-retry, apps/oxfmt/DIVERGENCES.md#dynamic-tag-name-end-tag
- [externals/webawesome/callout/callout.ts](diffs/html-in-js/externals__webawesome__callout__callout.ts.md)
- crates/oxc_formatter/DIVERGENCES.md#union-annotation-flat-retry
- [externals/webawesome/checkbox/checkbox.ts](diffs/html-in-js/externals__webawesome__checkbox__checkbox.ts.md)
@@ -137,12 +139,14 @@
- [externals/webawesome/textarea/textarea.ts](diffs/html-in-js/externals__webawesome__textarea__textarea.ts.md)
- crates/oxc_formatter/DIVERGENCES.md#union-annotation-flat-retry
### Option 2: 187/201 (93.03%)
### Option 2: 187/202 (92.57%)
```json
{"printWidth":100,"htmlWhitespaceSensitivity":"ignore"}
```
- [edge-cases/html-in-js/dynamic-tag-name.js](diffs/html-in-js/edge-cases__html-in-js__dynamic-tag-name.js.md)
- apps/oxfmt/DIVERGENCES.md#dynamic-tag-name-end-tag
- [edge-cases/html-in-js/placeholder-case-in-child.js](diffs/html-in-js/edge-cases__html-in-js__placeholder-case-in-child.js.md)
- apps/oxfmt/DIVERGENCES.md#placeholder-case-in-child
- [edge-cases/html-in-js/pre-like-template-expression.js](diffs/html-in-js/edge-cases__html-in-js__pre-like-template-expression.js.md)
@@ -152,7 +156,7 @@
- [externals/prettier/js/embeded/indention/19518.js](diffs/html-in-js/externals__prettier__js__embeded__indention__19518.js.md)
- apps/oxfmt/DIVERGENCES.md#pre-like-template-expression
- [externals/webawesome/button/button.ts](diffs/html-in-js/externals__webawesome__button__button.ts.md)
- crates/oxc_formatter/DIVERGENCES.md#union-annotation-flat-retry
- crates/oxc_formatter/DIVERGENCES.md#union-annotation-flat-retry, apps/oxfmt/DIVERGENCES.md#dynamic-tag-name-end-tag
- [externals/webawesome/callout/callout.ts](diffs/html-in-js/externals__webawesome__callout__callout.ts.md)
- crates/oxc_formatter/DIVERGENCES.md#union-annotation-flat-retry
- [externals/webawesome/copy-button/copy-button.ts](diffs/html-in-js/externals__webawesome__copy-button__copy-button.ts.md)
@@ -0,0 +1,110 @@
# edge-cases/html-in-js/dynamic-tag-name.js
## Option 1
`````json
{"printWidth":80}
`````
### Diff
`````diff
===================================================================
--- prettier
+++ oxfmt
@@ -1,5 +1,8 @@
-html`<${For} each=${() => items}><li class="item">${() => content}</li><li class="item">more items</li></${For}>`;
+html`<${For} each=${() => items}
+ ><li class="item">${() => content}</li>
+ <li class="item">more items</li></${For}
+>`;
html`<${Layout}
><${Header} title="a long title attribute" />
<main class="content">main content</main><//
>`;
`````
### Actual (oxfmt)
`````js
html`<${For} each=${() => items}
><li class="item">${() => content}</li>
<li class="item">more items</li></${For}
>`;
html`<${Layout}
><${Header} title="a long title attribute" />
<main class="content">main content</main><//
>`;
`````
### Expected (prettier)
`````js
html`<${For} each=${() => items}><li class="item">${() => content}</li><li class="item">more items</li></${For}>`;
html`<${Layout}
><${Header} title="a long title attribute" />
<main class="content">main content</main><//
>`;
`````
## Option 2
`````json
{"printWidth":100,"htmlWhitespaceSensitivity":"ignore"}
`````
### Diff
`````diff
===================================================================
--- prettier
+++ oxfmt
@@ -1,6 +1,11 @@
-html`<${For} each=${() => items}><li class="item">${() => content}</li><li class="item">more items</li></${For}>`;
html`
+ <${For} each=${() => items}>
+ <li class="item">${() => content}</li>
+ <li class="item">more items</li>
+ </${For}>
+`;
+html`
<${Layout}>
<${Header} title="a long title attribute" />
<main class="content">main content</main>
<//>
`````
### Actual (oxfmt)
`````js
html`
<${For} each=${() => items}>
<li class="item">${() => content}</li>
<li class="item">more items</li>
</${For}>
`;
html`
<${Layout}>
<${Header} title="a long title attribute" />
<main class="content">main content</main>
<//>
`;
`````
### Expected (prettier)
`````js
html`<${For} each=${() => items}><li class="item">${() => content}</li><li class="item">more items</li></${For}>`;
html`
<${Layout}>
<${Header} title="a long title attribute" />
<main class="content">main content</main>
<//>
`;
`````
@@ -15,7 +15,7 @@
@@ -1,7 +1,7 @@
const a = html`<style>
a {
- prettier_html_placeholder_0_13_in_js: ${c};
- prettier_html_placeholder_0_15_in_js: ${c};
+ ${b}: ${c};
}
</style>`;
@@ -46,7 +46,7 @@ const d = html`<style>
`````js
const a = html`<style>
a {
prettier_html_placeholder_0_13_in_js: ${c};
prettier_html_placeholder_0_15_in_js: ${c};
}
</style>`;
const d = html`<style>
@@ -74,7 +74,7 @@ const d = html`<style>
const a = html`
<style>
a {
- prettier_html_placeholder_0_2992_in_js: ${c};
- prettier_html_placeholder_0_2996_in_js: ${c};
+ ${b}: ${c};
}
</style>
@@ -110,7 +110,7 @@ const d = html`
const a = html`
<style>
a {
prettier_html_placeholder_0_2992_in_js: ${c};
prettier_html_placeholder_0_2996_in_js: ${c};
}
</style>
`;
@@ -76,6 +76,21 @@
private constructLightDOMButton() {
const button = document.createElement("button");
@@ -351,9 +372,13 @@
@invalid=${this.isButton() ? this.handleInvalid : null}
@click=${this.handleClick}
>
<slot name="start" part="start" class="start"></slot>
- <slot part="label" class="label" @slotchange=${this.handleLabelSlotChange}></slot>
+ <slot
+ part="label"
+ class="label"
+ @slotchange=${this.handleLabelSlotChange}
+ ></slot>
<slot name="end" part="end" class="end"></slot>
${
this.withCaret
? html`
`````
@@ -457,7 +472,11 @@ export default class WaButton extends WebAwesomeFormAssociatedElement {
@click=${this.handleClick}
>
<slot name="start" part="start" class="start"></slot>
<slot part="label" class="label" @slotchange=${this.handleLabelSlotChange}></slot>
<slot
part="label"
class="label"
@slotchange=${this.handleLabelSlotChange}
></slot>
<slot name="end" part="end" class="end"></slot>
${
this.withCaret
+15 -8
View File
@@ -7,7 +7,7 @@ use oxc_formatter_core::{
write, write_front_matter,
};
use oxc_htmlish_parser::{
Dialect, Parser,
Dialect, Parser, ParserOptions,
ast::{NodeId, Root},
};
@@ -95,10 +95,11 @@ pub fn parse_for_format<'a>(
options: &HtmlishFormatOptions,
) -> Result<ParsedHtmlish<'a>, OxcDiagnostic> {
let (has_bom, stripped) = split_bom(source_text);
parse_root(allocator, stripped, has_bom, options.variant.to_dialect()).map_err(|mut err| {
oxc_formatter_core::rebase_labels(&mut err.labels, source_text, stripped);
err
})
parse_root(allocator, stripped, has_bom, options.variant.to_dialect(), ParserOptions::default())
.map_err(|mut err| {
oxc_formatter_core::rebase_labels(&mut err.labels, source_text, stripped);
err
})
}
/// Parse `source_text` and build the formatter IR for embedding into another
@@ -117,8 +118,11 @@ pub fn format_to_ir<'a>(
embedded_in: Option<HtmlishEmbeddedIn>,
) -> Result<EmbeddedIr<'a>, OxcDiagnostic> {
let allocator = session.allocator();
let template_placeholder = embedded_in.and_then(|e| e.placeholder).map(|(_, is)| is);
let parser_options = ParserOptions { template_placeholder };
// `FormatSession::dispatch` never hands a BOM-headed input to an embedded part
let parsed = parse_root(allocator, source_text, false, options.variant.to_dialect())?;
let parsed =
parse_root(allocator, source_text, false, options.variant.to_dialect(), parser_options)?;
if parsed.front_matter.is_some() && !session.input_kind().owns_front_matter() {
// A fragment never acquires file envelope semantics
return Err(OxcDiagnostic::error(
@@ -147,7 +151,8 @@ pub struct HtmlishEmbeddedIn {
pub indent_multiple_roots: bool,
/// The parent's placeholder for an expression (html-in-js's `${}`):
/// a value that is one stays unquoted (`attr=${value}` passes a value, not a string),
/// and a value holding one is no class list to sort.
/// a value holding one is no class list to sort,
/// and a tag named by one is a component (`<${Tag} />`, `<${Tag}>…</${Tag}>`).
pub placeholder: Option<Placeholder>,
}
@@ -175,6 +180,7 @@ fn parse_root<'a>(
source_text: &str,
has_bom: bool,
dialect: Dialect,
options: ParserOptions,
) -> Result<ParsedHtmlish<'a>, OxcDiagnostic> {
// The printer slices verbatim text from the source, and the IR forbids `\r`.
let unblanked: &'a str =
@@ -185,7 +191,8 @@ fn parse_root<'a>(
Some(fm) => allocator.alloc_str(&blank_front_matter(unblanked, fm.raw.len())),
None => unblanked,
};
let root = Parser::new(allocator, source, dialect).parse().map_err(|diagnostic| {
let root = Parser::new(allocator, source, dialect).with_options(options).parse();
let root = root.map_err(|diagnostic| {
let mut err = OxcDiagnostic::error(format!("Syntax error: {diagnostic}"))
.with_label(oxc_span::Span::new(diagnostic.span.start, diagnostic.span.end));
oxc_formatter_core::restore_crlf_labels(&mut err.labels, source_text);