feat(formatter_htmlish): Implement HTML-ish formatter Html variant (#28272)

Part of #28263
This commit is contained in:
Yuji Sugiura authored and GitHub committed 2026-10-30 16:00:59 +09:00
1 parent c62fbbb088
commit 44d8c2b824
123 files changed
+6750 -6

No files matched your search

+4
View File
@@ -5,6 +5,10 @@ crates/oxc_formatter_json/tests/fixtures/** text=auto eol=lf
crates/oxc_formatter_json/tests/fixtures/jsonc/crlf/** text=auto eol=crlf
crates/oxc_formatter_graphql/tests/fixtures/** text=auto eol=lf
crates/oxc_formatter_graphql/tests/fixtures/graphql/crlf/** text=auto eol=crlf
crates/oxc_formatter_htmlish/tests/fixtures/** text=auto eol=lf
crates/oxc_formatter_htmlish/tests/fixtures/html/crlf/** text=auto eol=crlf
# NUL bytes would make git read these as binary
crates/oxc_formatter_htmlish/tests/fixtures/html/nul/** text eol=lf diff
crates/oxc_formatter_yaml/tests/fixtures/** text=auto eol=lf
crates/oxc_formatter_yaml/tests/fixtures/yaml/crlf/** text=auto eol=crlf
crates/oxc_formatter_markdown/tests/fixtures/** text=auto eol=lf
+1
View File
@@ -31,6 +31,7 @@ pnpm-lock.yaml
/crates/oxc_formatter_core @leaysgur
/crates/oxc_formatter_json @leaysgur
/crates/oxc_formatter_graphql @leaysgur
/crates/oxc_formatter_htmlish @leaysgur
/crates/oxc_formatter_css @leaysgur
/crates/oxc_formatter_yaml @leaysgur
/crates/oxc_formatter_markdown @leaysgur
Generated
+33 -6
View File
@@ -280,7 +280,7 @@ version = "3.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34"
dependencies = [
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -682,7 +682,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -1517,7 +1517,7 @@ version = "0.50.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
dependencies = [
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -1703,6 +1703,16 @@ dependencies = [
"thiserror",
]
[[package]]
name = "oxc-htmlish-parser"
version = "0.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "909fd32d29f076e5129a27244801bed7de5ce33a974310c710f8777dee016495"
dependencies = [
"memchr",
"oxc_allocator",
]
[[package]]
name = "oxc-markdown-parser"
version = "0.0.3"
@@ -2123,6 +2133,23 @@ dependencies = [
"pico-args",
]
[[package]]
name = "oxc_formatter_htmlish"
version = "0.75.0"
dependencies = [
"cow-utils",
"insta",
"memchr",
"oxc-htmlish-parser",
"oxc_allocator",
"oxc_diagnostics",
"oxc_formatter_core",
"oxc_formatter_tests",
"oxc_span",
"pico-args",
"zmij_ecma",
]
[[package]]
name = "oxc_formatter_json"
version = "0.75.0"
@@ -3379,7 +3406,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -3798,7 +3825,7 @@ dependencies = [
"getrandom 0.4.3",
"once_cell",
"rustix",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -4332,7 +4359,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
+1
View File
@@ -242,6 +242,7 @@ num-bigint = "0.5.0" # Big integers
num-traits = "0.2.19" # Numeric traits
oxc-css-parser = "0.0.18" # CSS/SCSS/Less parser (raffia 0.12.3 fork: adds `template_placeholder` typed placeholders for css-in-js + valid-syntax coverage fixes (see crates/oxc_formatter_css/AGENTS.md))
oxc-graphql-parser = "0.0.9" # GraphQL parser (direct AST; apollo-parser 0.8.6 fork aligned with graphql-js 17 syntax)
oxc-htmlish-parser = "0.0.1" # HTML / Vue / Angular parser (loose, span-faithful AST)
oxc-markdown-parser = "0.0.3" # CommonMark + GFM parser (span-faithful, style-preserving typed AST)
oxc-toml = "0.15.1" # TOML parser and formatter
oxc-yaml-parser = "0.0.7" # YAML 1.2 parser (comment-preserving typed AST, node naming from `yaml-unist-parser`)
+53
View File
@@ -0,0 +1,53 @@
# Coding agent guides for `crates/oxc_formatter_htmlish`
Follow @../oxc_formatter_core/FORMATTER_POLICY.md , this file holds only the htmlish-specific rules.
Known divergences live in DIVERGENCES.md.
## Overview
Prettier compatible formatter for HTML and the template languages built on it (Vue, Angular, ...), on `oxc_formatter_core`.
### Parser
Parses with [`oxc-htmlish-parser`](https://github.com/oxc-project/oxc-htmlish-parser).
Parse behavior and its own divergences are that repo's `AGENTS.md` / `DIVERGENCES.md`.
### Dialects
What a dialect changes in the shared rules is a `DialectRules` field (`src/rules.rs`), resolved once per document;
a dialect adds a field there, not a `Dialect` check at the use site.
### Preprocess
The facts Prettier's `print-preprocess` computes (CSS display, whitespace sensitivity, kept children, borrowed tag markers)
are a table keyed by `NodeId` (`src/tree/`).
The AST is never rewritten.
### Tags
An end tag the source omits is never added (DIVERGENCES.md#unclosed-at-end, DIVERGENCES.md#omitted-end-tag).
A normalization is applied only where it is known safe for every reader, as in `oxc_formatter_css` (its AGENTS.md "Case"):
a void element prints `/>`, a doctype `<!doctype html>` but in XHTML, which an XML parser may read (DIVERGENCES.md#doctype-case),
and the tag and attribute names Prettier knows print lowercase in a `.html` file only (DIVERGENCES.md#tag-name-case).
The borrow rules (`src/print/tag.rs`) are shaped like Prettier's `print/tag.js` against the policy, kept deliberately (why: its module doc).
## Verification
```sh
cargo run -p oxc_formatter_htmlish --example htmlish_formatter [filename]
DUMP_IR=1 cargo run -p oxc_formatter_htmlish --example htmlish_formatter [filename]
# The oracle. The parser follows the file name as the example's variant does (`*.component.html` is angular).
node apps/oxfmt/node_modules/prettier/bin/prettier.cjs [filename]
```
### Prettier conformance
`tests/conformance.rs` runs `tests/format/html`.
Files whose output depends on embedded formatting (`<script>` / `<style>` bodies, `style` / `on*` attributes, a YAML front matter body) are ignored here and covered by oxfmt's E2E conformance;
the ignore list names the reason for each.
Every failing file is accounted for by a DIVERGENCES.md entry's `Conformance:` line; there are no unclassified failures.
Fixtures and the example read `.vue` as Vue, `.xhtml` / `.xht` as XHTML and `*.component.html` as Angular.
+39
View File
@@ -0,0 +1,39 @@
[package]
name = "oxc_formatter_htmlish"
version = "0.75.0"
authors.workspace = true
categories.workspace = true
edition.workspace = true
homepage.workspace = true
include = ["/src"]
keywords.workspace = true
license.workspace = true
publish = false
repository.workspace = true
rust-version.workspace = true
description.workspace = true
[lints]
workspace = true
[dependencies]
cow-utils = { workspace = true }
zmij_ecma = { workspace = true }
memchr = { workspace = true }
oxc-htmlish-parser = { workspace = true }
oxc_allocator = { workspace = true }
oxc_diagnostics = { workspace = true }
oxc_formatter_core = { workspace = true }
oxc_span = { workspace = true }
[dev-dependencies]
insta = { workspace = true }
oxc_formatter_tests = { workspace = true, features = ["conformance"] }
pico-args = { workspace = true }
[build-dependencies]
oxc_formatter_tests = { workspace = true }
[lib]
doctest = false
test = false
+789
View File
@@ -0,0 +1,789 @@
# Known divergences
Admission reasons and rules: see `crates/oxc_formatter_core/FORMATTER_POLICY.md` "Known divergences".
## whitespace-only-pre
- Why: semantics (whitespace in `pre` / `textarea` is content)
- Pin: `tests/fixtures/html/whitespace-only-pre.html`
```html
<!-- input -->
<pre> </pre>
<textarea> </textarea>
<!-- ours -->
<pre> </pre>
<textarea> </textarea>
<!-- prettier -->
<pre></pre>
<textarea> </textarea>
```
A whitespace-only `<pre>` or `<textarea>` keeps its content.
Prettier drops a lone whitespace-only text in `pre` (as in every element) and collapses one in `textarea` to a single space,
which changes what `pre` renders and what `textarea` submits.
## doctype-case
- Why: uniform-rule (same construct, same output: the doctype of a `.html` file, which Prettier lowercases)
- Pin: `tests/fixtures/vue/doctype-case.vue`
```vue
<!-- input -->
<!DOCTYPE html>
<html><body><p>{{ a }}</p></body></html>
<!-- ours -->
<!doctype html>
<html>
<body>
<p>{{ a }}</p>
</body>
</html>
<!-- prettier -->
<!DOCTYPE html>
<html>
<body>
<p>{{ a }}</p>
</body>
</html>
```
`<!DOCTYPE html>` prints `<!doctype html>` in every HTML document but XHTML (`.xhtml`, `.xht`), where an XML parser needs `<!DOCTYPE`:
a Vue page, html-in-js and an HTML code block too, and `.hta` / `.inc` files in oxfmt.
Prettier lowercases it in a file named `.html` / `.htm` only, as it decides by file name.
The `html` in any doctype prints lowercase (`<!DOCTYPE html PUBLIC …>`), as in Prettier.
## doctype-html-in-one-pass
- Why: invariant
- Pin: `tests/fixtures/html/doctype-html-in-one-pass.html`
```html
<!-- input -->
<!DOCTYPE HTML>
<!-- ours, and Prettier's second pass -->
<!doctype html>
<!-- prettier -->
<!DOCTYPE html>
```
Prettier's output is not a fixpoint: it lowercases the `HTML` and the `<!DOCTYPE` in different passes.
`<!DOCTYPE HTML>` prints `<!doctype html>` in one.
## cdata-as-written
- Why: uniform-rule (a section's content prints as written, as a comment's does: in HTML content it is a bogus comment to browsers; prettier/prettier#20256, prettier/prettier#16147)
- Pin: `tests/fixtures/html/cdata-as-written.html`
```html
<!-- input (printWidth: 20) -->
<p>text <![CDATA[aaaa bbbb cccc dddd eeee]]> more</p>
<!-- ours -->
<p>
text
<![CDATA[aaaa bbbb cccc dddd eeee]]>
more
</p>
<!-- prettier -->
<p>
text <![CDATA[aaaa
bbbb cccc dddd
eeee]]> more
</p>
```
A CDATA section is one unbreakable word, together with any text touching it; the text around it wraps as usual.
Prettier merges it into the surrounding text and re-wraps its words, changing what it holds:
in HTML the section is comment data, and in SVG / MathML its text, where a re-wrap only moves collapsible whitespace.
Prettier `main` keeps it as written (prettier/prettier#20256), unreleased at the pin.
## class-ascii-whitespace
- Why: semantics (class names are separated by ASCII whitespace only)
- Pin: `tests/fixtures/html/class-ascii-whitespace.html`
```html
<!-- input (the space in `a b` is U+00A0) -->
<div class="a b c"></div>
<!-- ours -->
<div class="a b c"></div>
<!-- prettier: `a` and `b` are now two classes -->
<div class="a b c"></div>
```
A class list collapses and trims ASCII whitespace; a no-break space stays part of a class name.
Prettier collapses and trims with JavaScript's `\s`, which splits `a b` written with a literal U+00A0 (one class) into two classes.
## end-tag-indentation-as-printed
- Why: uniform-rule (same construct, same output: Prettier's output for the same layout indented with spaces; prettier/prettier#17048)
- Pin: `tests/fixtures/html/end-tag-indentation-as-printed.html`, `tests/fixtures/html/use-tabs/end-tag-indentation-as-printed.html`
```html
<!-- input (useTabs) -->
<div>
<div>
<!--
comment
-->
</div>
</div>
<!-- ours -->
<div>
<div>
<!--
comment
-->
</div>
</div>
<!-- prettier -->
<div>
<div>
<!--
comment
--></div>
</div>
```
When the last child is a comment or `pre` text ending in a line break plus the element's indentation, the end tag follows it on the same line.
That indentation is counted in the printer's indent unit: a tab per level with `useTabs`, `indentWidth` spaces otherwise.
Prettier counts `tabWidth` × depth characters, tabs or spaces alike.
With `useTabs`, a comment closing one level deeper than its element then counts as being at the element's indentation and the end tag joins it,
while the same layout written with spaces keeps the end tag on its own line.
Without `useTabs`, a tab-indented tail of that many characters joins likewise.
## srcset-zero-density
- Why: semantics (`0x` is a density the HTML srcset parser accepts; without it the candidate defaults to `1x`)
- Pin: `tests/fixtures/html/srcset-zero-density.html`
```html
<!-- input -->
<img srcset="a.png 0x, b.png 2x">
<!-- ours -->
<img srcset="a.png 0x, b.png 2x" />
<!-- prettier -->
<img srcset="a.png, b.png 2x" />
```
A `0x` (or `-0x`) descriptor is kept, printed as `0x`.
Authors must write a density above zero, but the HTML srcset parser reads `0x` as density 0 (infinite natural dimensions), not as the default `1x`.
Prettier's srcset parser treats a zero density as absent and drops it.
## script-display-none
- Why: semantics (whitespace added on both sides of a `script` renders between its inline neighbors)
- Pin: `tests/fixtures/html/script-display-none.html`
- Conformance: `html/tags/tags.html`
```html
<!-- input -->
<p><span>a</span><script src="a.js"></script><span>b</span></p>
<!-- ours -->
<p>
<span>a</span
><script src="a.js"></script
><span>b</span>
</p>
<!-- prettier -->
<p>
<span>a</span>
<script src="a.js"></script>
<span>b</span>
</p>
```
```html
<!-- input -->
<div>Price: <span>10</span> <meta itemprop="priceCurrency" content="USD">USD, and some more text that wraps the line</div>
<!-- ours -->
<div>
Price: <span>10</span> <meta itemprop="priceCurrency" content="USD" /> USD,
and some more text that wraps the line
</div>
<!-- prettier -->
<div>
Price: <span>10</span> <meta itemprop="priceCurrency" content="USD" />USD, and
some more text that wraps the line
</div>
```
An element with the UA stylesheet's `display: none` (`script`, `style`, `link`, `meta`, `title`, ...) renders nothing:
the whitespace on both its sides is one run between the content around it, judged as if the element were not there.
- Between inline content, a run without whitespace stays glued (`>` borrowed);
one with whitespace may break at any of its gaps, as it renders one space either way.
- Next to a block or a parent's edge the run renders nothing and breaks freely,
though a gap between two non-blocks stays on the line where it fits (`<p>x<meta /></p>`).
Prettier lays `script` out as a block and puts it on its own line, which turns `a` and `b` glued around it into `a b`:
it did so for glued scripts between blocks (`</script><script>` borrowing `>`, prettier/prettier#6393, prettier/prettier#6423),
which passing hidden elements prints the same way without changing what renders.
It keeps the whitespace around the other hidden elements as written,
so a glued one borrows `>` even where nothing renders, and a spaced run breaks only at the space.
A `script` / `style` body renders nothing, so its end tag takes its own line even where the next sibling borrows its `>`.
Prettier's other block overrides for boxless elements (`param`, `source`, `track`) are kept:
they sit inside `object` / `video` / `audio`, whose children render only as fallback content.
## svg-text-content-inline
- Why: semantics (whitespace added between a `<text>`'s children renders: `H<tspan>2</tspan>O` reads "H 2 O")
- Pin: `tests/fixtures/html/svg-text-content-inline.html`
```html
<!-- input -->
<svg><text>H<tspan baseline-shift="sub">2</tspan>O</text></svg>
<!-- ours -->
<svg>
<text>H<tspan baseline-shift="sub">2</tspan>O</text>
</svg>
<!-- prettier -->
<svg>
<text>
H
<tspan baseline-shift="sub">2</tspan>
O
</text>
</svg>
```
An SVG `<text>` lays out its content as a line of text, so its descendants (`tspan`, `textPath`, `a`, ...) are inline:
whitespace between them is kept or omitted as written, as between HTML inline elements.
`<text>` itself stays a block, whose leading and trailing whitespace collapses away.
Prettier lays out every SVG element but `<svg>` as a block (prettier/prettier#17713, prettier/prettier#10121).
With `htmlWhitespaceSensitivity: "ignore"` they stay blocks, as HTML inline elements do.
## dialog-out-of-flow
- Why: semantics (whitespace added on both sides of a `dialog` renders between its inline neighbors)
- Pin: `tests/fixtures/html/dialog-out-of-flow.html`
- Conformance: `html/tags/tags2.html`
```html
<!-- input -->
<main><b>a</b><dialog></dialog><b>b</b></main>
<!-- ours -->
<main><b>a</b><dialog></dialog><b>b</b></main>
<!-- prettier -->
<main>
<b>a</b>
<dialog></dialog>
<b>b</b>
</main>
```
A `dialog` takes no room in its line: the UA stylesheet hides it while closed (`dialog:not([open])`)
and positions it absolutely while open, so whitespace around it is kept or omitted as written, as around a `script` (DIVERGENCES.md#script-display-none).
Its content has a box of its own, so it lays out as an inline-block's: line breaks inside its tags are free.
Prettier reads `dialog` as a block from the UA stylesheet's `display: block` and skips the `:not([open])` rule.
Between blocks, where it usually sits, the output is the same.
## nul-as-written
- Why: semantics (Prettier drops everything after a NUL between tags)
- Pin: `tests/fixtures/html/nul/nul-as-written.html`
```html
<!-- input (`␀` is U+0000) -->
<p a>b</p>
<p>c␀d</p>
<p >e</p>
<!-- ours -->
<p a>b</p>
<p>c␀d</p>
<p>e</p>
<!-- prettier -->
<p a>b</p>
<p>c</p>
```
A NUL prints as written, as in every oxc formatter (CSS, Markdown, YAML and JS strings keep it too).
HTML makes it a parse error that browsers ignore in text (or replace with U+FFFD elsewhere), so the output reads as the input does.
## pre-after-borrowed-closing-end
- Why: semantics (Prettier prints the borrowed `>` twice; the second is text; prettier/prettier#16184)
- Pin: `tests/fixtures/html/strict/pre-after-borrowed-closing-end.html`
```html
<!-- input (htmlWhitespaceSensitivity: strict) -->
<b>a</b><pre><b>x</b></pre>
<!-- ours -->
<b>a</b><pre><b>x</b></pre>
<!-- prettier -->
<b>a</b>><pre><b>x</b></pre>
```
A `pre` holding an element (printed as written) that borrows its previous sibling's `>` prints it once.
Prettier prints it again, adding a `>` to the rendered text.
Prettier `main` prints it once (prettier/prettier#20215), unreleased at the pin.
## ignored-closing-end-once
- Why: semantics (Prettier prints the borrowed `>` twice; the second is text)
- Pin: `tests/fixtures/html/ignored-closing-end-once.html`
```html
<!-- input -->
<time><!-- prettier-ignore -->
<mark>
<input />
</mark></time>
<!-- ours -->
<time
><!-- prettier-ignore -->
<mark>
<input />
</mark
></time>
<!-- prettier: a `>` text after </mark> -->
<time
><!-- prettier-ignore -->
<mark>
<input />
</mark>
></time>
```
A `prettier-ignore`d last child whose closing `>` its parent borrows prints its source without that `>`.
Prettier strips it only when the next sibling borrows it, so the parent prints it a second time.
## line-break-before-borrowed-end
- Why: invariant
- Pin: `tests/fixtures/html/line-break-before-borrowed-end.html`, `tests/fixtures/html/strict/line-break-before-borrowed-end.html`
```html
<!-- input -->
<div><progress><progress>x</progress><wbr />
amet,
</progress><abbr></abbr></div>
<!-- ours, and Prettier's second pass -->
<div>
<progress><progress>x</progress><wbr /> amet,</progress><abbr></abbr>
</div>
<!-- prettier -->
<div>
<progress>
<progress>x</progress><wbr />
amet,</progress
><abbr></abbr>
</div>
```
Prettier's output is not a fixpoint: its second pass joins the lines the first broke.
A line break after a last child, before an end tag whose `>` is borrowed, does not count as one
(for an only child on its own line, or a hard line before a node with line breaks around it).
No printer can keep it there: the end tag follows on the same line, after a space where whitespace matters and right after the child otherwise.
Prettier counts it from the source, then its next pass no longer sees it and joins the lines.
Where that layout stays for other reasons, Prettier's first pass is stable and differs from ours.
## text-display-width
- Why: uniform-rule (text width is the display width; `oxc_formatter_core`'s FORMATTER_POLICY.md "Text width"; prettier/prettier#20081)
- Pin: `tests/fixtures/html/text-display-width.html`
```html
<!-- input (print width 80; `{60 × U+00A0 U+200C}` stands for the characters) -->
<div>{60 × U+00A0 U+200C}</div>
<!-- ours -->
<div>{60 × U+00A0 U+200C}</div>
<!-- prettier -->
<div>
{60 × U+00A0 U+200C}
</div>
```
Zero-width characters count as 0 toward the print width, so the text fits next to its tags.
Prettier counts the format characters (`U+200B..U+200F`, `U+FEFF`) as 1 and breaks.
An email template pads its preview text this way, NBSP alternating with zero-width characters.
## unclosed-at-end
- Why: semantics (an end tag added where a fragment ends changes the page it is joined into; prettier/prettier#17761, prettier/prettier#5665)
- Pin: `tests/fixtures/html/omitted-end-tag-at-eof.html`
- Conformance: `html/basics/broken-html.html`, `html/prettier_ignore/unclosed2.html`
```html
<!-- input (header.html, closed by footer.html) -->
<html>
<body>
<div class="wrap">
<header>Site</header>
<!-- ours -->
<html>
<body>
<div class="wrap">
<header>Site</header>
<!-- prettier -->
<html>
<body>
<div class="wrap">
<header>Site</header>
</div>
</body>
</html>
```
An element left open at the end gets no end tag: it is a fragment that something else closes,
html-in-js joined with other templates (Prettier turns `` html`<div>` `` into `` html`<div></div>` ``),
or a partial `.html` file included before its closing counterpart (a header for a footer).
Prettier closes it where the fragment ends, which changes the page it is joined into.
## omitted-end-tag
- Why: uniform-rule (a normalization is applied only where it is known safe for every reader: `oxc_formatter_css`'s AGENTS.md "Case")
- Pin: `tests/fixtures/html/omitted-end-tag.html`
```html
<!-- input -->
<ul>
<li>one
<li>two
</ul>
<!-- ours -->
<ul>
<li>one
<li>two
</ul>
<!-- prettier -->
<ul>
<li>one</li>
<li>two</li>
</ul>
```
An end tag the source omits where HTML closes the element (`</li>`, `</p>`) is not added.
To a browser reading the page the end tag changes nothing, but in a template the place HTML closes it may sit in a branch:
```html
{% if x %}<p>a{% else %}<p>b{% endif %}<div>
<!-- an end tag added before the second <p> lands in the else branch: a stray </p>, which makes an empty p -->
```
## pre-content-without-end-tag
- Why: semantics (whitespace in `pre`-like elements is content; prettier/prettier#16205)
- Pin: `tests/fixtures/html/pre-content-without-end-tag.html`
```html
<!-- input -->
<div><pre />
<b>x</b></div>
<pre><b>x</b> unclosed
<!-- ours -->
<div>
<pre />
<b>x</b></div>
<pre><b>x</b> unclosed
<!-- prettier -->
<div>
<pre />
<b>x</b>
</div>
<pre>>
```
A `pre`, `listing`, `plaintext` or `xmp` holding markup without its end tag keeps its content as written, up to where HTML closes it.
`<pre />` reads as one: HTML ignores the `/` and the content up to an ancestor's end tag is inside it.
Prettier reads `<pre />` as self-closing and re-indents that content, adding whitespace to what `pre` renders;
a `pre` left open at the end loses its content to a stray `>`.
## tag-name-case
- Why: semantics (consumers other than an HTML parser tell names apart by case; prettier/prettier#16898)
- Pin: `tests/fixtures/html/tag-name-case.xhtml`
```html
<!-- input (an .xhtml file) -->
<DIV CLASS="a"><P>Text</P></DIV>
<!-- ours -->
<DIV CLASS="a"><P>Text</P></DIV>
<!-- prettier -->
<div class="a"><p>Text</p></div>
```
```js
// input
html`<A someThing="bla"></A>`;
// ours
html`<A someThing="bla"></A>`;
// prettier
html`<a someThing="bla"></a>`;
```
Tag and attribute names keep their case where a reader other than an HTML parser may read them: embedded HTML and XHTML.
In a `.html` file, whose reader is an HTML parser, the names Prettier knows print lowercase as it does (`src/tree/names.rs`).
Prettier lowercases them in embedded HTML and XHTML too, by the language alone:
- a framework's `html` tagged template is read by its own runtime, which tells `<A>` (a router component) from the element `<a>`;
- a code block labelled `html` often holds a template of another language (a Vue `<Button>`);
- an XML parser reads XHTML's names case-sensitively.
Vue and Angular keep every name as written, as Prettier does.
## prefixed-script-style
- Why: uniform-rule (same construct, same output: `<html:p>` and every other prefixed name, which Prettier lays out as an unknown element)
- Pin: `tests/fixtures/html/prefixed-script-style.html`
- Conformance: `html/basics/with-colon.html`
```html
<!-- input -->
<div><html:style>.a{color:red}</html:style></div>
<div>x<html:p>p</html:p>y</div>
<!-- ours -->
<div><html:style>.a{color:red}</html:style></div>
<div>x<html:p>p</html:p>y</div>
<!-- prettier -->
<div>
<html:style
>.a{color:red}</html:style
>
</div>
<div>x<html:p>p</html:p>y</div>
```
In HTML, an element whose name has a `prefix:` is an unknown element, as browsers read it (the parser's DIVERGENCES.md, "Namespace prefixes"):
`<html:script>` and `<html:style>` too, whose content is text.
Prettier lays out every prefixed name so, but sees `script` / `style` through the prefix and puts them on their own lines.
A `prefix:` comes from XML namespaces; only an XML parser (XHTML, XUL, a standalone SVG) reads `html:script` as a script.
Angular's compiler reads a prefix as a namespace (`svg:rect`), and so does the Angular dialect.
## unquoted-value-with-slash
- Why: semantics (Prettier ends an unquoted value at `/`: `src=a.png/` becomes `src="a.png"`, another URL)
- Pin: `tests/fixtures/html/unquoted-value-with-slash.html`
```html
<!-- input -->
<img src=a.png/>
<!-- ours -->
<img src="a.png/" />
<!-- prettier -->
<img src="a.png" />
```
```html
<!-- input -->
<a href=https://www.mozilla.org/>favorite website</a>
<!-- ours -->
<a href="https://www.mozilla.org/">favorite website</a>
<!-- prettier: error, Opening tag "a" not terminated -->
```
In HTML, an unquoted attribute value runs to whitespace or `>`, as browsers read it (the parser's DIVERGENCES.md, "Unquoted attribute values").
Prettier's parser also ends it at `/`: a value ending in `/` loses it and the tag self-closes,
and a `/` inside the value (a URL) leaves the rest of the tag unreadable, a parse error
(in a Markdown code block, the block is left as written).
## htm-closing-tag-as-written
- Why: uniform-rule (same construct, same output: an htm end tag Prettier prints as written outside a merged text)
- Pin: `tests/fixtures/html/htm-closing-tag-as-written.html`
```html
<!-- input -->
<span>a<b>x<//>c</span>
<!-- ours -->
<span>a<b>x<//>c</span>
<!-- prettier -->
<span>a<b>x</b>c</span>
```
htm's `<//>`, an end tag closing the innermost element, prints as written.
Prettier prints it as written too, except for an element it merges into the text around it, whose end tag it rebuilds from the element's name.
## end-tag-with-space-is-comment
- Why: semantics (HTML reads `</ div>` as a comment, so the element stays open)
- Pin: `tests/fixtures/html/end-tag-with-space-is-comment.html`
```html
<!-- input -->
<div>a</ div>b
<!-- ours -->
<div>a</ div>b
<!-- prettier -->
<div>a</div>
b
```
In HTML, `</` without a name right after it is a bogus comment, as in browsers, and prints as written.
Vue's and Angular's compilers read `</ div>` as an end tag, and so do those dialects.
Prettier reads it as an end tag and prints `</div>`, which closes the `div` there and moves what follows out of it.
## vue-element-gap-kind
- Why: semantics (Vue's `whitespace: 'condense'` drops whitespace with a line break between two elements and keeps a same-line one as a space)
- Pin: `tests/fixtures/vue/vue-element-gap-kind.vue`
```vue
<!-- input -->
<template>
<ul>
<li>
<router-link to="/using-store">/using-store</router-link> — <b>universal</b>
<code>reactive()</code> stores.
</li>
</ul>
<p><span>a long enough first element here</span> <span>and a second one that does not fit</span></p>
</template>
<!-- ours -->
<template>
<ul>
<li>
<router-link to="/using-store">/using-store</router-link> —
<b>universal</b>
<code>reactive()</code> stores.
</li>
</ul>
<p>
<span>a long enough first element here</span> <span
>and a second one that does not fit</span
>
</p>
</template>
<!-- prettier -->
<template>
<ul>
<li>
<router-link to="/using-store">/using-store</router-link> —
<b>universal</b> <code>reactive()</code> stores.
</li>
</ul>
<p>
<span>a long enough first element here</span>
<span>and a second one that does not fit</span>
</p>
</template>
```
In Vue, whitespace between two sibling elements keeps its kind where it renders (between inline elements):
a line break stays a line break and a same-line space stays a space that never breaks.
Vue's compiler drops whitespace containing a line break between elements and condenses the rest to a space,
so Prettier's output renders `universal reactive()` (a space the source did not render) and `hereand` (losing one).
Where the render layer ignores the gap (next to a block), and at text edges, the layout is Prettier's.
## angular-glued-block-markers
- Why: semantics (whitespace added around a block marker renders between the content it joins; prettier/prettier#16577, prettier/prettier#19020)
- Pin: `tests/fixtures/angular/angular-glued-block-markers.component.html`
```html
<!-- input -->
<p>a@if (x) {b}c</p>
<span>@if (test) {text}</span>
<!-- ours -->
<p>a@if (x) {b}c</p>
<span>@if (test) {text}</span>
<!-- prettier -->
<p>
a
@if (x) {
b
}
c
</p>
<span>
@if (test) {
text
}
</span>
```
An Angular control flow block makes no box: its markers (`@if (x) {`, `}`) render nothing,
so the whitespace on both sides of a marker is one run between the content around it.
Where that run is significant and empty, the markers stay glued to their neighbors;
otherwise a block is laid out on its own lines, as Prettier does.
Prettier always breaks around a block, which turns `abc` into `a b c` (and adds spaces inside the `span`).
## angular-block-name-with-line-break
- Why: semantics (Angular reads `@else⏎ if` as an unknown block, a compile error, and `@else if` as an else-if branch)
- Pin: `tests/fixtures/angular/angular-block-name-with-line-break.component.html`
```html
<!-- input -->
@if (a) {
x
} @else
if (b) {
y
}
<!-- ours -->
@if (a) {
x
}
@else
if (b) {
y
}
<!-- prettier -->
@if (a) {
x
} @else if (b) {
y
}
```
A block name holding a line break prints as written, on its own line, as an unknown block: Angular's compiler reports `Unrecognized block @else`.
Prettier collapses the name to `else if` and joins it to the previous block, turning the error into an else-if branch.
Whitespace without a line break (`@else if`) is collapsed, as Angular reads it.
+15
View File
@@ -0,0 +1,15 @@
use std::{
env,
path::{Path, PathBuf},
};
use oxc_formatter_tests::{GenerateConfig, generate_tests};
fn main() {
let out_dir = env::var("OUT_DIR").unwrap();
let dest_path = PathBuf::from(out_dir).join("generated_tests.rs");
let config = GenerateConfig { extensions: &["html", "vue", "xhtml"] };
generate_tests(&dest_path, Path::new("tests/fixtures"), &config).unwrap();
}
@@ -0,0 +1,61 @@
#![expect(clippy::print_stdout)]
//! Format a file and print the result.
//!
//! ```bash
//! cargo run -p oxc_formatter_htmlish --example htmlish_formatter [filename]
//! cargo run -p oxc_formatter_htmlish --example htmlish_formatter -- --print-width 100 [filename]
//! cargo run -p oxc_formatter_htmlish --example htmlish_formatter -- --use-tabs --bracket-same-line \
//! --single-attribute-per-line --html-whitespace-sensitivity ignore [filename]
//! DUMP_IR=1 cargo run -p oxc_formatter_htmlish --example htmlish_formatter [filename]
//! ```
use std::fs;
use pico_args::Arguments;
use oxc_allocator::Allocator;
use oxc_formatter_core::{IndentStyle, LineWidth};
use oxc_formatter_htmlish::{HtmlishFormatOptions, HtmlishVariant, WhitespaceSensitivity};
fn main() -> Result<(), String> {
let mut args = Arguments::from_env();
let print_width = args.opt_value_from_str::<&'static str, u16>("--print-width").unwrap_or(None);
let use_tabs = args.contains("--use-tabs");
let bracket_same_line = args.contains("--bracket-same-line");
let single_attribute_per_line = args.contains("--single-attribute-per-line");
let whitespace_sensitivity = match args
.opt_value_from_str::<&'static str, String>("--html-whitespace-sensitivity")
.unwrap_or(None)
.as_deref()
{
Some("strict") => WhitespaceSensitivity::Strict,
Some("ignore") => WhitespaceSensitivity::Ignore,
_ => WhitespaceSensitivity::Css,
};
let name = args.free_from_str().unwrap_or_else(|_| "test.html".to_string());
let source_text = fs::read_to_string(&name).map_err(|_| format!("Missing '{name}'"))?;
let variant = match std::path::Path::new(&name).extension().and_then(|ext| ext.to_str()) {
Some("vue") => HtmlishVariant::Vue,
Some("xhtml" | "xht") => HtmlishVariant::Xhtml,
_ if name.ends_with(".component.html") => HtmlishVariant::Angular,
_ => HtmlishVariant::Html,
};
let options = HtmlishFormatOptions {
line_width: LineWidth::try_from(print_width.unwrap_or(80)).unwrap(),
indent_style: if use_tabs { IndentStyle::Tab } else { IndentStyle::Space },
whitespace_sensitivity,
bracket_same_line,
single_attribute_per_line,
variant,
..Default::default()
};
let allocator = Allocator::new();
let formatted = oxc_formatter_htmlish::format(&allocator, &source_text, options)
.map_err(|error| error.to_string())?;
if std::env::var("DUMP_IR").is_ok() {
println!("{:#?}", formatted.document());
}
print!("{}", formatted.print().map_err(|error| error.to_string())?.into_code());
Ok(())
}
@@ -0,0 +1,33 @@
use oxc_formatter_core::{FormatContext, SourceText};
use crate::{options::HtmlishFormatOptions, tree::Tree};
/// Formatting context for htmlish.
pub struct HtmlishFormatContext<'a> {
options: HtmlishFormatOptions,
source_text: SourceText<'a>,
tree: &'a Tree<'a>,
}
impl<'a> HtmlishFormatContext<'a> {
pub fn new(options: HtmlishFormatOptions, tree: &'a Tree<'a>) -> Self {
Self { options, source_text: SourceText::new(tree.source()), tree }
}
/// The preprocess table, with the arena lifetime so it outlives borrows of the formatter.
pub fn tree(&self) -> &'a Tree<'a> {
self.tree
}
}
impl FormatContext for HtmlishFormatContext<'_> {
type Options = HtmlishFormatOptions;
fn options(&self) -> &Self::Options {
&self.options
}
fn source_code(&self) -> &str {
&self.source_text
}
}
+228
View File
@@ -0,0 +1,228 @@
use oxc_allocator::Allocator;
use oxc_diagnostics::OxcDiagnostic;
use oxc_formatter_core::{
Document, EmbeddedIr, Format, FormatSession, FormatState, Formatted, Formatter, InputKind,
builders::{empty_line, hard_line_break, text},
spec::{FrontMatter, blank_front_matter, parse_front_matter, split_bom},
write, write_front_matter,
};
use oxc_htmlish_parser::{
Dialect, Parser,
ast::{NodeId, Root},
};
use crate::{
context::HtmlishFormatContext,
options::HtmlishFormatOptions,
print::{self, HtmlishFormatter},
tree::Tree,
};
/// Parse `source_text` as `options.variant` and build its formatter IR.
///
/// # Errors
/// Returns an [`OxcDiagnostic`] when the parser reports broken structure.
pub fn format<'a>(
allocator: &'a Allocator,
source_text: &str,
options: HtmlishFormatOptions,
) -> Result<Formatted<'a, HtmlishFormatContext<'a>>, OxcDiagnostic> {
// A service-less session: front matter stays as-is.
// Hosts that install services (oxfmt) use [`format_with_session`].
format_with_session(
&FormatSession::new(allocator, InputKind::PhysicalFile),
source_text,
options,
)
}
/// Like [`format()`], but on a caller-supplied [`FormatSession`]:
/// the root whose session carries the host's `SessionServices` (front matter dispatch, Tailwind sorting).
///
/// # Errors
/// Same as [`format()`].
pub fn format_with_session<'a>(
session: &FormatSession<'a>,
source_text: &str,
options: HtmlishFormatOptions,
) -> Result<Formatted<'a, HtmlishFormatContext<'a>>, OxcDiagnostic> {
debug_assert!(
session.input_kind() == InputKind::PhysicalFile,
"format_with_session is the physical-root entry; embedded inputs go through format_to_ir"
);
let parsed = parse_for_format(session.allocator(), source_text, &options)?;
let context = build_context(session.allocator(), &parsed, options, true);
// A root `Document` owns a fresh Tailwind class scope
let session = session.with_new_tailwind_scope();
let mut state = FormatState::new_with_session(context, session.clone());
// Pre-allocate: measured on 771 HTML files (markup_fmt, biome, deno, Prettier tests, large spec / wikipedia pages),
// 0.75x source bytes plus a 1024-element floor avoids reallocation for 97.8% of the corpus.
let capacity = (parsed.source.len() * 3 / 4).max(1024);
let mut buffer = Formatter::with_capacity(capacity, &mut state);
write!(&mut buffer, FormatHtmlishRoot { parsed: &parsed });
let elements = buffer.into_vec();
let context = state.into_context();
let ir = Document::new(elements, session.take_sorted_tailwind_classes());
Ok(Formatted::new(ir, context))
}
/// [`parse_for_format`] output: the AST, plus the envelope [`format()`] prints around it.
pub struct ParsedHtmlish<'a> {
pub root: &'a Root<'a>,
/// The arena source every span indexes into, `\r` normalized away and the front matter blanked.
pub source: &'a str,
/// The leading `---` / `+++` block (it is not markup), sliced from the unblanked source.
pub front_matter: Option<FrontMatter<'a>>,
has_bom: bool,
}
/// Parse `source_text` the way the formatter does, for callers that inspect the AST
/// (e.g. a harness comparing what the source held against the formatted output).
///
/// [`format()`] goes through this too, so what a caller sees is exactly what gets formatted.
/// Owns the BOM split and `\r` normalization.
///
/// # Errors
/// Same as [`format()`].
pub fn parse_for_format<'a>(
allocator: &'a Allocator,
source_text: &str,
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 `source_text` and build the formatter IR for embedding into another
/// formatter's document (dispatcher path).
///
/// Unlike [`format()`], this:
/// - allocates from the session's shared arena and `GroupId` space, so the IR lives as long as the parent's document
/// - emits neither a BOM nor the trailing newline
///
/// # Errors
/// Same as [`format()`], and when a fragment starts with front matter.
pub fn format_to_ir<'a>(
session: &FormatSession<'a>,
source_text: &str,
options: HtmlishFormatOptions,
) -> Result<EmbeddedIr<'a>, OxcDiagnostic> {
let allocator = session.allocator();
// `FormatSession::dispatch` never hands a BOM-headed input to an embedded part
let parsed = parse_root(allocator, source_text, false, options.variant.to_dialect())?;
if parsed.front_matter.is_some() && !session.input_kind().owns_front_matter() {
// A fragment never acquires file envelope semantics
return Err(OxcDiagnostic::error(
"Front matter in an HTML fragment; the part is preserved as-is",
));
}
let context = build_context(allocator, &parsed, options, false);
let mut state = FormatState::new_with_session(context, session.clone());
let mut buffer = Formatter::new(&mut state);
write!(&mut buffer, FormatHtmlishEmbedded { parsed: &parsed });
Ok(EmbeddedIr { ir: buffer.into_vec() })
}
/// Parse the source into the AST, bailing out on any diagnostic.
///
/// Copies the source into the arena so every slice taken from it carries `'a`.
/// Entries own the BOM strip; this layer assumes BOM-free input (see [`split_bom`]).
fn parse_root<'a>(
allocator: &'a Allocator,
source_text: &str,
has_bom: bool,
dialect: Dialect,
) -> Result<ParsedHtmlish<'a>, OxcDiagnostic> {
// The printer slices verbatim text from the source, and the IR forbids `\r`.
let unblanked: &'a str =
allocator.alloc_str(&oxc_formatter_core::normalize_newlines(source_text, ['\r']));
// Blanked byte for byte, so every span still indexes the same source.
let front_matter = parse_front_matter(unblanked);
let source: &'a str = match &front_matter {
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 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);
err
})?;
Ok(ParsedHtmlish { root: allocator.alloc(root), source, front_matter, has_bom })
}
/// The context over the preprocess table [`Tree::build`] computes from the AST.
fn build_context<'a>(
allocator: &'a Allocator,
parsed: &ParsedHtmlish<'a>,
options: HtmlishFormatOptions,
standalone: bool,
) -> HtmlishFormatContext<'a> {
let tree = allocator.alloc(Tree::build(
allocator,
parsed.root,
parsed.source,
options.whitespace_sensitivity,
options.variant,
standalone,
));
HtmlishFormatContext::new(options, tree)
}
/// Emits the front matter and the nodes with the final newline.
struct FormatHtmlishRoot<'b, 'a> {
parsed: &'b ParsedHtmlish<'a>,
}
impl<'a> Format<'a, HtmlishFormatContext<'a>> for FormatHtmlishRoot<'_, 'a> {
fn fmt(&self, f: &mut HtmlishFormatter<'_, 'a>) {
if self.parsed.has_bom {
write!(f, text("\u{feff}"));
}
write_document(self.parsed, f);
// Prettier prints an empty document as the empty string, not `\n`.
let has_body = f.context().tree()[NodeId::DOCUMENT].first_child.is_some();
if self.parsed.front_matter.is_some() || has_body {
write!(f, hard_line_break());
}
}
}
/// Emits the front matter and the nodes only;
/// no BOM, no final newline (the parent document owns the surrounding layout).
struct FormatHtmlishEmbedded<'b, 'a> {
parsed: &'b ParsedHtmlish<'a>,
}
impl<'a> Format<'a, HtmlishFormatContext<'a>> for FormatHtmlishEmbedded<'_, 'a> {
fn fmt(&self, f: &mut HtmlishFormatter<'_, 'a>) {
write_document(self.parsed, f);
}
}
/// The front matter (a blank line after it when a body follows) and the nodes.
fn write_document<'a>(parsed: &ParsedHtmlish<'a>, f: &mut HtmlishFormatter<'_, 'a>) {
let has_body = f.context().tree()[NodeId::DOCUMENT].first_child.is_some();
if let Some(fm) = &parsed.front_matter {
write_front_matter(fm, &["yaml", "toml"], f);
if has_body {
write!(f, empty_line());
}
}
if has_body {
print::write_root(f);
}
}
+16
View File
@@ -0,0 +1,16 @@
//! HTML (and HTML-based template language) formatter built on top of `oxc_formatter_core`.
//!
//! Parses with `oxc-htmlish-parser` and prints Prettier-compatible output.
mod context;
mod format;
mod options;
mod print;
mod rules;
mod tree;
pub use crate::{
context::HtmlishFormatContext,
format::{ParsedHtmlish, format, format_to_ir, format_with_session, parse_for_format},
options::{HtmlishFormatOptions, HtmlishVariant, WhitespaceSensitivity},
};
@@ -0,0 +1,82 @@
use oxc_formatter_core::{
CoreFormatOptions, FormatOptions, IndentStyle, IndentWidth, LineEnding, LineWidth,
};
use oxc_htmlish_parser::Dialect;
/// HTML-based template language variant.
#[derive(Debug, Default, Clone, Copy, Eq, PartialEq)]
pub enum HtmlishVariant {
/// Prettier's `parser: html` equivalent.
#[default]
Html,
/// Prettier's `parser: vue` equivalent.
Vue,
/// Prettier's `parser: angular` equivalent.
Angular,
/// Prettier's `parser: html` on an XHTML file (`.xhtml`, `.xht`), which an XML parser may read:
/// read and printed as HTML, but the doctype keeps its case.
Xhtml,
}
impl HtmlishVariant {
pub(crate) fn to_dialect(self) -> Dialect {
match self {
Self::Html | Self::Xhtml => Dialect::Html,
Self::Vue => Dialect::Vue,
Self::Angular => Dialect::Angular,
}
}
}
/// Format options for htmlish (HTML and the template languages built on it).
#[derive(Debug, Default, Clone, Copy, Eq, PartialEq)]
pub struct HtmlishFormatOptions {
pub indent_style: IndentStyle,
pub indent_width: IndentWidth,
pub line_width: LineWidth,
pub line_ending: LineEnding,
pub variant: HtmlishVariant,
/// Mirrors Prettier's `htmlWhitespaceSensitivity`.
pub whitespace_sensitivity: WhitespaceSensitivity,
/// Put the `>` of a multi-line start tag at the end of the last line. Mirrors Prettier's `bracketSameLine`.
pub bracket_same_line: bool,
/// Mirrors Prettier's `singleAttributePerLine`.
pub single_attribute_per_line: bool,
}
/// Which whitespace between elements is significant.
#[derive(Clone, Copy, Debug, Default, Eq, Hash, PartialEq)]
pub enum WhitespaceSensitivity {
/// As the element's default CSS `display` says.
#[default]
Css,
/// Every element is inline: all whitespace is significant.
Strict,
/// Every element is a block: no whitespace is significant.
Ignore,
}
impl FormatOptions for HtmlishFormatOptions {
fn indent_style(&self) -> IndentStyle {
self.indent_style
}
fn indent_width(&self) -> IndentWidth {
self.indent_width
}
fn line_width(&self) -> LineWidth {
self.line_width
}
fn line_ending(&self) -> LineEnding {
self.line_ending
}
fn apply_core(&mut self, core: CoreFormatOptions) {
self.indent_style = core.indent_style;
self.indent_width = core.indent_width;
self.line_width = core.line_width;
self.line_ending = core.line_ending;
}
}
@@ -0,0 +1,320 @@
//! Attributes: the list in the start tag (line breaks, `prettier-ignore-attribute`),
//! each one's name as written, then the value, which a few attributes get re-printed by their own grammar.
use std::borrow::Cow;
use cow_utils::CowUtils;
use oxc_formatter_core::{
Format, arena_cow_str,
builders::{
group, hard_line_break, if_group_breaks, if_group_fits_on_line, indent, soft_block_indent,
soft_line_break, soft_line_break_or_space, space, text,
},
spec::is_suppression_marker,
write,
};
use oxc_htmlish_parser::ast::{Attribute, NodeId, NodeKind};
use super::{HtmlishFormatter, format_with, tag::opening_end_borrowed};
use crate::{
context::HtmlishFormatContext,
tree::{Tree, collapse_whitespace},
};
/// `<!-- prettier-ignore-attribute -->` right before the element: the attribute names it lists (all when none),
/// which print as written.
fn ignored_attributes<'a>(t: &Tree<'a>, id: NodeId) -> Option<&'a str> {
let NodeKind::Comment(comment) = t.kind(t[id].prev?) else { return None };
if comment.bogus {
return None;
}
let content = comment.content.slice(t.source()).trim_ascii();
let (marker, names) =
content.split_once(|c: char| c.is_ascii_whitespace()).unwrap_or((content, ""));
marker.strip_suffix("-attribute").is_some_and(is_suppression_marker).then_some(names)
}
pub fn write_attributes<'a>(t: &'a Tree<'a>, id: NodeId, f: &mut HtmlishFormatter<'_, 'a>) {
let item = &t[id];
let Some(el) = t.element(id) else { return };
// `<br />`
let slash = el.self_closing || el.void;
if el.attributes.is_empty() {
if slash {
write!(f, space());
}
return;
}
let options = *f.options();
let force_not_to_break = t.tag(id) == "script"
&& matches!(el.attributes.as_slice(), [only] if t.attribute_is(only.name.slice(t.source()), "src"))
&& item.first_child.is_none();
let per_line = options.single_attribute_per_line && el.attributes.len() > 1;
let ignored = ignored_attributes(t, id);
let is_ignored = |name: &str| {
ignored.is_some_and(|names| {
names.is_empty() || names.split_ascii_whitespace().any(|n| t.attribute_is(name, n))
})
};
write!(
f,
indent(&format_with(|f| {
if force_not_to_break {
write!(f, space());
} else {
write!(f, soft_line_break_or_space());
}
for (i, attribute) in el.attributes.iter().enumerate() {
if i > 0 {
if per_line {
write!(f, hard_line_break());
} else {
write!(f, soft_line_break_or_space());
}
}
let name = attribute.name.slice(t.source());
if is_ignored(name) {
write!(f, text(attribute.span.slice(t.source())));
} else {
write_attribute(t, id, attribute, f);
}
}
}))
);
let hug_end = opening_end_borrowed(t, id)
|| (item.self_closing && t[t.parent(id)].borrows.last_child_closing_end)
|| force_not_to_break;
if hug_end || options.bracket_same_line {
if slash {
write!(f, space());
}
} else if slash {
write!(f, soft_line_break_or_space());
} else {
write!(f, soft_line_break());
}
}
fn write_attribute<'a>(
t: &'a Tree<'a>,
element: NodeId,
attribute: &Attribute<'a>,
f: &mut HtmlishFormatter<'_, 'a>,
) {
let source = t.source();
let name = attribute.name.slice(source);
let is = |known| t.attribute_is(name, known);
write!(f, text(t.attribute_name(element, name)));
let Some(value) = attribute.value else { return };
let value = value.span.slice(source);
write!(f, "=");
let tag = t.tag(element);
// A class list that collapses to nothing prints as written;
// the names are split at ASCII whitespace only (DIVERGENCES.md#class-ascii-whitespace).
if is("class") && !value.contains("{{") && !value.trim_ascii().is_empty() {
let names = collapse_whitespace(f.allocator(), unescaped(value, f));
write!(f, ["\"", text(quoted_piece(names, f)), "\""]);
} else if is("srcset")
&& matches!(tag, "img" | "source")
&& let Some(candidates) = parse_srcset(unescaped(value, f))
{
write_srcset(&candidates, f);
} else if is("allow") && tag == "iframe" && !value.contains("{{") {
write_permissions_policy(unescaped(value, f), f);
} else {
write!(f, AttributeValue(value));
}
}
/// A quoted attribute value: `"` unless the value holds more `"` than `'`, the chosen quote escaped.
/// Quote entities are read back first, so the choice does not depend on how the source quoted.
pub struct AttributeValue<'a>(pub &'a str);
impl<'a> Format<'a, HtmlishFormatContext<'a>> for AttributeValue<'a> {
fn fmt(&self, f: &mut HtmlishFormatter<'_, 'a>) {
let raw = unescaped(self.0, f);
let doubles = raw.bytes().filter(|&b| b == b'"').count();
let singles = raw.bytes().filter(|&b| b == b'\'').count();
let (quote, escaped) = if doubles > singles { ("'", "&apos;") } else { ("\"", "&quot;") };
let printed = arena_cow_str(&raw.cow_replace(quote, escaped), f);
write!(f, [text(quote), text(printed), text(quote)]);
}
}
/// `&apos;` and `&quot;` read back.
fn unescaped<'a>(value: &'a str, f: &HtmlishFormatter<'_, 'a>) -> &'a str {
if !value.contains('&') {
return value;
}
let apos = value.cow_replace("&apos;", "'");
match apos.cow_replace("&quot;", "\"") {
Cow::Borrowed(_) => arena_cow_str(&apos, f),
Cow::Owned(both) => f.allocator().alloc_str(&both),
}
}
/// `"` re-escaped for a value printed in `"`.
fn quoted_piece<'a>(piece: &'a str, f: &HtmlishFormatter<'_, 'a>) -> &'a str {
arena_cow_str(&piece.cow_replace('"', "&quot;"), f)
}
/// Prettier's `printExpand`: `content` on its own indented lines when the group breaks.
fn write_expanded<'a>(
content: &impl Format<'a, HtmlishFormatContext<'a>>,
f: &mut HtmlishFormatter<'_, 'a>,
) {
write!(f, ["\"", group(&soft_block_indent(content)), "\""]);
}
struct Candidate<'a> {
url: &'a str,
/// The descriptor's number as printed, and its unit (`w` / `h` / `x`).
descriptor: Option<(String, &'a str)>,
}
/// One candidate per line when long, descriptors aligned on their decimal point.
fn write_srcset<'a>(candidates: &[Candidate<'a>], f: &mut HtmlishFormatter<'_, 'a>) {
let max_url = candidates.iter().map(|c| c.url.len()).max().unwrap_or(0);
let left = |number: &str| number.find('.').unwrap_or(number.len());
let max_left =
candidates.iter().filter_map(|c| c.descriptor.as_ref().map(|d| left(&d.0))).max();
let max_left = max_left.unwrap_or(0);
let padding: &'a str = f.allocator().alloc_str(&" ".repeat(max_url + 1 + max_left));
write_expanded(
&format_with(|f| {
for (i, c) in candidates.iter().enumerate() {
if i > 0 {
write!(f, [",", soft_line_break_or_space()]);
}
let url = quoted_piece(c.url, f);
write!(f, text(url));
if let Some((number, unit)) = &c.descriptor {
let padding = &padding[..max_url - c.url.len() + 1 + max_left - left(number)];
let number = f.allocator().alloc_str(number);
write!(
f,
[
if_group_breaks(&text(padding)),
if_group_fits_on_line(&space()),
text(number),
text(unit)
]
);
}
}
}),
f,
);
}
/// `@prettier/parse-srcset`: `None` for what it rejects (a bad descriptor, or no candidate),
/// and for mixed descriptor kinds, which Prettier does not print either.
fn parse_srcset(input: &str) -> Option<Vec<Candidate<'_>>> {
let is_ws = |c: char| c.is_ascii_whitespace();
let mut candidates = vec![];
let mut kind = None;
let mut rest = input;
loop {
rest = rest.trim_start_matches(|c| is_ws(c) || c == ',');
if rest.is_empty() {
break;
}
let url_end = rest.find(is_ws).unwrap_or(rest.len());
let mut url = &rest[..url_end];
rest = &rest[url_end..];
let descriptors = if url.ends_with(',') {
url = url.trim_end_matches(',');
""
} else {
// Descriptors up to the next `,` outside parentheses.
rest = rest.trim_ascii_start();
let mut depth = 0;
let mut end = rest.len();
for (i, c) in rest.char_indices() {
match c {
'(' => depth += 1,
')' if depth > 0 => depth -= 1,
',' if depth == 0 => {
end = i;
break;
}
_ => {}
}
}
let descriptors = &rest[..end];
rest = rest.get(end + 1..).unwrap_or("");
descriptors
};
let mut descriptor = None;
for d in descriptors.split_ascii_whitespace() {
let unit = d.chars().last()?;
let value = &d[..d.len() - unit.len_utf8()];
let parsed = match unit {
'w' | 'h' if value.bytes().all(|b| b.is_ascii_digit()) => {
value.parse::<f64>().ok().filter(|&v| v > 0.0)
}
// A zero density is kept (DIVERGENCES.md#srcset-zero-density).
'x' if is_floating_point(value) => value.parse::<f64>().ok().filter(|&v| v >= 0.0),
_ => None,
}?;
let parsed = zmij_ecma::Buffer::new().format(parsed).to_owned();
// One descriptor per candidate and one kind per list:
// a second is an error to Prettier or a mix it rejects.
if descriptor.is_some() || kind.is_some_and(|kind| kind != unit) {
return None;
}
kind = Some(unit);
descriptor = Some((parsed, &d[d.len() - unit.len_utf8()..]));
}
candidates.push(Candidate { url, descriptor });
}
(!candidates.is_empty()).then_some(candidates)
}
/// `/^-?(?:[0-9]+|[0-9]*\.[0-9]+)(?:[eE][+-]?[0-9]+)?$/`
fn is_floating_point(value: &str) -> bool {
let value = value.strip_prefix('-').unwrap_or(value);
let (mantissa, exponent) = match value.find(['e', 'E']) {
Some(i) => (&value[..i], Some(&value[i + 1..])),
None => (value, None),
};
let digits = |s: &str| !s.is_empty() && s.bytes().all(|b| b.is_ascii_digit());
let mantissa_ok = match mantissa.split_once('.') {
Some((int, frac)) => (int.is_empty() || digits(int)) && digits(frac),
None => digits(mantissa),
};
let exponent_ok = exponent.is_none_or(|e| digits(e.strip_prefix(['+', '-']).unwrap_or(e)));
mantissa_ok && exponent_ok
}
/// `allow` (permissions policy): one directive per line when long, `;` between them.
fn write_permissions_policy<'a>(value: &'a str, f: &mut HtmlishFormatter<'_, 'a>) {
let directives: Vec<&'a str> = value
.split(';')
.map(|token| collapse_whitespace(f.allocator(), token))
.filter(|directive| !directive.is_empty())
.map(|directive| quoted_piece(directive, f))
.collect();
if directives.is_empty() {
write!(f, "\"\"");
return;
}
write_expanded(
&format_with(|f| {
for (i, directive) in directives.iter().enumerate() {
write!(f, text(directive));
if i + 1 == directives.len() {
write!(f, if_group_breaks(&text(";")));
} else {
write!(f, [";", soft_line_break_or_space()]);
}
}
}),
f,
);
}
@@ -0,0 +1,138 @@
//! Angular control flow blocks: transparent, their markers print no box (`@if (a) {` … `}`).
use oxc_formatter_core::{
builders::{hard_line_break, indent, text},
write,
};
use oxc_htmlish_parser::ast::{Block, NodeId, NodeKind};
use super::{
HtmlishFormatter,
children::{Between, write_children},
format_with,
};
use oxc_allocator::{Allocator, StringBuilder};
use crate::tree::{Tree, collapse_whitespace};
/// Next to a block marker: glued where the run is significant and empty (DIVERGENCES.md#angular-glued-block-markers),
/// a space before a block that continues the previous one (`} @else {`), a line break otherwise.
pub fn between_block(t: &Tree<'_>, prev: NodeId, next: NodeId) -> Between {
let (p, n) = (&t[prev], &t[next]);
// The run continues into a block's content past its marker.
let spaces = p.has_trailing_spaces
|| n.has_leading_spaces
|| (t.is_block(prev) && p.last_child.is_some_and(|last| t[last].has_trailing_spaces))
|| (t.is_block(next) && n.first_child.is_some_and(|first| t[first].has_leading_spaces));
if n.leading_sensitive && !spaces {
Between::Nothing
} else if continues_block(t, prev, next) {
Between::Space
} else {
Between::Hard
}
}
/// `next` is a block that continues `prev` (`@if` … `@else`), printed after its `}`.
fn continues_block(t: &Tree<'_>, prev: NodeId, next: NodeId) -> bool {
let (NodeKind::Block(prev), NodeKind::Block(next)) = (t.kind(prev), t.kind(next)) else {
return false;
};
let next = block_name(t, next);
match block_name(t, prev) {
"if" | "else if" => matches!(next, "else if" | "else"),
"for" => next == "empty",
"defer" | "placeholder" | "loading" | "error" => {
matches!(next, "placeholder" | "loading" | "error")
}
"boundary" => next == "error",
_ => false,
}
}
/// The block's name, whitespace collapsed unless it spans lines (`else\n if` is no `else if`, DIVERGENCES.md#angular-block-name-with-line-break).
fn block_name<'a>(t: &'a Tree<'a>, block: &Block<'_>) -> &'a str {
let name = block.name.slice(t.source());
if name.contains('\n') { name } else { t.collapsed(block.name) }
}
pub fn write_block<'a>(
t: &'a Tree<'a>,
id: NodeId,
block: &Block<'a>,
f: &mut HtmlishFormatter<'_, 'a>,
) {
let item = &t[id];
let name = block_name(t, block);
let default_never = name == "default never";
write!(f, ["@", text(name)]);
if !block.parameters.is_empty() {
// `@default never(state)`: Prettier glues the parameters to `never`.
write!(f, if default_never { "(" } else { " (" });
for (i, parameter) in block.parameters.iter().enumerate() {
if i > 0 {
write!(f, "; ");
}
// As written until expressions are formatted, on one line.
let parameter =
collapse_whitespace_outside_quotes(f.allocator(), parameter.slice(t.source()));
write!(f, text(parameter));
}
write!(f, ")");
}
if block.close.is_none() {
// `@default never;`; a fall-through `@case (a)` has nothing more.
if default_never {
write!(f, ";");
}
return;
}
write!(f, " {");
if let (Some(first), Some(last)) = (item.first_child, item.last_child) {
let glued_before = t[first].glued_before() && !item.has_leading_spaces;
let glued_after = t[last].glued_after() && !item.has_trailing_spaces;
write!(
f,
indent(&format_with(|f| {
if !glued_before {
write!(f, hard_line_break());
}
write_children(t, id, false, f);
}))
);
if !glued_after {
write!(f, hard_line_break());
}
} else if item.next.is_some_and(|next| continues_block(t, id, next)) {
// `@if (a) {⏎} @else {`, as Prettier prints a closing `}` before the next block.
write!(f, hard_line_break());
}
write!(f, "}");
}
/// [`collapse_whitespace`] outside string literals (`'`, `"`, `` ` ``, a `\\` escaping the next character).
fn collapse_whitespace_outside_quotes<'a>(allocator: &'a Allocator, text: &'a str) -> &'a str {
if !text.contains(['\'', '"', '`']) {
return collapse_whitespace(allocator, text);
}
let mut out = StringBuilder::new_in(allocator);
let (mut quote, mut escaped, mut space) = (None, false, false);
for c in text.trim_ascii().chars() {
if quote.is_none() && c.is_ascii_whitespace() {
space = true;
continue;
}
if std::mem::take(&mut space) {
out.push(' ');
}
out.push(c);
match (c, quote) {
_ if std::mem::take(&mut escaped) => {}
('\\', _) => escaped = true,
(c, Some(q)) if c == q => quote = None,
('\'' | '"' | '`', None) => quote = Some(c),
_ => {}
}
}
out.into_str()
}
@@ -0,0 +1,292 @@
//! What separates siblings (Prettier's `printChildren`), and the line break facts it reads from the source.
use oxc_formatter_core::{
Format, GroupId,
builders::{
empty_line, expand_parent, group, hard_line_break, if_group_fits_on_line, soft_line_break,
soft_line_break_or_space, space,
},
write,
};
use oxc_htmlish_parser::ast::{NodeId, NodeKind};
use super::{
HtmlishFormatter, block::between_block, format_with, tag::closing_end_borrowed,
tag::closing_start_borrowed, write_child,
};
use crate::{context::HtmlishFormatContext, tree::Tree};
/// What separates two siblings (Prettier's `printBetweenLine`).
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum Between {
Nothing,
Soft,
Line,
/// A space that never breaks (the compiler layer keeps a same-line gap).
Space,
Hard,
}
impl<'a> Format<'a, HtmlishFormatContext<'a>> for Between {
fn fmt(&self, f: &mut HtmlishFormatter<'_, 'a>) {
match self {
Self::Nothing => {}
Self::Soft => write!(f, soft_line_break()),
Self::Line => write!(f, soft_line_break_or_space()),
Self::Space => write!(f, space()),
Self::Hard => write!(f, hard_line_break()),
}
}
}
fn between(t: &Tree<'_>, prev: NodeId, next: NodeId) -> Between {
if t.is_block(prev) || t.is_block(next) {
return between_block(t, prev, next);
}
let (p, n) = (&t[prev], &t[next]);
if p.text_like && n.text_like {
if p.trailing_sensitive {
if p.has_trailing_spaces {
return if prefer_hard_line_as_leading(t, next) {
Between::Hard
} else {
Between::Line
};
}
return Between::Nothing;
}
return if prefer_hard_line_as_leading(t, next) { Between::Hard } else { Between::Soft };
}
if t[next].borrows.prev_closing_end
&& t.element(prev).is_some()
&& p.only_child().is_some_and(|only| t.is_interpolation(only))
&& p.has_leading_spaces
{
return Between::Nothing;
}
if (t[prev].borrows.next_opening_start
&& (n.prettier_ignore
// 123<a
// ~
// ><b>
|| n.first_child.is_some()
// 123<!--
// ~
// -->
|| n.self_closing
// 123<span
// ~
// attr
|| t.element(next).is_some_and(|el| !el.attributes.is_empty())))
// <img
// src="long"
// ~
// />123
|| (t.element(prev).is_some() && p.self_closing && t[next].borrows.prev_closing_end)
{
return Between::Nothing;
}
// <div>{{ x }}<!-- comment --></div>
// ~
if matches!(t.kind(next), NodeKind::Comment(_)) && n.leading_sensitive && !n.has_leading_spaces
{
return Between::Soft;
}
// Past a hidden element, insensitive whitespace between two non-blocks is free to stay on the line,
// as written (DIVERGENCES.md#script-display-none): `<p>x<meta /></p>`, `<link /> <link />`
if !n.leading_sensitive
&& (t.is_transparent(prev) || t.is_transparent(next))
&& p.display.sides_sensitive()
&& n.display.sides_sensitive()
&& !prefer_hard_line_as_leading(t, next)
{
return if n.has_leading_spaces { Between::Line } else { Between::Soft };
}
if !n.leading_sensitive
|| prefer_hard_line_as_leading(t, next)
// Want to write us a letter? Use our<a
// ><b><a>mailing address</a></b></a
// ~
// >.
|| (t[next].borrows.prev_closing_end
&& p.last_child.is_some_and(|last| {
t[last].borrows.parent_closing_start && closing_start_borrowed(t, last)
}))
{
return Between::Hard;
}
if !n.has_leading_spaces {
return Between::Soft;
}
// The compiler layer: between two elements, whitespace that renders keeps its kind (DIVERGENCES.md#vue-element-gap-kind).
if t.rules().keeps_element_gap_kind && t.element(prev).is_some() && t.element(next).is_some() {
return if has_trailing_line_break(t, prev) { Between::Hard } else { Between::Space };
}
Between::Line
}
pub fn write_children<'a>(
t: &'a Tree<'a>,
id: NodeId,
force_break: bool,
f: &mut HtmlishFormatter<'_, 'a>,
) {
if force_break {
write!(f, expand_parent());
for child in t.children(id) {
if let Some(prev) = t[child].prev {
write_between(between(t, prev, child), force_next_empty_line(t, prev), f);
}
write_child(t, child, f);
}
return;
}
// Each pair's separator and blank-line fact, computed once and carried to the next child.
let mut carried = (Between::Nothing, false);
let mut prev_group_id: Option<GroupId> = None;
for child in t.children(id) {
let node = &t[child];
let (next_between, next_blank) = match node.next {
Some(next) => (between(t, child, next), force_next_empty_line(t, child)),
None => (Between::Nothing, false),
};
let (prev_between, prev_blank) =
std::mem::replace(&mut carried, (next_between, next_blank));
if node.text_like {
if node.prev.is_some_and(|prev| t[prev].text_like) {
write_between(prev_between, prev_blank, f);
}
write_child(t, child, f);
continue;
}
// A line break on a side is printed outside the child's group; a softer separator inside it.
let breaks =
|between, blank| between != Between::Nothing && (blank || between == Between::Hard);
let prev_breaks = breaks(prev_between, prev_blank);
let leading = match node.prev {
// After a `Space` (the previous group's trailing), no break before the next child.
Some(prev)
if !matches!(prev_between, Between::Nothing | Between::Space) && !prev_breaks =>
{
if t[prev].text_like {
Some(Leading::Between(prev_between))
} else {
prev_group_id.map(Leading::IfPrevFlat)
}
}
_ => None,
};
let next_breaks = breaks(next_between, next_blank);
let trailing = if next_breaks { Between::Nothing } else { next_between };
if prev_breaks {
write_between(prev_between, prev_blank, f);
}
let group_id = f.state().group_id("htmlChild");
write!(
f,
group(&format_with(|f| {
match leading {
Some(Leading::Between(between)) => write!(f, between),
Some(Leading::IfPrevFlat(prev_id)) => write!(
f,
if_group_fits_on_line(&soft_line_break()).with_group_id(Some(prev_id))
),
None => {}
}
write!(
f,
group(&format_with(|f| {
write_child(t, child, f);
write!(f, trailing);
}))
.with_group_id(Some(group_id))
);
}))
);
prev_group_id = Some(group_id);
if next_breaks && node.next.is_some_and(|next| t[next].text_like) {
write_between(next_between, next_blank, f);
}
}
}
/// A separator between siblings; a kept blank line is `empty_line`
/// (Prettier's two `hardline`s, which would collapse into one here).
fn write_between(between: Between, blank: bool, f: &mut HtmlishFormatter<'_, '_>) {
match between {
Between::Nothing => {}
_ if blank => write!(f, empty_line()),
_ => write!(f, between),
}
}
#[derive(Clone, Copy)]
enum Leading {
Between(Between),
/// A soft line break printed only while the previous sibling's group stays flat.
IfPrevFlat(GroupId),
}
fn force_next_empty_line(t: &Tree<'_>, id: NodeId) -> bool {
t[id].next.is_some_and(|next| t.line_breaks(t.source_end(id), t[next].span.start) >= 2)
}
fn prefer_hard_line_as_leading(t: &Tree<'_>, id: NodeId) -> bool {
prefer_hard_line_as_surrounding(t, id)
|| t[id].prev.is_some_and(|prev| prefer_hard_line_as_trailing(t, prev))
|| has_surrounding_line_break(t, id)
}
fn prefer_hard_line_as_trailing(t: &Tree<'_>, id: NodeId) -> bool {
prefer_hard_line_as_surrounding(t, id) || t.tag(id) == "br" || has_surrounding_line_break(t, id)
}
fn prefer_hard_line_as_surrounding(t: &Tree<'_>, id: NodeId) -> bool {
match t.kind(id) {
NodeKind::ConditionalComment(_) | NodeKind::Comment(_) => true,
NodeKind::Element(_) => matches!(t.tag(id), "script" | "select"),
_ => false,
}
}
fn has_surrounding_line_break(t: &Tree<'_>, id: NodeId) -> bool {
has_leading_line_break(t, id) && has_trailing_line_break(t, id)
}
pub fn has_leading_line_break(t: &Tree<'_>, id: NodeId) -> bool {
let item = &t[id];
if !item.has_leading_spaces {
return false;
}
if let Some(prev) = item.prev {
return t.line_breaks(t.source_end(prev), item.span.start) > 0;
}
let parent = t.parent(id);
parent == NodeId::DOCUMENT || t.line_breaks(t.content_start(parent), item.span.start) > 0
}
pub fn has_trailing_line_break(t: &Tree<'_>, id: NodeId) -> bool {
let item = &t[id];
if !item.has_trailing_spaces {
return false;
}
if let Some(next) = item.next {
return t.line_breaks(t.source_end(id), t[next].span.start) > 0;
}
let parent = t.parent(id);
parent == NodeId::DOCUMENT
// A line break before a borrowed `>` cannot be printed (`b </p\n>`, `b</p\n>`),
// so it is not read either: the next pass sees what this one did (DIVERGENCES.md#line-break-before-borrowed-end).
|| (!closing_end_borrowed(t, parent)
&& t.end_tag_start(parent).is_some_and(|end| t.line_breaks(t.source_end(id), end) > 0))
}
@@ -0,0 +1,222 @@
//! Elements (and conditional comments): the tags around the children, or the content as written.
use oxc_formatter_core::{
Format,
builders::{
dedent_to_root, expand_parent, group, if_group_breaks, indent, indent_if_group_breaks,
soft_line_break, soft_line_break_or_space, space, text,
},
write,
};
use oxc_htmlish_parser::ast::{NodeId, NodeKind};
use super::{
HtmlishFormatter,
children::{has_leading_line_break, has_trailing_line_break, write_children},
format_with,
tag::{
closing_end_borrowed, closing_end_marker, closing_start_borrowed, closing_start_marker,
opening_end_borrowed, opening_end_marker, write_closing_tag, write_opening_tag,
},
};
use crate::{
HtmlishFormatOptions,
context::HtmlishFormatContext,
tree::{Display, Tree},
};
pub fn write_element<'a>(t: &'a Tree<'a>, id: NodeId, f: &mut HtmlishFormatter<'_, 'a>) {
let item = &t[id];
if should_preserve_content(t, id) {
// The opening tag prints the borrowed prefix, once (DIVERGENCES.md#pre-after-borrowed-closing-end).
write!(f, group(&format_with(|f| write_opening_tag(t, id, f))));
write!(f, text(node_content(t, id)));
write_closing_tag(t, id, f);
return;
}
let attr_group_id = f.state().group_id("htmlAttributes");
let print_tag = |f: &mut HtmlishFormatter<'_, 'a>,
body: &dyn Format<'a, HtmlishFormatContext<'a>>| {
write!(
f,
group(&format_with(|f| {
write!(
f,
group(&format_with(|f| write_opening_tag(t, id, f)))
.with_group_id(Some(attr_group_id))
);
write!(f, body);
write_closing_tag(t, id, f);
}))
);
};
let (Some(first), Some(last)) = (item.first_child, item.last_child) else {
let dangling = item.has_dangling_spaces && item.dangling_sensitive;
print_tag(
f,
&format_with(|f| {
if dangling {
write!(f, soft_line_break_or_space());
}
}),
);
return;
};
let hug = item.only_child().is_some()
&& t.is_interpolation(first)
&& t[first].leading_sensitive
&& !t[first].has_leading_spaces
&& t[last].trailing_sensitive
&& !t[last].has_trailing_spaces;
let line_before = format_with(|f| {
if hug {
write!(f, if_group_breaks(&soft_line_break()).with_group_id(Some(attr_group_id)));
} else if t[first].has_leading_spaces && t[first].leading_sensitive {
write!(f, soft_line_break_or_space());
} else if t.is_text(first) && item.pre_like {
write!(f, dedent_to_root(&soft_line_break()));
} else {
write!(f, soft_line_break());
}
});
let line_after = format_with(|f| {
if closing_end_borrowed(t, id) {
if item.script_like {
// The body renders nothing, so its end tag takes its own line (DIVERGENCES.md#script-display-none).
write!(f, soft_line_break());
} else if t[last].has_trailing_spaces && t[last].trailing_sensitive {
write!(f, space());
}
} else if item.pre_like && t[last].borrows.parent_closing_start {
// `<pre>` whose last text prints `</pre`: a line break would be content.
} else if hug {
write!(f, if_group_breaks(&soft_line_break()).with_group_id(Some(attr_group_id)));
} else if t[last].has_trailing_spaces && t[last].trailing_sensitive {
write!(f, soft_line_break_or_space());
} else if t[last].ends_in_pre {
// `<div><pre />⏎<b>x</b></div>`: the unclosed `pre` ends here, so a line break would be its content
// (DIVERGENCES.md#pre-content-without-end-tag).
} else if !ends_at_indentation(t, id, last, *f.options()) {
write!(f, soft_line_break());
}
});
let force_break = force_break_children(t, id);
let children = format_with(|f| {
write!(f, line_before);
write_children(t, id, force_break, f);
});
print_tag(
f,
&format_with(|f| {
if force_break || force_break_content(t, id) {
write!(f, expand_parent());
}
if hug {
write!(f, indent_if_group_breaks(&children, attr_group_id));
} else {
write!(f, indent(&children));
}
write!(f, line_after);
}),
);
}
/// The last child is a comment or `pre` text ending in a line break plus exactly the element's indentation,
/// counted in the indent unit (DIVERGENCES.md#end-tag-indentation-as-printed): the end tag already sits where it would be printed.
fn ends_at_indentation(
t: &Tree<'_>,
id: NodeId,
last: NodeId,
options: HtmlishFormatOptions,
) -> bool {
let value = match t.kind(last) {
NodeKind::Comment(comment) => comment.content.slice(t.source()),
NodeKind::Text(_) | NodeKind::Cdata if t[id].pre_like => t[last].span.slice(t.source()),
_ => return false,
};
let Some((_, tail)) = value.rsplit_once('\n') else { return false };
let (unit, per_level) = if options.indent_style.is_tab() {
(b'\t', 1)
} else {
(b' ', usize::from(options.indent_width.value()))
};
if !tail.bytes().all(|b| b == unit) {
return false;
}
// The element's own indentation: one level per ancestor below the document.
let mut depth = 0;
let mut ancestor = t.parent(id);
while ancestor != NodeId::DOCUMENT {
depth += 1;
ancestor = t.parent(ancestor);
}
tail.len() == per_level * depth
}
/// Children Prettier prints as written between the tags.
fn should_preserve_content(t: &Tree<'_>, id: NodeId) -> bool {
let item = &t[id];
match t.kind(id) {
NodeKind::ConditionalComment(comment) => {
!comment.parsed
// `<!--[if lt IE 9]><html><![endif]-->`: an unclosed element inside.
|| item.last_child.is_some_and(|last| t.is_unclosed(last))
}
// `<pre>` with markup inside.
_ => {
item.pre_like
&& t.children(id).any(|child| !(t.is_text(child) || t.is_interpolation(child)))
}
}
}
/// The source between the tags, adjusted for markers the edges borrow (Prettier's `getNodeContent`).
fn node_content<'a>(t: &'a Tree<'a>, id: NodeId) -> &'a str {
let item = &t[id];
let mut start = t.content_start(id) as usize;
// An unclosed element's content runs to where the parser closed it.
let mut end = t.end_tag_start(id).unwrap_or(item.span.end) as usize;
if opening_end_borrowed(t, id) {
start -= opening_end_marker(t, id).len();
}
if closing_start_borrowed(t, id) {
end += closing_start_marker(t, id).len();
} else if t[id].borrows.last_child_closing_end {
end -= closing_end_marker(t, item.last_child.expect("borrowed by the last child")).len();
}
&t.source()[start..end]
}
fn force_break_children(t: &Tree<'_>, id: NodeId) -> bool {
let item = &t[id];
t.element(id).is_some()
&& item.first_child.is_some()
&& (matches!(t.tag(id), "html" | "head" | "ul" | "ol" | "select")
|| item.display == Display::Table)
}
/// Besides `force_break_children`: the content of `body`, `script`, `style`, nested markup, or a lone child on its own lines.
fn force_break_content(t: &Tree<'_>, id: NodeId) -> bool {
let item = &t[id];
if t.element(id).is_some()
&& item.first_child.is_some()
&& (item.script_like
|| t.tag(id) == "body"
|| t.children(id)
.any(|child| t.children(child).any(|grandchild| !t.is_text(grandchild))))
{
return true;
}
item.only_child().is_some_and(|only| {
!t.is_text(only)
&& has_leading_line_break(t, only)
&& (!t[only].trailing_sensitive || has_trailing_line_break(t, only))
})
}
@@ -0,0 +1,136 @@
//! The printer: dispatch by node kind; elements, text and blocks get their own module,
//! besides the separators between siblings (`children`), the tag markers (`tag`) and attributes (`attribute`).
use oxc_formatter_core::{
Format, Formatter,
builders::{FormatWith, group, soft_space_or_block_indent, space, text, token},
write,
};
use oxc_htmlish_parser::ast::{NodeId, NodeKind};
use crate::{context::HtmlishFormatContext, tree::Tree};
mod attribute;
mod block;
mod children;
mod element;
mod tag;
mod text;
use block::write_block;
use children::write_children;
use element::write_element;
use tag::{
after_html_word, closing_end_borrowed, closing_end_marker, closing_tag_suffix,
opening_start_borrowed, opening_start_marker, opening_tag_prefix, write_closing_tag_end,
write_opening_tag_start,
};
use text::{write_dedented_lines, write_text};
pub type HtmlishFormatter<'buf, 'a> = Formatter<'buf, 'a, HtmlishFormatContext<'a>>;
/// `Format` impl for `&'static str` specialized to `HtmlishFormatContext`.
impl<'a> Format<'a, HtmlishFormatContext<'a>> for &'static str {
#[inline]
fn fmt(&self, f: &mut HtmlishFormatter<'_, 'a>) {
write!(f, token(self));
}
}
/// Wraps a re-entrant closure in a [`FormatWith`], its context pinned to [`HtmlishFormatContext`].
#[inline]
pub const fn format_with<'a, T>(formatter: T) -> FormatWith<T>
where
T: Fn(&mut HtmlishFormatter<'_, 'a>),
{
FormatWith::new(formatter)
}
pub fn write_root(f: &mut HtmlishFormatter<'_, '_>) {
let t = f.context().tree();
write!(f, group(&format_with(|f| write_children(t, NodeId::DOCUMENT, false, f))));
}
fn write_child<'a>(t: &'a Tree<'a>, id: NodeId, f: &mut HtmlishFormatter<'_, 'a>) {
if !t[id].prettier_ignore {
write_node(t, id, f);
return;
}
let mut start = t[id].span.start as usize;
if opening_start_borrowed(t, id) {
start += opening_start_marker(t, id).len();
}
let mut end = ignored_end(t, id) as usize;
// Whoever borrows the closing `>` prints it, the parent included (DIVERGENCES.md#ignored-closing-end-once).
if closing_end_borrowed(t, id) {
end -= closing_end_marker(t, id).len();
}
let raw = t.source()[start..end].trim_ascii_end();
write!(f, [opening_tag_prefix(t, id), text(raw), closing_tag_suffix(t, id)]);
}
/// Prettier's `getEndLocation`: an unclosed element extends to its last child.
fn ignored_end(t: &Tree<'_>, id: NodeId) -> u32 {
let end = t.source_end(id);
match t.kind(id) {
NodeKind::Element(el) if el.end_tag.is_none() => {
t[id].last_child.map_or(end, |last| end.max(ignored_end(t, last)))
}
_ => end,
}
}
fn write_node<'a>(t: &'a Tree<'a>, id: NodeId, f: &mut HtmlishFormatter<'_, 'a>) {
let item = &t[id];
match t.kind(id) {
NodeKind::Element(_) | NodeKind::ConditionalComment(_) => write_element(t, id, f),
NodeKind::ConditionalStart(_) | NodeKind::ConditionalEnd => {
write_opening_tag_start(t, id, f);
write_closing_tag_end(t, id, f);
}
NodeKind::Text(_) | NodeKind::Cdata => write_text(t, id, f),
// `{{ }}`; the expression keeps its lines until it is formatted.
NodeKind::Interpolation(interpolation) => {
write_opening_tag_start(t, id, f);
let expression = interpolation.expression.slice(t.source());
if !expression.trim_ascii().is_empty() {
write!(
f,
soft_space_or_block_indent(&format_with(|f| write_dedented_lines(
expression, f
)))
);
}
write_closing_tag_end(t, id, f);
}
NodeKind::DocType(doctype) => {
let value = t.collapsed(doctype.value);
write!(
f,
group(&format_with(|f| {
write_opening_tag_start(t, id, f);
write!(f, space());
// Its leading `html` lowercased (`<!DOCTYPE HTML PUBLIC …>`)
if let Some(rest) = after_html_word(value) {
write!(f, [text("html"), text(rest)]);
} else {
write!(f, text(value));
}
}))
);
write_closing_tag_end(t, id, f);
}
// Comments are printed as written, a `</ div>` too (DIVERGENCES.md#end-tag-with-space-is-comment),
// and an omitted element's end tag (`</body>`).
NodeKind::Comment(_) | NodeKind::EndTag(_) => write!(
f,
[
opening_tag_prefix(t, id),
text(item.span.slice(t.source())),
closing_tag_suffix(t, id)
]
),
NodeKind::Block(block) => write_block(t, id, block, f),
NodeKind::Document => unreachable!("the root is written by `write_root`"),
}
}
@@ -0,0 +1,211 @@
//! Tags and their markers, and the "borrow" rules that move a marker to a neighbor
//! so that a line break can sit inside a tag where whitespace would be significant:
//!
//! ```html
//! <span>text</span
//! ><b>
//! ```
//!
//! The borrower side is [`Borrows`](crate::tree::Borrows), computed with the tree;
//! the owner side here (`*_borrowed`) omits the marker.
//!
//! The rules end up shaped like Prettier's `print/tag.js`, not ported from it:
//! which neighbor prints a marker is the output itself, and matching that output leaves no other structure.
use oxc_formatter_core::{Format, builders::text, write};
use super::{HtmlishFormatter, attribute::write_attributes};
use oxc_htmlish_parser::{
Span,
ast::{NodeId, NodeKind},
};
use crate::{context::HtmlishFormatContext, tree::Tree};
/// A tag marker, written as up to four pieces (`<` + name, `<!--[if ` + condition + `]><!--><` + name, ...).
#[derive(Clone, Copy, Default)]
pub struct Marker<'a>([&'a str; 4]);
impl<'a> Marker<'a> {
const fn one(piece: &'a str) -> Self {
Self([piece, "", "", ""])
}
const fn two(piece: &'a str, name: &'a str) -> Self {
Self([piece, name, "", ""])
}
pub fn len(self) -> usize {
self.0.iter().map(|piece| piece.len()).sum()
}
}
impl<'a> Format<'a, HtmlishFormatContext<'a>> for Marker<'a> {
fn fmt(&self, f: &mut HtmlishFormatter<'_, 'a>) {
for piece in self.0 {
if !piece.is_empty() {
write!(f, text(piece));
}
}
}
}
// ---
// Owner side
/// The node's closing `>` is printed by its next sibling or its parent.
pub fn closing_end_borrowed(t: &Tree<'_>, id: NodeId) -> bool {
match t[id].next {
Some(next) => t[next].borrows.prev_closing_end,
None => t[t.parent(id)].borrows.last_child_closing_end,
}
}
/// The node's `<name` is printed by its previous sibling.
pub fn opening_start_borrowed(t: &Tree<'_>, id: NodeId) -> bool {
t[id].prev.is_some_and(|prev| t[prev].borrows.next_opening_start)
}
/// The node's opening `>` is printed by its first child.
pub fn opening_end_borrowed(t: &Tree<'_>, id: NodeId) -> bool {
t[id].first_child.is_some_and(|first| t[first].borrows.parent_opening_end)
}
/// The node's `</name` is printed by its last child.
pub fn closing_start_borrowed(t: &Tree<'_>, id: NodeId) -> bool {
t[id].last_child.is_some_and(|last| t[last].borrows.parent_closing_start)
}
// ---
// Markers
pub fn opening_start_marker<'a>(t: &'a Tree<'a>, id: NodeId) -> Marker<'a> {
match t.kind(id) {
NodeKind::ConditionalComment(comment) => {
Marker::two("<!--[if ", t.collapsed(comment.condition))
}
NodeKind::ConditionalStart(start) => Marker::two("<!--[if ", t.collapsed(start.condition)),
NodeKind::ConditionalEnd => Marker::one("<!--<!"),
NodeKind::Interpolation(_) => Marker::one("{{"),
// `<!doctype html>` but where an XML parser may read it, otherwise as written
// (DIVERGENCES.md#doctype-case, DIVERGENCES.md#doctype-html-in-one-pass)
NodeKind::DocType(doctype) => {
if t.rules().lowercase_doctype
&& after_html_word(doctype.value.slice(t.source()).trim_ascii()) == Some("")
{
Marker::one("<!doctype")
} else {
let start = t[id].span.start;
Marker::one(Span::new(start, start + 9).slice(t.source()))
}
}
NodeKind::Element(_) => match t[id].condition.map(|start| t.kind(start)) {
Some(NodeKind::ConditionalStart(start)) => {
Marker(["<!--[if ", t.collapsed(start.condition), "]><!--><", t.name(id)])
}
_ => Marker::two("<", t.name(id)),
},
_ => Marker::two("<", t.name(id)),
}
}
pub fn opening_end_marker<'a>(t: &'a Tree<'a>, id: NodeId) -> Marker<'a> {
match t.kind(id) {
NodeKind::ConditionalComment(_) => Marker::one("]>"),
NodeKind::Element(_) if t[id].condition.is_some() => Marker::one("><!--<![endif]-->"),
NodeKind::Element(el) if el.self_closing => Marker::one("/>"),
_ => Marker::one(">"),
}
}
pub fn closing_start_marker<'a>(t: &'a Tree<'a>, id: NodeId) -> Marker<'a> {
match t.kind(id) {
NodeKind::ConditionalComment(_) => Marker::one("<!"),
// The end tag as written, its name lowercase with the start tag's (DIVERGENCES.md#tag-name-case);
// an omitted one is never added (DIVERGENCES.md#omitted-end-tag).
// What HTML ignores after the name (`</div foo>`) is kept too, and so is htm's `<//>`.
NodeKind::Element(el) => el.end_tag.map_or(Marker::default(), |tag| {
let name = t.printed_name(id, el, tag.name.slice(t.source()));
let ignored = tag
.ignored
.map_or("", |ignored| Span::new(tag.name.end, ignored.end).slice(t.source()));
Marker(["</", name, ignored, ""])
}),
_ => Marker::two("</", t.name(id)),
}
}
pub fn closing_end_marker<'a>(t: &'a Tree<'a>, id: NodeId) -> Marker<'a> {
let item = &t[id];
match t.kind(id) {
NodeKind::ConditionalComment(_) | NodeKind::ConditionalEnd => Marker::one("[endif]-->"),
NodeKind::ConditionalStart(_) => Marker::one("]><!-->"),
NodeKind::Interpolation(_) => Marker::one("}}"),
// A void element prints `/>` too
NodeKind::Element(_) if item.self_closing => Marker::one("/>"),
NodeKind::Element(el) if el.end_tag.is_none() => Marker::default(),
_ => Marker::one(">"),
}
}
/// What follows a leading `html` word in a doctype's value (`HTML PUBLIC …`), which HTML reads case-insensitively.
pub fn after_html_word(value: &str) -> Option<&str> {
let rest = value.get(4..).filter(|_| value[..4].eq_ignore_ascii_case("html"))?;
(!rest.starts_with(|c: char| c.is_ascii_alphanumeric() || c == '_')).then_some(rest)
}
// ---
// Prefixes and suffixes: what a node prints for its neighbors
pub fn opening_tag_prefix<'a>(t: &'a Tree<'a>, id: NodeId) -> Marker<'a> {
if t[id].borrows.parent_opening_end {
opening_end_marker(t, t.parent(id))
} else if t[id].borrows.prev_closing_end {
closing_end_marker(t, t[id].prev.expect("borrowed from the previous sibling"))
} else {
Marker::default()
}
}
pub fn closing_tag_suffix<'a>(t: &'a Tree<'a>, id: NodeId) -> Marker<'a> {
if t[id].borrows.parent_closing_start {
closing_start_marker(t, t.parent(id))
} else if t[id].borrows.next_opening_start {
opening_start_marker(t, t[id].next.expect("borrowed from the next sibling"))
} else {
Marker::default()
}
}
// ---
// Tags
pub fn write_opening_tag_start<'a>(t: &'a Tree<'a>, id: NodeId, f: &mut HtmlishFormatter<'_, 'a>) {
if !opening_start_borrowed(t, id) {
write!(f, [opening_tag_prefix(t, id), opening_start_marker(t, id)]);
}
}
pub fn write_opening_tag<'a>(t: &'a Tree<'a>, id: NodeId, f: &mut HtmlishFormatter<'_, 'a>) {
write_opening_tag_start(t, id, f);
write_attributes(t, id, f);
if !t[id].self_closing && !opening_end_borrowed(t, id) {
write!(f, opening_end_marker(t, id));
}
}
pub fn write_closing_tag<'a>(t: &'a Tree<'a>, id: NodeId, f: &mut HtmlishFormatter<'_, 'a>) {
if !t[id].self_closing && !closing_start_borrowed(t, id) {
if t[id].borrows.last_child_closing_end {
write!(f, closing_end_marker(t, t[id].last_child.expect("borrowed by the last child")));
}
write!(f, closing_start_marker(t, id));
}
write_closing_tag_end(t, id, f);
}
pub fn write_closing_tag_end<'a>(t: &'a Tree<'a>, id: NodeId, f: &mut HtmlishFormatter<'_, 'a>) {
if !closing_end_borrowed(t, id) {
write!(f, [closing_end_marker(t, id), closing_tag_suffix(t, id)]);
}
}
@@ -0,0 +1,117 @@
//! Text: filled words, or lines as written where whitespace is content.
use oxc_formatter_core::{
builders::{exact_line_breaks, soft_line_break_or_space, text},
write,
};
use oxc_htmlish_parser::ast::NodeId;
use super::{
HtmlishFormatter, format_with,
tag::{closing_tag_suffix, opening_tag_prefix},
};
use crate::tree::Tree;
pub fn write_text<'a>(t: &'a Tree<'a>, id: NodeId, f: &mut HtmlishFormatter<'_, 'a>) {
let parent = &t[t.parent(id)];
let value = t.text(id);
let prefix = opening_tag_prefix(t, id);
let suffix = closing_tag_suffix(t, id);
if parent.pre_like {
write!(f, [prefix, text(value), suffix]);
return;
}
if parent.script_like {
write!(f, prefix);
write_dedented_lines(value, f);
write!(f, suffix);
return;
}
let mut words = words(value, t[id].has_cdata).peekable();
let mut fill = f.fill();
let mut first = true;
while let Some(word) = words.next() {
let last = words.peek().is_none();
fill.entry(
&soft_line_break_or_space(),
&format_with(|f| {
if first {
write!(f, prefix);
}
write!(f, text(word));
if last {
write!(f, suffix);
}
}),
);
first = false;
}
fill.finish();
}
/// Writes `value`'s lines, trimmed and dedented (Prettier's `htmlTrimPreserveIndentation` + `dedentString`),
/// joined by line breaks: every blank line is kept, and prints without indentation.
pub fn write_dedented_lines<'a>(value: &'a str, f: &mut HtmlishFormatter<'_, 'a>) {
let mut breaks = 0;
for line in dedent(trim_preserving_indentation(value)) {
if line.is_empty() {
breaks += 1;
continue;
}
if breaks > 0 {
write!(f, exact_line_breaks(breaks));
}
write!(f, text(line));
breaks = 1;
}
}
/// Whitespace-separated words; with `cdata`, a CDATA section is part of one word, whatever it holds
/// (DIVERGENCES.md#cdata-as-written).
fn words(value: &str, cdata: bool) -> impl Iterator<Item = &str> {
let bytes = value.as_bytes();
let mut pos = 0;
std::iter::from_fn(move || {
pos += bytes[pos..].iter().take_while(|b| b.is_ascii_whitespace()).count();
if pos == bytes.len() {
return None;
}
let start = pos;
while pos < bytes.len() && !bytes[pos].is_ascii_whitespace() {
pos = if cdata && bytes[pos..].starts_with(b"<![CDATA[") {
memchr::memmem::find(&bytes[pos..], b"]]>").map_or(bytes.len(), |end| pos + end + 3)
} else {
pos + 1
};
}
Some(&value[start..pos])
})
}
/// Prettier's `htmlTrimPreserveIndentation`: trailing whitespace and leading blank lines go.
fn trim_preserving_indentation(value: &str) -> &str {
let value = value.trim_ascii_end();
let mut start = 0;
while let Some(newline) = value[start..].find('\n') {
if value[start..start + newline].trim_ascii().is_empty() {
start += newline + 1;
} else {
break;
}
}
&value[start..]
}
/// Prettier's `dedentString`: the lines with their common indentation removed.
fn dedent(value: &str) -> impl Iterator<Item = &str> {
let indentation = |line: &str| line.len() - line.trim_ascii_start().len();
let min = value
.split('\n')
.filter(|line| !line.trim_ascii_start().is_empty())
.map(indentation)
.min()
.unwrap_or(0);
value.split('\n').map(move |line| line.get(min..).unwrap_or(""))
}
+37
View File
@@ -0,0 +1,37 @@
use oxc_htmlish_parser::Dialect;
use crate::HtmlishVariant;
/// What the variant changes in the shared rules, resolved once per document.
#[derive(Clone, Copy)]
pub struct DialectRules {
/// Known attribute names match case-insensitively (Prettier's HTML parser lowercases them;
/// a Vue file read as HTML too, Angular never).
pub names_ignore_case: bool,
/// Vue's compiler drops whitespace with a line break between two elements and keeps a same-line one as a space.
pub keeps_element_gap_kind: bool,
/// Angular: control flow blocks can occur (checked before looking at a node's kind).
pub has_blocks: bool,
/// `<!DOCTYPE html>` prints `<!doctype html>`: everywhere but XHTML, where an XML parser needs `<!DOCTYPE`
/// (DIVERGENCES.md#doctype-case).
pub lowercase_doctype: bool,
/// A known HTML element's name and its known attributes print lowercase, as Prettier's HTML parser reads them:
/// in a `.html` file, whose reader is an HTML parser; as written where embedded, in XHTML, Vue and Angular
/// (DIVERGENCES.md#tag-name-case).
pub lowercase_known_names: bool,
}
impl DialectRules {
/// `sfc`: a Vue file read as a single-file component, not as HTML.
/// `standalone`: a file of its own, not a part embedded in another language.
pub fn new(variant: HtmlishVariant, sfc: bool, standalone: bool) -> Self {
let dialect = variant.to_dialect();
Self {
names_ignore_case: dialect == Dialect::Html || (dialect == Dialect::Vue && !sfc),
keeps_element_gap_kind: dialect == Dialect::Vue,
has_blocks: dialect == Dialect::Angular,
lowercase_doctype: variant != HtmlishVariant::Xhtml,
lowercase_known_names: standalone && variant == HtmlishVariant::Html,
}
}
}
@@ -0,0 +1,684 @@
use oxc_allocator::{Allocator, ArenaVec, StringBuilder};
use oxc_formatter_core::spec::is_suppression_marker;
use oxc_htmlish_parser::{
Span,
ast::{Element, Namespace, Node, NodeId, NodeKind, Root, Whitespace},
};
use super::{
Borrows, Display, Facts, Tree,
display::{display_comment, ua_display},
};
use crate::{HtmlishVariant, options::WhitespaceSensitivity, rules::DialectRules};
impl<'a> Tree<'a> {
pub fn build(
allocator: &'a Allocator,
root: &'a Root<'a>,
source: &'a str,
sensitivity: WhitespaceSensitivity,
variant: HtmlishVariant,
standalone: bool,
) -> Self {
let facts = ArenaVec::from_iter_in(
root.nodes.iter().map(|node| Facts { span: node.span, ..Facts::default() }),
&allocator,
);
let mut builder = Builder {
tree: Self {
allocator,
rules: DialectRules::new(variant, root.sfc, standalone),
root,
source,
facts,
merged_texts: ArenaVec::new_in(&allocator),
},
sensitivity,
};
builder.facts(NodeId::DOCUMENT).display = builder.display(NodeId::DOCUMENT);
builder.children(NodeId::DOCUMENT);
builder.facts(NodeId::DOCUMENT).borrows = builder.borrows(NodeId::DOCUMENT);
// Merges push in post-order (children first)
builder.tree.merged_texts.sort_unstable_by_key(|(text, _)| *text);
builder.tree
}
}
struct Builder<'a> {
tree: Tree<'a>,
sensitivity: WhitespaceSensitivity,
}
impl Builder<'_> {
fn facts(&mut self, id: NodeId) -> &mut Facts {
&mut self.tree.facts[id.index()]
}
fn children(&mut self, parent: NodeId) {
let root = self.tree.root;
let whitespace_sensitive = self.tree[self.flow_parent(parent)].whitespace_sensitive();
// The end comment a conditional start tag absorbed
let absorbed_end = self.tree[parent].condition.and(root[parent].first_child);
let nodes = || {
std::iter::successors(root[parent].first_child, move |&id| next_child(root, parent, id))
.filter(move |&id| Some(id) != absorbed_end)
.map(|id| (id, &root[id]))
};
// Prettier drops a lone whitespace-only text even where whitespace is content;
// a whitespace-sensitive parent keeps it (DIVERGENCES.md#whitespace-only-pre), except `script` / `style`.
let mut first_two = nodes();
let (first, second) = (first_two.next(), first_two.next());
let only_whitespace = (!whitespace_sensitive || self.tree[parent].script_like)
&& second.is_none()
&& first.is_some_and(|(_, node)| is_whitespace_text(node));
if first.is_none() || only_whitespace {
let facts = self.facts(parent);
facts.has_dangling_spaces = only_whitespace;
facts.dangling_sensitive = facts.display.edges_sensitive() && !facts.script_like;
return;
}
let mut prev: Option<NodeId> = None;
let mut pending_space = false;
let mut any_cdata = false;
let mut any_hidden = false;
for (id, node) in nodes() {
if let Some(element) = self.absorbing_element(id) {
let facts = self.facts(element);
facts.condition = Some(id);
facts.span.start = node.span.start;
continue;
}
let (leading, trailing, whitespace_only) = match &node.kind {
NodeKind::Text(text) if !whitespace_sensitive => (
text.leading != Whitespace::None,
text.trailing != Whitespace::None,
text.is_whitespace_only(),
),
// `<pre />⏎`: the line break HTML drops after the start tag is whitespace after a tag that reads self-closing
NodeKind::Element(el)
if !whitespace_sensitive && el.leading_lf && closes_at_start_tag(node) =>
{
(false, true, false)
}
_ => (false, false, false),
};
if leading && let Some(prev) = prev {
self.facts(prev).has_trailing_spaces = true;
}
if whitespace_only {
pending_space = true;
continue;
}
self.node(id, whitespace_sensitive);
if matches!(node.kind, NodeKind::Cdata) {
any_cdata = true;
self.facts(id).has_cdata = true;
}
let facts = self.facts(id);
facts.parent = Some(parent);
facts.prev = prev;
facts.has_leading_spaces = leading || pending_space;
facts.has_trailing_spaces = trailing;
match prev {
Some(prev) => {
if let NodeKind::Comment(comment) = &root[prev].kind
&& !comment.bogus
{
let content = comment.content.slice(self.tree.source);
let facts = self.facts(id);
facts.prettier_ignore = is_suppression_marker(content);
if let Some(display) = display_comment(content) {
facts.display = display;
}
}
self.facts(prev).next = Some(id);
}
None => self.facts(parent).first_child = Some(id),
}
any_hidden |=
self.tree[id].display == Display::None && matches!(node.kind, NodeKind::Element(_));
if self.tree.rules.lowercase_known_names {
self.mark_name_case(id, parent);
}
prev = Some(id);
pending_space = trailing;
}
self.facts(parent).last_child = prev;
// Inside a hidden parent (`head`) no whitespace renders: nothing to look past
let past_hidden = any_hidden && self.tree[parent].display != Display::None;
// Runs meet the nodes that render around them, past hidden ones (Angular's blocks look past them themselves):
// the one before is the last seen, the one ahead is looked up again on reaching it, so each child is read once
let look_past = past_hidden && !self.tree.rules.has_blocks;
let mut before: Option<NodeId> = None;
let mut ahead =
if look_past { self.flow_past(self.tree[parent].first_child, true) } else { None };
// A side is sensitive when both nodes facing each other say so
let mut prev_trailing = false;
let mut next = self.tree[parent].first_child;
while let Some(id) = next {
let (prev, after) = (self.tree[id].prev, self.tree[id].next);
let (leading, trailing) = if look_past {
let own = (ahead == Some(id)).then_some(id);
if own.is_some() {
ahead = self.flow_past(after, true);
}
let sides = (
self.side_sensitive_through(id, parent, own.or(ahead), before, true),
self.side_sensitive_through(id, parent, own.or(before), ahead, false),
);
before = own.or(before);
sides
} else {
(
self.side_sensitive(id, parent, prev, true),
self.side_sensitive(id, parent, after, false),
)
};
if let Some(prev) = prev {
self.facts(prev).trailing_sensitive &= leading;
}
let facts = self.facts(id);
facts.leading_sensitive = leading && (prev.is_none() || prev_trailing);
facts.trailing_sensitive = trailing;
prev_trailing = trailing;
next = after;
}
if past_hidden {
self.spread_spaces_past_hidden(parent);
}
let mut next = self.tree[parent].first_child;
while let Some(id) = next {
next = self.tree[id].next;
if printed_first_child(&root[id]).is_some() {
self.children(id);
}
let facts = &self.tree[id];
let ends_in_text_like = facts.last_child.map_or_else(
|| facts.text_like || self.tree.is_interpolation(id),
|last| self.tree[last].ends_in_text_like,
);
let ends_in_pre = self.tree.is_unclosed(id)
&& (facts.pre_like
|| facts.last_child.is_some_and(|last| self.tree[last].ends_in_pre));
let facts = self.facts(id);
facts.ends_in_text_like = ends_in_text_like;
facts.ends_in_pre = ends_in_pre;
}
if any_cdata {
self.merge_cdata(parent);
}
self.merge_simple_elements(parent);
let mut next = self.tree[parent].first_child;
while let Some(id) = next {
self.facts(id).borrows = self.borrows(id);
next = self.tree[id].next;
}
}
/// Needs the node's final siblings and its children's facts.
fn borrows(&self, id: NodeId) -> Borrows {
let t = &self.tree;
let item = &t[id];
// A block's markers are no tags: nothing borrows them, and a block borrows nothing
if t.is_block(id) {
return Borrows::default();
}
let in_block = item.parent.is_some_and(|parent| t.is_block(parent));
Borrows {
parent_opening_end: item.prev.is_none() && item.glued_before() && !in_block,
prev_closing_end: item.prev.is_some_and(|prev| {
!matches!(t.kind(prev), NodeKind::DocType(_))
&& !t.is_block(prev)
&& !t[prev].text_like
}) && item.glued_before(),
last_child_closing_end: item.last_child.is_some_and(|last| {
t[last].glued_after() && !t[last].ends_in_text_like && !t.is_block(last)
}) && !item.pre_like,
parent_closing_start: item.next.is_none()
&& item.glued_after()
&& item.ends_in_text_like
&& !in_block,
next_opening_start: item
.next
.is_some_and(|next| !t[next].text_like && !t.is_block(next))
&& item.text_like
&& item.glued_after(),
}
}
/// `<!--[if IE]><!--><html><!--<![endif]-->`: for the start comment, the element whose start tag absorbs it
/// together with the end comment, its first child (Prettier's `mergeIfConditionalStartEndCommentIntoElementOpeningTag`).
fn absorbing_element(&self, id: NodeId) -> Option<NodeId> {
let root = self.tree.root;
let start = &root[id];
if !matches!(start.kind, NodeKind::ConditionalStart(_)) {
return None;
}
let element = start.next?;
let NodeKind::Element(el) = &root[element].kind else { return None };
let end = printed_first_child(&root[element])?;
(root[element].span.start == start.span.end
&& matches!(root[end].kind, NodeKind::ConditionalEnd)
&& root[end].span.start == el.start_tag.end)
.then_some(element)
}
/// Texts and CDATA sections next to each other print as one text (Prettier's `mergeCdataIntoText`);
/// the source between them, whitespace included, is the merged value.
fn merge_cdata(&mut self, parent: NodeId) {
let mut next = self.tree[parent].first_child;
while let Some(id) = next {
next = self.tree[id].next;
let Some(after) = next else { break };
// The parser never puts two texts side by side, so a pair here always holds a section
if self.tree.is_text(id) && self.tree.is_text(after) {
self.absorb(parent, id, after);
// The merged text may absorb the next one too
next = Some(id);
}
}
}
/// `keep` extends over its siblings up to `last`, which leave the printed links.
fn absorb(&mut self, parent: NodeId, keep: NodeId, last: NodeId) {
let last = self.tree[last];
let facts = self.facts(keep);
facts.span.end = last.span.end;
facts.trailing_sensitive = last.trailing_sensitive;
facts.has_trailing_spaces = last.has_trailing_spaces;
facts.has_cdata |= last.has_cdata;
facts.next = last.next;
match last.next {
Some(after_next) => self.facts(after_next).prev = Some(keep),
None => self.facts(parent).last_child = Some(keep),
}
}
/// `a<b>x</b>c` prints as one text (Prettier's `mergeSimpleElementIntoText`):
/// the text before absorbs the element and the text after.
fn merge_simple_elements(&mut self, parent: NodeId) {
let mut next = self.tree[parent].first_child;
while let Some(text) = next {
// Prettier rebuilds the text from the parts (`<b >` prints `<b>`);
// the source serves when it reads the same.
// A chain's text is rebuilt once (`a<b >x</b>c<i>y</i>d`).
// The end tag keeps its written name, htm's `<//>` included (DIVERGENCES.md#htm-closing-tag-as-written),
// but where known names print lowercase (DIVERGENCES.md#tag-name-case).
let mut rebuilt: Option<StringBuilder<'_>> = None;
while let Some(id) = self.tree[text].next.filter(|&id| self.is_simple_element(id)) {
let t = &self.tree;
// `is_simple_element` checked each of these
let (el, source) = (t.element(id).expect("an element"), t.source);
let child = t[id].first_child.expect("one child");
let after = t[id].next.expect("a text after");
let end_tag = el.end_tag.expect("an end tag");
let (written, written_end) = (el.name.slice(source), end_tag.name.slice(source));
let (name, end_name) =
(t.printed_name(id, el, written), t.printed_name(id, el, written_end));
let plain = el.start_tag.slice(source).len() == written.len() + 2
&& end_tag.span.slice(source).len() == written_end.len() + 3
&& name == written
&& end_name == written_end;
if !plain || rebuilt.is_some() {
let value = rebuilt.get_or_insert_with(|| {
StringBuilder::from_str_in(t[text].span.slice(source), t.allocator)
});
for piece in [
"<",
name,
">",
t[child].span.slice(source),
"</",
end_name,
">",
t[after].span.slice(source),
] {
value.push_str(piece);
}
}
let child_has_cdata = t[child].has_cdata;
self.absorb(parent, text, after);
self.facts(text).has_cdata |= child_has_cdata;
}
if let Some(value) = rebuilt {
self.tree.merged_texts.push((text, value.into_str()));
}
next = self.tree[text].next;
}
}
/// An inline element with one word inside, glued to texts on both sides.
fn is_simple_element(&self, id: NodeId) -> bool {
let t = &self.tree;
let (item, source) = (&t[id], t.source);
let Some(el) = t.element(id) else { return false };
let is_text = |id: NodeId| t.is_text(id);
item.glued_before()
&& item.glued_after()
&& item.prev.is_some_and(is_text)
&& item.next.is_some_and(is_text)
&& el.attributes.is_empty()
&& el.end_tag.is_some_and(|tag| tag.ignored.is_none())
&& item.only_child().is_some_and(|child| {
is_text(child)
&& !t[child].has_leading_spaces
&& !t[child].has_trailing_spaces
&& !t[child].span.slice(source).bytes().any(|b| b.is_ascii_whitespace())
})
}
/// The facts of a node that does not depend on its siblings.
fn node(&mut self, id: NodeId, in_whitespace_sensitive: bool) {
let node = &self.tree.root[id];
let (mut self_closing, mut pre_like, mut script_like) = (true, false, false);
match &node.kind {
NodeKind::Text(text) if !in_whitespace_sensitive => self.facts(id).span = text.content,
NodeKind::Interpolation(_) | NodeKind::ConditionalComment(_) | NodeKind::Block(_) => {
self_closing = false;
}
NodeKind::Element(el) => {
self_closing = closes_at_start_tag(node);
pre_like = is_pre_like(el);
script_like = matches!(el.key, "script" | "style");
}
_ => {}
}
// CDATA is text to Prettier (`mergeCdataIntoText`),
// printed as written here (DIVERGENCES.md#cdata-as-written).
let text_like =
matches!(node.kind, NodeKind::Text(_) | NodeKind::Comment(_) | NodeKind::Cdata);
let display = self.display(id);
let facts = self.facts(id);
facts.self_closing = self_closing;
facts.pre_like = pre_like;
facts.script_like = script_like;
facts.text_like = text_like;
facts.display = display;
}
/// The display from the element's table and the option;
/// a `<!-- display: -->` comment overrides it in `children`.
fn display(&self, id: NodeId) -> Display {
let Some(el) = self.tree.element(id) else {
// Text, comments and the document: only the option applies
return match self.sensitivity {
WhitespaceSensitivity::Ignore => Display::Block,
_ => Display::Inline,
};
};
if el.namespace == Namespace::Svg {
return if el.key == "svg" {
Display::InlineBlock
} else if self.sensitivity != WhitespaceSensitivity::Ignore && self.in_svg_text(id) {
// Whitespace between a `<text>`'s children renders (DIVERGENCES.md#svg-text-content-inline)
Display::Inline
} else {
Display::Block
};
}
match self.sensitivity {
WhitespaceSensitivity::Strict => Display::Inline,
WhitespaceSensitivity::Ignore => Display::Block,
WhitespaceSensitivity::Css => ua_display(el.key),
}
}
/// Whether an SVG element is inside a `<text>`, whose content lays out as inline text.
fn in_svg_text(&self, id: NodeId) -> bool {
std::iter::successors(self.tree.root[id].parent, |&p| self.tree.root[p].parent)
.map_while(|p| self.tree.element(p).filter(|el| el.namespace == Namespace::Svg))
.any(|el| el.key == "text")
}
/// Whether whitespace on one side of the node is significant, before the neighbor's side is combined in:
/// the leading side looks at the previous sibling as `neighbor`, the trailing side at the next one.
fn side_sensitive(
&self,
id: NodeId,
parent_id: NodeId,
neighbor: Option<NodeId>,
leading: bool,
) -> bool {
if self.tree.rules.has_blocks {
return self.side_sensitive_around_blocks(id, parent_id, neighbor, leading);
}
let display = neighbor.map(|n| self.tree[n].display);
self.side_sensitive_with(id, id, parent_id, neighbor, display, leading)
}
/// Blocks are transparent: a whitespace run continues past their markers, and past hidden elements.
fn side_sensitive_around_blocks(
&self,
id: NodeId,
parent_id: NodeId,
neighbor: Option<NodeId>,
leading: bool,
) -> bool {
// Across a block, the run meets the content at its near edge, past empty blocks
let neighbor = self.flow_past(neighbor, !leading);
self.side_sensitive_through(
id,
parent_id,
self.flow_past(Some(id), leading),
neighbor,
leading,
)
}
/// `flow_item` is the node the run meets on `id`'s side (`None` past the parent's edge), `neighbor` the one across it.
fn side_sensitive_through(
&self,
id: NodeId,
parent_id: NodeId,
flow_item: Option<NodeId>,
neighbor: Option<NodeId>,
leading: bool,
) -> bool {
let t = &self.tree;
// An empty block passes the run: its sides are those of the content around it
let (flow_item, neighbor, leading) = match (flow_item, neighbor) {
(Some(flow_item), neighbor) => (flow_item, neighbor, leading),
(None, Some(neighbor)) => (neighbor, None, !leading),
(None, None) => (id, None, leading),
};
// At the content's edge, the run is the one the block's own side faces, decided outside it
if neighbor.is_none() && t.is_block(parent_id) {
let block = &t[parent_id];
return if leading { block.leading_sensitive } else { block.trailing_sensitive };
}
// The rules are the nearest box's
let display = neighbor.map(|n| self.display_of(n));
self.side_sensitive_with(
id,
flow_item,
self.flow_parent(parent_id),
neighbor,
display,
leading,
)
}
/// `flow_item` is the node the run meets on `id`'s side (`id` itself, unless it is a block),
/// `display` the neighbor's.
fn side_sensitive_with(
&self,
id: NodeId,
flow_item: NodeId,
parent_id: NodeId,
neighbor: Option<NodeId>,
display: Option<Display>,
leading: bool,
) -> bool {
let t = &self.tree;
let (item, parent) = (&t[id], &t[parent_id]);
let text_or_interpolation = |id: NodeId| t.is_text(id) || t.is_interpolation(id);
let sensitive =
if text_or_interpolation(flow_item) && neighbor.is_some_and(text_or_interpolation) {
true
} else if parent.display == Display::None {
false
} else if parent.pre_like {
true
} else if let Some(display) = display {
display.sides_sensitive()
} else {
!(parent_id == NodeId::DOCUMENT
|| item.pre_like
|| parent.script_like
|| !parent.display.edges_sensitive())
};
// The line break HTML drops after `<pre>` is not content
if leading
&& sensitive
&& neighbor.is_none()
&& t.element(parent_id).is_some_and(|el| el.ignores_first_lf)
{
return t.is_interpolation(id);
}
sensitive
}
/// The node a whitespace run meets at `id`'s start (`start`) or end, looking into blocks:
/// a block's first (last) child that is not whitespace-only;
/// `None` for a block without content or a hidden element, which the run passes.
fn flow_edge(&self, id: NodeId, start: bool) -> Option<NodeId> {
if self.is_hidden(id) {
return None;
}
if !self.tree.is_block(id) {
return Some(id);
}
let mut edges = self
.tree
.root
.children(id)
.filter(|(_, node)| !is_whitespace_text(node))
.filter_map(|(child, _)| self.flow_edge(child, start));
if start { edges.next() } else { edges.last() }
}
/// The node a whitespace run meets from `sibling` on, going forward (`forward`) or back, past empty blocks.
fn flow_past(&self, sibling: Option<NodeId>, forward: bool) -> Option<NodeId> {
let t = &self.tree;
std::iter::successors(sibling, |&id| if forward { t[id].next } else { t[id].prev })
.find_map(|id| self.flow_edge(id, forward))
}
/// An element with `display: none` renders nothing, so the whitespace on both its sides is one run
/// between the content around it (DIVERGENCES.md#script-display-none).
/// Not `Tree::is_transparent`: a block's children are read before they are built.
#[inline]
fn is_hidden(&self, id: NodeId) -> bool {
self.display_of(id) == Display::None && self.tree.element(id).is_some()
}
/// Where hidden siblings join gaps into one run, whitespace in one gap lets every gap of the run take it:
/// the run renders the one space either way (`a</span> <script></script><span>b` puts the `script` on its own line).
/// A run that renders nothing keeps its gaps as written, or a line break printed next to a block would spread next time.
/// A gap's two sides agree on spaces and sensitivity, so a pass each way carries it.
fn spread_spaces_past_hidden(&mut self, parent: NodeId) {
let mut next = self.tree[parent].first_child;
while let Some(id) = next {
next = self.tree[id].next;
let facts = &self.tree[id];
if self.is_hidden(id) && facts.has_leading_spaces && facts.leading_sensitive {
self.facts(id).has_trailing_spaces = true;
if let Some(next) = next {
self.facts(next).has_leading_spaces = true;
}
}
}
let mut prev = self.tree[parent].last_child;
while let Some(id) = prev {
prev = self.tree[id].prev;
let facts = &self.tree[id];
if self.is_hidden(id) && facts.has_trailing_spaces && facts.trailing_sensitive {
self.facts(id).has_leading_spaces = true;
if let Some(prev) = prev {
self.facts(prev).has_trailing_spaces = true;
}
}
}
}
/// Where known names print lowercase (`DialectRules::lowercase_known_names`):
/// marks foreign content, under which they keep their case, and a tag name written in another case.
fn mark_name_case(&mut self, id: NodeId, parent: NodeId) {
let t = &self.tree;
let Some(el) = t.element(id) else { return };
let foreign = t[parent].foreign || el.namespace != Namespace::Html;
let uppercase = |name: Span| name.slice(t.source).bytes().any(|b| b.is_ascii_uppercase());
let lowercase_name = (uppercase(el.name)
|| el.end_tag.is_some_and(|tag| uppercase(tag.name)))
&& t.lowercases_names(id, el);
let facts = self.facts(id);
facts.foreign = foreign;
facts.lowercase_name = lowercase_name;
}
/// A node's display, also for one in a block whose children are not built yet.
fn display_of(&self, id: NodeId) -> Display {
if self.tree[id].parent.is_some() { self.tree[id].display } else { self.display(id) }
}
/// The nearest box: `id`, or the nearest ancestor that is not a block.
fn flow_parent(&self, mut id: NodeId) -> NodeId {
while self.tree.is_block(id) {
id = self.tree.parent(id);
}
id
}
}
fn is_whitespace_text(node: &Node<'_>) -> bool {
matches!(&node.kind, NodeKind::Text(text) if text.is_whitespace_only())
}
/// CSS `white-space: pre*`.
fn is_pre_like(el: &Element<'_>) -> bool {
el.namespace != Namespace::Svg
&& matches!(el.key, "pre" | "textarea" | "listing" | "plaintext" | "xmp")
}
/// A void element, or one written `/>` without an end tag.
///
/// HTML ignores the `/` of `<my-el />` and an ancestor's end tag closes it, but the tag reads as self-closing (as Prettier reads it):
/// its content follows it as siblings (`next_child`).
/// The text prints the same either way; whitespace inside such an element is judged as whitespace between siblings,
/// so `<div><my-el />⏎</div>` drops the `⏎` HTML puts in `my-el`, collapsible at the end of a block and never rendered.
/// Except in `<pre />`, whose content keeps its whitespace: re-indented as siblings, it would render differently
/// (DIVERGENCES.md#pre-content-without-end-tag).
fn closes_at_start_tag(node: &Node<'_>) -> bool {
let NodeKind::Element(el) = &node.kind else { return false };
el.void
|| (el.self_closing
&& el.end_tag.is_none()
&& (!is_pre_like(el) || node.first_child.is_none()))
}
/// The parser's first child, unless the content follows the element as siblings (`next_child`).
fn printed_first_child(node: &Node<'_>) -> Option<NodeId> {
node.first_child.filter(|_| !closes_at_start_tag(node))
}
/// The node after `id` among `parent`'s children,
/// with the content of an element whose start tag closes it following it as siblings.
fn next_child(root: &Root<'_>, parent: NodeId, mut id: NodeId) -> Option<NodeId> {
if closes_at_start_tag(&root[id])
&& let Some(first) = root[id].first_child
{
return Some(first);
}
loop {
if let Some(next) = root[id].next {
return Some(next);
}
id = root[id].parent.filter(|&up| up != parent)?;
}
}
@@ -0,0 +1,73 @@
/// The CSS `display` an element gets from the UA stylesheet (Prettier's table, `constants.evaluate.js`).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum Display {
#[default]
Inline,
Block,
InlineBlock,
ListItem,
TableCell,
/// Every other `table*` value.
Table,
None,
/// `contents`, `ruby`, or a value from a `<!-- display: -->` comment.
Other,
}
impl Display {
fn block_like(self) -> bool {
matches!(self, Self::Block | Self::ListItem | Self::Table | Self::TableCell)
}
/// Whitespace at the start of the first child / the end of the last child / of an empty element is significant.
pub fn edges_sensitive(self) -> bool {
!self.block_like() && self != Self::InlineBlock
}
/// Whitespace between this and a sibling is significant.
pub fn sides_sensitive(self) -> bool {
!self.block_like()
}
}
/// `<!-- display: block -->` (Prettier's `/^\s*display:\s*([a-z]+)\s*$/`).
pub fn display_comment(content: &str) -> Option<Display> {
let value = content.trim().strip_prefix("display:")?.trim_start();
if value.is_empty() || !value.bytes().all(|b| b.is_ascii_lowercase()) {
return None;
}
Some(match value {
"block" => Display::Block,
"inline" => Display::Inline,
"table" => Display::Table,
"none" => Display::None,
_ => Display::Other,
})
}
/// The UA stylesheet's `display` for an HTML element, as Prettier tabulates it.
pub fn ua_display(tag: &str) -> Display {
match tag {
// `script` as the UA has it;
// Prettier lays it out as a block (DIVERGENCES.md#script-display-none).
"area" | "base" | "basefont" | "datalist" | "head" | "link" | "meta" | "noembed"
| "noframes" | "rp" | "script" | "style" | "title" => Display::None,
"param" | "html" | "body" | "address" | "blockquote" | "center" | "div" | "figure"
| "figcaption" | "footer" | "form" | "header" | "hr" | "legend" | "listing" | "main"
| "p" | "plaintext" | "pre" | "search" | "xmp" | "article" | "aside" | "h1" | "h2"
| "h3" | "h4" | "h5" | "h6" | "hgroup" | "nav" | "section" | "dir" | "dd" | "dl" | "dt"
| "menu" | "ol" | "ul" | "fieldset" | "details" | "summary" | "option" | "optgroup"
| "source" | "track" => Display::Block,
"slot" | "ruby" | "rt" => Display::Other,
"li" => Display::ListItem,
"table" | "caption" | "colgroup" | "col" | "thead" | "tbody" | "tfoot" | "tr" => {
Display::Table
}
"td" | "th" => Display::TableCell,
// `dialog`: out of flow (`display: none` closed, `position: absolute` open), with a box for its content;
// Prettier lays it out as a block (DIVERGENCES.md#dialog-out-of-flow).
"input" | "button" | "marquee" | "select" | "meter" | "progress" | "object" | "video"
| "audio" | "dialog" => Display::InlineBlock,
_ => Display::Inline,
}
}
@@ -0,0 +1,317 @@
//! The facts Prettier's `print-preprocess` attaches to its tree, as a table keyed by the parser's [`NodeId`].
//!
//! The AST is not rewritten; names, kinds and the tag table's verdict are read from it.
//! What the table adds:
//! - the nodes that print, linked by `parent` / `prev` / `next` / `first_child` / `last_child`:
//! a whitespace-only text of a whitespace-insensitive parent is skipped (it becomes `has_*_spaces` of its neighbors),
//! and the content of an element that reads as self-closing (`<my-el />`) follows it as siblings;
//! - CSS display and whitespace sensitivity, which depend on the options;
//! - facts the printer asks repeatedly (self-closing, `ends_in_text_like`, `prettier-ignore`).
mod build;
mod display;
mod names;
use oxc_allocator::{Allocator, ArenaVec, StringBuilder};
use oxc_htmlish_parser::{
Span,
ast::{Element, Namespace, NodeId, NodeKind, Root},
};
use crate::rules::DialectRules;
pub use display::Display;
#[derive(Clone, Copy, Default)]
pub struct Facts {
/// Prettier's `sourceSpan`: a text in a whitespace-insensitive parent is trimmed.
pub span: Span,
/// The parent, siblings and children that print.
pub parent: Option<NodeId>,
pub prev: Option<NodeId>,
pub next: Option<NodeId>,
pub first_child: Option<NodeId>,
pub last_child: Option<NodeId>,
pub display: Display,
/// Text and comments: printed without tags whose markers could be borrowed.
pub text_like: bool,
/// No children and no end tag: the start tag closes the node (Prettier's `isSelfClosing`).
/// Text-like nodes count too.
pub self_closing: bool,
/// CSS `white-space: pre*`: `pre`, `textarea`, `listing`, `plaintext`, `xmp`, outside SVG.
/// Children keep their whitespace and indentation.
pub pre_like: bool,
/// `script` / `style`: children keep their whitespace.
pub script_like: bool,
/// The deepest last descendant is text-like, or the node itself is
/// (an interpolation ends in its text).
pub ends_in_text_like: bool,
/// The node is a `pre` without an end tag, or ends in one through its last children without end tags:
/// what follows it up to the end tag that closes them is `pre` content.
pub ends_in_pre: bool,
/// A CDATA section, or a text that merged one: its sections are never re-wrapped.
pub has_cdata: bool,
/// The previous sibling is `<!-- prettier-ignore -->`.
pub prettier_ignore: bool,
/// An SVG / MathML element, or one under it (`DialectRules::lowercase_known_names` only).
pub foreign: bool,
/// The tag name prints lowercase, written otherwise in the start or end tag (`Tree::printed_name`).
pub lowercase_name: bool,
pub has_leading_spaces: bool,
pub has_trailing_spaces: bool,
pub leading_sensitive: bool,
pub trailing_sensitive: bool,
/// An element without children had only whitespace inside.
pub has_dangling_spaces: bool,
pub dangling_sensitive: bool,
/// An element whose start tag absorbs `<!--[if X]><!-->` right before it and `<!--<![endif]-->` right after it
/// (`<!--[if X]><!--><html><!--<![endif]-->`): those two comments, which do not print on their own.
/// The start comment; the end comment is the element's first child.
pub condition: Option<NodeId>,
pub borrows: Borrows,
}
/// The tag markers a node prints for its neighbors (the borrow rules in `print/tag.rs`);
/// the owners omit them (`*_borrowed`).
#[derive(Clone, Copy, Default)]
pub struct Borrows {
/// `<p\n >123`: the first child prints its parent's opening `>`.
pub parent_opening_end: bool,
/// `<p></p\n>123`: the previous sibling's closing `>`.
pub prev_closing_end: bool,
/// `<p\n ><a></a\n ></p\n>`: the last child's closing `>`, before the node's own end tag.
pub last_child_closing_end: bool,
/// `123</p\n>`: the last text prints its parent's `</p`.
pub parent_closing_start: bool,
/// `123<p\n>`: the text prints its next sibling's `<p`.
pub next_opening_start: bool,
}
impl Facts {
/// Children keep their whitespace.
pub fn whitespace_sensitive(&self) -> bool {
self.pre_like || self.script_like
}
/// Whitespace before the node is significant and there is none.
pub fn glued_before(&self) -> bool {
self.leading_sensitive && !self.has_leading_spaces
}
/// Whitespace after the node is significant and there is none.
pub fn glued_after(&self) -> bool {
self.trailing_sensitive && !self.has_trailing_spaces
}
/// Exactly one child prints.
pub fn only_child(&self) -> Option<NodeId> {
self.first_child.filter(|_| self.first_child == self.last_child)
}
}
pub struct Tree<'a> {
allocator: &'a Allocator,
rules: DialectRules,
root: &'a Root<'a>,
source: &'a str,
facts: ArenaVec<'a, Facts>,
/// Texts whose merged value differs from their source
/// (an element merged in had whitespace in a tag), sorted by `NodeId`.
merged_texts: ArenaVec<'a, (NodeId, &'a str)>,
}
impl std::ops::Index<NodeId> for Tree<'_> {
type Output = Facts;
fn index(&self, id: NodeId) -> &Facts {
&self.facts[id.index()]
}
}
impl<'a> Tree<'a> {
pub fn source(&self) -> &'a str {
self.source
}
pub fn kind(&self, id: NodeId) -> &'a NodeKind<'a> {
&self.root[id].kind
}
pub fn rules(&self) -> DialectRules {
self.rules
}
/// An Angular control flow block: transparent, its markers print no box (`@if (a) {` … `}`).
pub fn is_block(&self, id: NodeId) -> bool {
self.rules.has_blocks && matches!(self.kind(id), NodeKind::Block(_))
}
/// A `display: none` element in a parent that renders: the whitespace on both its sides is one run
/// (DIVERGENCES.md#script-display-none).
#[inline]
pub fn is_transparent(&self, id: NodeId) -> bool {
self[id].display == Display::None
&& self.element(id).is_some()
&& self[self.parent(id)].display != Display::None
}
pub fn is_interpolation(&self, id: NodeId) -> bool {
matches!(self.kind(id), NodeKind::Interpolation(_))
}
/// A text or a CDATA section, which Prettier merges into text (`mergeCdataIntoText`).
pub fn is_text(&self, id: NodeId) -> bool {
matches!(self.kind(id), NodeKind::Text(_) | NodeKind::Cdata)
}
pub fn element(&self, id: NodeId) -> Option<&'a Element<'a>> {
match &self.root[id].kind {
NodeKind::Element(el) => Some(el),
_ => None,
}
}
/// The tag name to print (DIVERGENCES.md#tag-name-case); empty for other nodes.
#[inline]
pub fn name(&self, id: NodeId) -> &'a str {
self.element(id).map_or("", |el| self.printed_name(id, el, el.name.slice(self.source)))
}
/// A start or end tag's name `written`, or the element's lowercase name where it prints so (DIVERGENCES.md#tag-name-case);
/// htm's `<//>` closes as written.
#[inline]
pub fn printed_name(&self, id: NodeId, el: &Element<'a>, written: &'a str) -> &'a str {
if self[id].lowercase_name && written != "/" { el.key } else { written }
}
/// An attribute's name written `written` to print: as written, or Prettier's known name in lowercase
/// (DIVERGENCES.md#tag-name-case).
#[inline]
pub fn attribute_name(&self, element: NodeId, written: &'a str) -> &'a str {
if !self.rules.lowercase_known_names || !written.bytes().any(|b| b.is_ascii_uppercase()) {
return written;
}
match self.element(element) {
Some(el) if self.lowercases_names(element, el) => {
names::known_attribute(el.key, written).unwrap_or(written)
}
_ => written,
}
}
/// Whether the element's known names print lowercase: a known HTML element outside foreign content.
/// `svg` / `math` themselves are known; under them Prettier keeps every name, `foreignObject`'s HTML too.
pub fn lowercases_names(&self, id: NodeId, el: &Element<'a>) -> bool {
!self[id].parent.is_some_and(|parent| self[parent].foreign)
&& (el.namespace == Namespace::Html || matches!(el.key, "svg" | "math"))
&& names::is_known_element(el.key)
}
/// Whether an attribute written `written` is the one named `name` (lowercase).
/// Prettier lowercases only the attributes it knows for the element; any name here is close enough.
pub fn attribute_is(&self, written: &str, name: &str) -> bool {
if self.rules.names_ignore_case {
written.eq_ignore_ascii_case(name)
} else {
written == name
}
}
/// A text's value: its source, unless a merge rewrote it.
pub fn text(&self, id: NodeId) -> &'a str {
match self.merged_texts.binary_search_by_key(&id, |(text, _)| *text) {
Ok(index) => self.merged_texts[index].1,
Err(_) => self[id].span.slice(self.source),
}
}
/// A conditional comment's condition or a doctype's value, whitespace collapsed.
pub fn collapsed(&self, span: Span) -> &'a str {
collapse_whitespace(self.allocator, span.slice(self.source))
}
/// The name the HTML tables are keyed by (the parser's, per dialect); empty for other nodes.
pub fn tag(&self, id: NodeId) -> &'a str {
self.element(id).map_or("", |el| el.key)
}
pub fn parent(&self, id: NodeId) -> NodeId {
self[id].parent.expect("only the document has no parent")
}
/// The printed children, in order.
pub fn children(&self, id: NodeId) -> impl Iterator<Item = NodeId> + '_ {
std::iter::successors(self[id].first_child, move |&child| self[child].next)
}
/// Where the content between the tags starts (Prettier's `startSourceSpan.end`).
pub fn content_start(&self, id: NodeId) -> u32 {
if self[id].condition.is_some()
&& let Some(end) = self.root[id].first_child
{
return self.root[end].span.end;
}
match &self.root[id].kind {
NodeKind::Element(el) => el.start_tag.end,
NodeKind::ConditionalComment(comment) => comment.content.start,
NodeKind::Block(block) => block.open.end,
_ => self[id].span.start,
}
}
/// Where the end tag starts, when there is one (Prettier's `endSourceSpan.start`).
pub fn end_tag_start(&self, id: NodeId) -> Option<u32> {
match &self.root[id].kind {
NodeKind::Element(el) => el.end_tag.map(|tag| tag.span.start),
NodeKind::ConditionalComment(comment) => Some(comment.content.end),
NodeKind::Block(block) => block.close.map(|close| close.start),
_ => None,
}
}
/// An element without an end tag (closed by an ancestor's, a sibling's or the end of input)
/// that does not read as self-closing.
pub fn is_unclosed(&self, id: NodeId) -> bool {
self.element(id).is_some_and(|el| el.end_tag.is_none()) && !self[id].self_closing
}
/// Where Prettier's `sourceSpan` of the node ends:
/// an element without an end tag ends at its start tag, children aside.
pub fn source_end(&self, id: NodeId) -> u32 {
match &self.root[id].kind {
NodeKind::Element(el) if el.end_tag.is_none() => el.start_tag.end,
_ => self[id].span.end,
}
}
/// Line breaks in `[from, to)`, counted up to 2:
/// Prettier compares line numbers, so a gap is on the same line, the next line, or further.
pub fn line_breaks(&self, from: u32, to: u32) -> usize {
if from >= to {
return 0;
}
memchr::memchr_iter(b'\n', &self.source.as_bytes()[from as usize..to as usize])
.take(2)
.count()
}
}
/// `text` trimmed, with its whitespace runs collapsed to single spaces; borrowed when already so.
pub fn collapse_whitespace<'a>(allocator: &'a Allocator, text: &'a str) -> &'a str {
let text = text.trim_ascii();
let collapsed = text
.as_bytes()
.windows(2)
.all(|pair| !pair[1].is_ascii_whitespace() || (pair[1] == b' ' && pair[0] != b' '));
if collapsed {
return text;
}
let mut out = StringBuilder::new_in(allocator);
for (i, word) in text.split_ascii_whitespace().enumerate() {
if i > 0 {
out.push(' ');
}
out.push_str(word);
}
out.into_str()
}
@@ -0,0 +1,586 @@
//! The names Prettier's HTML parser lowercases (DIVERGENCES.md#tag-name-case):
//! `@prettier/html-tags` 2.2.0 and `html-element-attributes` 3.5.0, as the pin bundles them.
//! Read only for a name holding an uppercase letter, so the tables are sorted slices.
const ELEMENTS: &[&str] = &[
"a",
"abbr",
"acronym",
"address",
"applet",
"area",
"article",
"aside",
"audio",
"b",
"base",
"basefont",
"bdi",
"bdo",
"bgsound",
"big",
"blink",
"blockquote",
"body",
"br",
"button",
"canvas",
"caption",
"center",
"cite",
"code",
"col",
"colgroup",
"command",
"content",
"data",
"datalist",
"dd",
"del",
"details",
"dfn",
"dialog",
"dir",
"div",
"dl",
"dt",
"em",
"embed",
"fencedframe",
"fieldset",
"figcaption",
"figure",
"font",
"footer",
"form",
"frame",
"frameset",
"geolocation",
"h1",
"h2",
"h3",
"h4",
"h5",
"h6",
"head",
"header",
"hgroup",
"hr",
"html",
"i",
"iframe",
"image",
"img",
"input",
"ins",
"isindex",
"kbd",
"keygen",
"label",
"legend",
"li",
"link",
"listing",
"main",
"map",
"mark",
"marquee",
"math",
"menu",
"menuitem",
"meta",
"meter",
"multicol",
"nav",
"nextid",
"nobr",
"noembed",
"noframes",
"noscript",
"object",
"ol",
"optgroup",
"option",
"output",
"p",
"param",
"picture",
"plaintext",
"pre",
"progress",
"q",
"rb",
"rbc",
"rp",
"rt",
"rtc",
"ruby",
"s",
"samp",
"script",
"search",
"section",
"select",
"selectedcontent",
"shadow",
"slot",
"small",
"source",
"spacer",
"span",
"strike",
"strong",
"style",
"sub",
"summary",
"sup",
"svg",
"table",
"tbody",
"td",
"template",
"textarea",
"tfoot",
"th",
"thead",
"time",
"title",
"tr",
"track",
"tt",
"u",
"ul",
"var",
"video",
"wbr",
"xmp",
];
const GLOBAL_ATTRIBUTES: &[&str] = &[
"accesskey",
"autocapitalize",
"autocorrect",
"autofocus",
"class",
"contenteditable",
"dir",
"draggable",
"enterkeyhint",
"exportparts",
"hidden",
"id",
"inert",
"inputmode",
"is",
"itemid",
"itemprop",
"itemref",
"itemscope",
"itemtype",
"lang",
"nonce",
"part",
"popover",
"slot",
"spellcheck",
"style",
"tabindex",
"title",
"translate",
"writingsuggestions",
];
/// By element, each list sorted.
const ELEMENT_ATTRIBUTES: &[(&str, &[&str])] = &[
(
"a",
&[
"charset",
"coords",
"download",
"href",
"hreflang",
"name",
"ping",
"referrerpolicy",
"rel",
"rev",
"shape",
"target",
"type",
],
),
(
"applet",
&[
"align", "alt", "archive", "code", "codebase", "height", "hspace", "name", "object",
"vspace", "width",
],
),
(
"area",
&[
"alt",
"coords",
"download",
"href",
"hreflang",
"nohref",
"ping",
"referrerpolicy",
"rel",
"shape",
"target",
"type",
],
),
("audio", &["autoplay", "controls", "crossorigin", "loop", "muted", "preload", "src"]),
("base", &["href", "target"]),
("basefont", &["color", "face", "size"]),
("blockquote", &["cite"]),
("body", &["alink", "background", "bgcolor", "link", "text", "vlink"]),
("br", &["clear"]),
(
"button",
&[
"command",
"commandfor",
"disabled",
"form",
"formaction",
"formenctype",
"formmethod",
"formnovalidate",
"formtarget",
"name",
"popovertarget",
"popovertargetaction",
"type",
"value",
],
),
("canvas", &["height", "width"]),
("caption", &["align"]),
("col", &["align", "char", "charoff", "span", "valign", "width"]),
("colgroup", &["align", "char", "charoff", "span", "valign", "width"]),
("data", &["value"]),
("del", &["cite", "datetime"]),
("details", &["name", "open"]),
("dialog", &["closedby", "open"]),
("dir", &["compact"]),
("div", &["align"]),
("dl", &["compact"]),
("embed", &["height", "src", "type", "width"]),
("fieldset", &["disabled", "form", "name"]),
("font", &["color", "face", "size"]),
(
"form",
&[
"accept",
"accept-charset",
"action",
"autocomplete",
"enctype",
"method",
"name",
"novalidate",
"target",
],
),
(
"frame",
&[
"frameborder",
"longdesc",
"marginheight",
"marginwidth",
"name",
"noresize",
"scrolling",
"src",
],
),
("frameset", &["cols", "rows"]),
("h1", &["align"]),
("h2", &["align"]),
("h3", &["align"]),
("h4", &["align"]),
("h5", &["align"]),
("h6", &["align"]),
("head", &["profile"]),
("hr", &["align", "noshade", "size", "width"]),
("html", &["manifest", "version"]),
(
"iframe",
&[
"align",
"allow",
"allowfullscreen",
"allowpaymentrequest",
"allowusermedia",
"frameborder",
"height",
"loading",
"longdesc",
"marginheight",
"marginwidth",
"name",
"referrerpolicy",
"sandbox",
"scrolling",
"src",
"srcdoc",
"width",
],
),
(
"img",
&[
"align",
"alt",
"border",
"crossorigin",
"decoding",
"fetchpriority",
"height",
"hspace",
"ismap",
"loading",
"longdesc",
"name",
"referrerpolicy",
"sizes",
"src",
"srcset",
"usemap",
"vspace",
"width",
],
),
(
"input",
&[
"accept",
"align",
"alpha",
"alt",
"autocomplete",
"checked",
"colorspace",
"dirname",
"disabled",
"form",
"formaction",
"formenctype",
"formmethod",
"formnovalidate",
"formtarget",
"height",
"ismap",
"list",
"max",
"maxlength",
"min",
"minlength",
"multiple",
"name",
"pattern",
"placeholder",
"popovertarget",
"popovertargetaction",
"readonly",
"required",
"size",
"src",
"step",
"type",
"usemap",
"value",
"width",
],
),
("ins", &["cite", "datetime"]),
("isindex", &["prompt"]),
("label", &["for", "form"]),
("legend", &["align"]),
("li", &["type", "value"]),
(
"link",
&[
"as",
"blocking",
"charset",
"color",
"crossorigin",
"disabled",
"fetchpriority",
"href",
"hreflang",
"imagesizes",
"imagesrcset",
"integrity",
"media",
"referrerpolicy",
"rel",
"rev",
"sizes",
"target",
"type",
],
),
("map", &["name"]),
("menu", &["compact"]),
("meta", &["charset", "content", "http-equiv", "media", "name", "scheme"]),
("meter", &["high", "low", "max", "min", "optimum", "value"]),
(
"object",
&[
"align",
"archive",
"border",
"classid",
"codebase",
"codetype",
"data",
"declare",
"form",
"height",
"hspace",
"name",
"standby",
"type",
"typemustmatch",
"usemap",
"vspace",
"width",
],
),
("ol", &["compact", "reversed", "start", "type"]),
("optgroup", &["disabled", "label"]),
("option", &["disabled", "label", "selected", "value"]),
("output", &["for", "form", "name"]),
("p", &["align"]),
("param", &["name", "type", "value", "valuetype"]),
("pre", &["width"]),
("progress", &["max", "value"]),
("q", &["cite"]),
(
"script",
&[
"async",
"blocking",
"charset",
"crossorigin",
"defer",
"fetchpriority",
"integrity",
"language",
"nomodule",
"referrerpolicy",
"src",
"type",
],
),
("select", &["autocomplete", "disabled", "form", "multiple", "name", "required", "size"]),
("slot", &["name"]),
("source", &["height", "media", "sizes", "src", "srcset", "type", "width"]),
("style", &["blocking", "media", "type"]),
(
"table",
&[
"align",
"bgcolor",
"border",
"cellpadding",
"cellspacing",
"frame",
"rules",
"summary",
"width",
],
),
("tbody", &["align", "char", "charoff", "valign"]),
(
"td",
&[
"abbr", "align", "axis", "bgcolor", "char", "charoff", "colspan", "headers", "height",
"nowrap", "rowspan", "scope", "valign", "width",
],
),
(
"template",
&[
"shadowrootclonable",
"shadowrootcustomelementregistry",
"shadowrootdelegatesfocus",
"shadowrootmode",
"shadowrootserializable",
],
),
(
"textarea",
&[
"autocomplete",
"cols",
"dirname",
"disabled",
"form",
"maxlength",
"minlength",
"name",
"placeholder",
"readonly",
"required",
"rows",
"wrap",
],
),
("tfoot", &["align", "char", "charoff", "valign"]),
(
"th",
&[
"abbr", "align", "axis", "bgcolor", "char", "charoff", "colspan", "headers", "height",
"nowrap", "rowspan", "scope", "valign", "width",
],
),
("thead", &["align", "char", "charoff", "valign"]),
("time", &["datetime"]),
("tr", &["align", "bgcolor", "char", "charoff", "valign"]),
("track", &["default", "kind", "label", "src", "srclang"]),
("ul", &["compact", "type"]),
(
"video",
&[
"autoplay",
"controls",
"crossorigin",
"height",
"loop",
"muted",
"playsinline",
"poster",
"preload",
"src",
"width",
],
),
];
/// An HTML element Prettier knows, by its lowercase name.
pub fn is_known_element(lowercase: &str) -> bool {
ELEMENTS.binary_search(&lowercase).is_ok()
}
/// The lowercase name of an attribute Prettier knows on `element` (lowercase), written `written` in any case:
/// a global one, but only on an element the attribute table lists.
pub fn known_attribute(element: &str, written: &str) -> Option<&'static str> {
let i = ELEMENT_ATTRIBUTES.binary_search_by_key(&element, |(name, _)| name).ok()?;
find(GLOBAL_ATTRIBUTES, written).or_else(|| find(ELEMENT_ATTRIBUTES[i].1, written))
}
fn find(names: &[&'static str], written: &str) -> Option<&'static str> {
names
.binary_search_by(|name| name.bytes().cmp(written.bytes().map(|b| b.to_ascii_lowercase())))
.ok()
.map(|i| names[i])
}
@@ -0,0 +1,74 @@
//! Prettier conformance for HTML.
//!
//! Compares output against the Prettier suite's `tests/format/html` snapshots via `oxc_formatter_tests::conformance`;
//! the failure report is pinned with `insta`.
//!
//! Debug a specific test: `PRETTIER_FILTER=<substring> cargo test -p oxc_formatter_htmlish --test conformance -- --nocapture`
use std::path::Path;
use oxc_allocator::Allocator;
use oxc_formatter_core::LineWidth;
use oxc_formatter_htmlish::{HtmlishFormatOptions, format};
use oxc_formatter_tests::{
OptionSet,
conformance::{ConformanceConfig, run_conformance},
};
#[path = "fixtures/options.rs"]
mod options;
use options::apply_htmlish_options;
const CONFIG: ConformanceConfig = ConformanceConfig {
language: "html",
fixture_roots: &["html"],
exact_parser: Some("html"),
ignore: &[
// Cursor tracking is an editor feature, not formatting.
"html/cursor/",
// Embedded languages need the dispatcher; covered by oxfmt's E2E conformance.
"html/css/",
"html/js/",
"html/multiparser/",
"html/script/",
"html/svg/embeded/",
"html/bracket-same-line/embed.html",
"html/prettier_ignore/issue-15738.html",
"html/basics/html5-boilerplate.html",
"html/case/case.html",
// `<script type="text/x-handlebars-template">` is formatted as Glimmer.
"html/handlebars-venerable/",
// The front matter's YAML body.
"html/yaml/yaml.html",
// `style="…"` is formatted as CSS, `on*="…"` as JS.
"html/attributes/style.html",
"html/attributes/event-attributes.html",
"html/attributes/no-semi/",
"html/doctype_declarations/xhtml1.1.html",
"html/tags/marquee.html",
"html/tags/menu.html",
"html/whitespace/fill.html",
"html/whitespace/nested-inline-without-whitespace.html",
// Pragma support (`@format` insertion / require).
"html/pragma/",
],
skip_spec: None,
};
fn format_html(_path: &Path, source_text: &str, spec: &OptionSet) -> Option<String> {
// Prettier's default `printWidth` is 80 (oxc defaults to 100).
let mut options = HtmlishFormatOptions {
line_width: LineWidth::try_from(80).unwrap(),
..HtmlishFormatOptions::default()
};
apply_htmlish_options(&mut options, spec);
let allocator = Allocator::default();
let formatted = format(&allocator, source_text, options).ok()?;
Some(formatted.print().ok()?.into_code())
}
#[test]
fn prettier_conformance() {
let Some(report) = run_conformance(&CONFIG, format_html) else { return };
insta::assert_snapshot!("prettier-html", report);
}
@@ -0,0 +1,6 @@
@if (a) {
x
} @else
if (b) {
y
}
@@ -0,0 +1,35 @@
---
source: crates/oxc_formatter_htmlish/tests/fixtures/mod.rs
---
==================== Input ====================
@if (a) {
x
} @else
if (b) {
y
}
==================== Output ====================
------------------
{ printWidth: 80 }
------------------
@if (a) {
x
}
@else
if (b) {
y
}
-------------------
{ printWidth: 100 }
-------------------
@if (a) {
x
}
@else
if (b) {
y
}
===================== End =====================
@@ -0,0 +1,5 @@
<p>a@if (x) {b}c</p>
<span>@if (test) {text}</span>
<p><b>a</b>@for (i of items; track i) {<i>{{ i }}</i>}<b>z</b></p>
<span>a@if (x) {}b</span>
<span>a@case (1)@case (2) {b}c</span>
@@ -0,0 +1,34 @@
---
source: crates/oxc_formatter_htmlish/tests/fixtures/mod.rs
---
==================== Input ====================
<p>a@if (x) {b}c</p>
<span>@if (test) {text}</span>
<p><b>a</b>@for (i of items; track i) {<i>{{ i }}</i>}<b>z</b></p>
<span>a@if (x) {}b</span>
<span>a@case (1)@case (2) {b}c</span>
==================== Output ====================
------------------
{ printWidth: 80 }
------------------
<p>a@if (x) {b}c</p>
<span>@if (test) {text}</span>
<p>
<b>a</b>@for (i of items; track i) {<i>{{ i }}</i>}<b>z</b>
</p>
<span>a@if (x) {}b</span>
<span>a@case (1)@case (2) {b}c</span>
-------------------
{ printWidth: 100 }
-------------------
<p>a@if (x) {b}c</p>
<span>@if (test) {text}</span>
<p>
<b>a</b>@for (i of items; track i) {<i>{{ i }}</i>}<b>z</b>
</p>
<span>a@if (x) {}b</span>
<span>a@case (1)@case (2) {b}c</span>
===================== End =====================
@@ -0,0 +1,2 @@
<div CLASS="a b" class="c d"></div>
<iframe ALLOW="a; b"></iframe>
@@ -0,0 +1,21 @@
---
source: crates/oxc_formatter_htmlish/tests/fixtures/mod.rs
---
==================== Input ====================
<div CLASS="a b" class="c d"></div>
<iframe ALLOW="a; b"></iframe>
==================== Output ====================
------------------
{ printWidth: 80 }
------------------
<div CLASS="a b" class="c d"></div>
<iframe ALLOW="a; b"></iframe>
-------------------
{ printWidth: 100 }
-------------------
<div CLASS="a b" class="c d"></div>
<iframe ALLOW="a; b"></iframe>
===================== End =====================
@@ -0,0 +1,24 @@
<ul>
@for (item of items; track item.id) {
<li>{{ item.name }}</li>
} @empty {
<li>none</li>
}
</ul>
@if (a) { <p>x</p> }
@else { <p>y</p> }
@if (a) {}
@if (isDev) {} @else if (test) {}
<p>text @if (x) { more } end</p>
@switch (mode) {
@case ("a")
@case ("b") {
<p>ab</p>
}
@default never;
}
@defer (on viewport) {
<x-chart />
} @placeholder (minimum 500ms) {
<p>loading</p>
}
@@ -0,0 +1,107 @@
---
source: crates/oxc_formatter_htmlish/tests/fixtures/mod.rs
---
==================== Input ====================
<ul>
@for (item of items; track item.id) {
<li>{{ item.name }}</li>
} @empty {
<li>none</li>
}
</ul>
@if (a) { <p>x</p> }
@else { <p>y</p> }
@if (a) {}
@if (isDev) {} @else if (test) {}
<p>text @if (x) { more } end</p>
@switch (mode) {
@case ("a")
@case ("b") {
<p>ab</p>
}
@default never;
}
@defer (on viewport) {
<x-chart />
} @placeholder (minimum 500ms) {
<p>loading</p>
}
==================== Output ====================
------------------
{ printWidth: 80 }
------------------
<ul>
@for (item of items; track item.id) {
<li>{{ item.name }}</li>
} @empty {
<li>none</li>
}
</ul>
@if (a) {
<p>x</p>
} @else {
<p>y</p>
}
@if (a) {}
@if (isDev) {
} @else if (test) {}
<p>
text
@if (x) {
more
}
end
</p>
@switch (mode) {
@case ("a")
@case ("b") {
<p>ab</p>
}
@default never;
}
@defer (on viewport) {
<x-chart />
} @placeholder (minimum 500ms) {
<p>loading</p>
}
-------------------
{ printWidth: 100 }
-------------------
<ul>
@for (item of items; track item.id) {
<li>{{ item.name }}</li>
} @empty {
<li>none</li>
}
</ul>
@if (a) {
<p>x</p>
} @else {
<p>y</p>
}
@if (a) {}
@if (isDev) {
} @else if (test) {}
<p>
text
@if (x) {
more
}
end
</p>
@switch (mode) {
@case ("a")
@case ("b") {
<p>ab</p>
}
@default never;
}
@defer (on viewport) {
<x-chart />
} @placeholder (minimum 500ms) {
<p>loading</p>
}
===================== End =====================
@@ -0,0 +1 @@
<div [class.[&>svg]:w-4]="x" [a]="b">y</div>
@@ -0,0 +1,18 @@
---
source: crates/oxc_formatter_htmlish/tests/fixtures/mod.rs
---
==================== Input ====================
<div [class.[&>svg]:w-4]="x" [a]="b">y</div>
==================== Output ====================
------------------
{ printWidth: 80 }
------------------
<div [class.[&>svg]:w-4]="x" [a]="b">y</div>
-------------------
{ printWidth: 100 }
-------------------
<div [class.[&>svg]:w-4]="x" [a]="b">y</div>
===================== End =====================
@@ -0,0 +1 @@
<div>@switch (a) {@case (1)@case (2) {x}}</div>
@@ -0,0 +1,32 @@
---
source: crates/oxc_formatter_htmlish/tests/fixtures/mod.rs
---
==================== Input ====================
<div>@switch (a) {@case (1)@case (2) {x}}</div>
==================== Output ====================
------------------
{ printWidth: 80 }
------------------
<div>
@switch (a) {
@case (1)
@case (2) {
x
}
}
</div>
-------------------
{ printWidth: 100 }
-------------------
<div>
@switch (a) {
@case (1)
@case (2) {
x
}
}
</div>
===================== End =====================
@@ -0,0 +1,144 @@
//! What formatting must not change, as one string per document:
//! elements (name, attributes, end tag presence; names in any case where known ones print lowercase),
//! comments as written, a doctype in any case,
//! attribute values with their quote entities read back (a class list as its names, `srcset` as its URLs, `allow` as its directives),
//! and text as its words (whitespace runs may be re-flowed), except where whitespace is content:
//! `pre` as written, a `script` / `style` body as its lines (re-indented, blank ones at its ends dropped).
use std::{borrow::Cow, fmt::Write as _};
use cow_utils::CowUtils;
use oxc_htmlish_parser::ast::{Namespace, NodeId, NodeKind, Root};
/// `fold_names`: a `.html` file, where known names print lowercase (DIVERGENCES.md#tag-name-case).
pub fn fingerprint(source: &str, root: &Root<'_>, fold_names: bool) -> String {
let mut out = String::new();
walk(source, root, NodeId::DOCUMENT, Text::Words, fold_names, &mut out);
out
}
/// How a text keeps its whitespace.
#[derive(Clone, Copy)]
enum Text {
Words,
Lines,
AsWritten,
}
fn walk(
source: &str,
root: &Root<'_>,
parent: NodeId,
text: Text,
fold_names: bool,
out: &mut String,
) {
for (id, node) in root.children(parent) {
match &node.kind {
NodeKind::Element(el) => {
let name = fold(el.name.slice(source), fold_names);
write!(out, "<{name}").unwrap();
for attribute in &el.attributes {
let attribute_name = attribute.name.slice(source);
write!(out, " {}", fold(attribute_name, fold_names)).unwrap();
let Some(value) = attribute.value else { continue };
let value = value.span.slice(source).cow_replace("&quot;", "\"");
let value = value.cow_replace("&apos;", "'");
let is = |name: &str| attribute_name.eq_ignore_ascii_case(name);
out.push('=');
if is("class") {
for name in value.split_ascii_whitespace() {
write!(out, " {name:?}").unwrap();
}
} else if is("srcset") {
// The URLs; descriptors are re-printed as numbers.
for candidate in value.split(',') {
if let Some(url) = candidate.split_ascii_whitespace().next() {
write!(out, " {url:?}").unwrap();
}
}
} else if is("allow") {
for directive in value.split(';') {
let words: Vec<&str> = directive.split_ascii_whitespace().collect();
if !words.is_empty() {
write!(out, " {:?}", words.join(" ")).unwrap();
}
}
} else {
write!(out, "{value:?}").unwrap();
}
}
out.push('>');
// The formatter's `pre_like` and `script_like`, restated rather than shared
// so that the check stays independent of the code under test.
let text = if el.namespace != Namespace::Svg
&& matches!(el.key, "pre" | "textarea" | "listing" | "plaintext" | "xmp")
{
Text::AsWritten
} else if matches!(el.key, "script" | "style") {
Text::Lines
} else {
text
};
walk(source, root, id, text, fold_names, out);
if el.end_tag.is_some() {
write!(out, "</{name}>").unwrap();
}
}
NodeKind::Text(_) => {
let value = node.span.slice(source);
match text {
Text::AsWritten => write!(out, "{value:?}").unwrap(),
Text::Lines => {
for line in value.trim_ascii().lines() {
write!(out, "{:?}", line.trim_ascii()).unwrap();
}
}
Text::Words => {
for word in value.split_ascii_whitespace() {
write!(out, " {word}").unwrap();
}
}
}
}
NodeKind::ConditionalComment(comment) => {
write!(out, "[cc {}]", words(comment.condition.slice(source))).unwrap();
if comment.parsed {
walk(source, root, id, text, fold_names, out);
} else {
write!(out, "{:?}", comment.content.slice(source)).unwrap();
}
}
// The expression's tokens may be re-flowed until it is formatted.
NodeKind::Interpolation(interpolation) => {
write!(out, "{{{{{}}}}}", words(interpolation.expression.slice(source))).unwrap();
}
NodeKind::Block(block) => {
write!(out, "[@{}", words(block.name.slice(source))).unwrap();
for parameter in &block.parameters {
write!(out, " ({})", words(parameter.slice(source))).unwrap();
}
out.push('{');
walk(source, root, id, text, fold_names, out);
out.push_str(if block.close.is_some() { "}]" } else { "]" });
}
// HTML reads a doctype ASCII case-insensitively
NodeKind::DocType(_) => {
write!(out, "[{:?}]", words(&node.span.slice(source).cow_to_ascii_lowercase()))
.unwrap();
}
_ => write!(out, "[{:?}]", node.span.slice(source)).unwrap(),
}
}
}
/// `name`, ASCII-lowercased with `fold_names`.
fn fold(name: &str, fold_names: bool) -> Cow<'_, str> {
if fold_names { name.cow_to_ascii_lowercase() } else { Cow::Borrowed(name) }
}
/// `text`'s words, one space apart.
fn words(text: &str) -> String {
text.split_ascii_whitespace().collect::<Vec<_>>().join(" ")
}
@@ -0,0 +1 @@
<div><p>Hello <b>world</b>!</p></div>
@@ -0,0 +1,22 @@
---
source: crates/oxc_formatter_htmlish/tests/fixtures/mod.rs
---
==================== Input ====================
<div><p>Hello <b>world</b>!</p></div>
==================== Output ====================
------------------
{ printWidth: 80 }
------------------
<div>
<p>Hello <b>world</b>!</p>
</div>
-------------------
{ printWidth: 100 }
-------------------
<div>
<p>Hello <b>world</b>!</p>
</div>
===================== End =====================
@@ -0,0 +1,18 @@
<div><p><![CDATA[x]]></p></div>
<div><p><![CDATA[x]]> here</p></div>
<div>
<![CDATA[x]]>
</div>
<div>
<![CDATA[x]]> here
</div>
<pre class="aaaaaaaaaaaaaaaaaaaaaa bbbbbbbbbbbbbbbbbbbbbbbbbbbbbb cccccccccccccccccccccccccccc"><![CDATA[x]]> a
b</pre>
<div>
<pre><![CDATA[x]]> a
</pre>
</div>
<div>
<pre>a <![CDATA[x]]>
</pre>
</div>
@@ -0,0 +1,69 @@
---
source: crates/oxc_formatter_htmlish/tests/fixtures/mod.rs
---
==================== Input ====================
<div><p><![CDATA[x]]></p></div>
<div><p><![CDATA[x]]> here</p></div>
<div>
<![CDATA[x]]>
</div>
<div>
<![CDATA[x]]> here
</div>
<pre class="aaaaaaaaaaaaaaaaaaaaaa bbbbbbbbbbbbbbbbbbbbbbbbbbbbbb cccccccccccccccccccccccccccc"><![CDATA[x]]> a
b</pre>
<div>
<pre><![CDATA[x]]> a
</pre>
</div>
<div>
<pre>a <![CDATA[x]]>
</pre>
</div>
==================== Output ====================
------------------
{ printWidth: 80 }
------------------
<div><p><![CDATA[x]]></p></div>
<div><p><![CDATA[x]]> here</p></div>
<div><![CDATA[x]]></div>
<div><![CDATA[x]]> here</div>
<pre
class="aaaaaaaaaaaaaaaaaaaaaa bbbbbbbbbbbbbbbbbbbbbbbbbbbbbb cccccccccccccccccccccccccccc"
>
<![CDATA[x]]> a
b</pre>
<div>
<pre>
<![CDATA[x]]> a
</pre>
</div>
<div>
<pre>
a <![CDATA[x]]>
</pre>
</div>
-------------------
{ printWidth: 100 }
-------------------
<div><p><![CDATA[x]]></p></div>
<div><p><![CDATA[x]]> here</p></div>
<div><![CDATA[x]]></div>
<div><![CDATA[x]]> here</div>
<pre class="aaaaaaaaaaaaaaaaaaaaaa bbbbbbbbbbbbbbbbbbbbbbbbbbbbbb cccccccccccccccccccccccccccc">
<![CDATA[x]]> a
b</pre>
<div>
<pre>
<![CDATA[x]]> a
</pre>
</div>
<div>
<pre>
a <![CDATA[x]]>
</pre>
</div>
===================== End =====================
@@ -0,0 +1 @@
<p>text <![CDATA[aaaa bbbb cccc dddd eeee ffff gggg hhhh iiii jjjj kkkk llll mmmm nnnn oooo pppp]]> more</p>
@@ -0,0 +1,25 @@
---
source: crates/oxc_formatter_htmlish/tests/fixtures/mod.rs
---
==================== Input ====================
<p>text <![CDATA[aaaa bbbb cccc dddd eeee ffff gggg hhhh iiii jjjj kkkk llll mmmm nnnn oooo pppp]]> more</p>
==================== Output ====================
------------------
{ printWidth: 80 }
------------------
<p>
text
<![CDATA[aaaa bbbb cccc dddd eeee ffff gggg hhhh iiii jjjj kkkk llll mmmm nnnn oooo pppp]]>
more
</p>
-------------------
{ printWidth: 100 }
-------------------
<p>
text <![CDATA[aaaa bbbb cccc dddd eeee ffff gggg hhhh iiii jjjj kkkk llll mmmm nnnn oooo pppp]]>
more
</p>
===================== End =====================
@@ -0,0 +1,2 @@
<div class="a b c"></div>
<div class=" a "></div>
@@ -0,0 +1,21 @@
---
source: crates/oxc_formatter_htmlish/tests/fixtures/mod.rs
---
==================== Input ====================
<div class="a b c"></div>
<div class=" a "></div>
==================== Output ====================
------------------
{ printWidth: 80 }
------------------
<div class="a b c"></div>
<div class=" a"></div>
-------------------
{ printWidth: 100 }
-------------------
<div class="a b c"></div>
<div class=" a"></div>
===================== End =====================
@@ -0,0 +1,4 @@
[
{ "endOfLine": "lf" },
{ "endOfLine": "crlf" }
]
@@ -0,0 +1,27 @@
<div>
<!-- prettier-ignore -->
<div class="a" >
kept as written
</div>
<pre>
line 1
line 2
</pre>
<!--
multi-line
comment
-->
<script>
const a = 1
foo( a )
</script>
<style>
a { color : red }
</style>
<p title="first
second">x</p>
<textarea>
value
</textarea>
</div>
@@ -0,0 +1,174 @@
---
source: crates/oxc_formatter_htmlish/tests/fixtures/mod.rs
---
==================== Input ====================
<div>
<!-- prettier-ignore -->
<div class="a" >
kept as written
</div>
<pre>
line 1
line 2
</pre>
<!--
multi-line
comment
-->
<script>
const a = 1
foo( a )
</script>
<style>
a { color : red }
</style>
<p title="first
second">x</p>
<textarea>
value
</textarea>
</div>
==================== Output ====================
-----------------------------------
{ endOfLine: "lf", printWidth: 80 }
-----------------------------------
<div>
<!-- prettier-ignore -->
<div class="a" >
kept as written
</div>
<pre>
line 1
line 2
</pre>
<!--
multi-line
comment
-->
<script>
const a = 1
foo( a )
</script>
<style>
a { color : red }
</style>
<p
title="first
second"
>
x
</p>
<textarea>
value
</textarea>
</div>
------------------------------------
{ endOfLine: "lf", printWidth: 100 }
------------------------------------
<div>
<!-- prettier-ignore -->
<div class="a" >
kept as written
</div>
<pre>
line 1
line 2
</pre>
<!--
multi-line
comment
-->
<script>
const a = 1
foo( a )
</script>
<style>
a { color : red }
</style>
<p
title="first
second"
>
x
</p>
<textarea>
value
</textarea>
</div>
-------------------------------------
{ endOfLine: "crlf", printWidth: 80 }
-------------------------------------
<div>
<!-- prettier-ignore -->
<div class="a" >
kept as written
</div>
<pre>
line 1
line 2
</pre>
<!--
multi-line
comment
-->
<script>
const a = 1
foo( a )
</script>
<style>
a { color : red }
</style>
<p
title="first
second"
>
x
</p>
<textarea>
value
</textarea>
</div>
--------------------------------------
{ endOfLine: "crlf", printWidth: 100 }
--------------------------------------
<div>
<!-- prettier-ignore -->
<div class="a" >
kept as written
</div>
<pre>
line 1
line 2
</pre>
<!--
multi-line
comment
-->
<script>
const a = 1
foo( a )
</script>
<style>
a { color : red }
</style>
<p
title="first
second"
>
x
</p>
<textarea>
value
</textarea>
</div>
===================== End =====================
@@ -0,0 +1,7 @@
<main><b>a</b><dialog></dialog><b>b</b></main>
<p>Open the <dialog open>settings</dialog> panel</p>
<body>
<div>before</div>
<dialog id="d"><form method="dialog"><p>Are you sure?</p><button>OK</button></form></dialog>
<div>after</div>
</body>
@@ -0,0 +1,46 @@
---
source: crates/oxc_formatter_htmlish/tests/fixtures/mod.rs
---
==================== Input ====================
<main><b>a</b><dialog></dialog><b>b</b></main>
<p>Open the <dialog open>settings</dialog> panel</p>
<body>
<div>before</div>
<dialog id="d"><form method="dialog"><p>Are you sure?</p><button>OK</button></form></dialog>
<div>after</div>
</body>
==================== Output ====================
------------------
{ printWidth: 80 }
------------------
<main><b>a</b><dialog></dialog><b>b</b></main>
<p>Open the <dialog open>settings</dialog> panel</p>
<body>
<div>before</div>
<dialog id="d">
<form method="dialog">
<p>Are you sure?</p>
<button>OK</button>
</form>
</dialog>
<div>after</div>
</body>
-------------------
{ printWidth: 100 }
-------------------
<main><b>a</b><dialog></dialog><b>b</b></main>
<p>Open the <dialog open>settings</dialog> panel</p>
<body>
<div>before</div>
<dialog id="d">
<form method="dialog">
<p>Are you sure?</p>
<button>OK</button>
</form>
</dialog>
<div>after</div>
</body>
===================== End =====================
@@ -0,0 +1,2 @@
<!DOCTYPE HTML>
<p>x</p>
@@ -0,0 +1,21 @@
---
source: crates/oxc_formatter_htmlish/tests/fixtures/mod.rs
---
==================== Input ====================
<!DOCTYPE HTML>
<p>x</p>
==================== Output ====================
------------------
{ printWidth: 80 }
------------------
<!doctype html>
<p>x</p>
-------------------
{ printWidth: 100 }
-------------------
<!doctype html>
<p>x</p>
===================== End =====================
@@ -0,0 +1,2 @@
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml"><body><p>a<br/>b</p></body></html>
@@ -0,0 +1,29 @@
---
source: crates/oxc_formatter_htmlish/tests/fixtures/mod.rs
---
==================== Input ====================
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml"><body><p>a<br/>b</p></body></html>
==================== Output ====================
------------------
{ printWidth: 80 }
------------------
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml">
<body>
<p>a<br />b</p>
</body>
</html>
-------------------
{ printWidth: 100 }
-------------------
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml">
<body>
<p>a<br />b</p>
</body>
</html>
===================== End =====================
@@ -0,0 +1,12 @@
<div>
<div>
<!--
comment
-->
</div>
<div>
<!--
comment
-->
</div>
</div>
@@ -0,0 +1,49 @@
---
source: crates/oxc_formatter_htmlish/tests/fixtures/mod.rs
---
==================== Input ====================
<div>
<div>
<!--
comment
-->
</div>
<div>
<!--
comment
-->
</div>
</div>
==================== Output ====================
------------------
{ printWidth: 80 }
------------------
<div>
<div>
<!--
comment
-->
</div>
<div>
<!--
comment
--></div>
</div>
-------------------
{ printWidth: 100 }
-------------------
<div>
<div>
<!--
comment
-->
</div>
<div>
<!--
comment
--></div>
</div>
===================== End =====================
@@ -0,0 +1 @@
<div>a</ div>b
@@ -0,0 +1,18 @@
---
source: crates/oxc_formatter_htmlish/tests/fixtures/mod.rs
---
==================== Input ====================
<div>a</ div>b
==================== Output ====================
------------------
{ printWidth: 80 }
------------------
<div>a</ div>b
-------------------
{ printWidth: 100 }
-------------------
<div>a</ div>b
===================== End =====================
@@ -0,0 +1,2 @@
<div><p>a<//></div>
<span>a<b>x<//>c</span>
@@ -0,0 +1,21 @@
---
source: crates/oxc_formatter_htmlish/tests/fixtures/mod.rs
---
==================== Input ====================
<div><p>a<//></div>
<span>a<b>x<//>c</span>
==================== Output ====================
------------------
{ printWidth: 80 }
------------------
<div><p>a<//></div>
<span>a<b>x<//>c</span>
-------------------
{ printWidth: 100 }
-------------------
<div><p>a<//></div>
<span>a<b>x<//>c</span>
===================== End =====================
@@ -0,0 +1,2 @@
<p>CDATA: <![CDATA[text]]> here.</p>
<p>a <![CDATA[x]]> b <![CDATA[y]]> c</p>
@@ -0,0 +1,21 @@
---
source: crates/oxc_formatter_htmlish/tests/fixtures/mod.rs
---
==================== Input ====================
<p>CDATA: <![CDATA[text]]> here.</p>
<p>a <![CDATA[x]]> b <![CDATA[y]]> c</p>
==================== Output ====================
-------------------------------------------------------
{ htmlWhitespaceSensitivity: "ignore", printWidth: 80 }
-------------------------------------------------------
<p>CDATA: <![CDATA[text]]> here.</p>
<p>a <![CDATA[x]]> b <![CDATA[y]]> c</p>
--------------------------------------------------------
{ htmlWhitespaceSensitivity: "ignore", printWidth: 100 }
--------------------------------------------------------
<p>CDATA: <![CDATA[text]]> here.</p>
<p>a <![CDATA[x]]> b <![CDATA[y]]> c</p>
===================== End =====================
@@ -0,0 +1 @@
[{ "htmlWhitespaceSensitivity": "ignore" }]
@@ -0,0 +1,4 @@
<time><!-- prettier-ignore -->
<mark>
<input />
</mark></time>
@@ -0,0 +1,31 @@
---
source: crates/oxc_formatter_htmlish/tests/fixtures/mod.rs
---
==================== Input ====================
<time><!-- prettier-ignore -->
<mark>
<input />
</mark></time>
==================== Output ====================
------------------
{ printWidth: 80 }
------------------
<time
><!-- prettier-ignore -->
<mark>
<input />
</mark
></time>
-------------------
{ printWidth: 100 }
-------------------
<time
><!-- prettier-ignore -->
<mark>
<input />
</mark
></time>
===================== End =====================
@@ -0,0 +1,4 @@
<div><progress><progress>x</progress><wbr />
amet,
</progress><abbr></abbr></div>
@@ -0,0 +1,25 @@
---
source: crates/oxc_formatter_htmlish/tests/fixtures/mod.rs
---
==================== Input ====================
<div><progress><progress>x</progress><wbr />
amet,
</progress><abbr></abbr></div>
==================== Output ====================
------------------
{ printWidth: 80 }
------------------
<div>
<progress><progress>x</progress><wbr /> amet,</progress><abbr></abbr>
</div>
-------------------
{ printWidth: 100 }
-------------------
<div>
<progress><progress>x</progress><wbr /> amet,</progress><abbr></abbr>
</div>
===================== End =====================
@@ -0,0 +1,8 @@
<SPAN CLASS="a" ID="b">x</SPAN>
<DIV CLASS="a" ALIGN="c" xml:Lang="en">x</DIV>
<SVG VIEWBOX="0 0 1 1"><RECT WIDTH="1"/><foreignObject><DIV CLASS="z">y</DIV></foreignObject></SVG>
<MY-EL CLASS="a"></MY-EL>
<INPUT AUTOCOMPLETE="off" Type="text">
<MATH><MI>x</MI></MATH>
<DIV>x<//>
<div>a<B>x</B>c</div>
@@ -0,0 +1,45 @@
---
source: crates/oxc_formatter_htmlish/tests/fixtures/mod.rs
---
==================== Input ====================
<SPAN CLASS="a" ID="b">x</SPAN>
<DIV CLASS="a" ALIGN="c" xml:Lang="en">x</DIV>
<SVG VIEWBOX="0 0 1 1"><RECT WIDTH="1"/><foreignObject><DIV CLASS="z">y</DIV></foreignObject></SVG>
<MY-EL CLASS="a"></MY-EL>
<INPUT AUTOCOMPLETE="off" Type="text">
<MATH><MI>x</MI></MATH>
<DIV>x<//>
<div>a<B>x</B>c</div>
==================== Output ====================
------------------
{ printWidth: 80 }
------------------
<span CLASS="a" ID="b">x</span>
<div class="a" align="c" xml:Lang="en">x</div>
<svg VIEWBOX="0 0 1 1">
<RECT WIDTH="1" />
<foreignObject><DIV CLASS="z">y</DIV></foreignObject>
</svg>
<MY-EL CLASS="a"></MY-EL>
<input autocomplete="off" type="text" />
<math><MI>x</MI></math>
<div>x<//>
<div>a<b>x</b>c</div>
-------------------
{ printWidth: 100 }
-------------------
<span CLASS="a" ID="b">x</span>
<div class="a" align="c" xml:Lang="en">x</div>
<svg VIEWBOX="0 0 1 1">
<RECT WIDTH="1" />
<foreignObject><DIV CLASS="z">y</DIV></foreignObject>
</svg>
<MY-EL CLASS="a"></MY-EL>
<input autocomplete="off" type="text" />
<math><MI>x</MI></math>
<div>x<//>
<div>a<b>x</b>c</div>
===================== End =====================
@@ -0,0 +1,6 @@
<div>
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa<b >x</b
>c<i>y</i>d<em
>z</em>e
</div>
<p>a<b >x</b>c<i>y</i>d<b >z</b>e and <i>q</i>r<u >s</u>t</p>
@@ -0,0 +1,29 @@
---
source: crates/oxc_formatter_htmlish/tests/fixtures/mod.rs
---
==================== Input ====================
<div>
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa<b >x</b
>c<i>y</i>d<em
>z</em>e
</div>
<p>a<b >x</b>c<i>y</i>d<b >z</b>e and <i>q</i>r<u >s</u>t</p>
==================== Output ====================
------------------
{ printWidth: 80 }
------------------
<div>
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa<b>x</b>c<i>y</i>d<em>z</em>e
</div>
<p>a<b>x</b>c<i>y</i>d<b>z</b>e and <i>q</i>r<u>s</u>t</p>
-------------------
{ printWidth: 100 }
-------------------
<div>
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa<b>x</b>c<i>y</i>d<em>z</em>e
</div>
<p>a<b>x</b>c<i>y</i>d<b>z</b>e and <i>q</i>r<u>s</u>t</p>
===================== End =====================
@@ -0,0 +1,4 @@
<html>
<body>
<div class="wrap">
<header>Site</header>
@@ -0,0 +1,27 @@
---
source: crates/oxc_formatter_htmlish/tests/fixtures/mod.rs
---
==================== Input ====================
<html>
<body>
<div class="wrap">
<header>Site</header>
==================== Output ====================
------------------
{ printWidth: 80 }
------------------
<html>
<body>
<div class="wrap">
<header>Site</header>
-------------------
{ printWidth: 100 }
-------------------
<html>
<body>
<div class="wrap">
<header>Site</header>
===================== End =====================
@@ -0,0 +1,6 @@
<ul>
<li>one
<li>two
</ul>
<p>para
<div>block</div>
@@ -0,0 +1,33 @@
---
source: crates/oxc_formatter_htmlish/tests/fixtures/mod.rs
---
==================== Input ====================
<ul>
<li>one
<li>two
</ul>
<p>para
<div>block</div>
==================== Output ====================
------------------
{ printWidth: 80 }
------------------
<ul>
<li>one
<li>two
</ul>
<p>para
<div>block</div>
-------------------
{ printWidth: 100 }
-------------------
<ul>
<li>one
<li>two
</ul>
<p>para
<div>block</div>
===================== End =====================
@@ -0,0 +1,14 @@
<div><ul><li><pre />
<b>x</b></ul></div>
<ul><li><pre />
<b>x</b></ul>
<div><pre />
x y</div>
<div><pre />
<b>x</b></div>
<span><listing />
<i>y</i>
</span>
<div><xmp />
<b>x</b></div>
<pre><b>x</b> unclosed
@@ -0,0 +1,73 @@
---
source: crates/oxc_formatter_htmlish/tests/fixtures/mod.rs
---
==================== Input ====================
<div><ul><li><pre />
<b>x</b></ul></div>
<ul><li><pre />
<b>x</b></ul>
<div><pre />
x y</div>
<div><pre />
<b>x</b></div>
<span><listing />
<i>y</i>
</span>
<div><xmp />
<b>x</b></div>
<pre><b>x</b> unclosed
==================== Output ====================
------------------
{ printWidth: 80 }
------------------
<div>
<ul>
<li>
<pre />
<b>x</b></ul>
</div>
<ul>
<li>
<pre />
<b>x</b></ul>
<div><pre />x y</div>
<div>
<pre />
<b>x</b></div>
<span>
<listing />
<i>y</i>
</span>
<div>
<xmp />
<b>x</b></div>
<pre><b>x</b> unclosed
-------------------
{ printWidth: 100 }
-------------------
<div>
<ul>
<li>
<pre />
<b>x</b></ul>
</div>
<ul>
<li>
<pre />
<b>x</b></ul>
<div><pre />x y</div>
<div>
<pre />
<b>x</b></div>
<span>
<listing />
<i>y</i>
</span>
<div>
<xmp />
<b>x</b></div>
<pre><b>x</b> unclosed
===================== End =====================
@@ -0,0 +1,3 @@
<div><html:style>.a{color:red}</html:style></div>
<div>x<html:script>let a = 1</html:script>y</div>
<div>x<html:p>p</html:p>y</div>
@@ -0,0 +1,24 @@
---
source: crates/oxc_formatter_htmlish/tests/fixtures/mod.rs
---
==================== Input ====================
<div><html:style>.a{color:red}</html:style></div>
<div>x<html:script>let a = 1</html:script>y</div>
<div>x<html:p>p</html:p>y</div>
==================== Output ====================
------------------
{ printWidth: 80 }
------------------
<div><html:style>.a{color:red}</html:style></div>
<div>x<html:script>let a = 1</html:script>y</div>
<div>x<html:p>p</html:p>y</div>
-------------------
{ printWidth: 100 }
-------------------
<div><html:style>.a{color:red}</html:style></div>
<div>x<html:script>let a = 1</html:script>y</div>
<div>x<html:p>p</html:p>y</div>
===================== End =====================
@@ -0,0 +1,8 @@
<!-- prettier-ignore-attribute -->
<div class="a b"
title="x
y" id="z"></div>
<!-- prettier-ignore-attribute class -->
<div class="a b" CLASS="a b" title="x"></div>
<!-- oxfmt-ignore-attribute -->
<div class="a b"></div>
@@ -0,0 +1,45 @@
---
source: crates/oxc_formatter_htmlish/tests/fixtures/mod.rs
---
==================== Input ====================
<!-- prettier-ignore-attribute -->
<div class="a b"
title="x
y" id="z"></div>
<!-- prettier-ignore-attribute class -->
<div class="a b" CLASS="a b" title="x"></div>
<!-- oxfmt-ignore-attribute -->
<div class="a b"></div>
==================== Output ====================
------------------
{ printWidth: 80 }
------------------
<!-- prettier-ignore-attribute -->
<div
class="a b"
title="x
y"
id="z"
></div>
<!-- prettier-ignore-attribute class -->
<div class="a b" CLASS="a b" title="x"></div>
<!-- oxfmt-ignore-attribute -->
<div class="a b"></div>
-------------------
{ printWidth: 100 }
-------------------
<!-- prettier-ignore-attribute -->
<div
class="a b"
title="x
y"
id="z"
></div>
<!-- prettier-ignore-attribute class -->
<div class="a b" CLASS="a b" title="x"></div>
<!-- oxfmt-ignore-attribute -->
<div class="a b"></div>
===================== End =====================
@@ -0,0 +1,13 @@
<p><span>a</span><script src="a.js"></script><span>b</span></p>
<div>text<script src="a.js"></script>more text that is long enough to wrap around the line width limit</div>
<div>
<span>a</span>
<script src="a.js"></script>
<span>b</span>
</div>
<p>x<script>var y=1</script>z</p>
<p><span>a</span><style>a{color:red}</style><span>b</span></p>
<div>x</div><script>a()</script><script src="b.js"></script><div>y</div>
<div>x</div><link rel="stylesheet" href="a.css"><meta name="a" content="b"><div>y</div>
<p><span>aaaaaaaaaaaaaaaaaaaaaaaa</span> <script src="a.js"></script><span>bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb</span></p>
<div>Price: <span>10</span> <meta itemprop="priceCurrency" content="USD">USD, and some more text that wraps the line</div>
@@ -0,0 +1,118 @@
---
source: crates/oxc_formatter_htmlish/tests/fixtures/mod.rs
---
==================== Input ====================
<p><span>a</span><script src="a.js"></script><span>b</span></p>
<div>text<script src="a.js"></script>more text that is long enough to wrap around the line width limit</div>
<div>
<span>a</span>
<script src="a.js"></script>
<span>b</span>
</div>
<p>x<script>var y=1</script>z</p>
<p><span>a</span><style>a{color:red}</style><span>b</span></p>
<div>x</div><script>a()</script><script src="b.js"></script><div>y</div>
<div>x</div><link rel="stylesheet" href="a.css"><meta name="a" content="b"><div>y</div>
<p><span>aaaaaaaaaaaaaaaaaaaaaaaa</span> <script src="a.js"></script><span>bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb</span></p>
<div>Price: <span>10</span> <meta itemprop="priceCurrency" content="USD">USD, and some more text that wraps the line</div>
==================== Output ====================
------------------
{ printWidth: 80 }
------------------
<p>
<span>a</span
><script src="a.js"></script
><span>b</span>
</p>
<div>
text<script src="a.js"></script
>more text that is long enough to wrap around the line width limit
</div>
<div>
<span>a</span>
<script src="a.js"></script>
<span>b</span>
</div>
<p>
x<script>
var y=1
</script
>z
</p>
<p>
<span>a</span
><style>
a{color:red}
</style
><span>b</span>
</p>
<div>x</div>
<script>
a()
</script>
<script src="b.js"></script>
<div>y</div>
<div>x</div>
<link rel="stylesheet" href="a.css" /><meta name="a" content="b" />
<div>y</div>
<p>
<span>aaaaaaaaaaaaaaaaaaaaaaaa</span>
<script src="a.js"></script>
<span>bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb</span>
</p>
<div>
Price: <span>10</span> <meta itemprop="priceCurrency" content="USD" /> USD,
and some more text that wraps the line
</div>
-------------------
{ printWidth: 100 }
-------------------
<p>
<span>a</span
><script src="a.js"></script
><span>b</span>
</p>
<div>
text<script src="a.js"></script
>more text that is long enough to wrap around the line width limit
</div>
<div>
<span>a</span>
<script src="a.js"></script>
<span>b</span>
</div>
<p>
x<script>
var y=1
</script
>z
</p>
<p>
<span>a</span
><style>
a{color:red}
</style
><span>b</span>
</p>
<div>x</div>
<script>
a()
</script>
<script src="b.js"></script>
<div>y</div>
<div>x</div>
<link rel="stylesheet" href="a.css" /><meta name="a" content="b" />
<div>y</div>
<p>
<span>aaaaaaaaaaaaaaaaaaaaaaaa</span>
<script src="a.js"></script>
<span>bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb</span>
</p>
<div>
Price: <span>10</span> <meta itemprop="priceCurrency" content="USD" /> USD, and some more text
that wraps the line
</div>
===================== End =====================
@@ -0,0 +1,2 @@
<div />after</div>
<section><div />a<p>b</p></section>
@@ -0,0 +1,29 @@
---
source: crates/oxc_formatter_htmlish/tests/fixtures/mod.rs
---
==================== Input ====================
<div />after</div>
<section><div />a<p>b</p></section>
==================== Output ====================
------------------
{ printWidth: 80 }
------------------
<div />after</div>
<section>
<div />
a
<p>b</p>
</section>
-------------------
{ printWidth: 100 }
-------------------
<div />after</div>
<section>
<div />
a
<p>b</p>
</section>
===================== End =====================
@@ -0,0 +1,13 @@
<span><i class="a" /><i class="b" /></span>
<div><my-el />
</div>
<div><my-el />
<p>x</p>
</div>
<span><my-el /><!-- c --></span>
<div><a-el /><b-el />text</div>
<!--[if IE]><!--><my-el /><!--<![endif]-->
<ul><li><my-el /></li></ul>
<!-- prettier-ignore -->
<my-el />
<p>after</p>
@@ -0,0 +1,58 @@
---
source: crates/oxc_formatter_htmlish/tests/fixtures/mod.rs
---
==================== Input ====================
<span><i class="a" /><i class="b" /></span>
<div><my-el />
</div>
<div><my-el />
<p>x</p>
</div>
<span><my-el /><!-- c --></span>
<div><a-el /><b-el />text</div>
<!--[if IE]><!--><my-el /><!--<![endif]-->
<ul><li><my-el /></li></ul>
<!-- prettier-ignore -->
<my-el />
<p>after</p>
==================== Output ====================
------------------
{ printWidth: 80 }
------------------
<span><i class="a" /><i class="b" /></span>
<div><my-el /></div>
<div>
<my-el />
<p>x</p>
</div>
<span><my-el /><!-- c --></span>
<div><a-el /><b-el />text</div>
<!--[if IE]><!--><my-el /><!--<![endif]-->
<ul>
<li><my-el /></li>
</ul>
<!-- prettier-ignore -->
<my-el />
<p>after</p>
-------------------
{ printWidth: 100 }
-------------------
<span><i class="a" /><i class="b" /></span>
<div><my-el /></div>
<div>
<my-el />
<p>x</p>
</div>
<span><my-el /><!-- c --></span>
<div><a-el /><b-el />text</div>
<!--[if IE]><!--><my-el /><!--<![endif]-->
<ul>
<li><my-el /></li>
</ul>
<!-- prettier-ignore -->
<my-el />
<p>after</p>
===================== End =====================
@@ -0,0 +1,2 @@
<img srcset="a.png 0x, b.png 2x">
<img srcset="a.png -0x, b.png 1e21x">
@@ -0,0 +1,21 @@
---
source: crates/oxc_formatter_htmlish/tests/fixtures/mod.rs
---
==================== Input ====================
<img srcset="a.png 0x, b.png 2x">
<img srcset="a.png -0x, b.png 1e21x">
==================== Output ====================
------------------
{ printWidth: 80 }
------------------
<img srcset="a.png 0x, b.png 2x" />
<img srcset="a.png 0x, b.png 1e+21x" />
-------------------
{ printWidth: 100 }
-------------------
<img srcset="a.png 0x, b.png 2x" />
<img srcset="a.png 0x, b.png 1e+21x" />
===================== End =====================
@@ -0,0 +1,3 @@
<p><b>a</b>
b
</p><i></i>
@@ -0,0 +1,20 @@
---
source: crates/oxc_formatter_htmlish/tests/fixtures/mod.rs
---
==================== Input ====================
<p><b>a</b>
b
</p><i></i>
==================== Output ====================
-------------------------------------------------------
{ htmlWhitespaceSensitivity: "strict", printWidth: 80 }
-------------------------------------------------------
<p><b>a</b> b </p><i></i>
--------------------------------------------------------
{ htmlWhitespaceSensitivity: "strict", printWidth: 100 }
--------------------------------------------------------
<p><b>a</b> b </p><i></i>
===================== End =====================
@@ -0,0 +1 @@
[{ "htmlWhitespaceSensitivity": "strict" }]
@@ -0,0 +1,3 @@
<b>a</b><pre><b>x</b></pre>
<b>a</b><pre>
<b>x</b></pre>
@@ -0,0 +1,24 @@
---
source: crates/oxc_formatter_htmlish/tests/fixtures/mod.rs
---
==================== Input ====================
<b>a</b><pre><b>x</b></pre>
<b>a</b><pre>
<b>x</b></pre>
==================== Output ====================
-------------------------------------------------------
{ htmlWhitespaceSensitivity: "strict", printWidth: 80 }
-------------------------------------------------------
<b>a</b><pre><b>x</b></pre> <b>a</b
><pre>
<b>x</b></pre>
--------------------------------------------------------
{ htmlWhitespaceSensitivity: "strict", printWidth: 100 }
--------------------------------------------------------
<b>a</b><pre><b>x</b></pre> <b>a</b
><pre>
<b>x</b></pre>
===================== End =====================
@@ -0,0 +1,4 @@
<span><pre />
</span>
<span>a<pre />
</span>
@@ -0,0 +1,23 @@
---
source: crates/oxc_formatter_htmlish/tests/fixtures/mod.rs
---
==================== Input ====================
<span><pre />
</span>
<span>a<pre />
</span>
==================== Output ====================
-------------------------------------------------------
{ htmlWhitespaceSensitivity: "strict", printWidth: 80 }
-------------------------------------------------------
<span><pre /></span>
<span>a<pre /></span>
--------------------------------------------------------
{ htmlWhitespaceSensitivity: "strict", printWidth: 100 }
--------------------------------------------------------
<span><pre /></span>
<span>a<pre /></span>
===================== End =====================
Loaded 100 of 123 files, more files were not shown because too many files have changed in this diff. Show more