feat(oxfmt)!: format parser:html files by oxc_formatter_htmlish (#28273)

Fixes #28263 

- Before this PR: `.html` and also html-in-{js,md,etc} are formatted by
Prettier.
  - Because of that, we had to run JS, which became an overhead.
- After this PR: All of them are formatted natively by
`oxc_formatter_htmlish`.
  - No overhead.

At the same time, since we are fixing Prettier bugs and inconsistent
behavior, please refer to DIVERGENCES.md for the differences.
This commit is contained in:
Yuji Sugiura authored and GitHub committed 2026-10-30 16:29:13 +09:00
1 parent 44d8c2b824
commit 296623d868
86 files changed
+11121 -577

No files matched your search

+3 -3
View File
@@ -325,13 +325,13 @@ jobs:
- uses: $/.github/actions/check-changes
id: filter
with:
paths: apps/oxfmt/,apps/shared/,npm/oxfmt/,crates/oxc_formatter/,crates/oxc_formatter_core/,crates/oxc_formatter_css/,crates/oxc_formatter_graphql/,crates/oxc_formatter_json/,crates/oxc_formatter_yaml/,crates/oxc_formatter_markdown/,crates/oxc_formatter_toml/,crates/oxc_config/,pnpm-lock.yaml,package.json
paths: apps/oxfmt/,apps/shared/,npm/oxfmt/,crates/oxc_formatter/,crates/oxc_formatter_core/,crates/oxc_formatter_css/,crates/oxc_formatter_graphql/,crates/oxc_formatter_json/,crates/oxc_formatter_yaml/,crates/oxc_formatter_markdown/,crates/oxc_formatter_htmlish/,crates/oxc_formatter_toml/,crates/oxc_config/,pnpm-lock.yaml,package.json
token: ${{ secrets.GITHUB_TOKEN }}
pr-number: ${{ github.event.pull_request.number }}
- uses: $/.github/actions/check-changes
id: filter-conformance
with:
paths: apps/oxfmt/,npm/oxfmt/,crates/oxc_formatter/,crates/oxc_formatter_core/,crates/oxc_formatter_css/,crates/oxc_formatter_graphql/,crates/oxc_formatter_json/,crates/oxc_formatter_yaml/,crates/oxc_formatter_markdown/,crates/oxc_formatter_toml/,pnpm-lock.yaml,package.json
paths: apps/oxfmt/,npm/oxfmt/,crates/oxc_formatter/,crates/oxc_formatter_core/,crates/oxc_formatter_css/,crates/oxc_formatter_graphql/,crates/oxc_formatter_json/,crates/oxc_formatter_yaml/,crates/oxc_formatter_markdown/,crates/oxc_formatter_htmlish/,crates/oxc_formatter_toml/,pnpm-lock.yaml,package.json
token: ${{ secrets.GITHUB_TOKEN }}
pr-number: ${{ github.event.pull_request.number }}
- uses: oxc-project/setup-node@f46a72f95efdc55273fcd042d61c84e723b2892c # v1.4.1
@@ -510,7 +510,7 @@ jobs:
- uses: $/.github/actions/check-changes
id: filter
with:
paths: apps/oxfmt/,apps/shared/,npm/oxfmt/,crates/oxc_formatter/,crates/oxc_formatter_core/,crates/oxc_formatter_css/,crates/oxc_formatter_graphql/,crates/oxc_formatter_json/,crates/oxc_formatter_yaml/,crates/oxc_formatter_markdown/,crates/oxc_formatter_toml/,crates/oxc_config/,pnpm-lock.yaml,package.json
paths: apps/oxfmt/,apps/shared/,npm/oxfmt/,crates/oxc_formatter/,crates/oxc_formatter_core/,crates/oxc_formatter_css/,crates/oxc_formatter_graphql/,crates/oxc_formatter_json/,crates/oxc_formatter_yaml/,crates/oxc_formatter_markdown/,crates/oxc_formatter_htmlish/,crates/oxc_formatter_toml/,crates/oxc_config/,pnpm-lock.yaml,package.json
token: ${{ secrets.GITHUB_TOKEN }}
pr-number: ${{ github.event.pull_request.number }}
- uses: samypr100/setup-dev-drive@562171fe8df7401fdf582d766cd3b6ab80d5b1a0 # v4.1.0
Generated
+1
View File
@@ -2933,6 +2933,7 @@ dependencies = [
"oxc_formatter_core",
"oxc_formatter_css",
"oxc_formatter_graphql",
"oxc_formatter_htmlish",
"oxc_formatter_json",
"oxc_formatter_markdown",
"oxc_formatter_toml",
+1
View File
@@ -164,6 +164,7 @@ oxc_formatter = { path = "crates/oxc_formatter" } # Code formatting
oxc_formatter_core = { path = "crates/oxc_formatter_core" } # Language-agnostic formatter core
oxc_formatter_css = { path = "crates/oxc_formatter_css" } # CSS formatter
oxc_formatter_graphql = { path = "crates/oxc_formatter_graphql" } # GraphQL formatter
oxc_formatter_htmlish = { path = "crates/oxc_formatter_htmlish" } # HTML (and HTML-based template languages) formatter
oxc_formatter_json = { path = "crates/oxc_formatter_json" } # JSON formatter
oxc_formatter_markdown = { path = "crates/oxc_formatter_markdown" } # Markdown formatter
oxc_formatter_tests = { path = "crates/oxc_formatter_tests" } # Formatter test infrastructure (fixture codegen + harness)
+7 -8
View File
@@ -99,16 +99,16 @@ Embedded languages (e.g. css-in-js, CSS front matter YAML) go through the `Forma
Routing is ONE table (`route_embedded` in `src/core/language.rs`, which also maps the LSP `languageId`):
- `Native` languages (js/ts/css/graphql/yaml/json/markdown/toml/...) get a Rust branch
- `Prettier` set (html/angular/vue/svelte/astro/handlebars/mdx) goes to the Prettier Doc→IR channel (`embed/prettier_doc.rs`)
- `Native` languages (js/ts/css/graphql/yaml/json/markdown/toml/html/...) get a Rust branch
- `Prettier` set (angular/vue/svelte/astro/handlebars/mdx) goes to the Prettier Doc→IR channel (`embed/prettier_doc.rs`)
- everything else is deliberately preserved
Language-pair data crosses as `DispatchRequest::parent_context` markers the dispatcher translates for the child, so no language crate depends on another.
Vocabulary: "fallback" = the dispatcher's optional `PrettierDocFallback` slot (a build/root may not install one).
The pure Rust build runs fallback-less, so non-native embeds (html-in-js, custom front matter) deliberately stay verbatim.
The pure Rust build runs fallback-less, so non-native embeds (angular-in-js, custom front matter) deliberately stay verbatim.
The roots install `SessionServices`, all via `embed/services.rs::for_root` (one definition per build; the napi one takes the `ExternalServices` transport, and adds the Prettier fallback / string embedder / Tailwind sorter to the registry dispatcher): the JS/TS, CSS and Markdown file roots (`core/format.rs`, `PhysicalFile` sessions, both builds) and the Vue/Svelte/Astro `<script>` and Astro frontmatter root (`api/text_to_doc_api.rs`, `VirtualDocument` session, napi only).
The roots install `SessionServices`, all via `embed/services.rs::for_root` (one definition per build; the napi one takes the `ExternalServices` transport, and adds the Prettier fallback / string embedder / Tailwind sorter to the registry dispatcher): the JS/TS, CSS, Markdown and HTML file roots (`core/format.rs`, `PhysicalFile` sessions, both builds) and the Vue/Svelte/Astro `<script>` and Astro frontmatter root (`api/text_to_doc_api.rs`, `VirtualDocument` session, napi only).
Which languages may dispatch AT ALL from a given host is the host crate's own gate (e.g. `oxc_formatter_css` dispatches only `yaml`/`toml` front matter, `oxc_formatter_markdown` every fenced code block's language); the shared `route_embedded()` table then decides who serves the language.
Adding a dispatch call to a host crate is therefore a routing decision (check `route_embedded()` and the embedded conformance when doing so. A root needing a bespoke service set would assemble the `SessionServices` struct literally), none does today.
@@ -120,12 +120,11 @@ Per-language options are NOT built up front: `ResolvedDispatchConfig` maps them
A separate string-out channel (the session's `string_embedder` service, NOT the dispatcher) carries JSDoc's string-in/string-out consumer:
- JSDoc fenced code blocks: the host (`oxc_formatter`) embeds only the `prettier-plugin-jsdoc` set (css/less/scss/json/yaml/html), then routing follows ONE rule, the same `route_embedded` table
- a `Native` fence language formats through `FormatSession::dispatch_to_string` via a thin string adapter (`embed/jsdoc_fence.rs::format_native_fence`, EVERY build, the pure Rust build wires it via `services::for_root`)
- html fences stay on the Prettier string path (`embed/prettier_string.rs`, napi only; its Doc→IR conversion has unrepresentable cases);
- a `Native` fence language (the whole set) formats through `FormatSession::dispatch_to_string` via a thin string adapter (`embed/jsdoc_fence.rs`, EVERY build, wired by `services::for_root`)
- everything else stays verbatim
- the embedder carries the caller's effective print width; both branches honor it (native via `PrintWidth` override, Prettier via `printWidth` in the options JSON), so a fence prints at the same width a JS/TS snippet in the same position would (see `upstream-jsdoc-bugs.md` #11 for the deliberate divergence from upstream's flat `printWidth - 4`)
- the embedder carries the caller's effective print width (a `PrintWidth` override), so a fence prints at the same width a JS/TS snippet in the same position would (see `upstream-jsdoc-bugs.md` #11 for the deliberate divergence from upstream's flat `printWidth - 4`)
NOTE: The string-out channel outlives the html rewrite; its full exit criterion is owned by `oxc_formatter_core`'s AGENTS.md (domain (4)).
NOTE: The string-out channel outlives the html rewrite; its exit criterion is owned by `oxc_formatter_core`'s AGENTS.md (domain (4)).
The half owned here: JSDoc's string-out is NOT structural, fences can move to IR-out (session dispatch inside the comment IR).
The printer already has the per-line prefix mechanism the `*` continuation needs (`prefix_align`); what remains is JSDoc emitting its output as lines plus embedded IR instead of one string, deferred for verification time, not by design.
+1
View File
@@ -34,6 +34,7 @@ oxc_formatter = { workspace = true }
oxc_formatter_core = { workspace = true }
oxc_formatter_css = { workspace = true }
oxc_formatter_graphql = { workspace = true }
oxc_formatter_htmlish = { workspace = true }
oxc_formatter_json = { workspace = true }
oxc_formatter_markdown = { workspace = true }
oxc_formatter_toml = { workspace = true }
+111
View File
@@ -304,3 +304,114 @@ title: Home
A front matter block whose body starts with an indented delimiter (` ---`, ` ...`, ` +++`) stays verbatim in CSS and Markdown (`write_front_matter` in `oxc_formatter_core`).
The indented delimiter does not close the block, but the body is trimmed before formatting, so it prints at the start of a line.
On the next parse (Prettier's and ours), the block closes there and the rest of the body moves into the document.
## script-end-in-embedded-template
- Why: semantics (the first `</script` ends an HTML `<script>`, the rest of the code becomes markup)
- Pin: `conformance/fixtures/edge-cases/html/script-end-in-gql-template.html`
```html
<!-- input -->
<script>
const query = gql`
query {
field # <\/script>
}
`;
</script>
<!-- ours -->
<script>
const query = gql`
query {
field # <\/script>
}
`;
</script>
<!-- prettier -->
<script>
const query = gql`
query {
field # </script>
}
`;
</script>
```
An HTML `<script>` body never prints `</script`: one the formatting produces (an embedded template part printed from its cooked value, html-in-js or gql-in-js) prints as `<\/script`.
Prettier keeps it in html-in-js, css-in-js and markdown-in-js, but a gql-in-js part prints the cooked `</script`, which closes the `<script>` there.
## placeholder-case-in-child
- Why: semantics (Prettier prints a lowercased placeholder in place of the `${}` expression)
- Pin: `conformance/fixtures/edge-cases/html-in-js/placeholder-case-in-child.js`
```js
// input
const a = html`<style>a { ${b}: ${c} }</style>`;
// ours
const a = html`<style>
a {
${b}: ${c};
}
</style>`;
// prettier
const a = html`<style>
a {
prettier_html_placeholder_0_0_in_js: ${c};
}
</style>`;
```
An html-in-js placeholder is found in the child's output ASCII case-insensitively (`HTML_PLACEHOLDER_PREFIX` in `oxc_formatter`):
CSS lowercases a property name, and the placeholder standing in it with it.
Prettier looks for the placeholder as it wrote it, so the lowercased one stays in the output and `${b}` is lost.
## pre-like-template-expression
- Why: uniform-rule (same construct, same output: an expression outside `pre`-like content, whose lines indent from the line its `${` starts on; prettier/prettier#20355)
- Pin: `conformance/fixtures/edge-cases/html-in-js/pre-like-template-expression.js`
- Conformance: `edge-cases/html-in-js/template-expression-indent.js`, `externals/prettier/js/embeded/indention/19518.js`
```js
// input
html`
<form>
<textarea>
${foo({
x: 1,
})}</textarea>
</form>
`;
// ours
html`
<form>
<textarea>
${foo({
x: 1,
})}</textarea>
</form>
`;
// prettier
html`
<form>
<textarea>
${foo({
x: 1,
})}</textarea>
</form>
`;
```
A `${}` expression in `pre`-like content (`pre`, `textarea`, ...) indents its lines from the whitespace its line starts with,
as an expression elsewhere does from the line its `${` starts on.
A line of that content prints from the root, so the expression aligns there (`oxc_formatter_htmlish`'s `write_pre_like_text`);
one on the line of an element before it (`<pre><b>x</b>${`) indents from that line, as elsewhere.
Prettier 3.9.6 aligned an expression starting its line so; since 3.9.7 (prettier/prettier#19725, for idempotency) it indents it by the elements around the content instead,
which leaves `})` right of the `${` it closes.
Reformatting the expression changes no content: its source is code, not text in the element.
@@ -92,6 +92,24 @@ const sources = [
repo: "mdn/content/files/en-us/web/css/guides",
version: "5137b45128dcf07ac636da68184f00aab30ec1cc",
},
// html
{
name: "vite",
repo: "vitejs/vite/playground",
version: "v8.3.5",
},
{
name: "mdn-learning-area",
repo: "mdn/learning-area",
// No tags; pin to a commit (2026-10 main)
version: "21973263ba859ff3477919087b488157f9d618a6",
},
{
name: "mdn-dom-examples",
repo: "mdn/dom-examples",
// No tags; pin to a commit (2026-10 main)
version: "03ae1ffb325a9202d3a64f26ab5b8361392a5368",
},
// jsdoc
{
name: "svelte",
@@ -0,0 +1,2 @@
const a = html`<style>a { ${b}: ${c} }</style>`;
const d = html`<style>${e} { color: red }</style><p>${f}</p>`;
@@ -0,0 +1,40 @@
// prettier/prettier#20355
html`
<html>
<body>
<form>
<textarea>
${Match.valueTags(form, {
EmptyForm: () => '',
InvalidForm: () => '',
CompletedForm: () => '',
})}</textarea>
</form>
</body>
</html>
`;
const indented = html`
<form>
<pre>
${foo({
x: 1,
})}</pre>
</form>
`;
const afterText = html`<pre>
a ${foo({
x: 1,
})} b</pre>`;
const sameLine = html`<pre>
${a({
x: 1,
})} ${b({
y: 2,
})}</pre>`;
const afterElement = html`<div><pre><b>x</b>${foo({
x: 1,
})}</pre></div>`;
@@ -0,0 +1,7 @@
<script>
const query = gql`
query {
field # <\/script>
}
`;
</script>
+24
View File
@@ -230,6 +230,30 @@ const categories: Category[] = [
// mdn's GitHub alerts and macros would fail on known divergences (`line-shapes`) in bulk.
optionSets: [{ printWidth: 80, bracketSameLine: true }, { printWidth: 100 }],
},
{
name: "html",
fixtures: [
{
dir: join(EXTERNALS_DIR, "prettier", "html"),
ext: ".html",
// Parser error messages, cursor offsets and pragma insertion are not formatter concerns
excludes: ["/_errors_/", "html/cursor/", "html/pragma/"],
},
{ dir: join(FIXTURES_DIR, "edge-cases", "html") },
],
// Real-world pages: demos and playgrounds, dense with `<script>` / `<style>`
projects: [
// vite's `invalid*.html` are deliberately broken, both sides error
{ dir: join(EXTERNALS_DIR, "vite"), ext: ".html", excludes: ["html/invalid"] },
{ dir: join(EXTERNALS_DIR, "mdn-learning-area"), ext: ".html" },
{ dir: join(EXTERNALS_DIR, "mdn-dom-examples"), ext: ".html" },
],
optionSets: [
{ printWidth: 80 },
{ printWidth: 100, htmlWhitespaceSensitivity: "ignore", singleAttributePerLine: true },
{ printWidth: 120, htmlWhitespaceSensitivity: "strict", bracketSameLine: true },
],
},
{
name: "graphql",
fixtures: [],
@@ -74,12 +74,20 @@
## html-in-js
### Option 1: 174/198 (87.88%)
### Option 1: 172/200 (86.00%)
```json
{"printWidth":80}
```
- [edge-cases/html-in-js/placeholder-case-in-child.js](diffs/html-in-js/edge-cases__html-in-js__placeholder-case-in-child.js.md)
- apps/oxfmt/DIVERGENCES.md#placeholder-case-in-child
- [edge-cases/html-in-js/pre-like-template-expression.js](diffs/html-in-js/edge-cases__html-in-js__pre-like-template-expression.js.md)
- apps/oxfmt/DIVERGENCES.md#pre-like-template-expression
- [edge-cases/html-in-js/template-expression-indent.js](diffs/html-in-js/edge-cases__html-in-js__template-expression-indent.js.md)
- apps/oxfmt/DIVERGENCES.md#pre-like-template-expression
- [externals/prettier/js/embeded/indention/19518.js](diffs/html-in-js/externals__prettier__js__embeded__indention__19518.js.md)
- apps/oxfmt/DIVERGENCES.md#pre-like-template-expression
- [externals/webawesome/badge/badge.ts](diffs/html-in-js/externals__webawesome__badge__badge.ts.md)
- crates/oxc_formatter/DIVERGENCES.md#union-annotation-flat-retry
- [externals/webawesome/button/button.ts](diffs/html-in-js/externals__webawesome__button__button.ts.md)
@@ -129,12 +137,20 @@
- [externals/webawesome/textarea/textarea.ts](diffs/html-in-js/externals__webawesome__textarea__textarea.ts.md)
- crates/oxc_formatter/DIVERGENCES.md#union-annotation-flat-retry
### Option 2: 188/198 (94.95%)
### Option 2: 186/200 (93.00%)
```json
{"printWidth":100,"htmlWhitespaceSensitivity":"ignore"}
```
- [edge-cases/html-in-js/placeholder-case-in-child.js](diffs/html-in-js/edge-cases__html-in-js__placeholder-case-in-child.js.md)
- apps/oxfmt/DIVERGENCES.md#placeholder-case-in-child
- [edge-cases/html-in-js/pre-like-template-expression.js](diffs/html-in-js/edge-cases__html-in-js__pre-like-template-expression.js.md)
- apps/oxfmt/DIVERGENCES.md#pre-like-template-expression
- [edge-cases/html-in-js/template-expression-indent.js](diffs/html-in-js/edge-cases__html-in-js__template-expression-indent.js.md)
- apps/oxfmt/DIVERGENCES.md#pre-like-template-expression
- [externals/prettier/js/embeded/indention/19518.js](diffs/html-in-js/externals__prettier__js__embeded__indention__19518.js.md)
- apps/oxfmt/DIVERGENCES.md#pre-like-template-expression
- [externals/webawesome/button/button.ts](diffs/html-in-js/externals__webawesome__button__button.ts.md)
- crates/oxc_formatter/DIVERGENCES.md#union-annotation-flat-retry
- [externals/webawesome/callout/callout.ts](diffs/html-in-js/externals__webawesome__callout__callout.ts.md)
@@ -318,7 +334,7 @@
## markdown-mdn
### Option 1: 360/362 (99.45%)
### Option 1: 357/362 (98.62%)
```json
{"printWidth":80,"bracketSameLine":true}
@@ -328,8 +344,14 @@
- crates/oxc_formatter_css/DIVERGENCES.md#value-semicolon-glue
- [externals/mdn-css-guides/values_and_units/textual_data_types/index.md](diffs/markdown-mdn/externals__mdn-css-guides__values_and_units__textual_data_types__index.md.md)
- crates/oxc_formatter_css/DIVERGENCES.md#prelude-string-requote
- [externals/mdn-learn/css_layout/multiple-column_layout/index.md](diffs/markdown-mdn/externals__mdn-learn__css_layout__multiple-column_layout__index.md.md)
- crates/oxc_formatter_htmlish/DIVERGENCES.md#doctype-case
- [externals/mdn-learn/css_layout/positioning/index.md](diffs/markdown-mdn/externals__mdn-learn__css_layout__positioning__index.md.md)
- crates/oxc_formatter_htmlish/DIVERGENCES.md#doctype-case
- [externals/mdn-learn/structuring_content/basic_html_syntax/index.md](diffs/markdown-mdn/externals__mdn-learn__structuring_content__basic_html_syntax__index.md.md)
- crates/oxc_formatter_htmlish/DIVERGENCES.md#unquoted-value-with-slash
### Option 2: 360/362 (99.45%)
### Option 2: 357/362 (98.62%)
```json
{"printWidth":100}
@@ -339,6 +361,69 @@
- crates/oxc_formatter_css/DIVERGENCES.md#value-semicolon-glue
- [externals/mdn-css-guides/values_and_units/textual_data_types/index.md](diffs/markdown-mdn/externals__mdn-css-guides__values_and_units__textual_data_types__index.md.md)
- crates/oxc_formatter_css/DIVERGENCES.md#prelude-string-requote
- [externals/mdn-learn/css_layout/multiple-column_layout/index.md](diffs/markdown-mdn/externals__mdn-learn__css_layout__multiple-column_layout__index.md.md)
- crates/oxc_formatter_htmlish/DIVERGENCES.md#doctype-case
- [externals/mdn-learn/css_layout/positioning/index.md](diffs/markdown-mdn/externals__mdn-learn__css_layout__positioning__index.md.md)
- crates/oxc_formatter_htmlish/DIVERGENCES.md#doctype-case
- [externals/mdn-learn/structuring_content/basic_html_syntax/index.md](diffs/markdown-mdn/externals__mdn-learn__structuring_content__basic_html_syntax__index.md.md)
- crates/oxc_formatter_htmlish/DIVERGENCES.md#unquoted-value-with-slash
## html
### Option 1: 617/623 (99.04%)
```json
{"printWidth":80}
```
- [edge-cases/html/script-end-in-gql-template.html](diffs/html/edge-cases__html__script-end-in-gql-template.html.md)
- apps/oxfmt/DIVERGENCES.md#script-end-in-embedded-template
- [externals/prettier/html/basics/broken-html.html](diffs/html/externals__prettier__html__basics__broken-html.html.md)
- crates/oxc_formatter_htmlish/DIVERGENCES.md#unclosed-at-end
- [externals/prettier/html/basics/with-colon.html](diffs/html/externals__prettier__html__basics__with-colon.html.md)
- crates/oxc_formatter_htmlish/DIVERGENCES.md#prefixed-script-style
- [externals/prettier/html/prettier_ignore/unclosed2.html](diffs/html/externals__prettier__html__prettier_ignore__unclosed2.html.md)
- crates/oxc_formatter_htmlish/DIVERGENCES.md#unclosed-at-end
- [externals/prettier/html/tags/tags2.html](diffs/html/externals__prettier__html__tags__tags2.html.md)
- crates/oxc_formatter_htmlish/DIVERGENCES.md#dialog-out-of-flow
- [externals/vite/html/valid.html](diffs/html/externals__vite__html__valid.html.md)
- crates/oxc_formatter_htmlish/DIVERGENCES.md#unquoted-value-with-slash
### Option 2: 618/623 (99.20%)
```json
{"printWidth":100,"htmlWhitespaceSensitivity":"ignore","singleAttributePerLine":true}
```
- [edge-cases/html/script-end-in-gql-template.html](diffs/html/edge-cases__html__script-end-in-gql-template.html.md)
- apps/oxfmt/DIVERGENCES.md#script-end-in-embedded-template
- [externals/prettier/html/basics/broken-html.html](diffs/html/externals__prettier__html__basics__broken-html.html.md)
- crates/oxc_formatter_htmlish/DIVERGENCES.md#unclosed-at-end
- [externals/prettier/html/basics/with-colon.html](diffs/html/externals__prettier__html__basics__with-colon.html.md)
- crates/oxc_formatter_htmlish/DIVERGENCES.md#prefixed-script-style
- [externals/prettier/html/prettier_ignore/unclosed2.html](diffs/html/externals__prettier__html__prettier_ignore__unclosed2.html.md)
- crates/oxc_formatter_htmlish/DIVERGENCES.md#unclosed-at-end
- [externals/vite/html/valid.html](diffs/html/externals__vite__html__valid.html.md)
- crates/oxc_formatter_htmlish/DIVERGENCES.md#unquoted-value-with-slash
### Option 3: 617/623 (99.04%)
```json
{"printWidth":120,"htmlWhitespaceSensitivity":"strict","bracketSameLine":true}
```
- [edge-cases/html/script-end-in-gql-template.html](diffs/html/edge-cases__html__script-end-in-gql-template.html.md)
- apps/oxfmt/DIVERGENCES.md#script-end-in-embedded-template
- [externals/prettier/html/basics/broken-html.html](diffs/html/externals__prettier__html__basics__broken-html.html.md)
- crates/oxc_formatter_htmlish/DIVERGENCES.md#unclosed-at-end
- [externals/prettier/html/basics/with-colon.html](diffs/html/externals__prettier__html__basics__with-colon.html.md)
- crates/oxc_formatter_htmlish/DIVERGENCES.md#prefixed-script-style
- [externals/prettier/html/prettier_ignore/unclosed2.html](diffs/html/externals__prettier__html__prettier_ignore__unclosed2.html.md)
- crates/oxc_formatter_htmlish/DIVERGENCES.md#unclosed-at-end
- [externals/prettier/html/script/script.html](diffs/html/externals__prettier__html__script__script.html.md)
- crates/oxc_formatter_htmlish/DIVERGENCES.md#script-display-none
- [externals/vite/html/valid.html](diffs/html/externals__vite__html__valid.html.md)
- crates/oxc_formatter_htmlish/DIVERGENCES.md#unquoted-value-with-slash
## graphql
@@ -0,0 +1,126 @@
# edge-cases/html-in-js/placeholder-case-in-child.js
## Option 1
`````json
{"printWidth":80}
`````
### Diff
`````diff
===================================================================
--- prettier
+++ oxfmt
@@ -1,7 +1,7 @@
const a = html`<style>
a {
- prettier_html_placeholder_0_13_in_js: ${c};
+ ${b}: ${c};
}
</style>`;
const d = html`<style>
${e} {
`````
### Actual (oxfmt)
`````js
const a = html`<style>
a {
${b}: ${c};
}
</style>`;
const d = html`<style>
${e} {
color: red;
}
</style>
<p>${f}</p>`;
`````
### Expected (prettier)
`````js
const a = html`<style>
a {
prettier_html_placeholder_0_13_in_js: ${c};
}
</style>`;
const d = html`<style>
${e} {
color: red;
}
</style>
<p>${f}</p>`;
`````
## Option 2
`````json
{"printWidth":100,"htmlWhitespaceSensitivity":"ignore"}
`````
### Diff
`````diff
===================================================================
--- prettier
+++ oxfmt
@@ -1,8 +1,8 @@
const a = html`
<style>
a {
- prettier_html_placeholder_0_2989_in_js: ${c};
+ ${b}: ${c};
}
</style>
`;
const d = html`
`````
### Actual (oxfmt)
`````js
const a = html`
<style>
a {
${b}: ${c};
}
</style>
`;
const d = html`
<style>
${e} {
color: red;
}
</style>
<p>${f}</p>
`;
`````
### Expected (prettier)
`````js
const a = html`
<style>
a {
prettier_html_placeholder_0_2989_in_js: ${c};
}
</style>
`;
const d = html`
<style>
${e} {
color: red;
}
</style>
<p>${f}</p>
`;
`````
@@ -0,0 +1,327 @@
# edge-cases/html-in-js/pre-like-template-expression.js
## Option 1
`````json
{"printWidth":80}
`````
### Diff
`````diff
===================================================================
--- prettier
+++ oxfmt
@@ -4,12 +4,12 @@
<body>
<form>
<textarea>
${Match.valueTags(form, {
- EmptyForm: () => "",
- InvalidForm: () => "",
- CompletedForm: () => "",
- })}</textarea>
+ EmptyForm: () => "",
+ InvalidForm: () => "",
+ CompletedForm: () => "",
+})}</textarea>
</form>
</body>
</html>
`;
@@ -17,17 +17,17 @@
const indented = html`
<form>
<pre>
${foo({
- x: 1,
- })}</pre>
+ x: 1,
+ })}</pre>
</form>
`;
const afterText = html`<pre>
a ${foo({
- x: 1,
- })} b</pre>`;
+ x: 1,
+})} b</pre>`;
const sameLine = html`<pre>
${a({
x: 1,
`````
### Actual (oxfmt)
`````js
// prettier/prettier#20355
html`
<html>
<body>
<form>
<textarea>
${Match.valueTags(form, {
EmptyForm: () => "",
InvalidForm: () => "",
CompletedForm: () => "",
})}</textarea>
</form>
</body>
</html>
`;
const indented = html`
<form>
<pre>
${foo({
x: 1,
})}</pre>
</form>
`;
const afterText = html`<pre>
a ${foo({
x: 1,
})} b</pre>`;
const sameLine = html`<pre>
${a({
x: 1,
})} ${b({
y: 2,
})}</pre>`;
const afterElement = html`<div>
<pre><b>x</b>${foo({
x: 1,
})}</pre>
</div>`;
`````
### Expected (prettier)
`````js
// prettier/prettier#20355
html`
<html>
<body>
<form>
<textarea>
${Match.valueTags(form, {
EmptyForm: () => "",
InvalidForm: () => "",
CompletedForm: () => "",
})}</textarea>
</form>
</body>
</html>
`;
const indented = html`
<form>
<pre>
${foo({
x: 1,
})}</pre>
</form>
`;
const afterText = html`<pre>
a ${foo({
x: 1,
})} b</pre>`;
const sameLine = html`<pre>
${a({
x: 1,
})} ${b({
y: 2,
})}</pre>`;
const afterElement = html`<div>
<pre><b>x</b>${foo({
x: 1,
})}</pre>
</div>`;
`````
## Option 2
`````json
{"printWidth":100,"htmlWhitespaceSensitivity":"ignore"}
`````
### Diff
`````diff
===================================================================
--- prettier
+++ oxfmt
@@ -4,12 +4,12 @@
<body>
<form>
<textarea>
${Match.valueTags(form, {
- EmptyForm: () => "",
- InvalidForm: () => "",
- CompletedForm: () => "",
- })}</textarea>
+ EmptyForm: () => "",
+ InvalidForm: () => "",
+ CompletedForm: () => "",
+})}</textarea>
</form>
</body>
</html>
`;
@@ -17,27 +17,27 @@
const indented = html`
<form>
<pre>
${foo({
- x: 1,
- })}</pre>
+ x: 1,
+ })}</pre>
</form>
`;
const afterText = html`
<pre>
a ${foo({
- x: 1,
- })} b</pre>
+ x: 1,
+})} b</pre>
`;
const sameLine = html`
<pre>
${a({
- x: 1,
- })} ${b({
- y: 2,
- })}</pre>
+ x: 1,
+ })} ${b({
+ y: 2,
+ })}</pre>
`;
const afterElement = html`
<div>
`````
### Actual (oxfmt)
`````js
// prettier/prettier#20355
html`
<html>
<body>
<form>
<textarea>
${Match.valueTags(form, {
EmptyForm: () => "",
InvalidForm: () => "",
CompletedForm: () => "",
})}</textarea>
</form>
</body>
</html>
`;
const indented = html`
<form>
<pre>
${foo({
x: 1,
})}</pre>
</form>
`;
const afterText = html`
<pre>
a ${foo({
x: 1,
})} b</pre>
`;
const sameLine = html`
<pre>
${a({
x: 1,
})} ${b({
y: 2,
})}</pre>
`;
const afterElement = html`
<div>
<pre><b>x</b>${foo({
x: 1,
})}</pre>
</div>
`;
`````
### Expected (prettier)
`````js
// prettier/prettier#20355
html`
<html>
<body>
<form>
<textarea>
${Match.valueTags(form, {
EmptyForm: () => "",
InvalidForm: () => "",
CompletedForm: () => "",
})}</textarea>
</form>
</body>
</html>
`;
const indented = html`
<form>
<pre>
${foo({
x: 1,
})}</pre>
</form>
`;
const afterText = html`
<pre>
a ${foo({
x: 1,
})} b</pre>
`;
const sameLine = html`
<pre>
${a({
x: 1,
})} ${b({
y: 2,
})}</pre>
`;
const afterElement = html`
<div>
<pre><b>x</b>${foo({
x: 1,
})}</pre>
</div>
`;
`````
@@ -0,0 +1,285 @@
# edge-cases/html-in-js/template-expression-indent.js
## Option 1
`````json
{"printWidth":80}
`````
### Diff
`````diff
===================================================================
--- prettier
+++ oxfmt
@@ -34,11 +34,11 @@
return html`
<div>
<pre>
${JSON.stringify({
- a: 1,
- b: 2,
- })}</pre>
+ a: 1,
+ b: 2,
+})}</pre>
</div>
`;
}
`````
### Actual (oxfmt)
`````js
_ = html`
<div>
${
a + //
b
}
</div>
`;
// prettier/prettier#19518: nested embeds were not idempotent
const t = html`
<ol>
${items.map(
(entry) => html`
<li>
${
entry.children
? html`
<ol>
${entry.children.map(
(child) => html`<li>${child.title}</li>`,
)}
</ol>
`
: entry.title
}
</li>
`,
)}
</ol>
`;
export function foo() {
return html`
<div>
<pre>
${JSON.stringify({
a: 1,
b: 2,
})}</pre>
</div>
`;
}
const a = html`
${{
c: y,
}}
`;
`````
### Expected (prettier)
`````js
_ = html`
<div>
${
a + //
b
}
</div>
`;
// prettier/prettier#19518: nested embeds were not idempotent
const t = html`
<ol>
${items.map(
(entry) => html`
<li>
${
entry.children
? html`
<ol>
${entry.children.map(
(child) => html`<li>${child.title}</li>`,
)}
</ol>
`
: entry.title
}
</li>
`,
)}
</ol>
`;
export function foo() {
return html`
<div>
<pre>
${JSON.stringify({
a: 1,
b: 2,
})}</pre>
</div>
`;
}
const a = html`
${{
c: y,
}}
`;
`````
## Option 2
`````json
{"printWidth":100,"htmlWhitespaceSensitivity":"ignore"}
`````
### Diff
`````diff
===================================================================
--- prettier
+++ oxfmt
@@ -36,11 +36,11 @@
return html`
<div>
<pre>
${JSON.stringify({
- a: 1,
- b: 2,
- })}</pre>
+ a: 1,
+ b: 2,
+})}</pre>
</div>
`;
}
`````
### Actual (oxfmt)
`````js
_ = html`
<div>
${
a + //
b
}
</div>
`;
// prettier/prettier#19518: nested embeds were not idempotent
const t = html`
<ol>
${items.map(
(entry) => html`
<li>
${
entry.children
? html`
<ol>
${entry.children.map(
(child) => html`
<li>${child.title}</li>
`,
)}
</ol>
`
: entry.title
}
</li>
`,
)}
</ol>
`;
export function foo() {
return html`
<div>
<pre>
${JSON.stringify({
a: 1,
b: 2,
})}</pre>
</div>
`;
}
const a = html`
${{
c: y,
}}
`;
`````
### Expected (prettier)
`````js
_ = html`
<div>
${
a + //
b
}
</div>
`;
// prettier/prettier#19518: nested embeds were not idempotent
const t = html`
<ol>
${items.map(
(entry) => html`
<li>
${
entry.children
? html`
<ol>
${entry.children.map(
(child) => html`
<li>${child.title}</li>
`,
)}
</ol>
`
: entry.title
}
</li>
`,
)}
</ol>
`;
export function foo() {
return html`
<div>
<pre>
${JSON.stringify({
a: 1,
b: 2,
})}</pre>
</div>
`;
}
const a = html`
${{
c: y,
}}
`;
`````
@@ -0,0 +1,245 @@
# externals/prettier/js/embeded/indention/19518.js
## Option 1
`````json
{"printWidth":80}
`````
### Diff
`````diff
===================================================================
--- prettier
+++ oxfmt
@@ -24,11 +24,11 @@
return html`
<div>
<pre>
${JSON.stringify({
- a: 1,
- b: 2,
- })}</pre>
+ a: 1,
+ b: 2,
+})}</pre>
</div>
`;
}
`````
### Actual (oxfmt)
`````js
const t = html`
<ol>
${items.map(
(entry) => html`
<li>
${
entry.children
? html`
<ol>
${entry.children.map(
(child) => html`<li>${child.title}</li>`,
)}
</ol>
`
: entry.title
}
</li>
`,
)}
</ol>
`;
export function foo() {
return html`
<div>
<pre>
${JSON.stringify({
a: 1,
b: 2,
})}</pre>
</div>
`;
}
const a = html`
${{
c: y,
}}
`;
`````
### Expected (prettier)
`````js
const t = html`
<ol>
${items.map(
(entry) => html`
<li>
${
entry.children
? html`
<ol>
${entry.children.map(
(child) => html`<li>${child.title}</li>`,
)}
</ol>
`
: entry.title
}
</li>
`,
)}
</ol>
`;
export function foo() {
return html`
<div>
<pre>
${JSON.stringify({
a: 1,
b: 2,
})}</pre>
</div>
`;
}
const a = html`
${{
c: y,
}}
`;
`````
## Option 2
`````json
{"printWidth":100,"htmlWhitespaceSensitivity":"ignore"}
`````
### Diff
`````diff
===================================================================
--- prettier
+++ oxfmt
@@ -26,11 +26,11 @@
return html`
<div>
<pre>
${JSON.stringify({
- a: 1,
- b: 2,
- })}</pre>
+ a: 1,
+ b: 2,
+})}</pre>
</div>
`;
}
`````
### Actual (oxfmt)
`````js
const t = html`
<ol>
${items.map(
(entry) => html`
<li>
${
entry.children
? html`
<ol>
${entry.children.map(
(child) => html`
<li>${child.title}</li>
`,
)}
</ol>
`
: entry.title
}
</li>
`,
)}
</ol>
`;
export function foo() {
return html`
<div>
<pre>
${JSON.stringify({
a: 1,
b: 2,
})}</pre>
</div>
`;
}
const a = html`
${{
c: y,
}}
`;
`````
### Expected (prettier)
`````js
const t = html`
<ol>
${items.map(
(entry) => html`
<li>
${
entry.children
? html`
<ol>
${entry.children.map(
(child) => html`
<li>${child.title}</li>
`,
)}
</ol>
`
: entry.title
}
</li>
`,
)}
</ol>
`;
export function foo() {
return html`
<div>
<pre>
${JSON.stringify({
a: 1,
b: 2,
})}</pre>
</div>
`;
}
const a = html`
${{
c: y,
}}
`;
`````
@@ -0,0 +1,151 @@
# edge-cases/html/script-end-in-gql-template.html
## Option 1
`````json
{"printWidth":80}
`````
### Diff
`````diff
===================================================================
--- prettier
+++ oxfmt
@@ -1,7 +1,7 @@
<script>
const query = gql`
query {
- field # </script>
+ field # <\/script>
}
`;
</script>
`````
### Actual (oxfmt)
`````html
<script>
const query = gql`
query {
field # <\/script>
}
`;
</script>
`````
### Expected (prettier)
`````html
<script>
const query = gql`
query {
field # </script>
}
`;
</script>
`````
## Option 2
`````json
{"printWidth":100,"htmlWhitespaceSensitivity":"ignore","singleAttributePerLine":true}
`````
### Diff
`````diff
===================================================================
--- prettier
+++ oxfmt
@@ -1,7 +1,7 @@
<script>
const query = gql`
query {
- field # </script>
+ field # <\/script>
}
`;
</script>
`````
### Actual (oxfmt)
`````html
<script>
const query = gql`
query {
field # <\/script>
}
`;
</script>
`````
### Expected (prettier)
`````html
<script>
const query = gql`
query {
field # </script>
}
`;
</script>
`````
## Option 3
`````json
{"printWidth":120,"htmlWhitespaceSensitivity":"strict","bracketSameLine":true}
`````
### Diff
`````diff
===================================================================
--- prettier
+++ oxfmt
@@ -1,7 +1,7 @@
<script>
const query = gql`
query {
- field # </script>
+ field # <\/script>
}
`;
</script>
`````
### Actual (oxfmt)
`````html
<script>
const query = gql`
query {
field # <\/script>
}
`;
</script>
`````
### Expected (prettier)
`````html
<script>
const query = gql`
query {
field # </script>
}
`;
</script>
`````
@@ -0,0 +1,157 @@
# externals/prettier/html/basics/broken-html.html
## Option 1
`````json
{"printWidth":80}
`````
### Diff
`````diff
===================================================================
--- prettier
+++ oxfmt
@@ -5,5 +5,5 @@
linebreak
`<`
extra space(s)
-->
-<div><span> < </span></div>
+<div><span> <
`````
### Actual (oxfmt)
`````html
<!--
#7241,
reproduction:
two different element,
linebreak
`<`
extra space(s)
-->
<div><span> <
`````
### Expected (prettier)
`````html
<!--
#7241,
reproduction:
two different element,
linebreak
`<`
extra space(s)
-->
<div><span> < </span></div>
`````
## Option 2
`````json
{"printWidth":100,"htmlWhitespaceSensitivity":"ignore","singleAttributePerLine":true}
`````
### Diff
`````diff
===================================================================
--- prettier
+++ oxfmt
@@ -5,5 +5,5 @@
linebreak
`<`
extra space(s)
-->
-<div><span><</span></div>
+<div><span><
`````
### Actual (oxfmt)
`````html
<!--
#7241,
reproduction:
two different element,
linebreak
`<`
extra space(s)
-->
<div><span><
`````
### Expected (prettier)
`````html
<!--
#7241,
reproduction:
two different element,
linebreak
`<`
extra space(s)
-->
<div><span><</span></div>
`````
## Option 3
`````json
{"printWidth":120,"htmlWhitespaceSensitivity":"strict","bracketSameLine":true}
`````
### Diff
`````diff
===================================================================
--- prettier
+++ oxfmt
@@ -5,5 +5,5 @@
linebreak
`<`
extra space(s)
-->
-<div><span> < </span></div>
+<div><span> <
`````
### Actual (oxfmt)
`````html
<!--
#7241,
reproduction:
two different element,
linebreak
`<`
extra space(s)
-->
<div><span> <
`````
### Expected (prettier)
`````html
<!--
#7241,
reproduction:
two different element,
linebreak
`<`
extra space(s)
-->
<div><span> < </span></div>
`````
@@ -0,0 +1,133 @@
# externals/prettier/html/prettier_ignore/unclosed2.html
## Option 1
`````json
{"printWidth":80}
`````
### Diff
`````diff
===================================================================
--- prettier
+++ oxfmt
@@ -1,5 +1,5 @@
<!-- prettier-ignore -->
<p>
# Hello world!
-<div></div>
+<div>
`````
### Actual (oxfmt)
`````html
<!-- prettier-ignore -->
<p>
# Hello world!
<div>
`````
### Expected (prettier)
`````html
<!-- prettier-ignore -->
<p>
# Hello world!
<div></div>
`````
## Option 2
`````json
{"printWidth":100,"htmlWhitespaceSensitivity":"ignore","singleAttributePerLine":true}
`````
### Diff
`````diff
===================================================================
--- prettier
+++ oxfmt
@@ -1,5 +1,5 @@
<!-- prettier-ignore -->
<p>
# Hello world!
-<div></div>
+<div>
`````
### Actual (oxfmt)
`````html
<!-- prettier-ignore -->
<p>
# Hello world!
<div>
`````
### Expected (prettier)
`````html
<!-- prettier-ignore -->
<p>
# Hello world!
<div></div>
`````
## Option 3
`````json
{"printWidth":120,"htmlWhitespaceSensitivity":"strict","bracketSameLine":true}
`````
### Diff
`````diff
===================================================================
--- prettier
+++ oxfmt
@@ -1,5 +1,5 @@
<!-- prettier-ignore -->
<p>
# Hello world!
-<div> </div>
+<div>
`````
### Actual (oxfmt)
`````html
<!-- prettier-ignore -->
<p>
# Hello world!
<div>
`````
### Expected (prettier)
`````html
<!-- prettier-ignore -->
<p>
# Hello world!
<div> </div>
`````
@@ -0,0 +1,113 @@
# externals/prettier/html/script/script.html
## Option 3
`````json
{"printWidth":120,"htmlWhitespaceSensitivity":"strict","bracketSameLine":true}
`````
### Diff
`````diff
===================================================================
--- prettier
+++ oxfmt
@@ -7,9 +7,10 @@
<script type="importmap">
{ "json": true }
</script>
<script type="systemjs-importmap">
- { "json": true }</script
+ { "json": true }
+</script
><script type="invalid">
{ "json":false }
</script>
<script type="text/html">
`````
### Actual (oxfmt)
`````html
<script type="application/ld+json">
{ "json": true }
</script>
<script type="application/json">
{ "json": true }
</script>
<script type="importmap">
{ "json": true }
</script>
<script type="systemjs-importmap">
{ "json": true }
</script
><script type="invalid">
{ "json":false }
</script>
<script type="text/html">
<div>
<p>foo</p>
</div>
</script>
<script async="" id="" src="/_next/static/development/pages/_app.js?ts=1565732195968"></script
><script></script>
<!-- #8147 -->
<script lang="vbscript">
Function hello()
End Function
</script>
<script lang="unknown"></script>
<script type="speculationrules">
{
"prerender": [{ "source": "list", "urls": ["https://a.test/foo"] }]
}
</script>
`````
### Expected (prettier)
`````html
<script type="application/ld+json">
{ "json": true }
</script>
<script type="application/json">
{ "json": true }
</script>
<script type="importmap">
{ "json": true }
</script>
<script type="systemjs-importmap">
{ "json": true }</script
><script type="invalid">
{ "json":false }
</script>
<script type="text/html">
<div>
<p>foo</p>
</div>
</script>
<script async="" id="" src="/_next/static/development/pages/_app.js?ts=1565732195968"></script
><script></script>
<!-- #8147 -->
<script lang="vbscript">
Function hello()
End Function
</script>
<script lang="unknown"></script>
<script type="speculationrules">
{
"prerender": [{ "source": "list", "urls": ["https://a.test/foo"] }]
}
</script>
`````
@@ -0,0 +1,121 @@
# externals/prettier/html/tags/tags2.html
## Option 1
`````json
{"printWidth":80}
`````
### Diff
`````diff
===================================================================
--- prettier
+++ oxfmt
@@ -12,11 +12,10 @@
after
</div>
<div>
- before
- <dialog open>dialog long long long long long long long long</dialog>
- after
+ before<dialog open>dialog long long long long long long long long</dialog
+ >after
</div>
<div>
before<object data="horse.wav">
`````
### Actual (oxfmt)
`````html
<div>
before<noscript>noscript long long long long long long long long</noscript
>after
</div>
<div>
before
<details>
<summary>summary long long long long</summary>
details
</details>
after
</div>
<div>
before<dialog open>dialog long long long long long long long long</dialog
>after
</div>
<div>
before<object data="horse.wav">
<param name="autoplay" value="true" />
<param name="autoplay" value="true" /></object
>after
</div>
<div>
before<meter
min="0"
max="1"
low=".4"
high=".7"
optimum=".5"
value=".2"
></meter
>after
</div>
<div>before<progress value=".5" max="1"></progress>after</div>
`````
### Expected (prettier)
`````html
<div>
before<noscript>noscript long long long long long long long long</noscript
>after
</div>
<div>
before
<details>
<summary>summary long long long long</summary>
details
</details>
after
</div>
<div>
before
<dialog open>dialog long long long long long long long long</dialog>
after
</div>
<div>
before<object data="horse.wav">
<param name="autoplay" value="true" />
<param name="autoplay" value="true" /></object
>after
</div>
<div>
before<meter
min="0"
max="1"
low=".4"
high=".7"
optimum=".5"
value=".2"
></meter
>after
</div>
<div>before<progress value=".5" max="1"></progress>after</div>
`````
@@ -0,0 +1,217 @@
# externals/vite/html/valid.html
## Option 1
`````json
{"printWidth":80}
`````
### Diff
`````diff
===================================================================
--- prettier
+++ oxfmt
@@ -1,1 +1,20 @@
-ERROR
\ No newline at end of file
+<!-- accepted HTML parsing errors, browsers accept these -->
+<div id="duplicated-attrs" style="color: green" style="color: red">
+ Accept duplicated attribute
+</div>
+
+<!-- accepted HTML constructs -->
+<datalist>
+ <option value="option-without-closing-tag">
+</datalist>
+
+<div id="no-quotes-on-attr">No quotes on Attr</div>
+<script type="module" src="/valid.js"></script>
+
+<svg>
+ <!-- attr with prefix -->
+ <use xlink:href="/sprite.svg#svg"></use>
+</svg>
+
+<!-- allow self closing on non-void elements -->
+<self-close-non-void />
`````
### Actual (oxfmt)
`````html
<!-- accepted HTML parsing errors, browsers accept these -->
<div id="duplicated-attrs" style="color: green" style="color: red">
Accept duplicated attribute
</div>
<!-- accepted HTML constructs -->
<datalist>
<option value="option-without-closing-tag">
</datalist>
<div id="no-quotes-on-attr">No quotes on Attr</div>
<script type="module" src="/valid.js"></script>
<svg>
<!-- attr with prefix -->
<use xlink:href="/sprite.svg#svg"></use>
</svg>
<!-- allow self closing on non-void elements -->
<self-close-non-void />
`````
### Expected (prettier)
`````html
ERROR
`````
## Option 2
`````json
{"printWidth":100,"htmlWhitespaceSensitivity":"ignore","singleAttributePerLine":true}
`````
### Diff
`````diff
===================================================================
--- prettier
+++ oxfmt
@@ -1,1 +1,27 @@
-ERROR
\ No newline at end of file
+<!-- accepted HTML parsing errors, browsers accept these -->
+<div
+ id="duplicated-attrs"
+ style="color: green"
+ style="color: red"
+>
+ Accept duplicated attribute
+</div>
+
+<!-- accepted HTML constructs -->
+<datalist>
+ <option value="option-without-closing-tag">
+</datalist>
+
+<div id="no-quotes-on-attr">No quotes on Attr</div>
+<script
+ type="module"
+ src="/valid.js"
+></script>
+
+<svg>
+ <!-- attr with prefix -->
+ <use xlink:href="/sprite.svg#svg"></use>
+</svg>
+
+<!-- allow self closing on non-void elements -->
+<self-close-non-void />
`````
### Actual (oxfmt)
`````html
<!-- accepted HTML parsing errors, browsers accept these -->
<div
id="duplicated-attrs"
style="color: green"
style="color: red"
>
Accept duplicated attribute
</div>
<!-- accepted HTML constructs -->
<datalist>
<option value="option-without-closing-tag">
</datalist>
<div id="no-quotes-on-attr">No quotes on Attr</div>
<script
type="module"
src="/valid.js"
></script>
<svg>
<!-- attr with prefix -->
<use xlink:href="/sprite.svg#svg"></use>
</svg>
<!-- allow self closing on non-void elements -->
<self-close-non-void />
`````
### Expected (prettier)
`````html
ERROR
`````
## Option 3
`````json
{"printWidth":120,"htmlWhitespaceSensitivity":"strict","bracketSameLine":true}
`````
### Diff
`````diff
===================================================================
--- prettier
+++ oxfmt
@@ -1,1 +1,16 @@
-ERROR
\ No newline at end of file
+<!-- accepted HTML parsing errors, browsers accept these -->
+<div id="duplicated-attrs" style="color: green" style="color: red">Accept duplicated attribute</div>
+
+<!-- accepted HTML constructs -->
+<datalist> <option value="option-without-closing-tag"> </datalist>
+
+<div id="no-quotes-on-attr">No quotes on Attr</div>
+<script type="module" src="/valid.js"></script>
+
+<svg>
+ <!-- attr with prefix -->
+ <use xlink:href="/sprite.svg#svg"></use>
+</svg>
+
+<!-- allow self closing on non-void elements -->
+<self-close-non-void />
`````
### Actual (oxfmt)
`````html
<!-- accepted HTML parsing errors, browsers accept these -->
<div id="duplicated-attrs" style="color: green" style="color: red">Accept duplicated attribute</div>
<!-- accepted HTML constructs -->
<datalist> <option value="option-without-closing-tag"> </datalist>
<div id="no-quotes-on-attr">No quotes on Attr</div>
<script type="module" src="/valid.js"></script>
<svg>
<!-- attr with prefix -->
<use xlink:href="/sprite.svg#svg"></use>
</svg>
<!-- allow self closing on non-void elements -->
<self-close-non-void />
`````
### Expected (prettier)
`````html
ERROR
`````
+4 -4
View File
@@ -44,7 +44,7 @@ export declare const enum Severity {
* # Panics
* Panics if the current working directory cannot be determined.
*/
export declare function format(filename: string, sourceText: string, options: any | undefined | null, formatFileCb: (options: Record<string, any>, code: string) => Promise<{ ok: true; code: string; } | { ok: false; error: string }>, formatEmbeddedCb: (options: Record<string, any>, code: string) => Promise<string | null>, formatEmbeddedDocCb: (options: Record<string, any>, code: string) => Promise<string | null>, sortTailwindClassesCb: (options: Record<string, any>, classes: string[]) => Promise<string[] | null>): Promise<FormatResult>
export declare function format(filename: string, sourceText: string, options: any | undefined | null, formatFileCb: (options: Record<string, any>, code: string) => Promise<{ ok: true; code: string; } | { ok: false; error: string }>, formatEmbeddedDocCb: (options: Record<string, any>, code: string) => Promise<string | null>, sortTailwindClassesCb: (options: Record<string, any>, classes: string[]) => Promise<string[] | null>): Promise<FormatResult>
export interface FormatResult {
/** The formatted code. */
@@ -59,7 +59,7 @@ export interface FormatResult {
* This API is specialized for JS/TS snippets embedded in non-JS files.
* Unlike `format()`, it is called only for js-in-xxx `textToDoc()` flow.
*/
export declare function jsTextToDoc(sourceExt: string, sourceText: string, oxfmtPluginOptionsJson: string, parentContext: string, formatFileCb: (options: Record<string, any>, code: string) => Promise<{ ok: true; code: string; } | { ok: false; error: string }>, formatEmbeddedCb: (options: Record<string, any>, code: string) => Promise<string | null>, formatEmbeddedDocCb: (options: Record<string, any>, code: string) => Promise<string | null>, sortTailwindClassesCb: (options: Record<string, any>, classes: string[]) => Promise<string[] | null>): Promise<string | null>
export declare function jsTextToDoc(sourceExt: string, sourceText: string, oxfmtPluginOptionsJson: string, parentContext: string, formatFileCb: (options: Record<string, any>, code: string) => Promise<{ ok: true; code: string; } | { ok: false; error: string }>, formatEmbeddedDocCb: (options: Record<string, any>, code: string) => Promise<string | null>, sortTailwindClassesCb: (options: Record<string, any>, classes: string[]) => Promise<string[] | null>): Promise<string | null>
/**
* NAPI based JS CLI entry point.
@@ -70,11 +70,11 @@ export declare function jsTextToDoc(sourceExt: string, sourceText: string, oxfmt
* 2. `load_js_config_cb`: Callback to load JS/TS config files
* 3. `init_external_services_cb`: Callback to initialize external services (JS worker pool)
* 4. `format_file_cb`: Callback to format files
* 5. `format_embedded_cb`: Callback to format embedded code in templates
* 5. `format_embedded_doc_cb`: Callback to format embedded code in templates as a Prettier Doc
* 6. `sort_tailwindcss_classes_cb`: Callback to sort Tailwind classes
*
* Returns a tuple of `[mode, exitCode]`:
* - `mode`: If main logic will run in JS side, use this to indicate which mode
* - `exitCode`: If main logic already ran in Rust side, return the exit code
*/
export declare function runCli(args: Array<string>, loadJsConfigCb: (path: string) => Promise<any>, initExternalServicesCb: (numThreads: number) => Promise<void>, formatFileCb: (options: Record<string, any>, code: string) => Promise<{ ok: true; code: string; } | { ok: false; error: string }>, formatEmbeddedCb: (options: Record<string, any>, code: string) => Promise<string | null>, formatEmbeddedDocCb: (options: Record<string, any>, code: string) => Promise<string | null>, sortTailwindcssClassesCb: (options: Record<string, any>, classes: string[]) => Promise<string[] | null>): Promise<[string, number | undefined | null]>
export declare function runCli(args: Array<string>, loadJsConfigCb: (path: string) => Promise<any>, initExternalServicesCb: (numThreads: number) => Promise<void>, formatFileCb: (options: Record<string, any>, code: string) => Promise<{ ok: true; code: string; } | { ok: false; error: string }>, formatEmbeddedDocCb: (options: Record<string, any>, code: string) => Promise<string | null>, sortTailwindcssClassesCb: (options: Record<string, any>, classes: string[]) => Promise<string[] | null>): Promise<[string, number | undefined | null]>
+1 -6
View File
@@ -1,9 +1,4 @@
// `oxfmt` CLI - Worker Thread Entry Point
// Re-exports core functions for use in `worker_threads`
export {
formatEmbeddedCode,
formatEmbeddedDoc,
formatFile,
sortTailwindClasses,
} from "./libs/apis";
export { formatEmbeddedDoc, formatFile, sortTailwindClasses } from "./libs/apis";
-2
View File
@@ -3,7 +3,6 @@ import {
initExternalServices,
disposeExternalServices,
formatFile,
formatEmbeddedCode,
formatEmbeddedDoc,
sortTailwindClasses,
} from "./cli/worker-proxy";
@@ -47,7 +46,6 @@ void (async () => {
process.env.VP_VERSION ? loadVitePlusConfig : loadJsConfig,
initExternalServices,
formatFile,
formatEmbeddedCode,
formatEmbeddedDoc,
sortTailwindClasses,
);
-14
View File
@@ -3,7 +3,6 @@ import { toFormatFileResult, toNullable } from "../libs/napi-callbacks";
import type { FormatFileResult } from "../libs/napi-callbacks";
import type {
FormatFileParam,
FormatEmbeddedCodeParam,
FormatEmbeddedDocParam,
SortTailwindClassesArgs,
} from "../libs/apis";
@@ -65,19 +64,6 @@ export function formatFile(
// ---
export function formatEmbeddedCode(
options: FormatEmbeddedCodeParam["options"],
code: string,
): Promise<string | null> {
return toNullable(
getPool().then((pool) =>
pool.run({ options, code } satisfies FormatEmbeddedCodeParam, {
name: "formatEmbeddedCode",
}),
),
);
}
export function formatEmbeddedDoc(
options: FormatEmbeddedDocParam["options"],
code: string,
+1 -8
View File
@@ -1,11 +1,6 @@
// napi-JS `oxfmt` API entry point
import {
formatFile,
formatEmbeddedCode,
formatEmbeddedDoc,
sortTailwindClasses,
} from "./libs/apis";
import { formatFile, formatEmbeddedDoc, sortTailwindClasses } from "./libs/apis";
import { toFormatFileResult, toNullable } from "./libs/napi-callbacks";
// Types are auto-generated from the JSON Schema.
import type {
@@ -95,7 +90,6 @@ export async function format(fileName: string, sourceText: string, options?: For
sourceText,
options ?? {},
(options, code) => toFormatFileResult(formatFile({ options, code })),
(options, code) => toNullable(formatEmbeddedCode({ options, code })),
(options, code) => toNullable(formatEmbeddedDoc({ options, code })),
(options, classes) => toNullable(sortTailwindClasses({ options, classes })),
);
@@ -117,7 +111,6 @@ export async function jsTextToDoc(
oxfmtPluginOptionsJson,
parentContext,
() => toFormatFileResult(Promise.reject("formatFile is unavailable for jsTextToDoc")),
(options, code) => toNullable(formatEmbeddedCode({ options, code })),
(options, code) => toNullable(formatEmbeddedDoc({ options, code })),
(options, classes) => toNullable(sortTailwindClasses({ options, classes })),
);
+5 -34
View File
@@ -50,14 +50,14 @@ async function loadPrettier(): Promise<typeof import("prettier")> {
// there should be no side effects on other calls that don't set these fields.
// @ts-expect-error: Use internal API
const { formatOptionsHiddenDefaults } = prettier.__internal;
// For every embedded call (html-in-js, xxx-in-md), as Prettier's `textToDoc()` always sets it.
// For every embedded call (angular-in-js, xxx-in-md), as Prettier's `textToDoc()` always sets it.
// For html-family children, this prevents attribute level formatting from running.
// (e.g., CSS in `style="..."` attributes, JS in `onclick="..."` event handlers)
// This does NOT affect `<style>`/`<script>` tags, they are always formatted.
// Ideally we'd only block JS attributes while allowing CSS attributes (because no nesting is possible in CSS),
// but Prettier's `!options.parentParser` check is all-or-nothing.
formatOptionsHiddenDefaults.parentParser = null;
// For html(angular)-in-js: Capture `htmlHasMultipleRootElements` from the HTML AST root during `__debug.printToDoc()`.
// For angular-in-js: Capture `htmlHasMultipleRootElements` from the HTML AST root during `__debug.printToDoc()`.
// This is used to decide whether to wrap content with `indent`.
// Without this, we'd need either:
// - double parse AST
@@ -129,35 +129,6 @@ function hasTsxScriptBlock(sourceText: string): boolean {
// ---
export type FormatEmbeddedCodeParam = {
code: string;
options: Options;
};
/**
* Format non-js code snippets into formatted string.
* Used for formatting code fences within JSDoc.
*
* @returns Formatted code snippet
*/
export async function formatEmbeddedCode({
code,
options,
}: FormatEmbeddedCodeParam): Promise<string> {
const prettier = CACHES.prettier ?? (await loadPrettier());
// Enable Tailwind CSS plugin for embedded code (e.g., html`...` in JS) if needed
if ("_useTailwindPlugin" in options) await setupTailwindPlugin(options);
// NOTE: This will throw if:
// - Specified parser is not available
// - Or, code has syntax errors
// In such cases, Rust side will fallback to original code
return prettier.format(code, options);
}
// ---
export type FormatEmbeddedDocParam = {
code: string;
options: Options;
@@ -180,7 +151,7 @@ export async function formatEmbeddedDoc({
if ("_useSveltePlugin" in options) await setupSveltePlugin(options);
// Add `astro` parser for ` ```astro ` code blocks in Markdown
if ("_useAstroPlugin" in options) await setupAstroPlugin(options);
// Enable Tailwind CSS plugin for embedded code (e.g., html`...` in JS) if needed
// Enable Tailwind CSS plugin for embedded code (e.g., Angular `@Component({ template })` in JS) if needed
if ("_useTailwindPlugin" in options) await setupTailwindPlugin(options);
const metadata: Record<string, unknown> = {};
@@ -189,8 +160,8 @@ export async function formatEmbeddedDoc({
// see the comment in `loadPrettier()` for rationale. Any truthy value works
options.parentParser = "OXFMT";
// html(angular)-in-js specific options
if (options.parser === "html" || options.parser === "angular") {
// angular-in-js specific options
if (options.parser === "angular") {
// https://github.com/prettier/prettier/blob/90983f40dce5e20beea4e5618b5e0426a6a7f4f0/src/language-js/embed/html.js#L42-L44
options.__onHtmlRoot = (root: { children?: unknown[] }) =>
(metadata.htmlHasMultipleRootElements = (root.children?.length ?? 0) > 1);
@@ -1,7 +1,7 @@
/**
* Prettier plugin that uses `oxc_formatter` for (j|t)s-in-xxx part.
*
* When Prettier formats Vue/HTML (which can embed JS/TS code inside) files,
* When Prettier formats Vue/Angular (which can embed JS/TS code inside) files,
* it calls the `embed()` function for each block.
*
* By default, it uses the `babel` or `typescript` parser and `estree` printer.
+4 -10
View File
@@ -5,9 +5,8 @@ use serde_json::Value;
use oxc_napi::OxcError;
use crate::core::{
ExternalServices, FormatResult, JsFormatEmbeddedCb, JsFormatEmbeddedDocCb, JsFormatFileCb,
JsSortTailwindClassesCb, ResolveOutcome, SourceFormatter, classify_file, resolve_for_api,
utils,
ExternalServices, FormatResult, JsFormatEmbeddedDocCb, JsFormatFileCb, JsSortTailwindClassesCb,
ResolveOutcome, SourceFormatter, classify_file, resolve_for_api, utils,
};
pub struct ApiFormatResult {
@@ -27,7 +26,6 @@ pub fn run(
source_text: String,
options: Option<Value>,
format_file_cb: JsFormatFileCb,
format_embedded_cb: JsFormatEmbeddedCb,
format_embedded_doc_cb: JsFormatEmbeddedDocCb,
sort_tailwind_classes_cb: JsSortTailwindClassesCb,
) -> ApiFormatResult {
@@ -45,12 +43,8 @@ pub fn run(
let cwd = env::current_dir().expect("Failed to get current working directory");
let num_of_threads = 1;
let external_services = ExternalServices::new(
format_file_cb,
format_embedded_cb,
format_embedded_doc_cb,
sort_tailwind_classes_cb,
);
let external_services =
ExternalServices::new(format_file_cb, format_embedded_doc_cb, sort_tailwind_classes_cb);
let _cleanup = external_services.cleanup_guard();
let filepath = utils::normalize_relative_path(&cwd, Path::new(filename));
+3 -11
View File
@@ -11,8 +11,7 @@ use oxc_span::SourceType;
use crate::{
core::{
ExternalServices, JsFormatEmbeddedCb, JsFormatEmbeddedDocCb, JsFormatFileCb,
JsSortTailwindClassesCb,
ExternalServices, JsFormatEmbeddedDocCb, JsFormatFileCb, JsSortTailwindClassesCb,
embed::{self, dispatcher::ResolvedDispatchConfig},
options::{ValidatedOptions, to_oxc_formatter, validate},
oxfmtrc::FormatConfig,
@@ -50,7 +49,6 @@ pub fn run(
oxfmt_plugin_options_json: &str,
parent_context: &str,
format_file_cb: JsFormatFileCb,
format_embedded_cb: JsFormatEmbeddedCb,
format_embedded_doc_cb: JsFormatEmbeddedDocCb,
sort_tailwind_classes_cb: JsSortTailwindClassesCb,
) -> Option<String> {
@@ -88,7 +86,6 @@ pub fn run(
source_text,
oxfmt_plugin_options_json,
format_file_cb,
format_embedded_cb,
format_embedded_doc_cb,
sort_tailwind_classes_cb,
)?
@@ -116,7 +113,6 @@ fn run_full(
source_text: &str,
oxfmt_plugin_options_json: &str,
format_file_cb: JsFormatFileCb,
format_embedded_cb: JsFormatEmbeddedCb,
format_embedded_doc_cb: JsFormatEmbeddedDocCb,
sort_tailwind_classes_cb: JsSortTailwindClassesCb,
) -> Option<Value> {
@@ -124,12 +120,8 @@ fn run_full(
// so no `cwd` is threaded through here.
let (config, validated, parent_filepath) = parse_payload(oxfmt_plugin_options_json);
let external_services = ExternalServices::new(
format_file_cb,
format_embedded_cb,
format_embedded_doc_cb,
sort_tailwind_classes_cb,
);
let external_services =
ExternalServices::new(format_file_cb, format_embedded_doc_cb, sort_tailwind_classes_cb);
let _cleanup = external_services.cleanup_guard();
// Per-language options (and the Prettier options JSON with the Tailwind payload)
+3 -3
View File
@@ -549,14 +549,14 @@ mod tests_slow_path_validation {
let resolver = resolver_from_json(serde_json::json!({
"printWidth": 80,
"overrides": [
{ "files": ["*.html"], "options": { "printWidth": 1000 } }
{ "files": ["*.vue"], "options": { "printWidth": 1000 } }
]
}));
// Slow path triggers because the override matches.
let path = Path::new("index.html");
let path = Path::new("App.vue");
let err =
resolver.resolve(path, FormatStrategy::Prettier(PrettierLanguage::Html)).unwrap_err();
resolver.resolve(path, FormatStrategy::Prettier(PrettierLanguage::Vue)).unwrap_err();
assert!(err.contains("printWidth"), "expected printWidth validation error, got: {err}");
}
+63 -15
View File
@@ -8,13 +8,19 @@ use std::sync::{Arc, OnceLock};
use tracing::{debug, debug_span};
use oxc_formatter::{CssInJsTemplate, JsEmbeddedIn, JsFormatOptions, MarkdownInJsTemplate};
use oxc_formatter::{
CssInJsTemplate, HtmlInJsTemplate, JsEmbeddedIn, JsFormatOptions, MarkdownInJsTemplate,
};
use oxc_formatter_core::{
DispatchRequest, DispatchResponse, EmbeddedIr, FormatDispatcher, FormatSession,
};
use oxc_formatter_core::{FormatOptions, PrinterOptions};
use oxc_formatter_css::{CssFormatOptions, CssVariant};
use oxc_formatter_css::{CssEmbeddedIn, CssFormatOptions, CssVariant};
use oxc_formatter_graphql::GraphqlFormatOptions;
use oxc_formatter_htmlish::{
CssInHtmlStyleAttribute, HtmlishEmbeddedIn, HtmlishFormatOptions, HtmlishVariant,
JsInHtmlEventHandler,
};
use oxc_formatter_json::{JsonFormatOptions, JsonVariant};
use oxc_formatter_markdown::{MarkdownFormatOptions, XxxInMarkdownCodeBlock};
use oxc_formatter_toml::TomlFormatOptions;
@@ -24,8 +30,8 @@ use crate::core::{
language::{Route, route_embedded},
options::{
ValidatedOptions, to_oxc_formatter, to_oxc_formatter_css, to_oxc_formatter_graphql,
to_oxc_formatter_json, to_oxc_formatter_markdown, to_oxc_formatter_toml,
to_oxc_formatter_yaml,
to_oxc_formatter_htmlish, to_oxc_formatter_json, to_oxc_formatter_markdown,
to_oxc_formatter_toml, to_oxc_formatter_yaml,
},
oxfmtrc::FormatConfig,
support::{NativeLanguage, PrettierLanguage},
@@ -56,13 +62,14 @@ pub struct ResolvedDispatchConfig {
json: [OnceLock<JsonFormatOptions>; 4],
markdown: OnceLock<MarkdownFormatOptions>,
toml: OnceLock<TomlFormatOptions>,
htmlish: OnceLock<HtmlishFormatOptions>,
/// The options handed to Prettier; see [`PrettierOptions`].
#[cfg(feature = "napi")]
prettier: PrettierOptions,
}
/// The lazily-built options JSON handed to Prettier (+ plugins),
/// consumed by the Doc→IR / string paths and the Tailwind sorter.
/// consumed by the Doc→IR path and the Tailwind sorter.
/// `path` is an ingredient, not a sibling datum: it becomes the JSON's `filepath` at last
/// (see [`crate::core::options::build_prettier_options`]).
///
@@ -90,6 +97,7 @@ impl ResolvedDispatchConfig {
json: [OnceLock::new(), OnceLock::new(), OnceLock::new(), OnceLock::new()],
markdown: OnceLock::new(),
toml: OnceLock::new(),
htmlish: OnceLock::new(),
#[cfg(feature = "napi")]
prettier: PrettierOptions::default(),
}
@@ -177,6 +185,13 @@ impl ResolvedDispatchConfig {
*self.toml.get_or_init(|| to_oxc_formatter_toml(&self.config, self.validated.core))
}
pub fn htmlish_options(&self, variant: HtmlishVariant) -> HtmlishFormatOptions {
let options = self
.htmlish
.get_or_init(|| to_oxc_formatter_htmlish(&self.config, self.validated.core));
HtmlishFormatOptions { variant, ..options.clone() }
}
/// Printer options from the shared resolved core bundle;
/// the fence adapter ([`super::jsdoc_fence`]) derives its per-fence options from these
/// (width overridden to the fence's effective width).
@@ -227,7 +242,7 @@ impl ResolvedDispatchConfig {
/// Fallback invoked for [`Route::Prettier`] languages.
/// Same shape as `FormatDispatcher`, plus the routed language
/// (the Doc path consumes `parent_context` only for html(angular)-in-js's [`oxc_formatter::HtmlInJsTemplate`], not `input_kind`;
/// (the Doc path consumes `parent_context` only for angular-in-js's [`HtmlInJsTemplate`], not `input_kind`;
/// re-examine if it ever serves envelope-bearing inputs).
///
/// Assembled only in napi builds ([`super::prettier_doc`]);
@@ -257,7 +272,12 @@ pub fn build_dispatcher(
NativeLanguage::Js(source_type) => {
let embedded_in = request
.parent_context_as::<XxxInMarkdownCodeBlock>()
.map(|_| JsEmbeddedIn::MarkdownCodeBlock);
.map(|_| JsEmbeddedIn::MarkdownCodeBlock)
.or_else(|| {
request
.parent_context_as::<JsInHtmlEventHandler>()
.map(|_| JsEmbeddedIn::HtmlEventHandler)
});
oxc_formatter::format_to_ir(
session,
text,
@@ -274,17 +294,21 @@ pub fn build_dispatcher(
NativeLanguage::Css(variant) => {
// css-in-js (typed `CssInJsTemplate` context) is always parsed as SCSS with `${}` placeholder markers.
// Any other caller gets the strict standalone grammar with the fence/request language's variant.
let (variant, template_placeholders) =
if request.parent_context_as::<CssInJsTemplate>().is_some() {
(CssVariant::Scss, true)
} else {
(variant, false)
};
let (variant, embedded_in) = if request
.parent_context_as::<CssInJsTemplate>()
.is_some()
{
(CssVariant::Scss, Some(CssEmbeddedIn::CssInJsTemplate))
} else if request.parent_context_as::<CssInHtmlStyleAttribute>().is_some() {
(variant, Some(CssEmbeddedIn::HtmlStyleAttribute))
} else {
(variant, None)
};
oxc_formatter_css::format_to_ir(
session,
text,
dispatch_config.css_options(variant),
template_placeholders,
embedded_in,
)
}
NativeLanguage::Yaml => oxc_formatter_yaml::format_to_ir(
@@ -316,6 +340,28 @@ pub fn build_dispatcher(
text,
dispatch_config.toml_options(),
),
NativeLanguage::Html(variant) => {
// Embedded in a JS template literal or a Markdown code block;
// `None` is a part formatted on its own (a JSDoc code block).
// An HTML parent added later needs its marker here, or reads as standalone.
let embedded_in = request
.parent_context_as::<HtmlInJsTemplate>()
.map(|c| HtmlishEmbeddedIn {
indent_multiple_roots: c.indent_multiple_roots,
placeholder: Some(c.placeholder),
})
.or_else(|| {
request
.parent_context_as::<XxxInMarkdownCodeBlock>()
.map(|_| HtmlishEmbeddedIn::default())
});
oxc_formatter_htmlish::format_to_ir(
session,
text,
dispatch_config.htmlish_options(variant),
embedded_in,
)
}
}))
}
@@ -418,6 +464,7 @@ mod tests {
"markdown",
"md",
"toml",
"html",
] {
let text = match language {
"graphql" | "gql" => "{ a }",
@@ -425,6 +472,7 @@ mod tests {
"yaml" | "yml" => "a: 1",
"json" | "jsonc" | "json5" => "{ \"a\": 1 }",
"markdown" | "md" => "# a",
"html" => "<p> a </p>",
_ => "a = 1",
};
let response = session.dispatch(DispatchRequest {
@@ -476,7 +524,7 @@ mod tests {
},
);
for (language, text) in [("html", "<div></div>"), ("ini", "a = 1")] {
for (language, text) in [("vue", "<template></template>"), ("ini", "a = 1")] {
let response = session.dispatch(DispatchRequest {
language,
text,
+25 -8
View File
@@ -1,11 +1,8 @@
//! Native-fence string adapter:
//! a JSDoc fenced code block whose language has a Rust formatter branch
//! formats through the dispatch registry, in EVERY build.
//! formats through the dispatch registry, in EVERY build; any other fence stays verbatim.
//!
//! This is the build-independent half of the string-out channel;
//! the profiles in [`super::services`] wire it into the session's string embedder
//! (the napi one via [`super::prettier_string`], which routes native fences here
//! before its Prettier string paths; the pure one directly, non-native fences stay verbatim).
//! This is the whole string-out channel; [`super::services`] installs it as the session's string embedder.
use std::sync::Arc;
@@ -14,11 +11,31 @@ use tracing::debug_span;
use oxc_allocator::Allocator;
use oxc_formatter_core::{
DispatchRequest, FormatDispatcher, FormatSession, InputKind, PrintWidth, SessionServices,
TailwindSorter,
StringEmbedder, TailwindSorter,
};
use super::dispatcher::ResolvedDispatchConfig;
/// The session's string embedder: [`format_native_fence`] over `fence_dispatcher`,
/// a fallback-less registry (a native fence never falls back to Prettier).
/// `sort_tailwind` is the root's pre-bound sorter, for the `@apply` classes a CSS fence collects.
pub fn build_string_embedder(
fence_dispatcher: FormatDispatcher,
dispatch_config: Arc<ResolvedDispatchConfig>,
sort_tailwind: Option<TailwindSorter>,
) -> StringEmbedder {
Arc::new(move |language: &str, code: &str, print_width: usize| {
format_native_fence(
language,
code,
print_width,
&fence_dispatcher,
&dispatch_config,
sort_tailwind.as_ref(),
)
})
}
/// Format a JSDoc fenced code block through the native dispatch registry:
/// a string-in/string-out adapter over `FormatSession::dispatch_to_string`.
///
@@ -28,9 +45,9 @@ use super::dispatcher::ResolvedDispatchConfig;
/// - `Err` keeps the fence verbatim, covering both the deliberate keep
/// (`Ok(None)`; a failed native language never re-routes to Prettier) and operational errors
/// - The session-less `StringEmbedder` contract forces a fresh root session per fence,
/// so `dispatch_depth` resets at this string boundary (inert today: no native fence language re-dispatches).
/// so `dispatch_depth` resets at this string boundary (inert today: the fence prints to a string, its placeholders never meet the parent's).
/// Threading the parent session through the callback is the eventual fix.
pub fn format_native_fence(
fn format_native_fence(
language: &str,
code: &str,
print_width: usize,
+2 -12
View File
@@ -9,9 +9,8 @@
//! `ResolvedDispatchConfig` (lazy per-language options) + `build_dispatcher` with a Rust branch per `NativeLanguage`;
//! IR integrates into the parent's arena / `GroupId` space
//! - [`services`] (every build): the root `SessionServices` assembly (`for_root`, one definition per build)
//! - [`jsdoc_fence`] (every build): the JSDoc native-fence string adapter over the registry
//! - [`jsdoc_fence`] (every build): the string-out channel, the JSDoc native-fence adapter over the registry
//! - [`prettier_doc`] (napi only): Prettier Doc→IR path for the `Route::Prettier` set
//! - [`prettier_string`] (napi only): the Prettier string paths of the string-out channel (html JSDoc fences; results re-embed line-by-line)
#[cfg(feature = "napi")]
use std::sync::Arc;
@@ -23,24 +22,15 @@ pub mod dispatcher;
pub mod jsdoc_fence;
#[cfg(feature = "napi")]
pub mod prettier_doc;
#[cfg(feature = "napi")]
pub mod prettier_string;
pub mod services;
// --- Cross-module callback types ---
//
// These describe the shape of the napi-wrapped callbacks the orchestration builders consume.
// NOTE: They live here (not in `external_services`),
// so the `prettier_doc` / `prettier_string` factories stay independent of the napi boundary.
// so the `prettier_doc` factory stays independent of the napi boundary.
// `external_services` is the producer of these types via its `wrap_*` functions, and orchestration is the consumer.
/// Callback function type for formatting embedded code with config.
/// Takes (options, code) and returns formatted code or an error.
/// The `options` Value is owned and includes `parser` set by the caller.
#[cfg(feature = "napi")]
pub type FormatEmbeddedWithConfigCallback =
Arc<dyn Fn(Value, &str) -> Result<String, String> + Send + Sync>;
/// Callback function type for formatting embedded code via the Doc IR path.
/// Takes (options, text) and returns a Doc JSON string or an error.
#[cfg(feature = "napi")]
+2 -2
View File
@@ -3,7 +3,7 @@
//! Sends the text to JS `printToDoc()`, then converts the returned Doc JSON into formatter IR
//! that integrates into the parent's arena / `GroupId` space.
//! Fills the dispatcher's optional [`PrettierDocFallback`] slot;
//! the string twin is [`super::prettier_string`].
//! the JSDoc fences' string channel is native only ([`super::jsdoc_fence`]).
use std::sync::Arc;
@@ -68,7 +68,7 @@ pub fn build_prettier_fallback(
session.group_id_builder(),
)?;
from_prettier_doc::postprocess(&mut ir, allocator);
// html(angular)-in-js: indent the content when more than one node prints at the root
// angular-in-js: what the native HTML formatter does on the same request
let indent = request
.parent_context_as::<HtmlInJsTemplate>()
.is_some_and(|c| c.indent_multiple_roots)
@@ -1,80 +0,0 @@
//! The napi string embedder: fence routing plus the Prettier string paths of the string-out channel.
//!
//! JSDoc fenced code blocks (` ```css `, ` ```yaml `, …) reach this channel through the session's `StringEmbedder` contract.
//!
//! Fence routing follows ONE rule, the shared routing table ([`route_embedded`]):
//! a `Route::Native` language formats through the dispatcher via [`super::jsdoc_fence::format_native_fence`] (the build-independent adapter);
//! the `Route::Prettier` set stays on the Prettier string path
//! (their Doc→IR conversion has unrepresentable cases, so forcing them through the dispatcher would regress to verbatim;
//! the wall falls with the HTML Rust port).
//!
//! Unlike the [`super::dispatcher`] contract (`PreserveOriginal` vs `Err`),
//! this channel has one failure meaning: `Err` keeps the input verbatim.
//! A failed native fence is never re-routed to Prettier, and a JSDoc fence surfaces no diagnostics (it lives inside a comment).
use std::sync::Arc;
use tracing::{debug, debug_span};
use oxc_formatter_core::{StringEmbedder, TailwindSorter};
use crate::core::{
embed::{FormatEmbeddedWithConfigCallback, dispatcher, jsdoc_fence},
language::{Route, route_embedded},
options::inject_string_embed_options,
};
/// Build the napi build's string embedder installed on the session.
///
/// Dispatches by language identifier: the native registry when available, otherwise Prettier via `format_embedded`.
/// The JSDoc fenced consumer sends only its embedded set (`EMBEDDED_FENCE_LANGUAGES` in `oxc_formatter`).
///
/// `sort_tailwind` is the SAME pre-bound sorter as the session's Tailwind service
/// (options JSON already applied by `services::for_root`), for the `@apply` classes a CSS fence collects.
pub fn build_string_embedder(
format_embedded: FormatEmbeddedWithConfigCallback,
sort_tailwind: Option<TailwindSorter>,
dispatch_config: Arc<dispatcher::ResolvedDispatchConfig>,
) -> StringEmbedder {
// Fence dispatchers never take the Prettier fallback (native fences never fall back),
// so one is invariant across the callback's lifetime: build it once, not per fence.
let fence_dispatcher = dispatcher::build_dispatcher(Arc::clone(&dispatch_config), None);
Arc::new(move |language: &str, code: &str, print_width: usize| {
let prettier_language = match route_embedded(language) {
// Native branch (JSDoc fenced code blocks): through the dispatcher, never Prettier.
Route::Native(_) => {
return jsdoc_fence::format_native_fence(
language,
code,
print_width,
&fence_dispatcher,
&dispatch_config,
sort_tailwind.as_ref(),
);
}
Route::Prettier(prettier_language) => prettier_language,
// NOTE: Do not return `Ok(original)` here.
// We need to keep unsupported content as-is.
Route::Unsupported => return Err(format!("Unsupported language: {language}")),
};
let parser_name = prettier_language.parser();
debug_span!("oxfmt::external::format_embedded", parser = parser_name).in_scope(|| {
let Some(mut options) = dispatch_config.prettier_options_for(prettier_language) else {
return Err(format!("No plugin enabled for parser: {parser_name}"));
};
// The same effective width and line ending the native branch prints with
inject_string_embed_options(&mut options, print_width);
(format_embedded)(options, code)
.map(|mut code| {
// Remove trailing newline added by Prettier without allocation.
// For embedded code, we never want trailing newlines, regardless of options.
let trimmed_len = code.trim_end().len();
code.truncate(trimmed_len);
code
})
.inspect_err(|err| {
debug!("Failed to format embedded code for parser '{parser_name}': {err}");
})
})
})
}
+12 -20
View File
@@ -9,8 +9,6 @@
use std::sync::Arc;
use oxc_formatter_core::SessionServices;
#[cfg(not(feature = "napi"))]
use oxc_formatter_core::StringEmbedder;
#[cfg(feature = "napi")]
use oxc_formatter_core::TailwindSorter;
#[cfg(feature = "napi")]
@@ -32,18 +30,11 @@ pub fn for_root(dispatch_config: &Arc<ResolvedDispatchConfig>) -> SessionService
// so the root's doubles as the fence adapter's.
let dispatcher = dispatch_config.root_dispatcher(None);
let string_embedder = dispatcher.as_ref().map(|dispatcher| {
let fence_dispatcher = Arc::clone(dispatcher);
let dispatch_config = Arc::clone(dispatch_config);
Arc::new(move |language: &str, code: &str, print_width: usize| {
super::jsdoc_fence::format_native_fence(
language,
code,
print_width,
&fence_dispatcher,
&dispatch_config,
None,
)
}) as StringEmbedder
super::jsdoc_fence::build_string_embedder(
Arc::clone(dispatcher),
Arc::clone(dispatch_config),
None,
)
});
SessionServices { dispatcher, string_embedder, tailwind_sorter: None }
}
@@ -54,7 +45,7 @@ pub fn for_root(dispatch_config: &Arc<ResolvedDispatchConfig>) -> SessionService
/// The options (including `filepath`) are captured in the closures and passed to JS on each call;
/// per-language options are mapped lazily at dispatch time (see [`ResolvedDispatchConfig`]).
///
/// This is the assembly point only: closure factories live in `embed::{prettier_string, dispatcher, prettier_doc}`,
/// This is the assembly point only: closure factories live in `embed::{jsdoc_fence, dispatcher, prettier_doc}`,
/// and the napi callback `Arc`s come from the transport ([`ExternalServices`]).
///
/// NOTE: Tailwind data paths
@@ -75,7 +66,7 @@ pub fn for_root(dispatch_config: &Arc<ResolvedDispatchConfig>) -> SessionService
/// Goes through the string embedder's fence adapter, which returns a formatted string (not parent-integrated IR),
/// so there is no parent index space to join:
/// `FormatSession::dispatch_to_string` collects the child's classes in a fresh scope and sorts it before printing.
/// The `prettier_string::build_string_embedder` factory receives the sorter for this case.
/// The `jsdoc_fence::build_string_embedder` factory receives the sorter for this case.
///
/// All four paths use the SAME `sort_tailwindcss_classes` napi callback; only the wiring differs.
/// Moving sorting to the wrong layer (e.g. sorting inside embedded CSS instead of at the root) would double-sort or drop classes.
@@ -105,11 +96,12 @@ pub fn for_root(
}) as TailwindSorter
});
let string_embedder = needs_embedded.then(|| {
super::prettier_string::build_string_embedder(
Arc::clone(&external_services.format_embedded),
sorter.clone(),
// Fences never take the Prettier fallback (a native fence never falls back)
let string_embedder = dispatch_config.root_dispatcher(None).map(|fence_dispatcher| {
super::jsdoc_fence::build_string_embedder(
fence_dispatcher,
Arc::clone(dispatch_config),
sorter.clone(),
)
});
+2 -60
View File
@@ -4,7 +4,7 @@
//! [`ExternalServices`] wraps the JS callbacks as `Arc<dyn Fn>`s (private `wrap_*` helpers), serving:
//! - [`ExternalServices::init`]: JS-side worker-pool setup
//! - [`ExternalServices::format_file`]: Tier 3/4 whole-file delegation (`core::format`)
//! - the embed callback `Arc`s (`format_embedded` / `format_embedded_doc` / `sort_tailwindcss_classes`),
//! - the embed callback `Arc`s (`format_embedded_doc` / `sort_tailwindcss_classes`),
//! consumed by the `core::embed::services` profiles
use std::sync::{Arc, RwLock};
@@ -17,10 +17,7 @@ use napi::{
use serde_json::Value;
use tracing::debug_span;
use crate::core::embed::{
FormatEmbeddedDocWithConfigCallback, FormatEmbeddedWithConfigCallback,
TailwindWithConfigCallback,
};
use crate::core::embed::{FormatEmbeddedDocWithConfigCallback, TailwindWithConfigCallback};
/// Callback function type for formatting whole files (Tier 3/4 delegation).
/// Takes (options, code) and returns formatted code or an error.
@@ -59,22 +56,6 @@ pub type JsFormatFileCb = ThreadsafeFunction<
false,
>;
/// Type alias for the callback function signature.
/// Takes (options, code) as arguments and returns formatted code or null on error.
/// The `options` object includes `parser` field set by Rust side.
pub type JsFormatEmbeddedCb = ThreadsafeFunction<
// Input arguments
FnArgs<(Value, String)>, // (options, code)
// Return type (what JS function returns)
Promise<Option<String>>,
// Arguments (repeated)
FnArgs<(Value, String)>,
// Error status
Status,
// CalleeHandled
false,
>;
/// Type alias for the Doc-path callback function signature.
/// Takes (options, code) as arguments and returns a Doc JSON string or null on error.
/// The `options` object includes `parser` field set by Rust side.
@@ -110,7 +91,6 @@ pub type JsSortTailwindClassesCb = ThreadsafeFunction<
struct TsfnHandles {
init: Arc<RwLock<Option<JsInitExternalServicesCb>>>,
format_file: Arc<RwLock<Option<JsFormatFileCb>>>,
format_embedded: Arc<RwLock<Option<JsFormatEmbeddedCb>>>,
format_embedded_doc: Arc<RwLock<Option<JsFormatEmbeddedDocCb>>>,
sort_tailwind: Arc<RwLock<Option<JsSortTailwindClassesCb>>>,
}
@@ -126,7 +106,6 @@ impl Drop for CleanupGuard {
let handles = &self.0;
let _ = handles.init.write().unwrap().take();
let _ = handles.format_file.write().unwrap().take();
let _ = handles.format_embedded.write().unwrap().take();
let _ = handles.format_embedded_doc.write().unwrap().take();
let _ = handles.sort_tailwind.write().unwrap().take();
}
@@ -144,7 +123,6 @@ pub struct ExternalServices {
handles: TsfnHandles,
pub init: InitExternalServicesCallback,
pub format_file: FormatFileWithConfigCallback,
pub format_embedded: FormatEmbeddedWithConfigCallback,
pub format_embedded_doc: FormatEmbeddedDocWithConfigCallback,
pub sort_tailwindcss_classes: TailwindWithConfigCallback,
}
@@ -154,7 +132,6 @@ impl std::fmt::Debug for ExternalServices {
f.debug_struct("ExternalServices")
.field("init", &"<callback>")
.field("format_file", &"<callback>")
.field("format_embedded", &"<callback>")
.field("format_embedded_doc", &"<callback>")
.field("sort_tailwindcss_classes", &"<callback>")
.finish()
@@ -169,14 +146,12 @@ impl ExternalServices {
/// crashes on Node.js exit when V8 cleans up global handles.
pub fn new(
format_file_cb: JsFormatFileCb,
format_embedded_cb: JsFormatEmbeddedCb,
format_embedded_doc_cb: JsFormatEmbeddedDocCb,
sort_tailwindcss_classes_cb: JsSortTailwindClassesCb,
) -> Self {
// Wrap TSFNs in Arc<RwLock<Option<...>>> so they can be explicitly dropped
let init_handle = Arc::new(RwLock::new(None));
let format_file_handle = Arc::new(RwLock::new(Some(format_file_cb)));
let format_embedded_handle = Arc::new(RwLock::new(Some(format_embedded_cb)));
let format_embedded_doc_handle = Arc::new(RwLock::new(Some(format_embedded_doc_cb)));
let sort_tailwind_handle = Arc::new(RwLock::new(Some(sort_tailwindcss_classes_cb)));
@@ -184,21 +159,18 @@ impl ExternalServices {
let handles = TsfnHandles {
init: Arc::clone(&init_handle),
format_file: Arc::clone(&format_file_handle),
format_embedded: Arc::clone(&format_embedded_handle),
format_embedded_doc: Arc::clone(&format_embedded_doc_handle),
sort_tailwind: Arc::clone(&sort_tailwind_handle),
};
let rust_init = wrap_init_external_services(init_handle);
let rust_format_file = wrap_format_file(format_file_handle);
let rust_format_embedded = wrap_format_embedded(Arc::clone(&format_embedded_handle));
let rust_format_embedded_doc = wrap_format_embedded_doc(format_embedded_doc_handle);
let rust_tailwind = wrap_sort_tailwind_classes(sort_tailwind_handle);
Self {
handles,
init: rust_init,
format_file: rust_format_file,
format_embedded: rust_format_embedded,
format_embedded_doc: rust_format_embedded_doc,
sort_tailwindcss_classes: rust_tailwind,
}
@@ -240,13 +212,11 @@ impl ExternalServices {
handles: TsfnHandles {
init: Arc::new(RwLock::new(None)),
format_file: Arc::new(RwLock::new(None)),
format_embedded: Arc::new(RwLock::new(None)),
format_embedded_doc: Arc::new(RwLock::new(None)),
sort_tailwind: Arc::new(RwLock::new(None)),
},
init: Arc::new(|_| Err("Dummy init called".to_string())),
format_file: Arc::new(|_, _| Err("Dummy format_file called".to_string())),
format_embedded: Arc::new(|_, _| Err("Dummy format_embedded called".to_string())),
format_embedded_doc: Arc::new(|_, _: &str| {
Err("Dummy format_embedded_doc called".to_string())
}),
@@ -343,34 +313,6 @@ fn parse_format_file_result(result: Value) -> Result<String, String> {
}
}
/// Wrap JS `formatEmbeddedCode` callback as a normal Rust function.
/// The `options` Value is received with `parser` already set by the caller.
fn wrap_format_embedded(
cb_handle: Arc<RwLock<Option<JsFormatEmbeddedCb>>>,
) -> FormatEmbeddedWithConfigCallback {
Arc::new(move |options: Value, code: &str| {
let guard = cb_handle.read().unwrap();
let Some(cb) = guard.as_ref() else {
return Err("JS callback unavailable (environment shutting down)".to_string());
};
let result = block_on(async {
let status = cb.call_async(FnArgs::from((options, code.to_string()))).await;
match status {
Ok(promise) => match promise.await {
Ok(Some(formatted_code)) => Ok(formatted_code),
Ok(None) => Err("Embedded formatting failed".to_string()),
// JS side never rejects; it returns `null` on error instead.
// `Err` here would only come from a napi-rs internal failure.
Err(err) => Err(err.reason),
},
Err(err) => Err(err.reason),
}
});
drop(guard);
result
})
}
/// Wrap JS `formatEmbeddedDoc` callback as a normal Rust function.
/// The `options` Value is received with `parser` already set by the caller.
fn wrap_format_embedded_doc(
+11 -2
View File
@@ -149,6 +149,15 @@ impl SourceFormatter {
oxc_formatter_markdown::format_with_session(&session, source_text, options)
.and_then(|formatted| print(formatted, &path))
}
FormatStrategy::Native(NativeLanguage::Html(variant)) => {
let allocator = self.allocator_pool.get();
let dispatch_config =
ResolvedDispatchConfig::for_root(Arc::clone(&config), validated, &path);
let session = self.root_session(&allocator, &dispatch_config);
let options = dispatch_config.htmlish_options(variant);
oxc_formatter_htmlish::format_with_session(&session, source_text, options)
.and_then(|formatted| print(formatted, &path))
}
FormatStrategy::Native(NativeLanguage::Toml) => {
oxc_formatter_toml::format(source_text, to_oxc_formatter_toml(&config, core))
}
@@ -249,14 +258,14 @@ impl SourceFormatter {
language: PrettierLanguage,
config: &FormatConfig,
) -> Result<String, OxcDiagnostic> {
use PrettierLanguage::{Angular, Astro, Glimmer, Html, Svelte, Vue};
use PrettierLanguage::{Angular, Astro, Glimmer, Svelte, Vue};
let mut prettier_options = to_prettier(config);
inject_parser(&mut prettier_options, language.parser());
inject_filepath(&mut prettier_options, path);
// CSS/SCSS/Less also benefit, but they are formatted by `oxc_formatter_css`
if matches!(language, Html | Vue | Angular | Glimmer | Svelte | Astro) {
if matches!(language, Vue | Angular | Glimmer | Svelte | Astro) {
inject_tailwind_plugin_payload(&mut prettier_options, config);
}
// Languages that embed JS/TS code.
+2 -2
View File
@@ -8,6 +8,7 @@ use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use oxc_formatter_css::CssVariant;
use oxc_formatter_htmlish::HtmlishVariant;
use oxc_formatter_json::JsonVariant;
use oxc_span::{FileExtension, SourceType};
@@ -159,7 +160,7 @@ impl Language {
Self::Yaml => Route::Native(NativeLanguage::Yaml),
Self::Markdown => Route::Native(NativeLanguage::Markdown),
Self::Toml => Route::Native(NativeLanguage::Toml),
Self::Html => Route::Prettier(PrettierLanguage::Html),
Self::Html => Route::Native(NativeLanguage::Html(HtmlishVariant::Html)),
Self::AngularHtml => Route::Prettier(PrettierLanguage::Angular),
Self::Vue => Route::Prettier(PrettierLanguage::Vue),
Self::Svelte => Route::Prettier(PrettierLanguage::Svelte),
@@ -192,7 +193,6 @@ mod tests {
#[test]
fn prettier_fences_and_lsp_ids() {
for (fence, parser) in [
("html", "html"),
("angular", "angular"),
("angular-html", "angular"),
("vue", "vue"),
+2 -2
View File
@@ -22,6 +22,6 @@ pub use support::classify_file;
#[cfg(feature = "napi")]
pub use external_services::{
ExternalServices, JsFormatEmbeddedCb, JsFormatEmbeddedDocCb, JsFormatFileCb,
JsInitExternalServicesCb, JsSortTailwindClassesCb,
ExternalServices, JsFormatEmbeddedDocCb, JsFormatFileCb, JsInitExternalServicesCb,
JsSortTailwindClassesCb,
};
+4 -2
View File
@@ -8,6 +8,7 @@
//! - [`to_oxc_formatter_graphql()`]: `oxc_formatter_graphql::GraphqlFormatOptions` for GraphQL formatting
//! - [`to_oxc_formatter_yaml()`]: `oxc_formatter_yaml::YamlFormatOptions` for YAML formatting
//! - [`to_oxc_formatter_markdown()`]: `oxc_formatter_markdown::MarkdownFormatOptions` for Markdown formatting
//! - [`to_oxc_formatter_htmlish()`]: `oxc_formatter_htmlish::HtmlishFormatOptions` for HTML formatting
//! - [`to_oxc_formatter_toml()`]: `TomlFormatOptions` for `oxc_formatter_toml`
//! - `to_prettier`(NAPI-only): Prettier-compatible JSON, plus `inject_*` helpers for
//! layering in `parser` / `filepath` / plugin payloads at the format step
@@ -19,6 +20,7 @@ mod to_core_options;
mod to_oxc_formatter;
mod to_oxc_formatter_css;
mod to_oxc_formatter_graphql;
mod to_oxc_formatter_htmlish;
mod to_oxc_formatter_json;
mod to_oxc_formatter_markdown;
mod to_oxc_formatter_toml;
@@ -30,6 +32,7 @@ mod validate;
pub use to_oxc_formatter::to_oxc_formatter;
pub use to_oxc_formatter_css::to_oxc_formatter_css;
pub use to_oxc_formatter_graphql::to_oxc_formatter_graphql;
pub use to_oxc_formatter_htmlish::to_oxc_formatter_htmlish;
pub use to_oxc_formatter_json::{to_oxc_formatter_json, to_sort_package_json};
pub use to_oxc_formatter_markdown::to_oxc_formatter_markdown;
pub use to_oxc_formatter_toml::to_oxc_formatter_toml;
@@ -37,7 +40,6 @@ pub use to_oxc_formatter_yaml::to_oxc_formatter_yaml;
#[cfg(feature = "napi")]
pub use to_prettier::{
build_prettier_options, inject_filepath, inject_opt_in_plugin_payloads,
inject_oxfmt_plugin_payload, inject_parser, inject_string_embed_options,
inject_tailwind_plugin_payload, to_prettier,
inject_oxfmt_plugin_payload, inject_parser, inject_tailwind_plugin_payload, to_prettier,
};
pub use validate::{ValidatedOptions, validate};
@@ -8,8 +8,6 @@ use oxc_formatter::{
};
use oxc_formatter_core::{CoreFormatOptions, FormatOptions};
#[cfg(feature = "napi")]
use super::super::oxfmtrc::SortTailwindcssUserConfig;
use super::super::oxfmtrc::{
ArrowParensConfig, CommentLineStrategyConfig, FormatConfig, HtmlWhitespaceSensitivityConfig,
ImportModifierConfig, ImportSelectorConfig, JsdocUserConfig, LineWrappingStyleConfig,
@@ -124,9 +122,7 @@ pub fn to_oxc_formatter(
// so enabling it without the JS-side sorter would apply half the feature
// (normalized but unsorted classes).
#[cfg(feature = "napi")]
if let Some(tw_config) =
config.sort_tailwindcss.clone().and_then(SortTailwindcssUserConfig::into_config)
{
if let Some(tw_config) = config.tailwind_config() {
// `config` / `stylesheet` / `preserve_duplicates` are JS-sorter-only
// and travel through `to_prettier::inject_tailwind_plugin_payload`,
// not the Rust formatter options.
@@ -0,0 +1,46 @@
use oxc_formatter_core::{CoreFormatOptions, FormatOptions};
use oxc_formatter_htmlish::{HtmlishFormatOptions, WhitespaceSensitivity};
use super::super::oxfmtrc::{FormatConfig, HtmlWhitespaceSensitivityConfig};
/// Convert `FormatConfig` into `HtmlishFormatOptions` for `oxc_formatter_htmlish`.
///
/// Prettier's `html` language consumes the shared layout options plus
/// `htmlWhitespaceSensitivity`, `bracketSameLine` and `singleAttributePerLine`.
///
/// NOTE: Pure field translation:
/// `core` comes pre-validated from the config-resolution gate (`validate()`), so this cannot fail.
pub fn to_oxc_formatter_htmlish(
config: &FormatConfig,
core_options: CoreFormatOptions,
) -> HtmlishFormatOptions {
let mut options = HtmlishFormatOptions::default();
options.apply_core(core_options);
// [Prettier] htmlWhitespaceSensitivity: "css" | "strict" | "ignore"
if let Some(sensitivity) = config.html_whitespace_sensitivity {
options.whitespace_sensitivity = match sensitivity {
HtmlWhitespaceSensitivityConfig::Css => WhitespaceSensitivity::Css,
HtmlWhitespaceSensitivityConfig::Strict => WhitespaceSensitivity::Strict,
HtmlWhitespaceSensitivityConfig::Ignore => WhitespaceSensitivity::Ignore,
};
}
// [Prettier] bracketSameLine: boolean
if let Some(bracket_same_line) = config.bracket_same_line {
options.bracket_same_line = bracket_same_line;
}
// [Prettier] singleAttributePerLine: boolean
if let Some(single_attribute_per_line) = config.single_attribute_per_line {
options.single_attribute_per_line = single_attribute_per_line;
}
// [Oxfmt] sortTailwindcss: collect `class` (and `attributes`) classes for batch sorting.
// napi only, like the JS / CSS mappers: the sorter itself is JS-side.
#[cfg(feature = "napi")]
{
options.sort_tailwindcss =
config.tailwind_config().map(|tw_config| tw_config.attributes.unwrap_or_default());
}
options
}
+2 -13
View File
@@ -10,8 +10,7 @@ use super::super::oxfmtrc::{
ArrowParensConfig, AstroCompressHtmlConfig, AstroConfig, AstroUserConfig,
EmbeddedLanguageFormattingConfig, EndOfLineConfig, FormatConfig,
HtmlWhitespaceSensitivityConfig, ObjectWrapConfig, OperatorPositionConfig, ProseWrapConfig,
QuotePropsConfig, SortTailwindcssUserConfig, SvelteConfig, SvelteUserConfig,
TrailingCommaConfig,
QuotePropsConfig, SvelteConfig, SvelteUserConfig, TrailingCommaConfig,
};
#[cfg(feature = "napi")]
use super::super::support::PrettierLanguage;
@@ -172,15 +171,6 @@ pub fn inject_parser(opts: &mut Value, parser_name: &str) {
as_object_mut(opts).insert("parser".to_string(), Value::String(parser_name.to_string()));
}
/// Inject the keys for a string re-embedded into the parent's IR, overriding the configured ones:
/// - `printWidth`: the effective width at the embedding position
/// - `endOfLine`: `lf`, since the IR only takes `\n`
pub fn inject_string_embed_options(opts: &mut Value, print_width: usize) {
let opts = as_object_mut(opts);
opts.insert("printWidth".to_string(), Value::from(print_width));
opts.insert("endOfLine".to_string(), Value::from("lf"));
}
/// Inject `filepath` key.
///
/// Some plugins (Tailwind sorter, etc.) depend on it.
@@ -198,8 +188,7 @@ pub fn inject_filepath(opts: &mut Value, path: &Path) {
///
/// See: <https://github.com/tailwindlabs/prettier-plugin-tailwindcss#options>
pub fn inject_tailwind_plugin_payload(opts: &mut Value, config: &FormatConfig) {
let Some(tw) = config.sort_tailwindcss.clone().and_then(SortTailwindcssUserConfig::into_config)
else {
let Some(tw) = config.tailwind_config() else {
return;
};
let map = as_object_mut(opts);
+9 -3
View File
@@ -229,15 +229,16 @@ pub struct FormatConfig {
#[serde(skip_serializing_if = "Option::is_none")]
pub prose_wrap: Option<ProseWrapConfig>,
// ============================================================================================
// Prettier compatible options and only used by Prettier
// ============================================================================================
/// Specify the global whitespace sensitivity.
///
/// - Languages: HTML, Angular, Vue, Handlebars, Svelte
/// - Default: `"css"`
#[serde(skip_serializing_if = "Option::is_none")]
pub html_whitespace_sensitivity: Option<HtmlWhitespaceSensitivityConfig>,
// ============================================================================================
// Prettier compatible options and only used by Prettier
// ============================================================================================
/// Whether or not to indent the code inside `<script>` and `<style>` tags in Vue files.
///
/// - Languages: Vue
@@ -356,6 +357,11 @@ impl FormatConfig {
matches!(self.astro, Some(AstroUserConfig::Bool(true) | AstroUserConfig::Object(_)))
}
/// The `sortTailwindcss` settings, `None` when sorting is off.
pub fn tailwind_config(&self) -> Option<SortTailwindcssConfig> {
self.sort_tailwindcss.clone().and_then(SortTailwindcssUserConfig::into_config)
}
/// Whether Tailwind class sorting is enabled by this config.
///
/// Enabled when `sortTailwindcss` is set to `true` or an object;
+47 -8
View File
@@ -3,6 +3,7 @@ use std::path::Path;
use phf::phf_set;
use oxc_formatter_css::CssVariant;
use oxc_formatter_htmlish::HtmlishVariant;
use oxc_formatter_json::JsonVariant;
use oxc_span::SourceType;
@@ -56,6 +57,12 @@ pub fn classify_file(path: &Path) -> Option<FormatStrategy> {
if MARKDOWN_FILENAMES.contains(file_name) || MARKDOWN_EXTENSIONS.contains(ext) {
return Some(FormatStrategy::Native(NativeLanguage::Markdown));
}
// Angular templates share the extension, and stay with Prettier
if let Some(variant) = html_variant(ext)
&& !file_name.ends_with(".component.html")
{
return Some(FormatStrategy::Native(NativeLanguage::Html(variant)));
}
#[cfg(feature = "napi")]
if let Some(language) = prettier_language(file_name, ext) {
@@ -129,6 +136,7 @@ pub enum NativeLanguage {
Json(JsonVariant),
Markdown,
Toml,
Html(HtmlishVariant),
}
impl NativeLanguage {
@@ -142,6 +150,7 @@ impl NativeLanguage {
Self::Json(_) => "json",
Self::Markdown => "markdown",
Self::Toml => "toml",
Self::Html(_) => "html",
}
}
}
@@ -155,7 +164,6 @@ impl NativeLanguage {
#[derive(Debug, Clone, Copy)]
pub enum PrettierLanguage {
Mdx,
Html,
Angular,
Vue,
/// Formatted only when `prettier-plugin-svelte` is enabled (`svelte` config key).
@@ -178,7 +186,6 @@ impl PrettierLanguage {
pub fn parser(self) -> &'static str {
match self {
Self::Mdx => "mdx",
Self::Html => "html",
Self::Angular => "angular",
Self::Vue => "vue",
Self::Svelte => "svelte",
@@ -383,6 +390,17 @@ static MARKDOWN_EXTENSIONS: phf::Set<&'static str> = phf_set! {
// ---
fn html_variant(ext: &str) -> Option<HtmlishVariant> {
match ext {
"html" | "hta" | "htm" | "inc" => Some(HtmlishVariant::Html),
// An XML parser may read XHTML
"xhtml" | "xht" => Some(HtmlishVariant::Xhtml),
_ => None,
}
}
// ---
/// Returns the [`PrettierLanguage`] for the file, if supported.
/// See also `prettier --support-info | jq '.languages[]'`
#[cfg(feature = "napi")]
@@ -391,7 +409,6 @@ fn prettier_language(file_name: &str, ext: &str) -> Option<PrettierLanguage> {
return Some(PrettierLanguage::Angular);
}
Some(match ext {
"html" | "hta" | "htm" | "inc" | "xht" | "xhtml" => PrettierLanguage::Html,
"vue" => PrettierLanguage::Vue,
// Formatting is gated by `ResolveOutcome::MissingPlugin` (requires `svelte` / `astro` config),
// classified here so that each caller can surface a friendly error or skip.
@@ -522,11 +539,11 @@ mod tests {
("package.json", None),
("composer.json", None),
("config.importmap", None),
// HTML
("index.html", Some("html")),
("page.htm", Some("html")),
("template.xhtml", Some("html")),
// Angular (must be detected before HTML)
// HTML files are routed to `oxc_formatter_htmlish` in `classify_file`
// and excluded from this map.
("index.html", None),
("page.htm", None),
// Angular
("app.component.html", Some("angular")),
// MJML
("email.mjml", Some("mjml")),
@@ -686,6 +703,28 @@ mod tests {
}
}
#[test]
fn test_html_files_route_to_oxc_formatter_htmlish() {
for (file_name, expected) in [
("index.html", HtmlishVariant::Html),
("page.htm", HtmlishVariant::Html),
("app.hta", HtmlishVariant::Html),
("header.inc", HtmlishVariant::Html),
("template.xhtml", HtmlishVariant::Xhtml),
("page.xht", HtmlishVariant::Xhtml),
] {
let result = classify_file(Path::new(file_name));
assert!(
matches!(result, Some(FormatStrategy::Native(NativeLanguage::Html(variant))) if variant == expected),
"`{file_name}` should be routed to oxc_formatter_htmlish as {expected:?}"
);
}
// Angular templates stay with Prettier (and are not formatted in the pure Rust build)
let result = classify_file(Path::new("app.component.html"));
assert!(!matches!(result, Some(FormatStrategy::Native(_))));
}
#[test]
fn test_toml_files() {
// Files that should be detected as TOML
+6 -19
View File
@@ -10,9 +10,8 @@ use crate::{
api::{format_api, text_to_doc_api},
cli::{MigrateSource, Mode, StdinRunner, WalkRunner, format_command, init_rayon},
core::{
ExternalServices, JsFormatEmbeddedCb, JsFormatEmbeddedDocCb, JsFormatFileCb,
JsInitExternalServicesCb, JsLoadJsConfigCb, JsSortTailwindClassesCb,
create_js_config_loader, utils,
ExternalServices, JsFormatEmbeddedDocCb, JsFormatFileCb, JsInitExternalServicesCb,
JsLoadJsConfigCb, JsSortTailwindClassesCb, create_js_config_loader, utils,
},
lsp::run_lsp,
};
@@ -25,7 +24,7 @@ use crate::{
/// 2. `load_js_config_cb`: Callback to load JS/TS config files
/// 3. `init_external_services_cb`: Callback to initialize external services (JS worker pool)
/// 4. `format_file_cb`: Callback to format files
/// 5. `format_embedded_cb`: Callback to format embedded code in templates
/// 5. `format_embedded_doc_cb`: Callback to format embedded code in templates as a Prettier Doc
/// 6. `sort_tailwindcss_classes_cb`: Callback to sort Tailwind classes
///
/// Returns a tuple of `[mode, exitCode]`:
@@ -44,8 +43,6 @@ pub async fn run_cli(
)]
format_file_cb: JsFormatFileCb,
#[napi(ts_arg_type = "(options: Record<string, any>, code: string) => Promise<string | null>")]
format_embedded_cb: JsFormatEmbeddedCb,
#[napi(ts_arg_type = "(options: Record<string, any>, code: string) => Promise<string | null>")]
format_embedded_doc_cb: JsFormatEmbeddedDocCb,
#[napi(
ts_arg_type = "(options: Record<string, any>, classes: string[]) => Promise<string[] | null>"
@@ -83,13 +80,9 @@ pub async fn run_cli(
// Otherwise, handle modes that require Rust side processing
let external_services = ExternalServices::new(
format_file_cb,
format_embedded_cb,
format_embedded_doc_cb,
sort_tailwindcss_classes_cb,
)
.with_init_cb(init_external_services_cb);
let external_services =
ExternalServices::new(format_file_cb, format_embedded_doc_cb, sort_tailwindcss_classes_cb)
.with_init_cb(init_external_services_cb);
let _cleanup = external_services.cleanup_guard();
let js_config_loader = create_js_config_loader(load_js_config_cb);
@@ -149,8 +142,6 @@ pub async fn format(
)]
format_file_cb: JsFormatFileCb,
#[napi(ts_arg_type = "(options: Record<string, any>, code: string) => Promise<string | null>")]
format_embedded_cb: JsFormatEmbeddedCb,
#[napi(ts_arg_type = "(options: Record<string, any>, code: string) => Promise<string | null>")]
format_embedded_doc_cb: JsFormatEmbeddedDocCb,
#[napi(
ts_arg_type = "(options: Record<string, any>, classes: string[]) => Promise<string[] | null>"
@@ -162,7 +153,6 @@ pub async fn format(
source_text,
options,
format_file_cb,
format_embedded_cb,
format_embedded_doc_cb,
sort_tailwind_classes_cb,
);
@@ -189,8 +179,6 @@ pub async fn js_text_to_doc(
)]
format_file_cb: JsFormatFileCb,
#[napi(ts_arg_type = "(options: Record<string, any>, code: string) => Promise<string | null>")]
format_embedded_cb: JsFormatEmbeddedCb,
#[napi(ts_arg_type = "(options: Record<string, any>, code: string) => Promise<string | null>")]
format_embedded_doc_cb: JsFormatEmbeddedDocCb,
#[napi(
ts_arg_type = "(options: Record<string, any>, classes: string[]) => Promise<string[] | null>"
@@ -205,7 +193,6 @@ pub async fn js_text_to_doc(
&oxfmt_plugin_options_json,
&parent_context,
format_file_cb,
format_embedded_cb,
format_embedded_doc_cb,
sort_tailwind_classes_cb,
)
+3 -4
View File
@@ -22,7 +22,7 @@ describe("JSDoc", () => {
* \`\`\`
*
* \`\`\`html
* <div class="body" > </ div>
* <div class="body" > </div>
* \`\`\`
*/
`.trim();
@@ -181,9 +181,8 @@ describe("JSDoc", () => {
);
});
it("should pass the fence's effective width to the Prettier string path too", async () => {
// Same 93-char boundary as the native case, exercised through the Prettier
// branch (html fences go to Prettier with `printWidth` injected):
it("should pass the fence's effective width to an html fence too", async () => {
// Same 93-char boundary as the native case, exercised through an html fence:
// the 96-char element must break, the 93-char one must not.
const source = `
/**
+28
View File
@@ -23,11 +23,36 @@ pub struct CssInJsTemplate;
/// which would need escaping in the template.
pub struct MarkdownInJsTemplate;
/// Prettier uses `PRETTIER_HTML_PLACEHOLDER_{index}_{counter}_IN_JS` as placeholders, the counter global.
/// Ours is `PRETTIER_HTML_PLACEHOLDER_{depth}_{index}_IN_JS`, the session's dispatch depth:
/// an html-in-js nested in this one (in its `<script>`) is deeper, so neither takes the other's placeholders,
/// and the same input always gets the same placeholders.
///
/// A child may change its case (CSS lowercases a property name: `${b}: 1px` in a `<style>`),
/// so it is found ASCII case-insensitively in the child's IR.
/// The shape stays Prettier's because angular-in-js still formats through Prettier,
/// which recognizes `/^PRETTIER_HTML_PLACEHOLDER_\d+_\d+_IN_JS$/` (unquoted values, parens around an identifier).
/// TODO: Once Angular formats natively, the name is ours to choose,
/// e.g. one no child changes the case of, and the case-insensitive search can go.
pub const HTML_PLACEHOLDER_PREFIX: &str = "PRETTIER_HTML_PLACEHOLDER_";
pub const HTML_PLACEHOLDER_SUFFIX: &str = "_IN_JS";
/// `name` is an html-in-js placeholder, of any depth.
/// One in a `<script>` stands in for an expression of any precedence (`f((${a + b}) / 2)`),
/// so the parens around it in the source stay (Prettier's `parentheses/identifier.js`).
pub fn is_html_placeholder(name: &str) -> bool {
name.strip_prefix(HTML_PLACEHOLDER_PREFIX)
.and_then(|id| id.strip_suffix(HTML_PLACEHOLDER_SUFFIX))
.is_some_and(|id| !id.is_empty() && id.bytes().all(|b| b.is_ascii_digit() || b == b'_'))
}
/// Parent→child context for HTML/Angular dispatched from a JS template literal (html-in-js).
pub struct HtmlInJsTemplate {
/// The backticks hug the content (no whitespace on both ends, whitespace sensitivity not `ignore`):
/// the child indents its content when more than one node prints at the root.
pub indent_multiple_roots: bool,
/// The prefix and suffix around the id of the placeholder standing in for a `${}` expression.
pub placeholder: (&'static str, &'static str),
}
/// What a dispatched JS/TS program is embedded in, for the print rules that depend on it.
@@ -38,4 +63,7 @@ pub enum JsEmbeddedIn {
/// A Markdown code block (js-in-markdown):
/// a program of a single JSX expression statement prints without its semicolon.
MarkdownCodeBlock,
/// An HTML event handler attribute's value (`onclick="..."`):
/// a single expression statement prints without its semicolon, and strings in `'` (the attribute's quote is `"`).
HtmlEventHandler,
}
+14 -1
View File
@@ -3,7 +3,7 @@ use oxc_formatter_core::{FormatElement, SourceText};
use oxc_span::{GetSpan, SourceType, Span};
use rustc_hash::FxHashMap;
use crate::{options::JsFormatOptions, utils::assignment_like::AssignmentLikeLayout};
use crate::{JsEmbeddedIn, options::JsFormatOptions, utils::assignment_like::AssignmentLikeLayout};
use super::Comments;
@@ -103,6 +103,8 @@ pub struct JsFormatContext<'ast> {
/// Set only for an embedded program (see [`crate::format_to_ir`]):
/// its sole statement prints without its semicolon.
embedding_omits_semicolon: bool,
/// What an embedded program is embedded in (see [`crate::format_to_ir`]).
embedded_in: Option<JsEmbeddedIn>,
}
impl std::fmt::Debug for JsFormatContext<'_> {
@@ -148,6 +150,7 @@ impl<'ast> JsFormatContext<'ast> {
quote_needed_stack: Vec::new(),
tailwind_context_stack: Vec::new(),
embedding_omits_semicolon: false,
embedded_in: None,
}
}
@@ -241,6 +244,16 @@ impl<'ast> JsFormatContext<'ast> {
self.embedding_omits_semicolon
}
pub(crate) fn set_embedded_in(&mut self, embedded_in: Option<JsEmbeddedIn>) {
self.embedded_in = embedded_in;
}
/// An HTML attribute's value (see [`JsEmbeddedIn::HtmlEventHandler`]):
/// strings print in `'`, whatever they hold, as the attribute's quote is `"`.
pub(crate) fn in_html_attribute(&self) -> bool {
self.embedded_in == Some(JsEmbeddedIn::HtmlEventHandler)
}
/// Push a Tailwind context entry onto the stack.
/// Call this when entering a JSXAttribute or CallExpression with Tailwind class context.
pub fn push_tailwind_context(&mut self, entry: TailwindContextEntry) {
@@ -756,7 +756,7 @@ const EMBEDDED_FENCE_LANGUAGES: [&str; 6] = ["css", "less", "scss", "json", "yam
/// formatted in-process via [`format_jsdoc_js_snippet`] (`oxc_formatter`'s own parser/formatter).
/// - Non-JS/TS fenced code of [`EMBEDDED_FENCE_LANGUAGES`]:
/// when the session carries a string embedder, routed through it (the string channel);
/// absent one (Rust-only conformance, or the language has no native branch), preserved verbatim.
/// absent one (Rust-only conformance) or on its failure, preserved verbatim.
///
/// NOTE: `@example` fences diverge here, see `tag_formatters::format_example_fenced_block`.
fn format_code_value<'a>(
+11 -6
View File
@@ -136,13 +136,18 @@ pub fn format_to_ir<'a>(
// Special cases for js-in-xxx:
// - Single JSX in Markdown code block: omit the semicolon
let embedding_omits_semicolon = embedded_in == Some(JsEmbeddedIn::MarkdownCodeBlock)
&& program.directives.is_empty()
&& matches!(
program.body.as_slice(),
[Statement::ExpressionStatement(statement)] if statement.expression.is_jsx()
);
// - Single expression in an HTML event handler attribute: omit the semicolon
let embedding_omits_semicolon = program.directives.is_empty()
&& match program.body.as_slice() {
[Statement::ExpressionStatement(statement)] => match embedded_in {
Some(JsEmbeddedIn::MarkdownCodeBlock) => statement.expression.is_jsx(),
Some(JsEmbeddedIn::HtmlEventHandler) => true,
_ => false,
},
_ => false,
};
context.set_embedding_omits_semicolon(embedding_omits_semicolon);
context.set_embedded_in(embedded_in);
let (ir, _) = formatter::build_ir(
context,
@@ -5,6 +5,7 @@ use oxc_span::GetSpan;
use crate::{
ast_nodes::{AstNode, AstNodes},
embed_context::is_html_placeholder,
formatter::{JsFormatter, JsFormatterExt as _},
print::{BinaryLikeOperator, should_flatten, unary_argument_takes_comment_parens},
utils::{expression::ExpressionLeftSide, typecast::is_cast_target},
@@ -73,6 +74,11 @@ impl NeedsParentheses<'_> for AstNode<'_, '_, IdentifierReference<'_>> {
if f.comments().is_marked_as_type_cast_node(self) {
return false;
}
// An html-in-js placeholder (reaching JS through HTML's `<script>` or `on*` attributes)
// stands in for an expression of any precedence, as Prettier's `parentheses/identifier.js` does
if is_html_placeholder(self.name.as_str()) {
return is_parenthesized_in_source(self, f);
}
// `(type) satisfies never;` and similar keyword-at-statement-start cast cases
// <https://github.com/prettier/prettier/blob/7584432401a47a26943dd7a9ca9a8e032ead7285/src/language-js/parentheses/identifier.js#L84-L107>
@@ -136,6 +142,47 @@ impl NeedsParentheses<'_> for AstNode<'_, '_, IdentifierReference<'_>> {
}
}
/// Whether the source wraps `node` in parentheses of its own (the AST keeps none),
/// besides those of the syntax around it (a sole call argument, an `if` test, ...).
fn is_parenthesized_in_source(
node: &AstNode<'_, '_, IdentifierReference<'_>>,
f: &JsFormatter<'_, '_>,
) -> bool {
let bytes = f.source_text().as_str().as_bytes();
let (mut start, mut end) = (node.span.start as usize, node.span.end as usize);
let mut pairs = 0;
loop {
let before = bytes[..start].iter().rposition(|b| !b.is_ascii_whitespace());
let after = bytes[end..].iter().position(|b| !b.is_ascii_whitespace()).map(|i| end + i);
match (before, after) {
(Some(before), Some(after)) if bytes[before] == b'(' && bytes[after] == b')' => {
pairs += 1;
(start, end) = (before, after + 1);
}
_ => break,
}
}
let span = node.span;
let syntax_parens = match node.parent() {
AstNodes::CallExpression(call) => {
matches!(call.arguments.as_slice(), [only] if only.span() == span)
}
AstNodes::NewExpression(new) => {
matches!(new.arguments.as_slice(), [only] if only.span() == span)
}
AstNodes::ImportExpression(import) => {
import.source.span() == span && import.options.is_none()
}
AstNodes::IfStatement(statement) => statement.test.span() == span,
AstNodes::WhileStatement(statement) => statement.test.span() == span,
AstNodes::DoWhileStatement(statement) => statement.test.span() == span,
AstNodes::SwitchStatement(statement) => statement.discriminant.span() == span,
AstNodes::WithStatement(statement) => statement.object.span() == span,
_ => false,
};
pairs > usize::from(syntax_parens)
}
impl NeedsParentheses<'_> for AstNode<'_, '_, BooleanLiteral> {
#[inline]
fn needs_parentheses(&self, _f: &JsFormatter<'_, '_>) -> bool {
@@ -104,8 +104,10 @@ pub(super) fn format_css_doc<'a>(
.iter()
.map(|el| match el {
FormatElement::EmbedPlaceholder(_) => 1,
FormatElement::Text { text, .. } => {
super::count_placeholders(text, PLACEHOLDER_PREFIX, PLACEHOLDER_SUFFIX)
FormatElement::Text { text, .. }
if super::find_ignore_ascii_case(text.as_str(), PLACEHOLDER_PREFIX).is_some() =>
{
super::split_on_placeholders(text, PLACEHOLDER_PREFIX, PLACEHOLDER_SUFFIX).len() / 2
}
_ => 0,
})
@@ -132,7 +134,10 @@ pub(super) fn format_css_doc<'a>(
// Same scan as html.rs: `split_on_placeholders` yields alternating
// [literal, index, literal, ...] (invalid matches fold into the literal),
// so even parts are text and odd parts are a placeholder.
FormatElement::Text { text, .. } if text.contains(PLACEHOLDER_PREFIX) => {
FormatElement::Text { text, .. }
if super::find_ignore_ascii_case(text.as_str(), PLACEHOLDER_PREFIX)
.is_some() =>
{
let parts = super::split_on_placeholders(
text.as_str(),
PLACEHOLDER_PREFIX,
@@ -6,7 +6,7 @@ use oxc_formatter_core::{
use crate::{
ast_nodes::AstNode,
embed_context::HtmlInJsTemplate,
embed_context::{HTML_PLACEHOLDER_PREFIX, HTML_PLACEHOLDER_SUFFIX, HtmlInJsTemplate},
format_args,
formatter::prelude::*,
print::template::{
@@ -15,17 +15,11 @@ use crate::{
write,
};
// Prettier uses `PRETTIER_HTML_PLACEHOLDER_{index}_{counter}_IN_JS` as placeholders.
// We use a fixed counter of 0 since we don't have nested embeds.
const PLACEHOLDER_PREFIX: &str = "PRETTIER_HTML_PLACEHOLDER_";
const PLACEHOLDER_SUFFIX: &str = "_IN_JS";
const COUNTER: &str = "0";
/// Format an HTML(Angular)-in-JS template literal via the Doc->IR path with placeholder replacement.
/// Format an HTML(Angular)-in-JS template literal via the dispatcher with placeholder replacement.
///
/// Uses `.cooked` values (unlike CSS which uses `.raw`), joins quasis with
/// `PRETTIER_HTML_PLACEHOLDER_{N}_0_IN_JS` markers, formats via the given `embedded_language`,
/// then replaces placeholder occurrences in the resulting IR with `${expr}` Docs.
/// `PRETTIER_HTML_PLACEHOLDER_{depth}_{N}_IN_JS` markers, formats via the given `embedded_language`,
/// then replaces placeholder occurrences in the resulting IR with `${expr}` IR.
///
/// Supports both html-in-js and angular-in-js (`@Component({ template })`).
pub(super) fn format_html_doc<'a>(
@@ -68,17 +62,17 @@ pub(super) fn format_html_doc<'a>(
}
// Phase 1: Build joined text using .cooked with HTML placeholders
// quasis[0].cooked + "PRETTIER_HTML_PLACEHOLDER_0_0_IN_JS" + quasis[1].cooked + ...
// quasis[0].cooked + "PRETTIER_HTML_PLACEHOLDER_{depth}_0_IN_JS" + quasis[1].cooked + ...
let allocator = f.allocator();
let prefix = std::format!("{HTML_PLACEHOLDER_PREFIX}{}_", f.session().dispatch_depth());
let prefix = prefix.as_str();
let joined = {
let mut sb = ArenaStringBuilder::new_in(allocator);
for (idx, quasi_elem) in quasis.iter().enumerate() {
if idx > 0 {
sb.push_str(PLACEHOLDER_PREFIX);
sb.push_str(prefix);
let _ = std::fmt::Write::write_fmt(&mut sb, std::format_args!("{}", idx - 1));
sb.push('_');
sb.push_str(COUNTER);
sb.push_str(PLACEHOLDER_SUFFIX);
sb.push_str(HTML_PLACEHOLDER_SUFFIX);
}
let Some(cooked) = quasi_elem.value.cooked.as_ref() else {
return false;
@@ -103,7 +97,7 @@ pub(super) fn format_html_doc<'a>(
// Formatting consumes comment state,
// so discovering an unusable placeholder layout afterwards would make the verbatim fallback unsafe.
let mut next_placeholder = 0;
if !placeholders_are_sequential(&ir, &mut next_placeholder)
if !placeholders_are_sequential(&ir, prefix, &mut next_placeholder)
|| next_placeholder != expressions.len()
{
return false;
@@ -128,8 +122,8 @@ pub(super) fn format_html_doc<'a>(
let ir = map_text_in_ir(ir, allocator, &mut |text, out| {
let escaped = super::escape_template_chars(text, allocator);
let text = escaped.unwrap_or(text);
if text.contains(PLACEHOLDER_PREFIX) {
let parts = super::split_on_placeholders(text, PLACEHOLDER_PREFIX, PLACEHOLDER_SUFFIX);
if super::find_ignore_ascii_case(text, prefix).is_some() {
let parts = super::split_on_placeholders(text, prefix, HTML_PLACEHOLDER_SUFFIX);
if parts.len() > 1 {
for (index, part) in parts.iter().enumerate() {
if index.is_multiple_of(2) {
@@ -164,7 +158,10 @@ fn html_in_js_template(
has_trailing_ws: bool,
ws_ignore: bool,
) -> HtmlInJsTemplate {
HtmlInJsTemplate { indent_multiple_roots: !(ws_ignore || has_leading_ws && has_trailing_ws) }
HtmlInJsTemplate {
indent_multiple_roots: !(ws_ignore || has_leading_ws && has_trailing_ws),
placeholder: (HTML_PLACEHOLDER_PREFIX, HTML_PLACEHOLDER_SUFFIX),
}
}
// ---
@@ -224,15 +221,14 @@ fn write_html_template<'a>(
/// `BestFitting` alternatives describe the same logical content,
/// so every variant must contain the same index sequence and contributes it only once to its parent sequence.
/// On success `*next` is one past the last index seen; the caller checks it against the expression count.
fn placeholders_are_sequential(ir: &[FormatElement<'_>], next: &mut usize) -> bool {
fn placeholders_are_sequential(ir: &[FormatElement<'_>], prefix: &str, next: &mut usize) -> bool {
for element in ir {
match element {
FormatElement::Text { text, .. } => {
if !text.contains(PLACEHOLDER_PREFIX) {
if super::find_ignore_ascii_case(text, prefix).is_none() {
continue;
}
let parts =
super::split_on_placeholders(text, PLACEHOLDER_PREFIX, PLACEHOLDER_SUFFIX);
let parts = super::split_on_placeholders(text, prefix, HTML_PLACEHOLDER_SUFFIX);
for part in parts.iter().skip(1).step_by(2) {
if !part.parse::<usize>().is_ok_and(|index| index == *next) {
return false;
@@ -244,19 +240,21 @@ fn placeholders_are_sequential(ir: &[FormatElement<'_>], next: &mut usize) -> bo
let start = *next;
let mut variants = best_fitting.variants().iter();
let Some(first) = variants.next() else { return false };
if !placeholders_are_sequential(first, next) {
if !placeholders_are_sequential(first, prefix, next) {
return false;
}
for variant in variants {
let mut variant_next = start;
if !placeholders_are_sequential(variant, &mut variant_next)
if !placeholders_are_sequential(variant, prefix, &mut variant_next)
|| variant_next != *next
{
return false;
}
}
}
FormatElement::Interned(interned) if !placeholders_are_sequential(interned, next) => {
FormatElement::Interned(interned)
if !placeholders_are_sequential(interned, prefix, next) =>
{
return false;
}
_ => {}
@@ -293,12 +291,12 @@ mod tests {
let allocator = Allocator::default();
let element = best_fitting(
&allocator,
text("aPRETTIER_HTML_PLACEHOLDER_0_0_IN_JSbPRETTIER_HTML_PLACEHOLDER_1_0_IN_JSc"),
text("PRETTIER_HTML_PLACEHOLDER_0_0_IN_JS PRETTIER_HTML_PLACEHOLDER_1_0_IN_JS"),
text("aPRETTIER_HTML_PLACEHOLDER_0_0_IN_JSbPRETTIER_HTML_PLACEHOLDER_0_1_IN_JSc"),
text("PRETTIER_HTML_PLACEHOLDER_0_0_IN_JS PRETTIER_HTML_PLACEHOLDER_0_1_IN_JS"),
);
let mut next = 0;
assert!(placeholders_are_sequential(&[element], &mut next));
assert!(placeholders_are_sequential(&[element], "PRETTIER_HTML_PLACEHOLDER_0_", &mut next));
assert_eq!(next, 2);
}
@@ -308,10 +306,14 @@ mod tests {
let element = best_fitting(
&allocator,
text("PRETTIER_HTML_PLACEHOLDER_0_0_IN_JS"),
text("PRETTIER_HTML_PLACEHOLDER_1_0_IN_JS"),
text("PRETTIER_HTML_PLACEHOLDER_0_1_IN_JS"),
);
let mut next = 0;
assert!(!placeholders_are_sequential(&[element], &mut next));
assert!(!placeholders_are_sequential(
&[element],
"PRETTIER_HTML_PLACEHOLDER_0_",
&mut next
));
}
}
@@ -302,16 +302,16 @@ fn get_angular_component_property<'a>(
///
/// Handles both:
/// - CSS: `` `PLACEHOLDER-{N}` ``
/// - HTML: `PRETTIER_HTML_PLACEHOLDER_{N}_{C}_IN_JS`
/// - HTML: `PRETTIER_HTML_PLACEHOLDER_{depth}_{N}_IN_JS`, the `{depth}_` passed as part of `prefix`
///
/// The optional `_{digits}` counter group between index and suffix is skipped when present,
/// so the same function works for both formats.
/// The prefix and suffix match ASCII case-insensitively: a child may change their case
/// (CSS lowercases a property name holding an HTML placeholder).
fn split_on_placeholders<'a>(text: &'a str, prefix: &str, suffix: &str) -> Vec<&'a str> {
let mut result = vec![];
let mut remaining = text;
loop {
let Some(start) = remaining.find(prefix) else {
let Some(start) = find_ignore_ascii_case(remaining, prefix) else {
result.push(remaining);
break;
};
@@ -337,21 +337,14 @@ fn split_on_placeholders<'a>(text: &'a str, prefix: &str, suffix: &str) -> Vec<&
}
let digits = &after_prefix[..digit_end];
let mut after_digits = &after_prefix[digit_end..];
// Skip optional `_{digits}` (e.g., HTML counter `_0`)
if let Some(after_underscore) = after_digits.strip_prefix('_') {
let counter_end = after_underscore
.bytes()
.position(|b| !b.is_ascii_digit())
.unwrap_or(after_underscore.len());
if counter_end > 0 {
after_digits = &after_underscore[counter_end..];
}
}
let after_digits = &after_prefix[digit_end..];
// Check for the suffix
if let Some(after_suffix) = after_digits.strip_prefix(suffix) {
let after_suffix = after_digits
.get(..suffix.len())
.filter(|s| s.eq_ignore_ascii_case(suffix))
.map(|_| &after_digits[suffix.len()..]);
if let Some(after_suffix) = after_suffix {
// Valid placeholder — push the digit index
result.push(digits);
remaining = after_suffix;
@@ -368,6 +361,14 @@ fn split_on_placeholders<'a>(text: &'a str, prefix: &str, suffix: &str) -> Vec<&
result
}
/// `haystack.find(needle)`, ASCII case-insensitively.
fn find_ignore_ascii_case(haystack: &str, needle: &str) -> Option<usize> {
haystack
.as_bytes()
.windows(needle.len())
.position(|w| w.eq_ignore_ascii_case(needle.as_bytes()))
}
// ---
/// Re-escape template-literal characters (`` ` ``, `${`, `\`) in every `Text` element of an embedded IR.
@@ -493,40 +494,3 @@ fn escape_backticks_raw<'a>(s: &'a str, allocator: &'a Allocator) -> Option<&'a
result.push_str(&s[run_start..]);
Some(result.into_str())
}
/// Count placeholder occurrences matching `{prefix}{digits}(_{digits})?{suffix}`.
///
/// The optional `_{digits}` middle group lets this serve both:
/// - CSS sentinel `` `PLACEHOLDER-{N}` `` (no counter)
/// - HTML sentinel `PRETTIER_HTML_PLACEHOLDER_{N}_{C}_IN_JS` (with counter)
///
/// Mirrors the grammar that [`split_on_placeholders`] uses for substitution.
fn count_placeholders(text: &str, prefix: &str, suffix: &str) -> usize {
let mut count = 0;
let mut rest = text;
while let Some(start) = rest.find(prefix) {
let after_prefix = &rest[start + prefix.len()..];
let digit_end =
after_prefix.bytes().position(|b| !b.is_ascii_digit()).unwrap_or(after_prefix.len());
if digit_end > 0 {
let mut after_digits = &after_prefix[digit_end..];
// Skip optional `_{digits}` counter (HTML).
if let Some(after_underscore) = after_digits.strip_prefix('_') {
let counter_end = after_underscore
.bytes()
.position(|b| !b.is_ascii_digit())
.unwrap_or(after_underscore.len());
if counter_end > 0 {
after_digits = &after_underscore[counter_end..];
}
}
if let Some(tail) = after_digits.strip_prefix(suffix) {
count += 1;
rest = tail;
continue;
}
}
rest = &rest[start + prefix.len()..];
}
count
}
+24 -14
View File
@@ -46,8 +46,17 @@ impl<'a> FormatLiteralStringToken<'a> {
let options = f.options();
let source_type = f.context().source_type();
let chosen_quote_style =
if self.jsx { options.jsx_quote_style } else { options.quote_style };
// In an HTML attribute, `'` always (Prettier's `__isInHtmlAttribute`), but a directive as usual
let in_html_attribute = !self.jsx
&& self.parent_kind != StringLiteralParentKind::Directive
&& f.context().in_html_attribute();
let chosen_quote_style = if in_html_attribute {
QuoteStyle::Single
} else if self.jsx {
options.jsx_quote_style
} else {
options.quote_style
};
let is_quote_needed = match options.quote_properties {
QuoteProperties::AsNeeded => false,
@@ -55,8 +64,13 @@ impl<'a> FormatLiteralStringToken<'a> {
QuoteProperties::Consistent => f.context().is_quote_needed(),
};
let string_cleaner =
LiteralStringNormalizer::new(*self, chosen_quote_style, is_quote_needed, f.allocator());
let string_cleaner = LiteralStringNormalizer {
token: *self,
chosen_quote_style,
force_chosen_quote: in_html_attribute,
is_quote_needed,
allocator: f.allocator(),
};
let content = string_cleaner.normalize_text(source_type);
@@ -187,6 +201,8 @@ struct LiteralStringNormalizer<'a> {
token: FormatLiteralStringToken<'a>,
/// The quote that was set inside the configuration
chosen_quote_style: QuoteStyle,
/// Use `chosen_quote_style` even where the other one would need fewer escapes.
force_chosen_quote: bool,
/// State whether we need to print the quotes or not.
is_quote_needed: bool,
/// Arena for the strings with other quotes, so that `fmt` does not copy them again.
@@ -194,22 +210,16 @@ struct LiteralStringNormalizer<'a> {
}
impl<'a> LiteralStringNormalizer<'a> {
pub fn new(
token: FormatLiteralStringToken<'a>,
chosen_quote_style: QuoteStyle,
is_quote_needed: bool,
allocator: &'a Allocator,
) -> Self {
Self { token, chosen_quote_style, is_quote_needed, allocator }
}
fn normalize_text(&self, source_type: SourceType) -> Cow<'a, str> {
// Handle JSX attribute strings specially - they use HTML entity escaping
if self.token.jsx && self.token.parent_kind == StringLiteralParentKind::Expression {
return self.normalize_jsx_attribute();
}
let str_info = self.token.compute_string_information(self.chosen_quote_style);
let mut str_info = self.token.compute_string_information(self.chosen_quote_style);
if self.force_chosen_quote {
str_info.preferred_quote = self.chosen_quote_style;
}
match self.token.parent_kind {
StringLiteralParentKind::Expression => self.normalize_string_literal(str_info),
StringLiteralParentKind::Directive => self.normalize_directive(str_info),
+1 -1
View File
@@ -125,7 +125,7 @@ Output targets Prettier compatibility, but the domain is defined by what it is,
Admission: core stores the service and hands it back (or applies it mechanically at finalize);
every value crossing the closure boundary is an opaque string/vec, never a language enum or an option type, and core makes no decision from the result.
`string_embedder` additionally carries an exit criterion: it is removed when (a) html gains an IR-capable formatter AND (b) the host's string-out consumers (JSDoc fences) can express their re-embedding in IR (see `apps/oxfmt`'s AGENTS.md for that half); until then it is the string-out channel's transport.
`string_embedder` additionally carries an exit criterion: it is removed when the host's string-out consumers (JSDoc fences) can express their re-embedding in IR (see `apps/oxfmt`'s AGENTS.md for that half; html, the other half, has an IR-capable formatter now); until then it is the string-out channel's transport.
- (5) `envelope/`: IR-composing behavior shared by document-envelope hosts (`write_front_matter`)
@@ -228,6 +228,12 @@ impl<'a> FormatSession<'a> {
self.input_kind
}
/// How many dispatches deep this session is (`0` for a root):
/// nested embeds tell their own markers apart by it.
pub fn dispatch_depth(&self) -> u8 {
self.dispatch_depth
}
/// The string channel service ([`StringEmbedder`]), when this run installs one.
/// (A handle rather than an invoking method; see the alias for the `Err`-means-verbatim contract.)
pub fn string_embedder(&self) -> Option<&StringEmbedder> {
@@ -13,7 +13,7 @@ use std::path::Path;
use oxc_allocator::Allocator;
use oxc_formatter_core::{Document, FormatOptions, FormatSession, InputKind};
use oxc_formatter_css::{CssFormatOptions, CssVariant, format_to_ir};
use oxc_formatter_css::{CssEmbeddedIn, CssFormatOptions, CssVariant, format_to_ir};
fn main() {
let mut args = pico_args::Arguments::from_env();
@@ -32,7 +32,7 @@ fn main() {
let allocator = Allocator::new();
let session = FormatSession::new(&allocator, InputKind::Fragment);
match format_to_ir(&session, &source_text, options, /* template_placeholders */ true) {
match format_to_ir(&session, &source_text, options, Some(CssEmbeddedIn::CssInJsTemplate)) {
Ok(embedded) => {
let document = Document::new(embedded.ir, Vec::new());
// `elements` borrows the arena (not the document) and group modes are `Cell`s,
+12 -6
View File
@@ -2,6 +2,7 @@ use oxc_formatter_core::{FormatContext, SourceText};
use crate::{
comments::{Comments, CssComment},
format::CssEmbeddedIn,
options::CssFormatOptions,
};
@@ -16,9 +17,9 @@ pub struct CssFormatContext<'a> {
/// Inside an ICSS rule (`:import(...)` / `:export`): property names keep
/// their case (Prettier's `insideIcssRuleNode`).
in_icss_rule: std::cell::Cell<bool>,
/// The source may contain css-in-js `${}` placeholder markers
/// (embedded entry point only); gates the printer's placeholder handling.
template_placeholders: bool,
/// What the part is embedded in (embedded entry point only);
/// css-in-js gates the printer's placeholder handling.
embedded_in: Option<CssEmbeddedIn>,
}
impl<'a> CssFormatContext<'a> {
@@ -26,7 +27,7 @@ impl<'a> CssFormatContext<'a> {
options: CssFormatOptions,
source_code: &'a str,
comments: &'a [CssComment],
template_placeholders: bool,
embedded_in: Option<CssEmbeddedIn>,
) -> Self {
Self {
options,
@@ -34,13 +35,18 @@ impl<'a> CssFormatContext<'a> {
comments: Comments::new(comments),
in_less_detached: std::cell::Cell::new(false),
in_icss_rule: std::cell::Cell::new(false),
template_placeholders,
embedded_in,
}
}
/// Whether the source may contain css-in-js `${}` placeholder markers.
pub fn template_placeholders(&self) -> bool {
self.template_placeholders
self.embedded_in == Some(CssEmbeddedIn::CssInJsTemplate)
}
/// The source is an HTML `style` attribute's value.
pub fn is_style_attribute(&self) -> bool {
self.embedded_in == Some(CssEmbeddedIn::HtmlStyleAttribute)
}
pub fn in_less_detached(&self) -> &std::cell::Cell<bool> {
+20 -7
View File
@@ -64,8 +64,7 @@ pub fn format_with_session<'a>(
let ParsedCss { stylesheet, comments, source, has_bom, front_matter } =
parse_for_format(session.allocator(), source_text, options, CssRoot::Stylesheet)?;
let context =
CssFormatContext::new(options, source, comments, /* template_placeholders */ false);
let context = CssFormatContext::new(options, source, comments, None);
// 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());
@@ -132,8 +131,8 @@ pub fn parse_for_format<'a>(
/// - allocates from the session's shared arena and `GroupId` space
/// - emits neither a BOM nor the trailing newline
/// - parses a fragment ([`CssRoot::Fragment`]), so root declarations are statements
/// - `template_placeholders` enables the css-in-js parse mode ([`CssRoot::CssInJsTemplate`]);
/// JSDoc / Markdown code blocks pass `false`
/// - `embedded_in` is what the part is embedded in, for the parse mode and print rules that depend on it ([`CssEmbeddedIn`]);
/// JSDoc / Markdown code blocks pass `None`
///
/// `@apply` Tailwind classes go into the session's class scope (shared with the parent),
/// the parent document owns the batch sort.
@@ -144,11 +143,15 @@ pub fn format_to_ir<'a>(
session: &FormatSession<'a>,
source_text: &str,
options: CssFormatOptions,
template_placeholders: bool,
embedded_in: Option<CssEmbeddedIn>,
) -> Result<EmbeddedIr<'a>, OxcDiagnostic> {
// `FormatSession::dispatch` never hands a BOM-headed input to an embedded part,
// so the BOM split inside `parse_for_format` is a no-op here.
let root = if template_placeholders { CssRoot::CssInJsTemplate } else { CssRoot::Fragment };
let root = if embedded_in == Some(CssEmbeddedIn::CssInJsTemplate) {
CssRoot::CssInJsTemplate
} else {
CssRoot::Fragment
};
let ParsedCss { stylesheet, comments, source, front_matter, .. } =
parse_for_format(session.allocator(), source_text, options, root)?;
if front_matter.is_some() && !session.input_kind().owns_front_matter() {
@@ -159,7 +162,7 @@ pub fn format_to_ir<'a>(
));
}
let context = CssFormatContext::new(options, source, comments, template_placeholders);
let context = CssFormatContext::new(options, source, comments, embedded_in);
let mut state = FormatState::new_with_session(context, session.clone());
let mut buffer = Formatter::new(&mut state);
@@ -168,6 +171,16 @@ pub fn format_to_ir<'a>(
Ok(EmbeddedIr { ir: buffer.into_vec() })
}
/// What an embedded CSS part is embedded in (the host translates the parent's request into this).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum CssEmbeddedIn {
/// A css-in-js template: SCSS grammar with `${}` placeholder markers ([`CssRoot::CssInJsTemplate`]).
CssInJsTemplate,
/// An HTML `style` attribute's value: its declarations print on one line when they fit,
/// the last `;` only when they do not (Prettier's `__isHTMLStyleAttribute`).
HtmlStyleAttribute,
}
/// What a CSS source is parsed as.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum CssRoot {
+4 -1
View File
@@ -38,6 +38,9 @@ pub const TEMPLATE_PLACEHOLDER_SUFFIX: &str = "`";
pub use crate::{
comments::CssComment,
context::CssFormatContext,
format::{CssRoot, ParsedCss, format, format_to_ir, format_with_session, parse_for_format},
format::{
CssEmbeddedIn, CssRoot, ParsedCss, format, format_to_ir, format_with_session,
parse_for_format,
},
options::{CssFormatOptions, CssVariant, SingleQuote, TrailingCommas},
};
@@ -8,7 +8,10 @@ use oxc_span::Span;
use oxc_formatter_core::{
arena_cow_str,
builders::{block_indent, dedent, empty_line, hard_line_break, indent, space, text},
builders::{
block_indent, dedent, empty_line, hard_line_break, if_group_breaks, indent,
soft_line_break_or_space, space, text, token,
},
write,
};
@@ -98,6 +101,8 @@ pub(super) fn write_statement_sequence_bounded<'a>(
// (`${m}\ncolor: red` -> no `;`, but `${m}\ncolor: red;\nx: 1` -> `x: 1;`).
// Track that run so swallowed declarations keep a source-driven `;`.
let mut swallowed = false;
// Only the attribute's own declarations, not a block's
let style_attribute = upper == u32::MAX && f.context().is_style_attribute();
for (i, stmt) in statements.iter().enumerate() {
let start = stmt_start(stmt);
if i > 0 {
@@ -118,7 +123,12 @@ pub(super) fn write_statement_sequence_bounded<'a>(
write!(f, space());
}
} else {
write!(f, hard_line_break());
// A `style` attribute's declarations share a line when they fit
if style_attribute {
write!(f, soft_line_break_or_space());
} else {
write!(f, hard_line_break());
}
if gap == Gap::Blank {
write!(f, empty_line());
}
@@ -145,6 +155,13 @@ pub(super) fn write_statement_sequence_bounded<'a>(
if has_source_semicolon {
write!(f, ";");
}
} else if style_attribute
&& i + 1 == statements.len()
&& let Statement::Declaration(decl) = stmt
{
// The last declaration's `;` only when the attribute breaks
write_declaration(decl, f);
write!(f, if_group_breaks(&token(";")));
} else {
write_statement(stmt, f);
}
+4 -1
View File
@@ -86,7 +86,10 @@ fn format_embedded(source: &str, options: CssFormatOptions) -> String {
let session =
oxc_formatter_core::FormatSession::new(&allocator, oxc_formatter_core::InputKind::Fragment);
let embedded = oxc_formatter_css::format_to_ir(
&session, source, options, /* template_placeholders */ true,
&session,
source,
options,
Some(oxc_formatter_css::CssEmbeddedIn::CssInJsTemplate),
)
.expect("format should succeed");
// Simulate the host: replace each typed placeholder with the canonical
@@ -29,6 +29,7 @@ which changes what `pre` renders and what `textarea` submits.
- Why: uniform-rule (same construct, same output: the doctype of a `.html` file, which Prettier lowercases)
- Pin: `tests/fixtures/vue/doctype-case.vue`
- Oxfmt: `externals/mdn-learn/css_layout/multiple-column_layout/index.md`, `externals/mdn-learn/css_layout/positioning/index.md`
```vue
<!-- input -->
@@ -189,6 +190,7 @@ Prettier's srcset parser treats a zero density as absent and drops it.
- 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`
- Oxfmt: `externals/prettier/html/script/script.html`
```html
<!-- input -->
@@ -277,6 +279,7 @@ With `htmlWhitespaceSensitivity: "ignore"` they stay blocks, as HTML inline elem
- 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`
- Oxfmt: `externals/prettier/html/tags/tags2.html`
```html
<!-- input -->
@@ -435,6 +438,7 @@ An email template pads its preview text this way, NBSP alternating with zero-wid
- 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`
- Oxfmt: `externals/prettier/html/basics/broken-html.html`, `externals/prettier/html/prettier_ignore/unclosed2.html`
```html
<!-- input (header.html, closed by footer.html) -->
@@ -568,6 +572,7 @@ Vue and Angular keep every name as written, as Prettier does.
- 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`
- Oxfmt: `externals/prettier/html/basics/with-colon.html`
```html
<!-- input -->
@@ -597,6 +602,7 @@ Angular's compiler reads a prefix as a namespace (`svg:rect`), and so does the A
- 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`
- Oxfmt: `externals/mdn-learn/structuring_content/basic_html_syntax/index.md`, `externals/vite/html/valid.html`
```html
<!-- input -->
+12 -3
View File
@@ -1,17 +1,26 @@
use oxc_formatter_core::{FormatContext, SourceText};
use crate::{options::HtmlishFormatOptions, tree::Tree};
use crate::{format::HtmlishEmbeddedIn, options::HtmlishFormatOptions, tree::Tree};
/// Formatting context for htmlish.
pub struct HtmlishFormatContext<'a> {
options: HtmlishFormatOptions,
source_text: SourceText<'a>,
tree: &'a Tree<'a>,
embedded_in: Option<HtmlishEmbeddedIn>,
}
impl<'a> HtmlishFormatContext<'a> {
pub fn new(options: HtmlishFormatOptions, tree: &'a Tree<'a>) -> Self {
Self { options, source_text: SourceText::new(tree.source()), tree }
pub fn new(
options: HtmlishFormatOptions,
tree: &'a Tree<'a>,
embedded_in: Option<HtmlishEmbeddedIn>,
) -> Self {
Self { options, source_text: SourceText::new(tree.source()), tree, embedded_in }
}
pub fn embedded_in(&self) -> Option<HtmlishEmbeddedIn> {
self.embedded_in
}
/// The preprocess table, with the arena lifetime so it outlives borrows of the formatter.
+51 -8
View File
@@ -2,7 +2,7 @@ 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},
builders::{empty_line, hard_line_break, indent, text},
spec::{FrontMatter, blank_front_matter, parse_front_matter, split_bom},
write, write_front_matter,
};
@@ -14,7 +14,7 @@ use oxc_htmlish_parser::{
use crate::{
context::HtmlishFormatContext,
options::HtmlishFormatOptions,
print::{self, HtmlishFormatter},
print::{self, HtmlishFormatter, format_with},
tree::Tree,
};
@@ -52,7 +52,7 @@ pub fn format_with_session<'a>(
);
let parsed = parse_for_format(session.allocator(), source_text, &options)?;
let context = build_context(session.allocator(), &parsed, options, true);
let context = build_context(session.allocator(), &parsed, options, true, None);
// 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());
@@ -114,6 +114,7 @@ pub fn format_to_ir<'a>(
session: &FormatSession<'a>,
source_text: &str,
options: HtmlishFormatOptions,
embedded_in: Option<HtmlishEmbeddedIn>,
) -> Result<EmbeddedIr<'a>, OxcDiagnostic> {
let allocator = session.allocator();
// `FormatSession::dispatch` never hands a BOM-headed input to an embedded part
@@ -125,7 +126,7 @@ pub fn format_to_ir<'a>(
));
}
let context = build_context(allocator, &parsed, options, false);
let context = build_context(allocator, &parsed, options, false, embedded_in);
let mut state = FormatState::new_with_session(context, session.clone());
let mut buffer = Formatter::new(&mut state);
@@ -134,6 +135,38 @@ pub fn format_to_ir<'a>(
Ok(EmbeddedIr { ir: buffer.into_vec() })
}
/// What the HTML is embedded in, for the print rules that depend on it.
///
/// The host translates the parent's request into this;
/// `None` is a part formatted on its own (e.g. a JSDoc code block).
/// Embedded HTML leaves `class` and `allow` values as written (as Prettier does with a `parentParser`).
#[derive(Clone, Copy, Debug, Default)]
pub struct HtmlishEmbeddedIn {
/// Indent the content when more than one node prints at the root
/// (html-in-js, whose backticks hug the content).
pub indent_multiple_roots: bool,
/// The parent's placeholder for an expression (html-in-js's `${}`), as the prefix and suffix around its ASCII digits and `_`:
/// a value that is one stays unquoted (`attr=${value}` passes a value, not a string),
/// and a value holding one is no class list to sort.
pub placeholder: Option<(&'static str, &'static str)>,
}
impl HtmlishEmbeddedIn {
/// `value` is the parent's placeholder as a whole.
pub(crate) fn is_placeholder(&self, value: &str) -> bool {
self.placeholder.is_some_and(|(prefix, suffix)| {
value.strip_prefix(prefix).and_then(|id| id.strip_suffix(suffix)).is_some_and(|id| {
!id.is_empty() && id.bytes().all(|b| b.is_ascii_digit() || b == b'_')
})
})
}
/// `value` holds the parent's placeholder.
pub(crate) fn has_placeholder(&self, value: &str) -> bool {
self.placeholder.is_some_and(|(prefix, _)| value.contains(prefix))
}
}
/// 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`.
@@ -168,6 +201,7 @@ fn build_context<'a>(
parsed: &ParsedHtmlish<'a>,
options: HtmlishFormatOptions,
standalone: bool,
embedded_in: Option<HtmlishEmbeddedIn>,
) -> HtmlishFormatContext<'a> {
let tree = allocator.alloc(Tree::build(
allocator,
@@ -177,7 +211,7 @@ fn build_context<'a>(
options.variant,
standalone,
));
HtmlishFormatContext::new(options, tree)
HtmlishFormatContext::new(options, tree, embedded_in)
}
/// Emits the front matter and the nodes with the final newline.
@@ -213,16 +247,25 @@ impl<'a> Format<'a, HtmlishFormatContext<'a>> for FormatHtmlishEmbedded<'_, 'a>
}
}
/// The front matter (a blank line after it when a body follows) and the nodes.
/// The front matter (a blank line after it when a body follows) and the nodes,
/// indented when the parent asks for it and more than one prints at the root.
fn write_document<'a>(parsed: &ParsedHtmlish<'a>, f: &mut HtmlishFormatter<'_, 'a>) {
let has_body = f.context().tree()[NodeId::DOCUMENT].first_child.is_some();
let document = &f.context().tree()[NodeId::DOCUMENT];
let has_body = 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 {
if !has_body {
return;
}
let indent_body = f.context().embedded_in().is_some_and(|e| e.indent_multiple_roots)
&& document.only_child().is_none();
if indent_body {
write!(f, indent(&format_with(print::write_root)));
} else {
print::write_root(f);
}
}
+5 -1
View File
@@ -11,6 +11,10 @@ mod tree;
pub use crate::{
context::HtmlishFormatContext,
format::{ParsedHtmlish, format, format_to_ir, format_with_session, parse_for_format},
format::{
HtmlishEmbeddedIn, ParsedHtmlish, format, format_to_ir, format_with_session,
parse_for_format,
},
options::{HtmlishFormatOptions, HtmlishVariant, WhitespaceSensitivity},
print::{CssInHtmlStyleAttribute, JsInHtmlEventHandler},
};
+4 -1
View File
@@ -29,7 +29,7 @@ impl HtmlishVariant {
}
/// Format options for htmlish (HTML and the template languages built on it).
#[derive(Debug, Default, Clone, Copy, Eq, PartialEq)]
#[derive(Debug, Default, Clone, Eq, PartialEq)]
pub struct HtmlishFormatOptions {
pub indent_style: IndentStyle,
pub indent_width: IndentWidth,
@@ -42,6 +42,9 @@ pub struct HtmlishFormatOptions {
pub bracket_same_line: bool,
/// Mirrors Prettier's `singleAttributePerLine`.
pub single_attribute_per_line: bool,
/// Sort Tailwind CSS classes (by the session's sorter, as prettier-plugin-tailwindcss does):
/// those of `class`, and of the attributes listed here; `None` leaves them as they are.
pub sort_tailwindcss: Option<Vec<String>>,
}
/// Which whitespace between elements is significant.
@@ -1,12 +1,12 @@
//! 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 std::{any::Any, borrow::Cow};
use cow_utils::CowUtils;
use oxc_formatter_core::{
Format, arena_cow_str,
Format, FormatElement, 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,
@@ -14,9 +14,16 @@ use oxc_formatter_core::{
spec::is_suppression_marker,
write,
};
use oxc_htmlish_parser::ast::{Attribute, NodeId, NodeKind};
use oxc_htmlish_parser::ast::{Attribute, NodeId, NodeKind, Quote};
use super::{HtmlishFormatter, format_with, tag::opening_end_borrowed};
use super::{
HtmlishFormatter,
embed::{
CssInHtmlStyleAttribute, JsInHtmlEventHandler, is_event_handler, write_attribute_value,
},
format_with,
tag::opening_end_borrowed,
};
use crate::{
context::HtmlishFormatContext,
tree::{Tree, collapse_whitespace},
@@ -46,12 +53,13 @@ pub fn write_attributes<'a>(t: &'a Tree<'a>, id: NodeId, f: &mut HtmlishFormatte
}
return;
}
let options = *f.options();
let (single_attribute_per_line, bracket_same_line) =
(f.options().single_attribute_per_line, f.options().bracket_same_line);
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 per_line = single_attribute_per_line && el.attributes.len() > 1;
let ignored = ignored_attributes(t, id);
let is_ignored = |name: &str| {
ignored.is_some_and(|names| {
@@ -88,7 +96,7 @@ pub fn write_attributes<'a>(t: &'a Tree<'a>, id: NodeId, f: &mut HtmlishFormatte
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 hug_end || bracket_same_line {
if slash {
write!(f, space());
}
@@ -110,13 +118,43 @@ fn write_attribute<'a>(
let is = |known| t.attribute_is(name, known);
write!(f, text(t.attribute_name(element, name)));
let Some(value) = attribute.value else { return };
let quoted = value.quote != Quote::None;
let value = value.span.slice(source);
write!(f, "=");
let embedded_in = f.context().embedded_in();
if !quoted && embedded_in.is_some_and(|e| e.is_placeholder(value)) {
write!(f, text(value));
return;
}
// A placeholder would hide in the sorted class list, out of the parent's reach
let tailwind = f.options().sort_tailwindcss.as_ref().is_some_and(|attributes| {
is("class") || attributes.iter().any(|attribute| attribute == name)
}) && !embedded_in.is_some_and(|e| e.has_placeholder(value));
if tailwind && write_tailwind_classes(value, is("class") && embedded_in.is_none(), f) {
return;
}
// `{{` may be a template's interpolation: those values print as written.
// Prettier formats the values below only in a document of its own.
let reformat = embedded_in.is_none() && !value.contains("{{");
let embedded = if !reformat {
None
} else if is("style") {
Some(("css", &CssInHtmlStyleAttribute as &dyn Any))
} else if is_event_handler(name) {
Some(("js", &JsInHtmlEventHandler as &dyn Any))
} else {
None
};
if let Some((language, parent_context)) = embedded
&& write_attribute_value(language, parent_context, unescaped(value, f), f)
{
return;
}
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() {
if is("class") && reformat && !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")
@@ -124,13 +162,34 @@ fn write_attribute<'a>(
&& let Some(candidates) = parse_srcset(unescaped(value, f))
{
write_srcset(&candidates, f);
} else if is("allow") && tag == "iframe" && !value.contains("{{") {
} else if is("allow") && tag == "iframe" && reformat {
write_permissions_policy(unescaped(value, f), f);
} else {
write!(f, AttributeValue(value));
}
}
/// A class list for the session's Tailwind sorter, which collapses its whitespace unless told to preserve it;
/// `class` collapses it anyway when printed on its own (`collapse`).
/// Returns `false` for a value the sorter leaves alone (`{{`, whitespace only),
/// or holding a `"`, whose quote is chosen by content.
fn write_tailwind_classes<'a>(
value: &'a str,
collapse: bool,
f: &mut HtmlishFormatter<'_, 'a>,
) -> bool {
let value = unescaped(value, f);
if value.contains('"') || value.contains("{{") || value.trim_ascii().is_empty() {
return false;
}
let classes = if collapse { collapse_whitespace(f.allocator(), value) } else { value };
let index = f.session().add_tailwind_class(classes.to_string());
write!(f, "\"");
f.write_element(FormatElement::TailwindClass(index));
write!(f, "\"");
true
}
/// 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);
@@ -159,12 +218,12 @@ fn unescaped<'a>(value: &'a str, f: &HtmlishFormatter<'_, 'a>) -> &'a str {
}
/// `"` re-escaped for a value printed in `"`.
fn quoted_piece<'a>(piece: &'a str, f: &HtmlishFormatter<'_, 'a>) -> &'a str {
pub(super) 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>(
pub(super) fn write_expanded<'a>(
content: &impl Format<'a, HtmlishFormatContext<'a>>,
f: &mut HtmlishFormatter<'_, 'a>,
) {
@@ -231,7 +290,7 @@ fn parse_srcset(input: &str) -> Option<Vec<Candidate<'_>>> {
url = url.trim_end_matches(',');
""
} else {
// Descriptors up to the next `,` outside parentheses.
// Descriptors up to the next `,` outside parentheses
rest = rest.trim_ascii_start();
let mut depth = 0;
let mut end = rest.len();
@@ -258,7 +317,7 @@ fn parse_srcset(input: &str) -> Option<Vec<Candidate<'_>>> {
'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).
// 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,
}?;
@@ -1,5 +1,6 @@
//! Angular control flow blocks: transparent, their markers print no box (`@if (a) {` … `}`).
use oxc_allocator::{Allocator, StringBuilder};
use oxc_formatter_core::{
builders::{hard_line_break, indent, text},
write,
@@ -11,7 +12,6 @@ use super::{
children::{Between, write_children},
format_with,
};
use oxc_allocator::{Allocator, StringBuilder};
use crate::tree::{Tree, collapse_whitespace};
@@ -19,7 +19,7 @@ use crate::tree::{Tree, collapse_whitespace};
/// 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.
// 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))
@@ -67,13 +67,13 @@ pub fn write_block<'a>(
let default_never = name == "default never";
write!(f, ["@", text(name)]);
if !block.parameters.is_empty() {
// `@default never(state)`: Prettier glues the parameters to `never`.
// `@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.
// 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));
@@ -81,7 +81,7 @@ pub fn write_block<'a>(
write!(f, ")");
}
if block.close.is_none() {
// `@default never;`; a fall-through `@case (a)` has nothing more.
// `@default never;`; a fall-through `@case (a)` has nothing more
if default_never {
write!(f, ";");
}
@@ -104,13 +104,14 @@ pub fn write_block<'a>(
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.
// `@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).
/// [`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);
@@ -124,7 +124,8 @@ fn between(t: &Tree<'_>, prev: NodeId, next: NodeId) -> Between {
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).
// 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 };
}
@@ -148,7 +149,7 @@ pub fn write_children<'a>(
return;
}
// Each pair's separator and blank-line fact, computed once and carried to the next child.
// 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) {
@@ -168,12 +169,12 @@ pub fn write_children<'a>(
continue;
}
// A line break on a side is printed outside the child's group; a softer separator inside it.
// 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.
// 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 =>
{
@@ -286,7 +287,8 @@ pub fn has_trailing_line_break(t: &Tree<'_>, id: NodeId) -> bool {
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).
// 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))
}
@@ -29,7 +29,7 @@ pub fn write_element<'a>(t: &'a Tree<'a>, id: NodeId, f: &mut HtmlishFormatter<'
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).
// 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);
@@ -88,13 +88,13 @@ pub fn write_element<'a>(t: &'a Tree<'a>, id: NodeId, f: &mut HtmlishFormatter<'
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).
// 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.
// `<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 {
@@ -102,7 +102,7 @@ pub fn write_element<'a>(t: &'a Tree<'a>, id: NodeId, f: &mut HtmlishFormatter<'
} 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()) {
} else if !ends_at_indentation(t, id, last, f.options()) {
write!(f, soft_line_break());
}
});
@@ -129,12 +129,13 @@ pub fn write_element<'a>(t: &'a Tree<'a>, id: NodeId, f: &mut HtmlishFormatter<'
}
/// 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.
/// 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,
options: &HtmlishFormatOptions,
) -> bool {
let value = match t.kind(last) {
NodeKind::Comment(comment) => comment.content.slice(t.source()),
@@ -150,7 +151,7 @@ fn ends_at_indentation(
if !tail.bytes().all(|b| b == unit) {
return false;
}
// The element's own indentation: one level per ancestor below the document.
// 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 {
@@ -166,10 +167,10 @@ fn should_preserve_content(t: &Tree<'_>, id: NodeId) -> bool {
match t.kind(id) {
NodeKind::ConditionalComment(comment) => {
!comment.parsed
// `<!--[if lt IE 9]><html><![endif]-->`: an unclosed element inside.
// `<!--[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.
// `<pre>` with markup inside
_ => {
item.pre_like
&& t.children(id).any(|child| !(t.is_text(child) || t.is_interpolation(child)))
@@ -181,7 +182,7 @@ fn should_preserve_content(t: &Tree<'_>, id: NodeId) -> bool {
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.
// 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();
@@ -202,7 +203,8 @@ fn force_break_children(t: &Tree<'_>, id: NodeId) -> bool {
|| item.display == Display::Table)
}
/// Besides `force_break_children`: the content of `body`, `script`, `style`, nested markup, or a lone child on its own lines.
/// 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()
@@ -0,0 +1,255 @@
//! Content written in another language, formatted by the session's dispatcher:
//! `<script>` and `<style>` bodies, `style` and event handler attribute values.
//! A part the dispatcher does not format prints as written.
use std::any::Any;
use oxc_allocator::{ArenaVec, StringBuilder};
use oxc_formatter_core::{
FormatElement, FormatOptions, builders::FormatOnce, dispatch_fragment_ir, map_text_in_ir, write,
};
use oxc_htmlish_parser::ast::NodeId;
use super::{
HtmlishFormatter,
attribute::{quoted_piece, write_expanded},
text::dedent_string,
};
use crate::tree::Tree;
/// Parent→child context for CSS dispatched from an HTML `style` attribute's value (css-in-html).
///
/// The host translates it into CSS's `CssEmbeddedIn`.
pub struct CssInHtmlStyleAttribute;
/// Parent→child context for JS dispatched from an HTML event handler attribute's value (js-in-html, `onclick="..."`).
///
/// The host translates it into JS's `JsEmbeddedIn`.
pub struct JsInHtmlEventHandler;
/// The event handler attributes (Prettier's `@prettier/html-event-attributes`).
const EVENT_HANDLERS: [&str; 90] = [
"onabort",
"onafterprint",
"onauxclick",
"onbeforeinput",
"onbeforematch",
"onbeforeprint",
"onbeforetoggle",
"onbeforeunload",
"onblur",
"oncancel",
"oncanplay",
"oncanplaythrough",
"onchange",
"onclick",
"onclose",
"oncommand",
"oncontextlost",
"oncontextmenu",
"oncontextrestored",
"oncopy",
"oncuechange",
"oncut",
"ondblclick",
"ondrag",
"ondragend",
"ondragenter",
"ondragleave",
"ondragover",
"ondragstart",
"ondrop",
"ondurationchange",
"onemptied",
"onended",
"onerror",
"onfocus",
"onformdata",
"onhashchange",
"oninput",
"oninvalid",
"onkeydown",
"onkeypress",
"onkeyup",
"onlanguagechange",
"onload",
"onloadeddata",
"onloadedmetadata",
"onloadstart",
"onmessage",
"onmessageerror",
"onmousedown",
"onmouseenter",
"onmouseleave",
"onmousemove",
"onmouseout",
"onmouseover",
"onmouseup",
"onoffline",
"ononline",
"onpagehide",
"onpagereveal",
"onpageshow",
"onpageswap",
"onpaste",
"onpause",
"onplay",
"onplaying",
"onpopstate",
"onprogress",
"onratechange",
"onrejectionhandled",
"onreset",
"onresize",
"onscroll",
"onscrollend",
"onsecuritypolicyviolation",
"onseeked",
"onseeking",
"onselect",
"onslotchange",
"onstalled",
"onstorage",
"onsubmit",
"onsuspend",
"ontimeupdate",
"ontoggle",
"onunhandledrejection",
"onunload",
"onvolumechange",
"onwaiting",
"onwheel",
];
/// An event handler attribute (`onclick`), whose value is JS.
/// The name matches as written, as Prettier does (`onClick` stays as written).
pub fn is_event_handler(name: &str) -> bool {
name.starts_with("on") && EVENT_HANDLERS.contains(&name)
}
/// Writes an attribute's (unescaped) `value` formatted in `language`, quoted, a `"` in it as `&quot;`;
/// `false` when the dispatcher leaves it as written.
pub fn write_attribute_value<'a>(
language: &str,
parent_context: &dyn Any,
value: &'a str,
f: &mut HtmlishFormatter<'_, 'a>,
) -> bool {
let Some(ir) = dispatch_fragment_ir(f, language, value, Some(parent_context)) else {
return false;
};
if ir.is_empty() {
write!(f, "\"\"");
return true;
}
let indent_width = f.options().indent_width();
let ir = map_text_in_ir(ir, f.allocator(), &mut |text, out| {
if !text.contains('"') {
return false;
}
out.push(FormatElement::text(quoted_piece(text, f), indent_width));
true
});
write_expanded(&FormatOnce::new(|f: &mut HtmlishFormatter<'_, 'a>| f.write_elements(ir)), f);
true
}
/// Writes the body of `<script>` / `<style>` element `id` formatted in its language;
/// `false` when it has none or the dispatcher leaves it as written.
pub fn write_embedded_content<'a>(
t: &'a Tree<'a>,
id: NodeId,
value: &'a str,
f: &mut HtmlishFormatter<'_, 'a>,
) -> bool {
let Some(language) = content_language(t, id) else { return false };
// A Markdown body starts at the line after the tag, dedented (Prettier's `dedentString`)
let value = if language == "markdown" {
let value = value
.find('\n')
.filter(|&lf| value[..lf].trim_ascii().is_empty())
.map_or(value, |lf| &value[lf + 1..]);
dedent_string(value, f)
} else {
value
};
let Some(ir) = dispatch_fragment_ir(f, language, value, None) else { return false };
let ir = if t.tag(id) == "script" { escape_script_end(ir, f) } else { ir };
f.write_elements(ir);
true
}
/// The first `</script` ends a `<script>` body, so the source body holds none:
/// one in the formatted body is the formatting's own (a template literal's re-cooked part, e.g. html-in-js),
/// printed as `<\/script`, where `\/` means `/` (in a JS string or template, and in JSON).
///
/// A nested HTML part prints its end tags as one text (`tag.rs`), so none is split across texts.
fn escape_script_end<'a>(
ir: ArenaVec<'a, FormatElement<'a>>,
f: &HtmlishFormatter<'_, 'a>,
) -> ArenaVec<'a, FormatElement<'a>> {
let allocator = f.allocator();
let indent_width = f.options().indent_width();
map_text_in_ir(ir, allocator, &mut |text, out| {
let is_script_end = |i: usize| {
let bytes = &text.as_bytes()[i + 2..];
bytes.get(..6).is_some_and(|name| name.eq_ignore_ascii_case(b"script"))
&& !bytes.get(6).is_some_and(|&b| b.is_ascii_alphanumeric() || b == b'_')
};
let mut ends = text.match_indices("</").filter(|&(i, _)| is_script_end(i)).peekable();
if ends.peek().is_none() {
return false;
}
let mut escaped = StringBuilder::new_in(allocator);
let mut run_start = 0;
for (i, _) in ends {
escaped.push_str(&text[run_start..i]);
escaped.push_str("<\\/");
run_start = i + 2;
}
escaped.push_str(&text[run_start..]);
out.push(FormatElement::text(escaped.into_str(), indent_width));
true
})
}
/// The language of the body of `<script>` / `<style>` element `id`, a name the dispatcher resolves
/// (Prettier's `inferScriptParser` / `inferStyleParser`); `None` for none (`<script src>`, a template type).
fn content_language<'a>(t: &'a Tree<'a>, id: NodeId) -> Option<&'a str> {
let el = t.element(id)?;
let attribute = |name: &str| {
el.attributes
.iter()
.find(|attribute| t.attribute_is(attribute.name.slice(t.source()), name))
};
let value =
|name: &str| attribute(name).map(|a| a.value.map_or("", |v| v.span.slice(t.source())));
match t.tag(id) {
"style" => Some(value("lang").unwrap_or("css")),
"script" if attribute("src").is_none() => match (value("lang"), value("type")) {
(None, None) => Some("js"),
(Some(lang), _) => Some(lang),
(None, Some(kind)) => script_type_language(kind),
},
_ => None,
}
}
/// A `<script type>` (Prettier's `inferParserByTypeAttribute`).
fn script_type_language(kind: &str) -> Option<&str> {
Some(match kind {
"module" | "text/javascript" | "application/javascript" => "js",
"text/babel" | "text/jsx" => "jsx",
"application/x-typescript" => "ts",
"text/markdown" => "markdown",
"text/html" => "html",
"text/x-handlebars-template" => "handlebars",
_ if kind.ends_with("json")
|| kind.ends_with("importmap")
|| kind == "speculationrules" =>
{
"json"
}
_ => return None,
})
}
@@ -14,9 +14,12 @@ mod attribute;
mod block;
mod children;
mod element;
mod embed;
mod tag;
mod text;
pub use embed::{CssInHtmlStyleAttribute, JsInHtmlEventHandler};
use block::write_block;
use children::write_children;
use element::write_element;
@@ -61,7 +64,8 @@ fn write_child<'a>(t: &'a Tree<'a>, id: NodeId, f: &mut HtmlishFormatter<'_, 'a>
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).
// 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();
}
@@ -89,7 +93,7 @@ fn write_node<'a>(t: &'a Tree<'a>, id: NodeId, f: &mut HtmlishFormatter<'_, 'a>)
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.
// `{{ }}`; 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());
@@ -120,8 +124,8 @@ fn write_node<'a>(t: &'a Tree<'a>, id: NodeId, f: &mut HtmlishFormatter<'_, 'a>)
);
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>`).
// 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,
[
+16 -15
View File
@@ -13,13 +13,13 @@
//! 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 super::{HtmlishFormatter, attribute::write_attributes};
use crate::{context::HtmlishFormatContext, tree::Tree};
/// A tag marker, written as up to four pieces (`<` + name, `<!--[if ` + condition + `]><!--><` + name, ...).
@@ -50,8 +50,7 @@ impl<'a> Format<'a, HtmlishFormatContext<'a>> for Marker<'a> {
}
}
// ---
// Owner side
// --- 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 {
@@ -76,8 +75,7 @@ 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
// --- Markers
pub fn opening_start_marker<'a>(t: &'a Tree<'a>, id: NodeId) -> Marker<'a> {
match t.kind(id) {
@@ -124,12 +122,17 @@ pub fn closing_start_marker<'a>(t: &'a Tree<'a>, id: NodeId) -> Marker<'a> {
// 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 `<//>`.
// As written it is one text: an enclosing `<script>` (html-in-js in it) sees a `</script` whole to escape it;
// lowercase names print only in a `.html` file, embedded in nothing.
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, ""])
let written = tag.name.slice(t.source());
let name = t.printed_name(id, el, written);
let end = tag.ignored.map_or(tag.name.end, |ignored| ignored.end);
if std::ptr::eq(name, written) {
Marker::one(Span::new(tag.span.start, end).slice(t.source()))
} else {
Marker(["</", name, Span::new(tag.name.end, end).slice(t.source()), ""])
}
}),
_ => Marker::two("</", t.name(id)),
}
@@ -154,8 +157,7 @@ pub fn after_html_word(value: &str) -> Option<&str> {
(!rest.starts_with(|c: char| c.is_ascii_alphanumeric() || c == '_')).then_some(rest)
}
// ---
// Prefixes and suffixes: what a node prints for its neighbors
// --- 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 {
@@ -177,8 +179,7 @@ pub fn closing_tag_suffix<'a>(t: &'a Tree<'a>, id: NodeId) -> Marker<'a> {
}
}
// ---
// Tags
// --- Tags
pub fn write_opening_tag_start<'a>(t: &'a Tree<'a>, id: NodeId, f: &mut HtmlishFormatter<'_, 'a>) {
if !opening_start_borrowed(t, id) {
+78 -12
View File
@@ -1,13 +1,17 @@
//! Text: filled words, or lines as written where whitespace is content.
use oxc_allocator::StringBuilder;
use oxc_formatter_core::{
builders::{exact_line_breaks, soft_line_break_or_space, text},
write,
FormatOptions,
builders::{align, dedent_to_root, exact_line_breaks, soft_line_break_or_space, text},
text_width, write,
};
use oxc_htmlish_parser::ast::NodeId;
use super::{
HtmlishFormatter, format_with,
HtmlishFormatter,
embed::write_embedded_content,
format_with,
tag::{closing_tag_suffix, opening_tag_prefix},
};
use crate::tree::Tree;
@@ -19,12 +23,20 @@ pub fn write_text<'a>(t: &'a Tree<'a>, id: NodeId, f: &mut HtmlishFormatter<'_,
let suffix = closing_tag_suffix(t, id);
if parent.pre_like {
write!(f, [prefix, text(value), suffix]);
// Opening the content, it starts on its own line from the root (`line_before` in `element.rs`)
let starts_line = parent.first_child == Some(id)
&& !(t[id].has_leading_spaces && t[id].leading_sensitive);
// A JS expression substituted into it indents from its line (`apps/oxfmt/DIVERGENCES.md#pre-like-template-expression`)
write!(f, prefix);
write_pre_like_text(value, starts_line, f);
write!(f, suffix);
return;
}
if parent.script_like {
write!(f, prefix);
write_dedented_lines(value, f);
if !write_embedded_content(t, t.parent(id), value, f) {
write_dedented_lines(value, f);
}
write!(f, suffix);
return;
}
@@ -104,14 +116,68 @@ fn trim_preserving_indentation(value: &str) -> &str {
&value[start..]
}
/// [`dedent`] as one text; borrowed when no line is indented.
pub fn dedent_string<'a>(value: &'a str, f: &HtmlishFormatter<'_, 'a>) -> &'a str {
let min = common_indentation(value);
if min == 0 {
return value;
}
let mut out = StringBuilder::new_in(f.allocator());
for (i, line) in dedent_by(value, min).enumerate() {
if i > 0 {
out.push('\n');
}
out.push_str(line);
}
out.into_str()
}
/// 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);
dedent_by(value, common_indentation(value))
}
/// The lines of `value` with `min` bytes of indentation removed.
fn dedent_by(value: &str, min: usize) -> impl Iterator<Item = &str> {
value.split('\n').map(move |line| line.get(min..).unwrap_or(""))
}
/// The indentation every non-blank line of `value` shares.
fn common_indentation(value: &str) -> usize {
value
.split('\n')
.filter(|line| !line.trim_ascii_start().is_empty())
.map(|line| line.len() - line.trim_ascii_start().len())
.min()
.unwrap_or(0)
}
/// `pre`-like text as written.
/// A parent's placeholder for a JS expression on a line that starts at the root (after a line break in the text,
/// or at its start when the text opens the content on its own line) aligns with the whitespace that line starts with,
/// so that the expression's own lines indent from its `${`, as anywhere else.
fn write_pre_like_text<'a>(value: &'a str, starts_line: bool, f: &mut HtmlishFormatter<'_, 'a>) {
let prefix = f.context().embedded_in().and_then(|e| e.placeholder).map(|(prefix, _)| prefix);
let indent_width = f.options().indent_width();
let mut written = 0;
while let Some(start) = prefix.and_then(|p| value[written..].find(p)).map(|i| written + i) {
let line_end = value[start..].find('\n').map_or(value.len(), |i| start + i);
let line_start = value[..start].rfind('\n').map(|i| i + 1).or(starts_line.then_some(0));
if let Some(line_start) = line_start {
let line = &value[line_start..start];
let indentation = &line[..line.len() - line.trim_start_matches([' ', '\t']).len()];
let width = u8::try_from(text_width(indentation, indent_width)).unwrap_or(u8::MAX);
write!(
f,
[
text(&value[written..start]),
dedent_to_root(&align(width, &text(&value[start..line_end])))
]
);
} else {
write!(f, text(&value[written..line_end]));
}
written = line_end;
}
write!(f, text(&value[written..]));
}
+2 -2
View File
@@ -32,7 +32,7 @@ impl FixtureFormatter for HtmlishHarness {
fn format(source: &str, path: &Path, options: &Self::Options) -> String {
let allocator = Allocator::default();
let options = HtmlishFormatOptions { variant: variant_of(path), ..*options };
let options = HtmlishFormatOptions { variant: variant_of(path), ..options.clone() };
format(&allocator, source, options)
.expect("format should succeed")
.print()
@@ -41,7 +41,7 @@ impl FixtureFormatter for HtmlishHarness {
}
fn fingerprint(source: &str, path: &Path, options: &Self::Options) -> String {
let options = HtmlishFormatOptions { variant: variant_of(path), ..*options };
let options = HtmlishFormatOptions { variant: variant_of(path), ..options.clone() };
let allocator = Allocator::default();
let parsed = parse_for_format(&allocator, source, &options).expect("source should parse");
fingerprint::fingerprint(
+1
View File
@@ -38,6 +38,7 @@ versioned_files = [
"crates/oxc_formatter/Cargo.toml",
"crates/oxc_formatter_json/Cargo.toml",
"crates/oxc_formatter_graphql/Cargo.toml",
"crates/oxc_formatter_htmlish/Cargo.toml",
"crates/oxc_formatter_css/Cargo.toml",
"crates/oxc_formatter_yaml/Cargo.toml",
"crates/oxc_formatter_markdown/Cargo.toml",