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 - uses: $/.github/actions/check-changes
id: filter id: filter
with: 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 }} token: ${{ secrets.GITHUB_TOKEN }}
pr-number: ${{ github.event.pull_request.number }} pr-number: ${{ github.event.pull_request.number }}
- uses: $/.github/actions/check-changes - uses: $/.github/actions/check-changes
id: filter-conformance id: filter-conformance
with: 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 }} token: ${{ secrets.GITHUB_TOKEN }}
pr-number: ${{ github.event.pull_request.number }} pr-number: ${{ github.event.pull_request.number }}
- uses: oxc-project/setup-node@f46a72f95efdc55273fcd042d61c84e723b2892c # v1.4.1 - uses: oxc-project/setup-node@f46a72f95efdc55273fcd042d61c84e723b2892c # v1.4.1
@@ -510,7 +510,7 @@ jobs:
- uses: $/.github/actions/check-changes - uses: $/.github/actions/check-changes
id: filter id: filter
with: 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 }} token: ${{ secrets.GITHUB_TOKEN }}
pr-number: ${{ github.event.pull_request.number }} pr-number: ${{ github.event.pull_request.number }}
- uses: samypr100/setup-dev-drive@562171fe8df7401fdf582d766cd3b6ab80d5b1a0 # v4.1.0 - uses: samypr100/setup-dev-drive@562171fe8df7401fdf582d766cd3b6ab80d5b1a0 # v4.1.0
Generated
+1
View File
@@ -2933,6 +2933,7 @@ dependencies = [
"oxc_formatter_core", "oxc_formatter_core",
"oxc_formatter_css", "oxc_formatter_css",
"oxc_formatter_graphql", "oxc_formatter_graphql",
"oxc_formatter_htmlish",
"oxc_formatter_json", "oxc_formatter_json",
"oxc_formatter_markdown", "oxc_formatter_markdown",
"oxc_formatter_toml", "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_core = { path = "crates/oxc_formatter_core" } # Language-agnostic formatter core
oxc_formatter_css = { path = "crates/oxc_formatter_css" } # CSS formatter oxc_formatter_css = { path = "crates/oxc_formatter_css" } # CSS formatter
oxc_formatter_graphql = { path = "crates/oxc_formatter_graphql" } # GraphQL 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_json = { path = "crates/oxc_formatter_json" } # JSON formatter
oxc_formatter_markdown = { path = "crates/oxc_formatter_markdown" } # Markdown 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) 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`): 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 - `Native` languages (js/ts/css/graphql/yaml/json/markdown/toml/html/...) get a Rust branch
- `Prettier` set (html/angular/vue/svelte/astro/handlebars/mdx) goes to the Prettier Doc→IR channel (`embed/prettier_doc.rs`) - `Prettier` set (angular/vue/svelte/astro/handlebars/mdx) goes to the Prettier Doc→IR channel (`embed/prettier_doc.rs`)
- everything else is deliberately preserved - 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. 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). 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. 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. 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: 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 - 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`) - 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`)
- html fences stay on the Prettier string path (`embed/prettier_string.rs`, napi only; its Doc→IR conversion has unrepresentable cases);
- everything else stays verbatim - 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 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. 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_core = { workspace = true }
oxc_formatter_css = { workspace = true } oxc_formatter_css = { workspace = true }
oxc_formatter_graphql = { workspace = true } oxc_formatter_graphql = { workspace = true }
oxc_formatter_htmlish = { workspace = true }
oxc_formatter_json = { workspace = true } oxc_formatter_json = { workspace = true }
oxc_formatter_markdown = { workspace = true } oxc_formatter_markdown = { workspace = true }
oxc_formatter_toml = { 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`). 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. 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. 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", repo: "mdn/content/files/en-us/web/css/guides",
version: "5137b45128dcf07ac636da68184f00aab30ec1cc", 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 // jsdoc
{ {
name: "svelte", 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. // mdn's GitHub alerts and macros would fail on known divergences (`line-shapes`) in bulk.
optionSets: [{ printWidth: 80, bracketSameLine: true }, { printWidth: 100 }], 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", name: "graphql",
fixtures: [], fixtures: [],
@@ -74,12 +74,20 @@
## html-in-js ## html-in-js
### Option 1: 174/198 (87.88%) ### Option 1: 172/200 (86.00%)
```json ```json
{"printWidth":80} {"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) - [externals/webawesome/badge/badge.ts](diffs/html-in-js/externals__webawesome__badge__badge.ts.md)
- crates/oxc_formatter/DIVERGENCES.md#union-annotation-flat-retry - crates/oxc_formatter/DIVERGENCES.md#union-annotation-flat-retry
- [externals/webawesome/button/button.ts](diffs/html-in-js/externals__webawesome__button__button.ts.md) - [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) - [externals/webawesome/textarea/textarea.ts](diffs/html-in-js/externals__webawesome__textarea__textarea.ts.md)
- crates/oxc_formatter/DIVERGENCES.md#union-annotation-flat-retry - crates/oxc_formatter/DIVERGENCES.md#union-annotation-flat-retry
### Option 2: 188/198 (94.95%) ### Option 2: 186/200 (93.00%)
```json ```json
{"printWidth":100,"htmlWhitespaceSensitivity":"ignore"} {"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) - [externals/webawesome/button/button.ts](diffs/html-in-js/externals__webawesome__button__button.ts.md)
- crates/oxc_formatter/DIVERGENCES.md#union-annotation-flat-retry - crates/oxc_formatter/DIVERGENCES.md#union-annotation-flat-retry
- [externals/webawesome/callout/callout.ts](diffs/html-in-js/externals__webawesome__callout__callout.ts.md) - [externals/webawesome/callout/callout.ts](diffs/html-in-js/externals__webawesome__callout__callout.ts.md)
@@ -318,7 +334,7 @@
## markdown-mdn ## markdown-mdn
### Option 1: 360/362 (99.45%) ### Option 1: 357/362 (98.62%)
```json ```json
{"printWidth":80,"bracketSameLine":true} {"printWidth":80,"bracketSameLine":true}
@@ -328,8 +344,14 @@
- crates/oxc_formatter_css/DIVERGENCES.md#value-semicolon-glue - 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) - [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 - 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 ```json
{"printWidth":100} {"printWidth":100}
@@ -339,6 +361,69 @@
- crates/oxc_formatter_css/DIVERGENCES.md#value-semicolon-glue - 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) - [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 - 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 ## 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
* Panics if the current working directory cannot be determined. * 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 { export interface FormatResult {
/** The formatted code. */ /** The formatted code. */
@@ -59,7 +59,7 @@ export interface FormatResult {
* This API is specialized for JS/TS snippets embedded in non-JS files. * 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. * 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. * 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 * 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) * 3. `init_external_services_cb`: Callback to initialize external services (JS worker pool)
* 4. `format_file_cb`: Callback to format files * 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 * 6. `sort_tailwindcss_classes_cb`: Callback to sort Tailwind classes
* *
* Returns a tuple of `[mode, exitCode]`: * Returns a tuple of `[mode, exitCode]`:
* - `mode`: If main logic will run in JS side, use this to indicate which mode * - `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 * - `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 // `oxfmt` CLI - Worker Thread Entry Point
// Re-exports core functions for use in `worker_threads` // Re-exports core functions for use in `worker_threads`
export { export { formatEmbeddedDoc, formatFile, sortTailwindClasses } from "./libs/apis";
formatEmbeddedCode,
formatEmbeddedDoc,
formatFile,
sortTailwindClasses,
} from "./libs/apis";
-2
View File
@@ -3,7 +3,6 @@ import {
initExternalServices, initExternalServices,
disposeExternalServices, disposeExternalServices,
formatFile, formatFile,
formatEmbeddedCode,
formatEmbeddedDoc, formatEmbeddedDoc,
sortTailwindClasses, sortTailwindClasses,
} from "./cli/worker-proxy"; } from "./cli/worker-proxy";
@@ -47,7 +46,6 @@ void (async () => {
process.env.VP_VERSION ? loadVitePlusConfig : loadJsConfig, process.env.VP_VERSION ? loadVitePlusConfig : loadJsConfig,
initExternalServices, initExternalServices,
formatFile, formatFile,
formatEmbeddedCode,
formatEmbeddedDoc, formatEmbeddedDoc,
sortTailwindClasses, sortTailwindClasses,
); );
-14
View File
@@ -3,7 +3,6 @@ import { toFormatFileResult, toNullable } from "../libs/napi-callbacks";
import type { FormatFileResult } from "../libs/napi-callbacks"; import type { FormatFileResult } from "../libs/napi-callbacks";
import type { import type {
FormatFileParam, FormatFileParam,
FormatEmbeddedCodeParam,
FormatEmbeddedDocParam, FormatEmbeddedDocParam,
SortTailwindClassesArgs, SortTailwindClassesArgs,
} from "../libs/apis"; } 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( export function formatEmbeddedDoc(
options: FormatEmbeddedDocParam["options"], options: FormatEmbeddedDocParam["options"],
code: string, code: string,
+1 -8
View File
@@ -1,11 +1,6 @@
// napi-JS `oxfmt` API entry point // napi-JS `oxfmt` API entry point
import { import { formatFile, formatEmbeddedDoc, sortTailwindClasses } from "./libs/apis";
formatFile,
formatEmbeddedCode,
formatEmbeddedDoc,
sortTailwindClasses,
} from "./libs/apis";
import { toFormatFileResult, toNullable } from "./libs/napi-callbacks"; import { toFormatFileResult, toNullable } from "./libs/napi-callbacks";
// Types are auto-generated from the JSON Schema. // Types are auto-generated from the JSON Schema.
import type { import type {
@@ -95,7 +90,6 @@ export async function format(fileName: string, sourceText: string, options?: For
sourceText, sourceText,
options ?? {}, options ?? {},
(options, code) => toFormatFileResult(formatFile({ options, code })), (options, code) => toFormatFileResult(formatFile({ options, code })),
(options, code) => toNullable(formatEmbeddedCode({ options, code })),
(options, code) => toNullable(formatEmbeddedDoc({ options, code })), (options, code) => toNullable(formatEmbeddedDoc({ options, code })),
(options, classes) => toNullable(sortTailwindClasses({ options, classes })), (options, classes) => toNullable(sortTailwindClasses({ options, classes })),
); );
@@ -117,7 +111,6 @@ export async function jsTextToDoc(
oxfmtPluginOptionsJson, oxfmtPluginOptionsJson,
parentContext, parentContext,
() => toFormatFileResult(Promise.reject("formatFile is unavailable for jsTextToDoc")), () => toFormatFileResult(Promise.reject("formatFile is unavailable for jsTextToDoc")),
(options, code) => toNullable(formatEmbeddedCode({ options, code })),
(options, code) => toNullable(formatEmbeddedDoc({ options, code })), (options, code) => toNullable(formatEmbeddedDoc({ options, code })),
(options, classes) => toNullable(sortTailwindClasses({ options, classes })), (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. // there should be no side effects on other calls that don't set these fields.
// @ts-expect-error: Use internal API // @ts-expect-error: Use internal API
const { formatOptionsHiddenDefaults } = prettier.__internal; 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. // For html-family children, this prevents attribute level formatting from running.
// (e.g., CSS in `style="..."` attributes, JS in `onclick="..."` event handlers) // (e.g., CSS in `style="..."` attributes, JS in `onclick="..."` event handlers)
// This does NOT affect `<style>`/`<script>` tags, they are always formatted. // 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), // 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. // but Prettier's `!options.parentParser` check is all-or-nothing.
formatOptionsHiddenDefaults.parentParser = null; 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`. // This is used to decide whether to wrap content with `indent`.
// Without this, we'd need either: // Without this, we'd need either:
// - double parse AST // - 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 = { export type FormatEmbeddedDocParam = {
code: string; code: string;
options: Options; options: Options;
@@ -180,7 +151,7 @@ export async function formatEmbeddedDoc({
if ("_useSveltePlugin" in options) await setupSveltePlugin(options); if ("_useSveltePlugin" in options) await setupSveltePlugin(options);
// Add `astro` parser for ` ```astro ` code blocks in Markdown // Add `astro` parser for ` ```astro ` code blocks in Markdown
if ("_useAstroPlugin" in options) await setupAstroPlugin(options); 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); if ("_useTailwindPlugin" in options) await setupTailwindPlugin(options);
const metadata: Record<string, unknown> = {}; const metadata: Record<string, unknown> = {};
@@ -189,8 +160,8 @@ export async function formatEmbeddedDoc({
// see the comment in `loadPrettier()` for rationale. Any truthy value works // see the comment in `loadPrettier()` for rationale. Any truthy value works
options.parentParser = "OXFMT"; options.parentParser = "OXFMT";
// html(angular)-in-js specific options // angular-in-js specific options
if (options.parser === "html" || options.parser === "angular") { if (options.parser === "angular") {
// https://github.com/prettier/prettier/blob/90983f40dce5e20beea4e5618b5e0426a6a7f4f0/src/language-js/embed/html.js#L42-L44 // https://github.com/prettier/prettier/blob/90983f40dce5e20beea4e5618b5e0426a6a7f4f0/src/language-js/embed/html.js#L42-L44
options.__onHtmlRoot = (root: { children?: unknown[] }) => options.__onHtmlRoot = (root: { children?: unknown[] }) =>
(metadata.htmlHasMultipleRootElements = (root.children?.length ?? 0) > 1); (metadata.htmlHasMultipleRootElements = (root.children?.length ?? 0) > 1);
@@ -1,7 +1,7 @@
/** /**
* Prettier plugin that uses `oxc_formatter` for (j|t)s-in-xxx part. * 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. * it calls the `embed()` function for each block.
* *
* By default, it uses the `babel` or `typescript` parser and `estree` printer. * 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 oxc_napi::OxcError;
use crate::core::{ use crate::core::{
ExternalServices, FormatResult, JsFormatEmbeddedCb, JsFormatEmbeddedDocCb, JsFormatFileCb, ExternalServices, FormatResult, JsFormatEmbeddedDocCb, JsFormatFileCb, JsSortTailwindClassesCb,
JsSortTailwindClassesCb, ResolveOutcome, SourceFormatter, classify_file, resolve_for_api, ResolveOutcome, SourceFormatter, classify_file, resolve_for_api, utils,
utils,
}; };
pub struct ApiFormatResult { pub struct ApiFormatResult {
@@ -27,7 +26,6 @@ pub fn run(
source_text: String, source_text: String,
options: Option<Value>, options: Option<Value>,
format_file_cb: JsFormatFileCb, format_file_cb: JsFormatFileCb,
format_embedded_cb: JsFormatEmbeddedCb,
format_embedded_doc_cb: JsFormatEmbeddedDocCb, format_embedded_doc_cb: JsFormatEmbeddedDocCb,
sort_tailwind_classes_cb: JsSortTailwindClassesCb, sort_tailwind_classes_cb: JsSortTailwindClassesCb,
) -> ApiFormatResult { ) -> ApiFormatResult {
@@ -45,12 +43,8 @@ pub fn run(
let cwd = env::current_dir().expect("Failed to get current working directory"); let cwd = env::current_dir().expect("Failed to get current working directory");
let num_of_threads = 1; let num_of_threads = 1;
let external_services = ExternalServices::new( let external_services =
format_file_cb, ExternalServices::new(format_file_cb, format_embedded_doc_cb, sort_tailwind_classes_cb);
format_embedded_cb,
format_embedded_doc_cb,
sort_tailwind_classes_cb,
);
let _cleanup = external_services.cleanup_guard(); let _cleanup = external_services.cleanup_guard();
let filepath = utils::normalize_relative_path(&cwd, Path::new(filename)); let filepath = utils::normalize_relative_path(&cwd, Path::new(filename));
+3 -11
View File
@@ -11,8 +11,7 @@ use oxc_span::SourceType;
use crate::{ use crate::{
core::{ core::{
ExternalServices, JsFormatEmbeddedCb, JsFormatEmbeddedDocCb, JsFormatFileCb, ExternalServices, JsFormatEmbeddedDocCb, JsFormatFileCb, JsSortTailwindClassesCb,
JsSortTailwindClassesCb,
embed::{self, dispatcher::ResolvedDispatchConfig}, embed::{self, dispatcher::ResolvedDispatchConfig},
options::{ValidatedOptions, to_oxc_formatter, validate}, options::{ValidatedOptions, to_oxc_formatter, validate},
oxfmtrc::FormatConfig, oxfmtrc::FormatConfig,
@@ -50,7 +49,6 @@ pub fn run(
oxfmt_plugin_options_json: &str, oxfmt_plugin_options_json: &str,
parent_context: &str, parent_context: &str,
format_file_cb: JsFormatFileCb, format_file_cb: JsFormatFileCb,
format_embedded_cb: JsFormatEmbeddedCb,
format_embedded_doc_cb: JsFormatEmbeddedDocCb, format_embedded_doc_cb: JsFormatEmbeddedDocCb,
sort_tailwind_classes_cb: JsSortTailwindClassesCb, sort_tailwind_classes_cb: JsSortTailwindClassesCb,
) -> Option<String> { ) -> Option<String> {
@@ -88,7 +86,6 @@ pub fn run(
source_text, source_text,
oxfmt_plugin_options_json, oxfmt_plugin_options_json,
format_file_cb, format_file_cb,
format_embedded_cb,
format_embedded_doc_cb, format_embedded_doc_cb,
sort_tailwind_classes_cb, sort_tailwind_classes_cb,
)? )?
@@ -116,7 +113,6 @@ fn run_full(
source_text: &str, source_text: &str,
oxfmt_plugin_options_json: &str, oxfmt_plugin_options_json: &str,
format_file_cb: JsFormatFileCb, format_file_cb: JsFormatFileCb,
format_embedded_cb: JsFormatEmbeddedCb,
format_embedded_doc_cb: JsFormatEmbeddedDocCb, format_embedded_doc_cb: JsFormatEmbeddedDocCb,
sort_tailwind_classes_cb: JsSortTailwindClassesCb, sort_tailwind_classes_cb: JsSortTailwindClassesCb,
) -> Option<Value> { ) -> Option<Value> {
@@ -124,12 +120,8 @@ fn run_full(
// so no `cwd` is threaded through here. // so no `cwd` is threaded through here.
let (config, validated, parent_filepath) = parse_payload(oxfmt_plugin_options_json); let (config, validated, parent_filepath) = parse_payload(oxfmt_plugin_options_json);
let external_services = ExternalServices::new( let external_services =
format_file_cb, ExternalServices::new(format_file_cb, format_embedded_doc_cb, sort_tailwind_classes_cb);
format_embedded_cb,
format_embedded_doc_cb,
sort_tailwind_classes_cb,
);
let _cleanup = external_services.cleanup_guard(); let _cleanup = external_services.cleanup_guard();
// Per-language options (and the Prettier options JSON with the Tailwind payload) // 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!({ let resolver = resolver_from_json(serde_json::json!({
"printWidth": 80, "printWidth": 80,
"overrides": [ "overrides": [
{ "files": ["*.html"], "options": { "printWidth": 1000 } } { "files": ["*.vue"], "options": { "printWidth": 1000 } }
] ]
})); }));
// Slow path triggers because the override matches. // Slow path triggers because the override matches.
let path = Path::new("index.html"); let path = Path::new("App.vue");
let err = 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}"); 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 tracing::{debug, debug_span};
use oxc_formatter::{CssInJsTemplate, JsEmbeddedIn, JsFormatOptions, MarkdownInJsTemplate}; use oxc_formatter::{
CssInJsTemplate, HtmlInJsTemplate, JsEmbeddedIn, JsFormatOptions, MarkdownInJsTemplate,
};
use oxc_formatter_core::{ use oxc_formatter_core::{
DispatchRequest, DispatchResponse, EmbeddedIr, FormatDispatcher, FormatSession, DispatchRequest, DispatchResponse, EmbeddedIr, FormatDispatcher, FormatSession,
}; };
use oxc_formatter_core::{FormatOptions, PrinterOptions}; 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_graphql::GraphqlFormatOptions;
use oxc_formatter_htmlish::{
CssInHtmlStyleAttribute, HtmlishEmbeddedIn, HtmlishFormatOptions, HtmlishVariant,
JsInHtmlEventHandler,
};
use oxc_formatter_json::{JsonFormatOptions, JsonVariant}; use oxc_formatter_json::{JsonFormatOptions, JsonVariant};
use oxc_formatter_markdown::{MarkdownFormatOptions, XxxInMarkdownCodeBlock}; use oxc_formatter_markdown::{MarkdownFormatOptions, XxxInMarkdownCodeBlock};
use oxc_formatter_toml::TomlFormatOptions; use oxc_formatter_toml::TomlFormatOptions;
@@ -24,8 +30,8 @@ use crate::core::{
language::{Route, route_embedded}, language::{Route, route_embedded},
options::{ options::{
ValidatedOptions, to_oxc_formatter, to_oxc_formatter_css, to_oxc_formatter_graphql, 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_htmlish, to_oxc_formatter_json, to_oxc_formatter_markdown,
to_oxc_formatter_yaml, to_oxc_formatter_toml, to_oxc_formatter_yaml,
}, },
oxfmtrc::FormatConfig, oxfmtrc::FormatConfig,
support::{NativeLanguage, PrettierLanguage}, support::{NativeLanguage, PrettierLanguage},
@@ -56,13 +62,14 @@ pub struct ResolvedDispatchConfig {
json: [OnceLock<JsonFormatOptions>; 4], json: [OnceLock<JsonFormatOptions>; 4],
markdown: OnceLock<MarkdownFormatOptions>, markdown: OnceLock<MarkdownFormatOptions>,
toml: OnceLock<TomlFormatOptions>, toml: OnceLock<TomlFormatOptions>,
htmlish: OnceLock<HtmlishFormatOptions>,
/// The options handed to Prettier; see [`PrettierOptions`]. /// The options handed to Prettier; see [`PrettierOptions`].
#[cfg(feature = "napi")] #[cfg(feature = "napi")]
prettier: PrettierOptions, prettier: PrettierOptions,
} }
/// The lazily-built options JSON handed to Prettier (+ plugins), /// 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 /// `path` is an ingredient, not a sibling datum: it becomes the JSON's `filepath` at last
/// (see [`crate::core::options::build_prettier_options`]). /// (see [`crate::core::options::build_prettier_options`]).
/// ///
@@ -90,6 +97,7 @@ impl ResolvedDispatchConfig {
json: [OnceLock::new(), OnceLock::new(), OnceLock::new(), OnceLock::new()], json: [OnceLock::new(), OnceLock::new(), OnceLock::new(), OnceLock::new()],
markdown: OnceLock::new(), markdown: OnceLock::new(),
toml: OnceLock::new(), toml: OnceLock::new(),
htmlish: OnceLock::new(),
#[cfg(feature = "napi")] #[cfg(feature = "napi")]
prettier: PrettierOptions::default(), prettier: PrettierOptions::default(),
} }
@@ -177,6 +185,13 @@ impl ResolvedDispatchConfig {
*self.toml.get_or_init(|| to_oxc_formatter_toml(&self.config, self.validated.core)) *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; /// Printer options from the shared resolved core bundle;
/// the fence adapter ([`super::jsdoc_fence`]) derives its per-fence options from these /// the fence adapter ([`super::jsdoc_fence`]) derives its per-fence options from these
/// (width overridden to the fence's effective width). /// (width overridden to the fence's effective width).
@@ -227,7 +242,7 @@ impl ResolvedDispatchConfig {
/// Fallback invoked for [`Route::Prettier`] languages. /// Fallback invoked for [`Route::Prettier`] languages.
/// Same shape as `FormatDispatcher`, plus the routed language /// 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). /// re-examine if it ever serves envelope-bearing inputs).
/// ///
/// Assembled only in napi builds ([`super::prettier_doc`]); /// Assembled only in napi builds ([`super::prettier_doc`]);
@@ -257,7 +272,12 @@ pub fn build_dispatcher(
NativeLanguage::Js(source_type) => { NativeLanguage::Js(source_type) => {
let embedded_in = request let embedded_in = request
.parent_context_as::<XxxInMarkdownCodeBlock>() .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( oxc_formatter::format_to_ir(
session, session,
text, text,
@@ -274,17 +294,21 @@ pub fn build_dispatcher(
NativeLanguage::Css(variant) => { NativeLanguage::Css(variant) => {
// css-in-js (typed `CssInJsTemplate` context) is always parsed as SCSS with `${}` placeholder markers. // 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. // Any other caller gets the strict standalone grammar with the fence/request language's variant.
let (variant, template_placeholders) = let (variant, embedded_in) = if request
if request.parent_context_as::<CssInJsTemplate>().is_some() { .parent_context_as::<CssInJsTemplate>()
(CssVariant::Scss, true) .is_some()
} else { {
(variant, false) (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( oxc_formatter_css::format_to_ir(
session, session,
text, text,
dispatch_config.css_options(variant), dispatch_config.css_options(variant),
template_placeholders, embedded_in,
) )
} }
NativeLanguage::Yaml => oxc_formatter_yaml::format_to_ir( NativeLanguage::Yaml => oxc_formatter_yaml::format_to_ir(
@@ -316,6 +340,28 @@ pub fn build_dispatcher(
text, text,
dispatch_config.toml_options(), 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", "markdown",
"md", "md",
"toml", "toml",
"html",
] { ] {
let text = match language { let text = match language {
"graphql" | "gql" => "{ a }", "graphql" | "gql" => "{ a }",
@@ -425,6 +472,7 @@ mod tests {
"yaml" | "yml" => "a: 1", "yaml" | "yml" => "a: 1",
"json" | "jsonc" | "json5" => "{ \"a\": 1 }", "json" | "jsonc" | "json5" => "{ \"a\": 1 }",
"markdown" | "md" => "# a", "markdown" | "md" => "# a",
"html" => "<p> a </p>",
_ => "a = 1", _ => "a = 1",
}; };
let response = session.dispatch(DispatchRequest { 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 { let response = session.dispatch(DispatchRequest {
language, language,
text, text,
+25 -8
View File
@@ -1,11 +1,8 @@
//! Native-fence string adapter: //! Native-fence string adapter:
//! a JSDoc fenced code block whose language has a Rust formatter branch //! 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; //! This is the whole string-out channel; [`super::services`] installs it as the session's string embedder.
//! 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).
use std::sync::Arc; use std::sync::Arc;
@@ -14,11 +11,31 @@ use tracing::debug_span;
use oxc_allocator::Allocator; use oxc_allocator::Allocator;
use oxc_formatter_core::{ use oxc_formatter_core::{
DispatchRequest, FormatDispatcher, FormatSession, InputKind, PrintWidth, SessionServices, DispatchRequest, FormatDispatcher, FormatSession, InputKind, PrintWidth, SessionServices,
TailwindSorter, StringEmbedder, TailwindSorter,
}; };
use super::dispatcher::ResolvedDispatchConfig; 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: /// Format a JSDoc fenced code block through the native dispatch registry:
/// a string-in/string-out adapter over `FormatSession::dispatch_to_string`. /// 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 /// - `Err` keeps the fence verbatim, covering both the deliberate keep
/// (`Ok(None)`; a failed native language never re-routes to Prettier) and operational errors /// (`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, /// - 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. /// Threading the parent session through the callback is the eventual fix.
pub fn format_native_fence( fn format_native_fence(
language: &str, language: &str,
code: &str, code: &str,
print_width: usize, print_width: usize,
+2 -12
View File
@@ -9,9 +9,8 @@
//! `ResolvedDispatchConfig` (lazy per-language options) + `build_dispatcher` with a Rust branch per `NativeLanguage`; //! `ResolvedDispatchConfig` (lazy per-language options) + `build_dispatcher` with a Rust branch per `NativeLanguage`;
//! IR integrates into the parent's arena / `GroupId` space //! IR integrates into the parent's arena / `GroupId` space
//! - [`services`] (every build): the root `SessionServices` assembly (`for_root`, one definition per build) //! - [`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_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")] #[cfg(feature = "napi")]
use std::sync::Arc; use std::sync::Arc;
@@ -23,24 +22,15 @@ pub mod dispatcher;
pub mod jsdoc_fence; pub mod jsdoc_fence;
#[cfg(feature = "napi")] #[cfg(feature = "napi")]
pub mod prettier_doc; pub mod prettier_doc;
#[cfg(feature = "napi")]
pub mod prettier_string;
pub mod services; pub mod services;
// --- Cross-module callback types --- // --- Cross-module callback types ---
// //
// These describe the shape of the napi-wrapped callbacks the orchestration builders consume. // These describe the shape of the napi-wrapped callbacks the orchestration builders consume.
// NOTE: They live here (not in `external_services`), // 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. // `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. /// Callback function type for formatting embedded code via the Doc IR path.
/// Takes (options, text) and returns a Doc JSON string or an error. /// Takes (options, text) and returns a Doc JSON string or an error.
#[cfg(feature = "napi")] #[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 //! Sends the text to JS `printToDoc()`, then converts the returned Doc JSON into formatter IR
//! that integrates into the parent's arena / `GroupId` space. //! that integrates into the parent's arena / `GroupId` space.
//! Fills the dispatcher's optional [`PrettierDocFallback`] slot; //! 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; use std::sync::Arc;
@@ -68,7 +68,7 @@ pub fn build_prettier_fallback(
session.group_id_builder(), session.group_id_builder(),
)?; )?;
from_prettier_doc::postprocess(&mut ir, allocator); 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 let indent = request
.parent_context_as::<HtmlInJsTemplate>() .parent_context_as::<HtmlInJsTemplate>()
.is_some_and(|c| c.indent_multiple_roots) .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 std::sync::Arc;
use oxc_formatter_core::SessionServices; use oxc_formatter_core::SessionServices;
#[cfg(not(feature = "napi"))]
use oxc_formatter_core::StringEmbedder;
#[cfg(feature = "napi")] #[cfg(feature = "napi")]
use oxc_formatter_core::TailwindSorter; use oxc_formatter_core::TailwindSorter;
#[cfg(feature = "napi")] #[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. // so the root's doubles as the fence adapter's.
let dispatcher = dispatch_config.root_dispatcher(None); let dispatcher = dispatch_config.root_dispatcher(None);
let string_embedder = dispatcher.as_ref().map(|dispatcher| { let string_embedder = dispatcher.as_ref().map(|dispatcher| {
let fence_dispatcher = Arc::clone(dispatcher); super::jsdoc_fence::build_string_embedder(
let dispatch_config = Arc::clone(dispatch_config); Arc::clone(dispatcher),
Arc::new(move |language: &str, code: &str, print_width: usize| { Arc::clone(dispatch_config),
super::jsdoc_fence::format_native_fence( None,
language, )
code,
print_width,
&fence_dispatcher,
&dispatch_config,
None,
)
}) as StringEmbedder
}); });
SessionServices { dispatcher, string_embedder, tailwind_sorter: 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; /// 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`]). /// 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`]). /// and the napi callback `Arc`s come from the transport ([`ExternalServices`]).
/// ///
/// NOTE: Tailwind data paths /// 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), /// 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: /// 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. /// `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. /// 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. /// 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 }) as TailwindSorter
}); });
let string_embedder = needs_embedded.then(|| { // Fences never take the Prettier fallback (a native fence never falls back)
super::prettier_string::build_string_embedder( let string_embedder = dispatch_config.root_dispatcher(None).map(|fence_dispatcher| {
Arc::clone(&external_services.format_embedded), super::jsdoc_fence::build_string_embedder(
sorter.clone(), fence_dispatcher,
Arc::clone(dispatch_config), 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`] wraps the JS callbacks as `Arc<dyn Fn>`s (private `wrap_*` helpers), serving:
//! - [`ExternalServices::init`]: JS-side worker-pool setup //! - [`ExternalServices::init`]: JS-side worker-pool setup
//! - [`ExternalServices::format_file`]: Tier 3/4 whole-file delegation (`core::format`) //! - [`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 //! consumed by the `core::embed::services` profiles
use std::sync::{Arc, RwLock}; use std::sync::{Arc, RwLock};
@@ -17,10 +17,7 @@ use napi::{
use serde_json::Value; use serde_json::Value;
use tracing::debug_span; use tracing::debug_span;
use crate::core::embed::{ use crate::core::embed::{FormatEmbeddedDocWithConfigCallback, TailwindWithConfigCallback};
FormatEmbeddedDocWithConfigCallback, FormatEmbeddedWithConfigCallback,
TailwindWithConfigCallback,
};
/// Callback function type for formatting whole files (Tier 3/4 delegation). /// Callback function type for formatting whole files (Tier 3/4 delegation).
/// Takes (options, code) and returns formatted code or an error. /// Takes (options, code) and returns formatted code or an error.
@@ -59,22 +56,6 @@ pub type JsFormatFileCb = ThreadsafeFunction<
false, 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. /// Type alias for the Doc-path callback function signature.
/// Takes (options, code) as arguments and returns a Doc JSON string or null on error. /// 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. /// The `options` object includes `parser` field set by Rust side.
@@ -110,7 +91,6 @@ pub type JsSortTailwindClassesCb = ThreadsafeFunction<
struct TsfnHandles { struct TsfnHandles {
init: Arc<RwLock<Option<JsInitExternalServicesCb>>>, init: Arc<RwLock<Option<JsInitExternalServicesCb>>>,
format_file: Arc<RwLock<Option<JsFormatFileCb>>>, format_file: Arc<RwLock<Option<JsFormatFileCb>>>,
format_embedded: Arc<RwLock<Option<JsFormatEmbeddedCb>>>,
format_embedded_doc: Arc<RwLock<Option<JsFormatEmbeddedDocCb>>>, format_embedded_doc: Arc<RwLock<Option<JsFormatEmbeddedDocCb>>>,
sort_tailwind: Arc<RwLock<Option<JsSortTailwindClassesCb>>>, sort_tailwind: Arc<RwLock<Option<JsSortTailwindClassesCb>>>,
} }
@@ -126,7 +106,6 @@ impl Drop for CleanupGuard {
let handles = &self.0; let handles = &self.0;
let _ = handles.init.write().unwrap().take(); let _ = handles.init.write().unwrap().take();
let _ = handles.format_file.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.format_embedded_doc.write().unwrap().take();
let _ = handles.sort_tailwind.write().unwrap().take(); let _ = handles.sort_tailwind.write().unwrap().take();
} }
@@ -144,7 +123,6 @@ pub struct ExternalServices {
handles: TsfnHandles, handles: TsfnHandles,
pub init: InitExternalServicesCallback, pub init: InitExternalServicesCallback,
pub format_file: FormatFileWithConfigCallback, pub format_file: FormatFileWithConfigCallback,
pub format_embedded: FormatEmbeddedWithConfigCallback,
pub format_embedded_doc: FormatEmbeddedDocWithConfigCallback, pub format_embedded_doc: FormatEmbeddedDocWithConfigCallback,
pub sort_tailwindcss_classes: TailwindWithConfigCallback, pub sort_tailwindcss_classes: TailwindWithConfigCallback,
} }
@@ -154,7 +132,6 @@ impl std::fmt::Debug for ExternalServices {
f.debug_struct("ExternalServices") f.debug_struct("ExternalServices")
.field("init", &"<callback>") .field("init", &"<callback>")
.field("format_file", &"<callback>") .field("format_file", &"<callback>")
.field("format_embedded", &"<callback>")
.field("format_embedded_doc", &"<callback>") .field("format_embedded_doc", &"<callback>")
.field("sort_tailwindcss_classes", &"<callback>") .field("sort_tailwindcss_classes", &"<callback>")
.finish() .finish()
@@ -169,14 +146,12 @@ impl ExternalServices {
/// crashes on Node.js exit when V8 cleans up global handles. /// crashes on Node.js exit when V8 cleans up global handles.
pub fn new( pub fn new(
format_file_cb: JsFormatFileCb, format_file_cb: JsFormatFileCb,
format_embedded_cb: JsFormatEmbeddedCb,
format_embedded_doc_cb: JsFormatEmbeddedDocCb, format_embedded_doc_cb: JsFormatEmbeddedDocCb,
sort_tailwindcss_classes_cb: JsSortTailwindClassesCb, sort_tailwindcss_classes_cb: JsSortTailwindClassesCb,
) -> Self { ) -> Self {
// Wrap TSFNs in Arc<RwLock<Option<...>>> so they can be explicitly dropped // Wrap TSFNs in Arc<RwLock<Option<...>>> so they can be explicitly dropped
let init_handle = Arc::new(RwLock::new(None)); let init_handle = Arc::new(RwLock::new(None));
let format_file_handle = Arc::new(RwLock::new(Some(format_file_cb))); 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 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))); let sort_tailwind_handle = Arc::new(RwLock::new(Some(sort_tailwindcss_classes_cb)));
@@ -184,21 +159,18 @@ impl ExternalServices {
let handles = TsfnHandles { let handles = TsfnHandles {
init: Arc::clone(&init_handle), init: Arc::clone(&init_handle),
format_file: Arc::clone(&format_file_handle), format_file: Arc::clone(&format_file_handle),
format_embedded: Arc::clone(&format_embedded_handle),
format_embedded_doc: Arc::clone(&format_embedded_doc_handle), format_embedded_doc: Arc::clone(&format_embedded_doc_handle),
sort_tailwind: Arc::clone(&sort_tailwind_handle), sort_tailwind: Arc::clone(&sort_tailwind_handle),
}; };
let rust_init = wrap_init_external_services(init_handle); let rust_init = wrap_init_external_services(init_handle);
let rust_format_file = wrap_format_file(format_file_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_format_embedded_doc = wrap_format_embedded_doc(format_embedded_doc_handle);
let rust_tailwind = wrap_sort_tailwind_classes(sort_tailwind_handle); let rust_tailwind = wrap_sort_tailwind_classes(sort_tailwind_handle);
Self { Self {
handles, handles,
init: rust_init, init: rust_init,
format_file: rust_format_file, format_file: rust_format_file,
format_embedded: rust_format_embedded,
format_embedded_doc: rust_format_embedded_doc, format_embedded_doc: rust_format_embedded_doc,
sort_tailwindcss_classes: rust_tailwind, sort_tailwindcss_classes: rust_tailwind,
} }
@@ -240,13 +212,11 @@ impl ExternalServices {
handles: TsfnHandles { handles: TsfnHandles {
init: Arc::new(RwLock::new(None)), init: Arc::new(RwLock::new(None)),
format_file: 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)), format_embedded_doc: Arc::new(RwLock::new(None)),
sort_tailwind: Arc::new(RwLock::new(None)), sort_tailwind: Arc::new(RwLock::new(None)),
}, },
init: Arc::new(|_| Err("Dummy init called".to_string())), init: Arc::new(|_| Err("Dummy init called".to_string())),
format_file: Arc::new(|_, _| Err("Dummy format_file 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| { format_embedded_doc: Arc::new(|_, _: &str| {
Err("Dummy format_embedded_doc called".to_string()) 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. /// Wrap JS `formatEmbeddedDoc` callback as a normal Rust function.
/// The `options` Value is received with `parser` already set by the caller. /// The `options` Value is received with `parser` already set by the caller.
fn wrap_format_embedded_doc( 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) oxc_formatter_markdown::format_with_session(&session, source_text, options)
.and_then(|formatted| print(formatted, &path)) .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) => { FormatStrategy::Native(NativeLanguage::Toml) => {
oxc_formatter_toml::format(source_text, to_oxc_formatter_toml(&config, core)) oxc_formatter_toml::format(source_text, to_oxc_formatter_toml(&config, core))
} }
@@ -249,14 +258,14 @@ impl SourceFormatter {
language: PrettierLanguage, language: PrettierLanguage,
config: &FormatConfig, config: &FormatConfig,
) -> Result<String, OxcDiagnostic> { ) -> 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); let mut prettier_options = to_prettier(config);
inject_parser(&mut prettier_options, language.parser()); inject_parser(&mut prettier_options, language.parser());
inject_filepath(&mut prettier_options, path); inject_filepath(&mut prettier_options, path);
// CSS/SCSS/Less also benefit, but they are formatted by `oxc_formatter_css` // 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); inject_tailwind_plugin_payload(&mut prettier_options, config);
} }
// Languages that embed JS/TS code. // Languages that embed JS/TS code.
+2 -2
View File
@@ -8,6 +8,7 @@ use schemars::JsonSchema;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use oxc_formatter_css::CssVariant; use oxc_formatter_css::CssVariant;
use oxc_formatter_htmlish::HtmlishVariant;
use oxc_formatter_json::JsonVariant; use oxc_formatter_json::JsonVariant;
use oxc_span::{FileExtension, SourceType}; use oxc_span::{FileExtension, SourceType};
@@ -159,7 +160,7 @@ impl Language {
Self::Yaml => Route::Native(NativeLanguage::Yaml), Self::Yaml => Route::Native(NativeLanguage::Yaml),
Self::Markdown => Route::Native(NativeLanguage::Markdown), Self::Markdown => Route::Native(NativeLanguage::Markdown),
Self::Toml => Route::Native(NativeLanguage::Toml), 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::AngularHtml => Route::Prettier(PrettierLanguage::Angular),
Self::Vue => Route::Prettier(PrettierLanguage::Vue), Self::Vue => Route::Prettier(PrettierLanguage::Vue),
Self::Svelte => Route::Prettier(PrettierLanguage::Svelte), Self::Svelte => Route::Prettier(PrettierLanguage::Svelte),
@@ -192,7 +193,6 @@ mod tests {
#[test] #[test]
fn prettier_fences_and_lsp_ids() { fn prettier_fences_and_lsp_ids() {
for (fence, parser) in [ for (fence, parser) in [
("html", "html"),
("angular", "angular"), ("angular", "angular"),
("angular-html", "angular"), ("angular-html", "angular"),
("vue", "vue"), ("vue", "vue"),
+2 -2
View File
@@ -22,6 +22,6 @@ pub use support::classify_file;
#[cfg(feature = "napi")] #[cfg(feature = "napi")]
pub use external_services::{ pub use external_services::{
ExternalServices, JsFormatEmbeddedCb, JsFormatEmbeddedDocCb, JsFormatFileCb, ExternalServices, JsFormatEmbeddedDocCb, JsFormatFileCb, JsInitExternalServicesCb,
JsInitExternalServicesCb, JsSortTailwindClassesCb, JsSortTailwindClassesCb,
}; };
+4 -2
View File
@@ -8,6 +8,7 @@
//! - [`to_oxc_formatter_graphql()`]: `oxc_formatter_graphql::GraphqlFormatOptions` for GraphQL formatting //! - [`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_yaml()`]: `oxc_formatter_yaml::YamlFormatOptions` for YAML formatting
//! - [`to_oxc_formatter_markdown()`]: `oxc_formatter_markdown::MarkdownFormatOptions` for Markdown 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_oxc_formatter_toml()`]: `TomlFormatOptions` for `oxc_formatter_toml`
//! - `to_prettier`(NAPI-only): Prettier-compatible JSON, plus `inject_*` helpers for //! - `to_prettier`(NAPI-only): Prettier-compatible JSON, plus `inject_*` helpers for
//! layering in `parser` / `filepath` / plugin payloads at the format step //! 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;
mod to_oxc_formatter_css; mod to_oxc_formatter_css;
mod to_oxc_formatter_graphql; mod to_oxc_formatter_graphql;
mod to_oxc_formatter_htmlish;
mod to_oxc_formatter_json; mod to_oxc_formatter_json;
mod to_oxc_formatter_markdown; mod to_oxc_formatter_markdown;
mod to_oxc_formatter_toml; mod to_oxc_formatter_toml;
@@ -30,6 +32,7 @@ mod validate;
pub use to_oxc_formatter::to_oxc_formatter; pub use to_oxc_formatter::to_oxc_formatter;
pub use to_oxc_formatter_css::to_oxc_formatter_css; 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_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_json::{to_oxc_formatter_json, to_sort_package_json};
pub use to_oxc_formatter_markdown::to_oxc_formatter_markdown; pub use to_oxc_formatter_markdown::to_oxc_formatter_markdown;
pub use to_oxc_formatter_toml::to_oxc_formatter_toml; 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")] #[cfg(feature = "napi")]
pub use to_prettier::{ pub use to_prettier::{
build_prettier_options, inject_filepath, inject_opt_in_plugin_payloads, build_prettier_options, inject_filepath, inject_opt_in_plugin_payloads,
inject_oxfmt_plugin_payload, inject_parser, inject_string_embed_options, inject_oxfmt_plugin_payload, inject_parser, inject_tailwind_plugin_payload, to_prettier,
inject_tailwind_plugin_payload, to_prettier,
}; };
pub use validate::{ValidatedOptions, validate}; pub use validate::{ValidatedOptions, validate};
@@ -8,8 +8,6 @@ use oxc_formatter::{
}; };
use oxc_formatter_core::{CoreFormatOptions, FormatOptions}; use oxc_formatter_core::{CoreFormatOptions, FormatOptions};
#[cfg(feature = "napi")]
use super::super::oxfmtrc::SortTailwindcssUserConfig;
use super::super::oxfmtrc::{ use super::super::oxfmtrc::{
ArrowParensConfig, CommentLineStrategyConfig, FormatConfig, HtmlWhitespaceSensitivityConfig, ArrowParensConfig, CommentLineStrategyConfig, FormatConfig, HtmlWhitespaceSensitivityConfig,
ImportModifierConfig, ImportSelectorConfig, JsdocUserConfig, LineWrappingStyleConfig, 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 // so enabling it without the JS-side sorter would apply half the feature
// (normalized but unsorted classes). // (normalized but unsorted classes).
#[cfg(feature = "napi")] #[cfg(feature = "napi")]
if let Some(tw_config) = if let Some(tw_config) = config.tailwind_config() {
config.sort_tailwindcss.clone().and_then(SortTailwindcssUserConfig::into_config)
{
// `config` / `stylesheet` / `preserve_duplicates` are JS-sorter-only // `config` / `stylesheet` / `preserve_duplicates` are JS-sorter-only
// and travel through `to_prettier::inject_tailwind_plugin_payload`, // and travel through `to_prettier::inject_tailwind_plugin_payload`,
// not the Rust formatter options. // 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, ArrowParensConfig, AstroCompressHtmlConfig, AstroConfig, AstroUserConfig,
EmbeddedLanguageFormattingConfig, EndOfLineConfig, FormatConfig, EmbeddedLanguageFormattingConfig, EndOfLineConfig, FormatConfig,
HtmlWhitespaceSensitivityConfig, ObjectWrapConfig, OperatorPositionConfig, ProseWrapConfig, HtmlWhitespaceSensitivityConfig, ObjectWrapConfig, OperatorPositionConfig, ProseWrapConfig,
QuotePropsConfig, SortTailwindcssUserConfig, SvelteConfig, SvelteUserConfig, QuotePropsConfig, SvelteConfig, SvelteUserConfig, TrailingCommaConfig,
TrailingCommaConfig,
}; };
#[cfg(feature = "napi")] #[cfg(feature = "napi")]
use super::super::support::PrettierLanguage; 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())); 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. /// Inject `filepath` key.
/// ///
/// Some plugins (Tailwind sorter, etc.) depend on it. /// 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> /// See: <https://github.com/tailwindlabs/prettier-plugin-tailwindcss#options>
pub fn inject_tailwind_plugin_payload(opts: &mut Value, config: &FormatConfig) { pub fn inject_tailwind_plugin_payload(opts: &mut Value, config: &FormatConfig) {
let Some(tw) = config.sort_tailwindcss.clone().and_then(SortTailwindcssUserConfig::into_config) let Some(tw) = config.tailwind_config() else {
else {
return; return;
}; };
let map = as_object_mut(opts); let map = as_object_mut(opts);
+9 -3
View File
@@ -229,15 +229,16 @@ pub struct FormatConfig {
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub prose_wrap: Option<ProseWrapConfig>, pub prose_wrap: Option<ProseWrapConfig>,
// ============================================================================================
// Prettier compatible options and only used by Prettier
// ============================================================================================
/// Specify the global whitespace sensitivity. /// Specify the global whitespace sensitivity.
/// ///
/// - Languages: HTML, Angular, Vue, Handlebars, Svelte /// - Languages: HTML, Angular, Vue, Handlebars, Svelte
/// - Default: `"css"` /// - Default: `"css"`
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub html_whitespace_sensitivity: Option<HtmlWhitespaceSensitivityConfig>, 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. /// Whether or not to indent the code inside `<script>` and `<style>` tags in Vue files.
/// ///
/// - Languages: Vue /// - Languages: Vue
@@ -356,6 +357,11 @@ impl FormatConfig {
matches!(self.astro, Some(AstroUserConfig::Bool(true) | AstroUserConfig::Object(_))) 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. /// Whether Tailwind class sorting is enabled by this config.
/// ///
/// Enabled when `sortTailwindcss` is set to `true` or an object; /// 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 phf::phf_set;
use oxc_formatter_css::CssVariant; use oxc_formatter_css::CssVariant;
use oxc_formatter_htmlish::HtmlishVariant;
use oxc_formatter_json::JsonVariant; use oxc_formatter_json::JsonVariant;
use oxc_span::SourceType; 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) { if MARKDOWN_FILENAMES.contains(file_name) || MARKDOWN_EXTENSIONS.contains(ext) {
return Some(FormatStrategy::Native(NativeLanguage::Markdown)); 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")] #[cfg(feature = "napi")]
if let Some(language) = prettier_language(file_name, ext) { if let Some(language) = prettier_language(file_name, ext) {
@@ -129,6 +136,7 @@ pub enum NativeLanguage {
Json(JsonVariant), Json(JsonVariant),
Markdown, Markdown,
Toml, Toml,
Html(HtmlishVariant),
} }
impl NativeLanguage { impl NativeLanguage {
@@ -142,6 +150,7 @@ impl NativeLanguage {
Self::Json(_) => "json", Self::Json(_) => "json",
Self::Markdown => "markdown", Self::Markdown => "markdown",
Self::Toml => "toml", Self::Toml => "toml",
Self::Html(_) => "html",
} }
} }
} }
@@ -155,7 +164,6 @@ impl NativeLanguage {
#[derive(Debug, Clone, Copy)] #[derive(Debug, Clone, Copy)]
pub enum PrettierLanguage { pub enum PrettierLanguage {
Mdx, Mdx,
Html,
Angular, Angular,
Vue, Vue,
/// Formatted only when `prettier-plugin-svelte` is enabled (`svelte` config key). /// Formatted only when `prettier-plugin-svelte` is enabled (`svelte` config key).
@@ -178,7 +186,6 @@ impl PrettierLanguage {
pub fn parser(self) -> &'static str { pub fn parser(self) -> &'static str {
match self { match self {
Self::Mdx => "mdx", Self::Mdx => "mdx",
Self::Html => "html",
Self::Angular => "angular", Self::Angular => "angular",
Self::Vue => "vue", Self::Vue => "vue",
Self::Svelte => "svelte", 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. /// Returns the [`PrettierLanguage`] for the file, if supported.
/// See also `prettier --support-info | jq '.languages[]'` /// See also `prettier --support-info | jq '.languages[]'`
#[cfg(feature = "napi")] #[cfg(feature = "napi")]
@@ -391,7 +409,6 @@ fn prettier_language(file_name: &str, ext: &str) -> Option<PrettierLanguage> {
return Some(PrettierLanguage::Angular); return Some(PrettierLanguage::Angular);
} }
Some(match ext { Some(match ext {
"html" | "hta" | "htm" | "inc" | "xht" | "xhtml" => PrettierLanguage::Html,
"vue" => PrettierLanguage::Vue, "vue" => PrettierLanguage::Vue,
// Formatting is gated by `ResolveOutcome::MissingPlugin` (requires `svelte` / `astro` config), // Formatting is gated by `ResolveOutcome::MissingPlugin` (requires `svelte` / `astro` config),
// classified here so that each caller can surface a friendly error or skip. // classified here so that each caller can surface a friendly error or skip.
@@ -522,11 +539,11 @@ mod tests {
("package.json", None), ("package.json", None),
("composer.json", None), ("composer.json", None),
("config.importmap", None), ("config.importmap", None),
// HTML // HTML files are routed to `oxc_formatter_htmlish` in `classify_file`
("index.html", Some("html")), // and excluded from this map.
("page.htm", Some("html")), ("index.html", None),
("template.xhtml", Some("html")), ("page.htm", None),
// Angular (must be detected before HTML) // Angular
("app.component.html", Some("angular")), ("app.component.html", Some("angular")),
// MJML // MJML
("email.mjml", Some("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] #[test]
fn test_toml_files() { fn test_toml_files() {
// Files that should be detected as TOML // Files that should be detected as TOML
+6 -19
View File
@@ -10,9 +10,8 @@ use crate::{
api::{format_api, text_to_doc_api}, api::{format_api, text_to_doc_api},
cli::{MigrateSource, Mode, StdinRunner, WalkRunner, format_command, init_rayon}, cli::{MigrateSource, Mode, StdinRunner, WalkRunner, format_command, init_rayon},
core::{ core::{
ExternalServices, JsFormatEmbeddedCb, JsFormatEmbeddedDocCb, JsFormatFileCb, ExternalServices, JsFormatEmbeddedDocCb, JsFormatFileCb, JsInitExternalServicesCb,
JsInitExternalServicesCb, JsLoadJsConfigCb, JsSortTailwindClassesCb, JsLoadJsConfigCb, JsSortTailwindClassesCb, create_js_config_loader, utils,
create_js_config_loader, utils,
}, },
lsp::run_lsp, lsp::run_lsp,
}; };
@@ -25,7 +24,7 @@ use crate::{
/// 2. `load_js_config_cb`: Callback to load JS/TS config files /// 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) /// 3. `init_external_services_cb`: Callback to initialize external services (JS worker pool)
/// 4. `format_file_cb`: Callback to format files /// 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 /// 6. `sort_tailwindcss_classes_cb`: Callback to sort Tailwind classes
/// ///
/// Returns a tuple of `[mode, exitCode]`: /// Returns a tuple of `[mode, exitCode]`:
@@ -44,8 +43,6 @@ pub async fn run_cli(
)] )]
format_file_cb: JsFormatFileCb, format_file_cb: JsFormatFileCb,
#[napi(ts_arg_type = "(options: Record<string, any>, code: string) => Promise<string | null>")] #[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, format_embedded_doc_cb: JsFormatEmbeddedDocCb,
#[napi( #[napi(
ts_arg_type = "(options: Record<string, any>, classes: string[]) => Promise<string[] | null>" 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 // Otherwise, handle modes that require Rust side processing
let external_services = ExternalServices::new( let external_services =
format_file_cb, ExternalServices::new(format_file_cb, format_embedded_doc_cb, sort_tailwindcss_classes_cb)
format_embedded_cb, .with_init_cb(init_external_services_cb);
format_embedded_doc_cb,
sort_tailwindcss_classes_cb,
)
.with_init_cb(init_external_services_cb);
let _cleanup = external_services.cleanup_guard(); let _cleanup = external_services.cleanup_guard();
let js_config_loader = create_js_config_loader(load_js_config_cb); let js_config_loader = create_js_config_loader(load_js_config_cb);
@@ -149,8 +142,6 @@ pub async fn format(
)] )]
format_file_cb: JsFormatFileCb, format_file_cb: JsFormatFileCb,
#[napi(ts_arg_type = "(options: Record<string, any>, code: string) => Promise<string | null>")] #[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, format_embedded_doc_cb: JsFormatEmbeddedDocCb,
#[napi( #[napi(
ts_arg_type = "(options: Record<string, any>, classes: string[]) => Promise<string[] | null>" ts_arg_type = "(options: Record<string, any>, classes: string[]) => Promise<string[] | null>"
@@ -162,7 +153,6 @@ pub async fn format(
source_text, source_text,
options, options,
format_file_cb, format_file_cb,
format_embedded_cb,
format_embedded_doc_cb, format_embedded_doc_cb,
sort_tailwind_classes_cb, sort_tailwind_classes_cb,
); );
@@ -189,8 +179,6 @@ pub async fn js_text_to_doc(
)] )]
format_file_cb: JsFormatFileCb, format_file_cb: JsFormatFileCb,
#[napi(ts_arg_type = "(options: Record<string, any>, code: string) => Promise<string | null>")] #[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, format_embedded_doc_cb: JsFormatEmbeddedDocCb,
#[napi( #[napi(
ts_arg_type = "(options: Record<string, any>, classes: string[]) => Promise<string[] | null>" 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, &oxfmt_plugin_options_json,
&parent_context, &parent_context,
format_file_cb, format_file_cb,
format_embedded_cb,
format_embedded_doc_cb, format_embedded_doc_cb,
sort_tailwind_classes_cb, sort_tailwind_classes_cb,
) )
+3 -4
View File
@@ -22,7 +22,7 @@ describe("JSDoc", () => {
* \`\`\` * \`\`\`
* *
* \`\`\`html * \`\`\`html
* <div class="body" > </ div> * <div class="body" > </div>
* \`\`\` * \`\`\`
*/ */
`.trim(); `.trim();
@@ -181,9 +181,8 @@ describe("JSDoc", () => {
); );
}); });
it("should pass the fence's effective width to the Prettier string path too", async () => { it("should pass the fence's effective width to an html fence too", async () => {
// Same 93-char boundary as the native case, exercised through the Prettier // Same 93-char boundary as the native case, exercised through an html fence:
// branch (html fences go to Prettier with `printWidth` injected):
// the 96-char element must break, the 93-char one must not. // the 96-char element must break, the 93-char one must not.
const source = ` const source = `
/** /**
+28
View File
@@ -23,11 +23,36 @@ pub struct CssInJsTemplate;
/// which would need escaping in the template. /// which would need escaping in the template.
pub struct MarkdownInJsTemplate; 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). /// Parent→child context for HTML/Angular dispatched from a JS template literal (html-in-js).
pub struct HtmlInJsTemplate { pub struct HtmlInJsTemplate {
/// The backticks hug the content (no whitespace on both ends, whitespace sensitivity not `ignore`): /// 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. /// the child indents its content when more than one node prints at the root.
pub indent_multiple_roots: bool, 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. /// 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 Markdown code block (js-in-markdown):
/// a program of a single JSX expression statement prints without its semicolon. /// a program of a single JSX expression statement prints without its semicolon.
MarkdownCodeBlock, 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 oxc_span::{GetSpan, SourceType, Span};
use rustc_hash::FxHashMap; use rustc_hash::FxHashMap;
use crate::{options::JsFormatOptions, utils::assignment_like::AssignmentLikeLayout}; use crate::{JsEmbeddedIn, options::JsFormatOptions, utils::assignment_like::AssignmentLikeLayout};
use super::Comments; use super::Comments;
@@ -103,6 +103,8 @@ pub struct JsFormatContext<'ast> {
/// Set only for an embedded program (see [`crate::format_to_ir`]): /// Set only for an embedded program (see [`crate::format_to_ir`]):
/// its sole statement prints without its semicolon. /// its sole statement prints without its semicolon.
embedding_omits_semicolon: bool, 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<'_> { impl std::fmt::Debug for JsFormatContext<'_> {
@@ -148,6 +150,7 @@ impl<'ast> JsFormatContext<'ast> {
quote_needed_stack: Vec::new(), quote_needed_stack: Vec::new(),
tailwind_context_stack: Vec::new(), tailwind_context_stack: Vec::new(),
embedding_omits_semicolon: false, embedding_omits_semicolon: false,
embedded_in: None,
} }
} }
@@ -241,6 +244,16 @@ impl<'ast> JsFormatContext<'ast> {
self.embedding_omits_semicolon 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. /// Push a Tailwind context entry onto the stack.
/// Call this when entering a JSXAttribute or CallExpression with Tailwind class context. /// Call this when entering a JSXAttribute or CallExpression with Tailwind class context.
pub fn push_tailwind_context(&mut self, entry: TailwindContextEntry) { 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). /// formatted in-process via [`format_jsdoc_js_snippet`] (`oxc_formatter`'s own parser/formatter).
/// - Non-JS/TS fenced code of [`EMBEDDED_FENCE_LANGUAGES`]: /// - Non-JS/TS fenced code of [`EMBEDDED_FENCE_LANGUAGES`]:
/// when the session carries a string embedder, routed through it (the string channel); /// 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`. /// NOTE: `@example` fences diverge here, see `tag_formatters::format_example_fenced_block`.
fn format_code_value<'a>( 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: // Special cases for js-in-xxx:
// - Single JSX in Markdown code block: omit the semicolon // - Single JSX in Markdown code block: omit the semicolon
let embedding_omits_semicolon = embedded_in == Some(JsEmbeddedIn::MarkdownCodeBlock) // - Single expression in an HTML event handler attribute: omit the semicolon
&& program.directives.is_empty() let embedding_omits_semicolon = program.directives.is_empty()
&& matches!( && match program.body.as_slice() {
program.body.as_slice(), [Statement::ExpressionStatement(statement)] => match embedded_in {
[Statement::ExpressionStatement(statement)] if statement.expression.is_jsx() Some(JsEmbeddedIn::MarkdownCodeBlock) => statement.expression.is_jsx(),
); Some(JsEmbeddedIn::HtmlEventHandler) => true,
_ => false,
},
_ => false,
};
context.set_embedding_omits_semicolon(embedding_omits_semicolon); context.set_embedding_omits_semicolon(embedding_omits_semicolon);
context.set_embedded_in(embedded_in);
let (ir, _) = formatter::build_ir( let (ir, _) = formatter::build_ir(
context, context,
@@ -5,6 +5,7 @@ use oxc_span::GetSpan;
use crate::{ use crate::{
ast_nodes::{AstNode, AstNodes}, ast_nodes::{AstNode, AstNodes},
embed_context::is_html_placeholder,
formatter::{JsFormatter, JsFormatterExt as _}, formatter::{JsFormatter, JsFormatterExt as _},
print::{BinaryLikeOperator, should_flatten, unary_argument_takes_comment_parens}, print::{BinaryLikeOperator, should_flatten, unary_argument_takes_comment_parens},
utils::{expression::ExpressionLeftSide, typecast::is_cast_target}, 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) { if f.comments().is_marked_as_type_cast_node(self) {
return false; 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 // `(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> // <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> { impl NeedsParentheses<'_> for AstNode<'_, '_, BooleanLiteral> {
#[inline] #[inline]
fn needs_parentheses(&self, _f: &JsFormatter<'_, '_>) -> bool { fn needs_parentheses(&self, _f: &JsFormatter<'_, '_>) -> bool {
@@ -104,8 +104,10 @@ pub(super) fn format_css_doc<'a>(
.iter() .iter()
.map(|el| match el { .map(|el| match el {
FormatElement::EmbedPlaceholder(_) => 1, FormatElement::EmbedPlaceholder(_) => 1,
FormatElement::Text { text, .. } => { FormatElement::Text { text, .. }
super::count_placeholders(text, PLACEHOLDER_PREFIX, PLACEHOLDER_SUFFIX) 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, _ => 0,
}) })
@@ -132,7 +134,10 @@ pub(super) fn format_css_doc<'a>(
// Same scan as html.rs: `split_on_placeholders` yields alternating // Same scan as html.rs: `split_on_placeholders` yields alternating
// [literal, index, literal, ...] (invalid matches fold into the literal), // [literal, index, literal, ...] (invalid matches fold into the literal),
// so even parts are text and odd parts are a placeholder. // 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( let parts = super::split_on_placeholders(
text.as_str(), text.as_str(),
PLACEHOLDER_PREFIX, PLACEHOLDER_PREFIX,
@@ -6,7 +6,7 @@ use oxc_formatter_core::{
use crate::{ use crate::{
ast_nodes::AstNode, ast_nodes::AstNode,
embed_context::HtmlInJsTemplate, embed_context::{HTML_PLACEHOLDER_PREFIX, HTML_PLACEHOLDER_SUFFIX, HtmlInJsTemplate},
format_args, format_args,
formatter::prelude::*, formatter::prelude::*,
print::template::{ print::template::{
@@ -15,17 +15,11 @@ use crate::{
write, write,
}; };
// Prettier uses `PRETTIER_HTML_PLACEHOLDER_{index}_{counter}_IN_JS` as placeholders. /// Format an HTML(Angular)-in-JS template literal via the dispatcher with placeholder replacement.
// 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.
/// ///
/// Uses `.cooked` values (unlike CSS which uses `.raw`), joins quasis with /// 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`, /// `PRETTIER_HTML_PLACEHOLDER_{depth}_{N}_IN_JS` markers, formats via the given `embedded_language`,
/// then replaces placeholder occurrences in the resulting IR with `${expr}` Docs. /// then replaces placeholder occurrences in the resulting IR with `${expr}` IR.
/// ///
/// Supports both html-in-js and angular-in-js (`@Component({ template })`). /// Supports both html-in-js and angular-in-js (`@Component({ template })`).
pub(super) fn format_html_doc<'a>( 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 // 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 allocator = f.allocator();
let prefix = std::format!("{HTML_PLACEHOLDER_PREFIX}{}_", f.session().dispatch_depth());
let prefix = prefix.as_str();
let joined = { let joined = {
let mut sb = ArenaStringBuilder::new_in(allocator); let mut sb = ArenaStringBuilder::new_in(allocator);
for (idx, quasi_elem) in quasis.iter().enumerate() { for (idx, quasi_elem) in quasis.iter().enumerate() {
if idx > 0 { 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)); let _ = std::fmt::Write::write_fmt(&mut sb, std::format_args!("{}", idx - 1));
sb.push('_'); sb.push_str(HTML_PLACEHOLDER_SUFFIX);
sb.push_str(COUNTER);
sb.push_str(PLACEHOLDER_SUFFIX);
} }
let Some(cooked) = quasi_elem.value.cooked.as_ref() else { let Some(cooked) = quasi_elem.value.cooked.as_ref() else {
return false; return false;
@@ -103,7 +97,7 @@ pub(super) fn format_html_doc<'a>(
// Formatting consumes comment state, // Formatting consumes comment state,
// so discovering an unusable placeholder layout afterwards would make the verbatim fallback unsafe. // so discovering an unusable placeholder layout afterwards would make the verbatim fallback unsafe.
let mut next_placeholder = 0; 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() || next_placeholder != expressions.len()
{ {
return false; 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 ir = map_text_in_ir(ir, allocator, &mut |text, out| {
let escaped = super::escape_template_chars(text, allocator); let escaped = super::escape_template_chars(text, allocator);
let text = escaped.unwrap_or(text); let text = escaped.unwrap_or(text);
if text.contains(PLACEHOLDER_PREFIX) { if super::find_ignore_ascii_case(text, prefix).is_some() {
let parts = super::split_on_placeholders(text, PLACEHOLDER_PREFIX, PLACEHOLDER_SUFFIX); let parts = super::split_on_placeholders(text, prefix, HTML_PLACEHOLDER_SUFFIX);
if parts.len() > 1 { if parts.len() > 1 {
for (index, part) in parts.iter().enumerate() { for (index, part) in parts.iter().enumerate() {
if index.is_multiple_of(2) { if index.is_multiple_of(2) {
@@ -164,7 +158,10 @@ fn html_in_js_template(
has_trailing_ws: bool, has_trailing_ws: bool,
ws_ignore: bool, ws_ignore: bool,
) -> HtmlInJsTemplate { ) -> 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, /// `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. /// 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. /// 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 { for element in ir {
match element { match element {
FormatElement::Text { text, .. } => { FormatElement::Text { text, .. } => {
if !text.contains(PLACEHOLDER_PREFIX) { if super::find_ignore_ascii_case(text, prefix).is_none() {
continue; continue;
} }
let parts = let parts = super::split_on_placeholders(text, prefix, HTML_PLACEHOLDER_SUFFIX);
super::split_on_placeholders(text, PLACEHOLDER_PREFIX, PLACEHOLDER_SUFFIX);
for part in parts.iter().skip(1).step_by(2) { for part in parts.iter().skip(1).step_by(2) {
if !part.parse::<usize>().is_ok_and(|index| index == *next) { if !part.parse::<usize>().is_ok_and(|index| index == *next) {
return false; return false;
@@ -244,19 +240,21 @@ fn placeholders_are_sequential(ir: &[FormatElement<'_>], next: &mut usize) -> bo
let start = *next; let start = *next;
let mut variants = best_fitting.variants().iter(); let mut variants = best_fitting.variants().iter();
let Some(first) = variants.next() else { return false }; let Some(first) = variants.next() else { return false };
if !placeholders_are_sequential(first, next) { if !placeholders_are_sequential(first, prefix, next) {
return false; return false;
} }
for variant in variants { for variant in variants {
let mut variant_next = start; 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 || variant_next != *next
{ {
return false; return false;
} }
} }
} }
FormatElement::Interned(interned) if !placeholders_are_sequential(interned, next) => { FormatElement::Interned(interned)
if !placeholders_are_sequential(interned, prefix, next) =>
{
return false; return false;
} }
_ => {} _ => {}
@@ -293,12 +291,12 @@ mod tests {
let allocator = Allocator::default(); let allocator = Allocator::default();
let element = best_fitting( let element = best_fitting(
&allocator, &allocator,
text("aPRETTIER_HTML_PLACEHOLDER_0_0_IN_JSbPRETTIER_HTML_PLACEHOLDER_1_0_IN_JSc"), 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_1_0_IN_JS"), text("PRETTIER_HTML_PLACEHOLDER_0_0_IN_JS PRETTIER_HTML_PLACEHOLDER_0_1_IN_JS"),
); );
let mut next = 0; 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); assert_eq!(next, 2);
} }
@@ -308,10 +306,14 @@ mod tests {
let element = best_fitting( let element = best_fitting(
&allocator, &allocator,
text("PRETTIER_HTML_PLACEHOLDER_0_0_IN_JS"), 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; 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: /// Handles both:
/// - CSS: `` `PLACEHOLDER-{N}` `` /// - 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, /// The prefix and suffix match ASCII case-insensitively: a child may change their case
/// so the same function works for both formats. /// (CSS lowercases a property name holding an HTML placeholder).
fn split_on_placeholders<'a>(text: &'a str, prefix: &str, suffix: &str) -> Vec<&'a str> { fn split_on_placeholders<'a>(text: &'a str, prefix: &str, suffix: &str) -> Vec<&'a str> {
let mut result = vec![]; let mut result = vec![];
let mut remaining = text; let mut remaining = text;
loop { loop {
let Some(start) = remaining.find(prefix) else { let Some(start) = find_ignore_ascii_case(remaining, prefix) else {
result.push(remaining); result.push(remaining);
break; 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 digits = &after_prefix[..digit_end];
let mut after_digits = &after_prefix[digit_end..]; let 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..];
}
}
// Check for the suffix // 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 // Valid placeholder — push the digit index
result.push(digits); result.push(digits);
remaining = after_suffix; remaining = after_suffix;
@@ -368,6 +361,14 @@ fn split_on_placeholders<'a>(text: &'a str, prefix: &str, suffix: &str) -> Vec<&
result 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. /// 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..]); result.push_str(&s[run_start..]);
Some(result.into_str()) 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 options = f.options();
let source_type = f.context().source_type(); let source_type = f.context().source_type();
let chosen_quote_style = // In an HTML attribute, `'` always (Prettier's `__isInHtmlAttribute`), but a directive as usual
if self.jsx { options.jsx_quote_style } else { options.quote_style }; 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 { let is_quote_needed = match options.quote_properties {
QuoteProperties::AsNeeded => false, QuoteProperties::AsNeeded => false,
@@ -55,8 +64,13 @@ impl<'a> FormatLiteralStringToken<'a> {
QuoteProperties::Consistent => f.context().is_quote_needed(), QuoteProperties::Consistent => f.context().is_quote_needed(),
}; };
let string_cleaner = let string_cleaner = LiteralStringNormalizer {
LiteralStringNormalizer::new(*self, chosen_quote_style, is_quote_needed, f.allocator()); 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); let content = string_cleaner.normalize_text(source_type);
@@ -187,6 +201,8 @@ struct LiteralStringNormalizer<'a> {
token: FormatLiteralStringToken<'a>, token: FormatLiteralStringToken<'a>,
/// The quote that was set inside the configuration /// The quote that was set inside the configuration
chosen_quote_style: QuoteStyle, 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. /// State whether we need to print the quotes or not.
is_quote_needed: bool, is_quote_needed: bool,
/// Arena for the strings with other quotes, so that `fmt` does not copy them again. /// 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> { 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> { fn normalize_text(&self, source_type: SourceType) -> Cow<'a, str> {
// Handle JSX attribute strings specially - they use HTML entity escaping // Handle JSX attribute strings specially - they use HTML entity escaping
if self.token.jsx && self.token.parent_kind == StringLiteralParentKind::Expression { if self.token.jsx && self.token.parent_kind == StringLiteralParentKind::Expression {
return self.normalize_jsx_attribute(); 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 { match self.token.parent_kind {
StringLiteralParentKind::Expression => self.normalize_string_literal(str_info), StringLiteralParentKind::Expression => self.normalize_string_literal(str_info),
StringLiteralParentKind::Directive => self.normalize_directive(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); 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. 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`) - (5) `envelope/`: IR-composing behavior shared by document-envelope hosts (`write_front_matter`)
@@ -228,6 +228,12 @@ impl<'a> FormatSession<'a> {
self.input_kind 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. /// 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.) /// (A handle rather than an invoking method; see the alias for the `Err`-means-verbatim contract.)
pub fn string_embedder(&self) -> Option<&StringEmbedder> { pub fn string_embedder(&self) -> Option<&StringEmbedder> {
@@ -13,7 +13,7 @@ use std::path::Path;
use oxc_allocator::Allocator; use oxc_allocator::Allocator;
use oxc_formatter_core::{Document, FormatOptions, FormatSession, InputKind}; 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() { fn main() {
let mut args = pico_args::Arguments::from_env(); let mut args = pico_args::Arguments::from_env();
@@ -32,7 +32,7 @@ fn main() {
let allocator = Allocator::new(); let allocator = Allocator::new();
let session = FormatSession::new(&allocator, InputKind::Fragment); 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) => { Ok(embedded) => {
let document = Document::new(embedded.ir, Vec::new()); let document = Document::new(embedded.ir, Vec::new());
// `elements` borrows the arena (not the document) and group modes are `Cell`s, // `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::{ use crate::{
comments::{Comments, CssComment}, comments::{Comments, CssComment},
format::CssEmbeddedIn,
options::CssFormatOptions, options::CssFormatOptions,
}; };
@@ -16,9 +17,9 @@ pub struct CssFormatContext<'a> {
/// Inside an ICSS rule (`:import(...)` / `:export`): property names keep /// Inside an ICSS rule (`:import(...)` / `:export`): property names keep
/// their case (Prettier's `insideIcssRuleNode`). /// their case (Prettier's `insideIcssRuleNode`).
in_icss_rule: std::cell::Cell<bool>, in_icss_rule: std::cell::Cell<bool>,
/// The source may contain css-in-js `${}` placeholder markers /// What the part is embedded in (embedded entry point only);
/// (embedded entry point only); gates the printer's placeholder handling. /// css-in-js gates the printer's placeholder handling.
template_placeholders: bool, embedded_in: Option<CssEmbeddedIn>,
} }
impl<'a> CssFormatContext<'a> { impl<'a> CssFormatContext<'a> {
@@ -26,7 +27,7 @@ impl<'a> CssFormatContext<'a> {
options: CssFormatOptions, options: CssFormatOptions,
source_code: &'a str, source_code: &'a str,
comments: &'a [CssComment], comments: &'a [CssComment],
template_placeholders: bool, embedded_in: Option<CssEmbeddedIn>,
) -> Self { ) -> Self {
Self { Self {
options, options,
@@ -34,13 +35,18 @@ impl<'a> CssFormatContext<'a> {
comments: Comments::new(comments), comments: Comments::new(comments),
in_less_detached: std::cell::Cell::new(false), in_less_detached: std::cell::Cell::new(false),
in_icss_rule: 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. /// Whether the source may contain css-in-js `${}` placeholder markers.
pub fn template_placeholders(&self) -> bool { 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> { 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 } = let ParsedCss { stylesheet, comments, source, has_bom, front_matter } =
parse_for_format(session.allocator(), source_text, options, CssRoot::Stylesheet)?; parse_for_format(session.allocator(), source_text, options, CssRoot::Stylesheet)?;
let context = let context = CssFormatContext::new(options, source, comments, None);
CssFormatContext::new(options, source, comments, /* template_placeholders */ false);
// A root `Document` owns a fresh Tailwind class scope // A root `Document` owns a fresh Tailwind class scope
let session = session.with_new_tailwind_scope(); let session = session.with_new_tailwind_scope();
let mut state = FormatState::new_with_session(context, session.clone()); 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 /// - allocates from the session's shared arena and `GroupId` space
/// - emits neither a BOM nor the trailing newline /// - emits neither a BOM nor the trailing newline
/// - parses a fragment ([`CssRoot::Fragment`]), so root declarations are statements /// - parses a fragment ([`CssRoot::Fragment`]), so root declarations are statements
/// - `template_placeholders` enables the css-in-js parse mode ([`CssRoot::CssInJsTemplate`]); /// - `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 `false` /// JSDoc / Markdown code blocks pass `None`
/// ///
/// `@apply` Tailwind classes go into the session's class scope (shared with the parent), /// `@apply` Tailwind classes go into the session's class scope (shared with the parent),
/// the parent document owns the batch sort. /// the parent document owns the batch sort.
@@ -144,11 +143,15 @@ pub fn format_to_ir<'a>(
session: &FormatSession<'a>, session: &FormatSession<'a>,
source_text: &str, source_text: &str,
options: CssFormatOptions, options: CssFormatOptions,
template_placeholders: bool, embedded_in: Option<CssEmbeddedIn>,
) -> Result<EmbeddedIr<'a>, OxcDiagnostic> { ) -> Result<EmbeddedIr<'a>, OxcDiagnostic> {
// `FormatSession::dispatch` never hands a BOM-headed input to an embedded part, // `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. // 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, .. } = let ParsedCss { stylesheet, comments, source, front_matter, .. } =
parse_for_format(session.allocator(), source_text, options, root)?; parse_for_format(session.allocator(), source_text, options, root)?;
if front_matter.is_some() && !session.input_kind().owns_front_matter() { 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 state = FormatState::new_with_session(context, session.clone());
let mut buffer = Formatter::new(&mut state); let mut buffer = Formatter::new(&mut state);
@@ -168,6 +171,16 @@ pub fn format_to_ir<'a>(
Ok(EmbeddedIr { ir: buffer.into_vec() }) 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. /// What a CSS source is parsed as.
#[derive(Clone, Copy, Debug, PartialEq, Eq)] #[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum CssRoot { pub enum CssRoot {
+4 -1
View File
@@ -38,6 +38,9 @@ pub const TEMPLATE_PLACEHOLDER_SUFFIX: &str = "`";
pub use crate::{ pub use crate::{
comments::CssComment, comments::CssComment,
context::CssFormatContext, 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}, options::{CssFormatOptions, CssVariant, SingleQuote, TrailingCommas},
}; };
@@ -8,7 +8,10 @@ use oxc_span::Span;
use oxc_formatter_core::{ use oxc_formatter_core::{
arena_cow_str, 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, 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;`). // (`${m}\ncolor: red` -> no `;`, but `${m}\ncolor: red;\nx: 1` -> `x: 1;`).
// Track that run so swallowed declarations keep a source-driven `;`. // Track that run so swallowed declarations keep a source-driven `;`.
let mut swallowed = false; 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() { for (i, stmt) in statements.iter().enumerate() {
let start = stmt_start(stmt); let start = stmt_start(stmt);
if i > 0 { if i > 0 {
@@ -118,7 +123,12 @@ pub(super) fn write_statement_sequence_bounded<'a>(
write!(f, space()); write!(f, space());
} }
} else { } 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 { if gap == Gap::Blank {
write!(f, empty_line()); write!(f, empty_line());
} }
@@ -145,6 +155,13 @@ pub(super) fn write_statement_sequence_bounded<'a>(
if has_source_semicolon { if has_source_semicolon {
write!(f, ";"); 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 { } else {
write_statement(stmt, f); write_statement(stmt, f);
} }
+4 -1
View File
@@ -86,7 +86,10 @@ fn format_embedded(source: &str, options: CssFormatOptions) -> String {
let session = let session =
oxc_formatter_core::FormatSession::new(&allocator, oxc_formatter_core::InputKind::Fragment); oxc_formatter_core::FormatSession::new(&allocator, oxc_formatter_core::InputKind::Fragment);
let embedded = oxc_formatter_css::format_to_ir( 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"); .expect("format should succeed");
// Simulate the host: replace each typed placeholder with the canonical // 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) - Why: uniform-rule (same construct, same output: the doctype of a `.html` file, which Prettier lowercases)
- Pin: `tests/fixtures/vue/doctype-case.vue` - 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 ```vue
<!-- input --> <!-- 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) - Why: semantics (whitespace added on both sides of a `script` renders between its inline neighbors)
- Pin: `tests/fixtures/html/script-display-none.html` - Pin: `tests/fixtures/html/script-display-none.html`
- Conformance: `html/tags/tags.html` - Conformance: `html/tags/tags.html`
- Oxfmt: `externals/prettier/html/script/script.html`
```html ```html
<!-- input --> <!-- 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) - Why: semantics (whitespace added on both sides of a `dialog` renders between its inline neighbors)
- Pin: `tests/fixtures/html/dialog-out-of-flow.html` - Pin: `tests/fixtures/html/dialog-out-of-flow.html`
- Conformance: `html/tags/tags2.html` - Conformance: `html/tags/tags2.html`
- Oxfmt: `externals/prettier/html/tags/tags2.html`
```html ```html
<!-- input --> <!-- 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) - 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` - Pin: `tests/fixtures/html/omitted-end-tag-at-eof.html`
- Conformance: `html/basics/broken-html.html`, `html/prettier_ignore/unclosed2.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 ```html
<!-- input (header.html, closed by footer.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) - 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` - Pin: `tests/fixtures/html/prefixed-script-style.html`
- Conformance: `html/basics/with-colon.html` - Conformance: `html/basics/with-colon.html`
- Oxfmt: `externals/prettier/html/basics/with-colon.html`
```html ```html
<!-- input --> <!-- 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) - 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` - 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 ```html
<!-- input --> <!-- input -->
+12 -3
View File
@@ -1,17 +1,26 @@
use oxc_formatter_core::{FormatContext, SourceText}; use oxc_formatter_core::{FormatContext, SourceText};
use crate::{options::HtmlishFormatOptions, tree::Tree}; use crate::{format::HtmlishEmbeddedIn, options::HtmlishFormatOptions, tree::Tree};
/// Formatting context for htmlish. /// Formatting context for htmlish.
pub struct HtmlishFormatContext<'a> { pub struct HtmlishFormatContext<'a> {
options: HtmlishFormatOptions, options: HtmlishFormatOptions,
source_text: SourceText<'a>, source_text: SourceText<'a>,
tree: &'a Tree<'a>, tree: &'a Tree<'a>,
embedded_in: Option<HtmlishEmbeddedIn>,
} }
impl<'a> HtmlishFormatContext<'a> { impl<'a> HtmlishFormatContext<'a> {
pub fn new(options: HtmlishFormatOptions, tree: &'a Tree<'a>) -> Self { pub fn new(
Self { options, source_text: SourceText::new(tree.source()), tree } 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. /// 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_diagnostics::OxcDiagnostic;
use oxc_formatter_core::{ use oxc_formatter_core::{
Document, EmbeddedIr, Format, FormatSession, FormatState, Formatted, Formatter, InputKind, 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}, spec::{FrontMatter, blank_front_matter, parse_front_matter, split_bom},
write, write_front_matter, write, write_front_matter,
}; };
@@ -14,7 +14,7 @@ use oxc_htmlish_parser::{
use crate::{ use crate::{
context::HtmlishFormatContext, context::HtmlishFormatContext,
options::HtmlishFormatOptions, options::HtmlishFormatOptions,
print::{self, HtmlishFormatter}, print::{self, HtmlishFormatter, format_with},
tree::Tree, tree::Tree,
}; };
@@ -52,7 +52,7 @@ pub fn format_with_session<'a>(
); );
let parsed = parse_for_format(session.allocator(), source_text, &options)?; 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 // A root `Document` owns a fresh Tailwind class scope
let session = session.with_new_tailwind_scope(); let session = session.with_new_tailwind_scope();
let mut state = FormatState::new_with_session(context, session.clone()); let mut state = FormatState::new_with_session(context, session.clone());
@@ -114,6 +114,7 @@ pub fn format_to_ir<'a>(
session: &FormatSession<'a>, session: &FormatSession<'a>,
source_text: &str, source_text: &str,
options: HtmlishFormatOptions, options: HtmlishFormatOptions,
embedded_in: Option<HtmlishEmbeddedIn>,
) -> Result<EmbeddedIr<'a>, OxcDiagnostic> { ) -> Result<EmbeddedIr<'a>, OxcDiagnostic> {
let allocator = session.allocator(); let allocator = session.allocator();
// `FormatSession::dispatch` never hands a BOM-headed input to an embedded part // `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 state = FormatState::new_with_session(context, session.clone());
let mut buffer = Formatter::new(&mut state); let mut buffer = Formatter::new(&mut state);
@@ -134,6 +135,38 @@ pub fn format_to_ir<'a>(
Ok(EmbeddedIr { ir: buffer.into_vec() }) 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. /// 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`. /// 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>, parsed: &ParsedHtmlish<'a>,
options: HtmlishFormatOptions, options: HtmlishFormatOptions,
standalone: bool, standalone: bool,
embedded_in: Option<HtmlishEmbeddedIn>,
) -> HtmlishFormatContext<'a> { ) -> HtmlishFormatContext<'a> {
let tree = allocator.alloc(Tree::build( let tree = allocator.alloc(Tree::build(
allocator, allocator,
@@ -177,7 +211,7 @@ fn build_context<'a>(
options.variant, options.variant,
standalone, standalone,
)); ));
HtmlishFormatContext::new(options, tree) HtmlishFormatContext::new(options, tree, embedded_in)
} }
/// Emits the front matter and the nodes with the final newline. /// 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>) { 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 { if let Some(fm) = &parsed.front_matter {
write_front_matter(fm, &["yaml", "toml"], f); write_front_matter(fm, &["yaml", "toml"], f);
if has_body { if has_body {
write!(f, empty_line()); 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); print::write_root(f);
} }
} }
+5 -1
View File
@@ -11,6 +11,10 @@ mod tree;
pub use crate::{ pub use crate::{
context::HtmlishFormatContext, 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}, 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). /// 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 struct HtmlishFormatOptions {
pub indent_style: IndentStyle, pub indent_style: IndentStyle,
pub indent_width: IndentWidth, pub indent_width: IndentWidth,
@@ -42,6 +42,9 @@ pub struct HtmlishFormatOptions {
pub bracket_same_line: bool, pub bracket_same_line: bool,
/// Mirrors Prettier's `singleAttributePerLine`. /// Mirrors Prettier's `singleAttributePerLine`.
pub single_attribute_per_line: bool, 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. /// Which whitespace between elements is significant.
@@ -1,12 +1,12 @@
//! Attributes: the list in the start tag (line breaks, `prettier-ignore-attribute`), //! 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. //! 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 cow_utils::CowUtils;
use oxc_formatter_core::{ use oxc_formatter_core::{
Format, arena_cow_str, Format, FormatElement, arena_cow_str,
builders::{ builders::{
group, hard_line_break, if_group_breaks, if_group_fits_on_line, indent, soft_block_indent, 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, soft_line_break, soft_line_break_or_space, space, text,
@@ -14,9 +14,16 @@ use oxc_formatter_core::{
spec::is_suppression_marker, spec::is_suppression_marker,
write, 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::{ use crate::{
context::HtmlishFormatContext, context::HtmlishFormatContext,
tree::{Tree, collapse_whitespace}, tree::{Tree, collapse_whitespace},
@@ -46,12 +53,13 @@ pub fn write_attributes<'a>(t: &'a Tree<'a>, id: NodeId, f: &mut HtmlishFormatte
} }
return; 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" 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")) && matches!(el.attributes.as_slice(), [only] if t.attribute_is(only.name.slice(t.source()), "src"))
&& item.first_child.is_none(); && 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 ignored = ignored_attributes(t, id);
let is_ignored = |name: &str| { let is_ignored = |name: &str| {
ignored.is_some_and(|names| { 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) let hug_end = opening_end_borrowed(t, id)
|| (item.self_closing && t[t.parent(id)].borrows.last_child_closing_end) || (item.self_closing && t[t.parent(id)].borrows.last_child_closing_end)
|| force_not_to_break; || force_not_to_break;
if hug_end || options.bracket_same_line { if hug_end || bracket_same_line {
if slash { if slash {
write!(f, space()); write!(f, space());
} }
@@ -110,13 +118,43 @@ fn write_attribute<'a>(
let is = |known| t.attribute_is(name, known); let is = |known| t.attribute_is(name, known);
write!(f, text(t.attribute_name(element, name))); write!(f, text(t.attribute_name(element, name)));
let Some(value) = attribute.value else { return }; let Some(value) = attribute.value else { return };
let quoted = value.quote != Quote::None;
let value = value.span.slice(source); let value = value.span.slice(source);
write!(f, "="); 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); let tag = t.tag(element);
// A class list that collapses to nothing prints as written; // A class list that collapses to nothing prints as written;
// the names are split at ASCII whitespace only (DIVERGENCES.md#class-ascii-whitespace). // 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)); let names = collapse_whitespace(f.allocator(), unescaped(value, f));
write!(f, ["\"", text(quoted_piece(names, f)), "\""]); write!(f, ["\"", text(quoted_piece(names, f)), "\""]);
} else if is("srcset") } else if is("srcset")
@@ -124,13 +162,34 @@ fn write_attribute<'a>(
&& let Some(candidates) = parse_srcset(unescaped(value, f)) && let Some(candidates) = parse_srcset(unescaped(value, f))
{ {
write_srcset(&candidates, 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); write_permissions_policy(unescaped(value, f), f);
} else { } else {
write!(f, AttributeValue(value)); 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. /// 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. /// Quote entities are read back first, so the choice does not depend on how the source quoted.
pub struct AttributeValue<'a>(pub &'a str); 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 `"`. /// `"` 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) arena_cow_str(&piece.cow_replace('"', "&quot;"), f)
} }
/// Prettier's `printExpand`: `content` on its own indented lines when the group breaks. /// 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>>, content: &impl Format<'a, HtmlishFormatContext<'a>>,
f: &mut HtmlishFormatter<'_, 'a>, f: &mut HtmlishFormatter<'_, 'a>,
) { ) {
@@ -231,7 +290,7 @@ fn parse_srcset(input: &str) -> Option<Vec<Candidate<'_>>> {
url = url.trim_end_matches(','); url = url.trim_end_matches(',');
"" ""
} else { } else {
// Descriptors up to the next `,` outside parentheses. // Descriptors up to the next `,` outside parentheses
rest = rest.trim_ascii_start(); rest = rest.trim_ascii_start();
let mut depth = 0; let mut depth = 0;
let mut end = rest.len(); 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()) => { 'w' | 'h' if value.bytes().all(|b| b.is_ascii_digit()) => {
value.parse::<f64>().ok().filter(|&v| v > 0.0) 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), 'x' if is_floating_point(value) => value.parse::<f64>().ok().filter(|&v| v >= 0.0),
_ => None, _ => None,
}?; }?;
@@ -1,5 +1,6 @@
//! Angular control flow blocks: transparent, their markers print no box (`@if (a) {` … `}`). //! Angular control flow blocks: transparent, their markers print no box (`@if (a) {` … `}`).
use oxc_allocator::{Allocator, StringBuilder};
use oxc_formatter_core::{ use oxc_formatter_core::{
builders::{hard_line_break, indent, text}, builders::{hard_line_break, indent, text},
write, write,
@@ -11,7 +12,6 @@ use super::{
children::{Between, write_children}, children::{Between, write_children},
format_with, format_with,
}; };
use oxc_allocator::{Allocator, StringBuilder};
use crate::tree::{Tree, collapse_whitespace}; 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. /// 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 { pub fn between_block(t: &Tree<'_>, prev: NodeId, next: NodeId) -> Between {
let (p, n) = (&t[prev], &t[next]); 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 let spaces = p.has_trailing_spaces
|| n.has_leading_spaces || n.has_leading_spaces
|| (t.is_block(prev) && p.last_child.is_some_and(|last| t[last].has_trailing_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"; let default_never = name == "default never";
write!(f, ["@", text(name)]); write!(f, ["@", text(name)]);
if !block.parameters.is_empty() { 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 { " (" }); write!(f, if default_never { "(" } else { " (" });
for (i, parameter) in block.parameters.iter().enumerate() { for (i, parameter) in block.parameters.iter().enumerate() {
if i > 0 { if i > 0 {
write!(f, "; "); write!(f, "; ");
} }
// As written until expressions are formatted, on one line. // As written until expressions are formatted, on one line
let parameter = let parameter =
collapse_whitespace_outside_quotes(f.allocator(), parameter.slice(t.source())); collapse_whitespace_outside_quotes(f.allocator(), parameter.slice(t.source()));
write!(f, text(parameter)); write!(f, text(parameter));
@@ -81,7 +81,7 @@ pub fn write_block<'a>(
write!(f, ")"); write!(f, ")");
} }
if block.close.is_none() { 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 { if default_never {
write!(f, ";"); write!(f, ";");
} }
@@ -104,13 +104,14 @@ pub fn write_block<'a>(
write!(f, hard_line_break()); write!(f, hard_line_break());
} }
} else if item.next.is_some_and(|next| continues_block(t, id, next)) { } 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, hard_line_break());
} }
write!(f, "}"); 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 { fn collapse_whitespace_outside_quotes<'a>(allocator: &'a Allocator, text: &'a str) -> &'a str {
if !text.contains(['\'', '"', '`']) { if !text.contains(['\'', '"', '`']) {
return collapse_whitespace(allocator, text); return collapse_whitespace(allocator, text);
@@ -124,7 +124,8 @@ fn between(t: &Tree<'_>, prev: NodeId, next: NodeId) -> Between {
if !n.has_leading_spaces { if !n.has_leading_spaces {
return Between::Soft; 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() { 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 }; return if has_trailing_line_break(t, prev) { Between::Hard } else { Between::Space };
} }
@@ -148,7 +149,7 @@ pub fn write_children<'a>(
return; 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 carried = (Between::Nothing, false);
let mut prev_group_id: Option<GroupId> = None; let mut prev_group_id: Option<GroupId> = None;
for child in t.children(id) { for child in t.children(id) {
@@ -168,12 +169,12 @@ pub fn write_children<'a>(
continue; 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 = let breaks =
|between, blank| between != Between::Nothing && (blank || between == Between::Hard); |between, blank| between != Between::Nothing && (blank || between == Between::Hard);
let prev_breaks = breaks(prev_between, prev_blank); let prev_breaks = breaks(prev_between, prev_blank);
let leading = match node.prev { 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) Some(prev)
if !matches!(prev_between, Between::Nothing | Between::Space) && !prev_breaks => 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); let parent = t.parent(id);
parent == NodeId::DOCUMENT parent == NodeId::DOCUMENT
// A line break before a borrowed `>` cannot be printed (`b </p\n>`, `b</p\n>`), // 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) || (!closing_end_borrowed(t, parent)
&& t.end_tag_start(parent).is_some_and(|end| t.line_breaks(t.source_end(id), end) > 0)) && 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]; let item = &t[id];
if should_preserve_content(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, group(&format_with(|f| write_opening_tag(t, id, f))));
write!(f, text(node_content(t, id))); write!(f, text(node_content(t, id)));
write_closing_tag(t, id, f); 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| { let line_after = format_with(|f| {
if closing_end_borrowed(t, id) { if closing_end_borrowed(t, id) {
if item.script_like { 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()); write!(f, soft_line_break());
} else if t[last].has_trailing_spaces && t[last].trailing_sensitive { } else if t[last].has_trailing_spaces && t[last].trailing_sensitive {
write!(f, space()); write!(f, space());
} }
} else if item.pre_like && t[last].borrows.parent_closing_start { } 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 { } else if hug {
write!(f, if_group_breaks(&soft_line_break()).with_group_id(Some(attr_group_id))); 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 { } 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 { } 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 // `<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). // (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()); 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, /// 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( fn ends_at_indentation(
t: &Tree<'_>, t: &Tree<'_>,
id: NodeId, id: NodeId,
last: NodeId, last: NodeId,
options: HtmlishFormatOptions, options: &HtmlishFormatOptions,
) -> bool { ) -> bool {
let value = match t.kind(last) { let value = match t.kind(last) {
NodeKind::Comment(comment) => comment.content.slice(t.source()), NodeKind::Comment(comment) => comment.content.slice(t.source()),
@@ -150,7 +151,7 @@ fn ends_at_indentation(
if !tail.bytes().all(|b| b == unit) { if !tail.bytes().all(|b| b == unit) {
return false; 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 depth = 0;
let mut ancestor = t.parent(id); let mut ancestor = t.parent(id);
while ancestor != NodeId::DOCUMENT { while ancestor != NodeId::DOCUMENT {
@@ -166,10 +167,10 @@ fn should_preserve_content(t: &Tree<'_>, id: NodeId) -> bool {
match t.kind(id) { match t.kind(id) {
NodeKind::ConditionalComment(comment) => { NodeKind::ConditionalComment(comment) => {
!comment.parsed !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)) || item.last_child.is_some_and(|last| t.is_unclosed(last))
} }
// `<pre>` with markup inside. // `<pre>` with markup inside
_ => { _ => {
item.pre_like item.pre_like
&& t.children(id).any(|child| !(t.is_text(child) || t.is_interpolation(child))) && 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 { fn node_content<'a>(t: &'a Tree<'a>, id: NodeId) -> &'a str {
let item = &t[id]; let item = &t[id];
let mut start = t.content_start(id) as usize; 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; let mut end = t.end_tag_start(id).unwrap_or(item.span.end) as usize;
if opening_end_borrowed(t, id) { if opening_end_borrowed(t, id) {
start -= opening_end_marker(t, id).len(); start -= opening_end_marker(t, id).len();
@@ -202,7 +203,8 @@ fn force_break_children(t: &Tree<'_>, id: NodeId) -> bool {
|| item.display == Display::Table) || 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 { fn force_break_content(t: &Tree<'_>, id: NodeId) -> bool {
let item = &t[id]; let item = &t[id];
if t.element(id).is_some() 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 block;
mod children; mod children;
mod element; mod element;
mod embed;
mod tag; mod tag;
mod text; mod text;
pub use embed::{CssInHtmlStyleAttribute, JsInHtmlEventHandler};
use block::write_block; use block::write_block;
use children::write_children; use children::write_children;
use element::write_element; 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(); start += opening_start_marker(t, id).len();
} }
let mut end = ignored_end(t, id) as usize; 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) { if closing_end_borrowed(t, id) {
end -= closing_end_marker(t, id).len(); 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); write_closing_tag_end(t, id, f);
} }
NodeKind::Text(_) | NodeKind::Cdata => write_text(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) => { NodeKind::Interpolation(interpolation) => {
write_opening_tag_start(t, id, f); write_opening_tag_start(t, id, f);
let expression = interpolation.expression.slice(t.source()); 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); write_closing_tag_end(t, id, f);
} }
// Comments are printed as written, a `</ div>` too (DIVERGENCES.md#end-tag-with-space-is-comment), // Comments are printed as written,
// and an omitted element's end tag (`</body>`). // a `</ div>` too (DIVERGENCES.md#end-tag-with-space-is-comment), and an omitted element's end tag (`</body>`).
NodeKind::Comment(_) | NodeKind::EndTag(_) => write!( NodeKind::Comment(_) | NodeKind::EndTag(_) => write!(
f, 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. //! 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 oxc_formatter_core::{Format, builders::text, write};
use super::{HtmlishFormatter, attribute::write_attributes};
use oxc_htmlish_parser::{ use oxc_htmlish_parser::{
Span, Span,
ast::{NodeId, NodeKind}, ast::{NodeId, NodeKind},
}; };
use super::{HtmlishFormatter, attribute::write_attributes};
use crate::{context::HtmlishFormatContext, tree::Tree}; use crate::{context::HtmlishFormatContext, tree::Tree};
/// A tag marker, written as up to four pieces (`<` + name, `<!--[if ` + condition + `]><!--><` + name, ...). /// 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. /// The node's closing `>` is printed by its next sibling or its parent.
pub fn closing_end_borrowed(t: &Tree<'_>, id: NodeId) -> bool { 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) 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> { pub fn opening_start_marker<'a>(t: &'a Tree<'a>, id: NodeId) -> Marker<'a> {
match t.kind(id) { 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); // 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). // 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 `<//>`. // 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| { NodeKind::Element(el) => el.end_tag.map_or(Marker::default(), |tag| {
let name = t.printed_name(id, el, tag.name.slice(t.source())); let written = tag.name.slice(t.source());
let ignored = tag let name = t.printed_name(id, el, written);
.ignored let end = tag.ignored.map_or(tag.name.end, |ignored| ignored.end);
.map_or("", |ignored| Span::new(tag.name.end, ignored.end).slice(t.source())); if std::ptr::eq(name, written) {
Marker(["</", name, ignored, ""]) 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)), _ => 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) (!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> { pub fn opening_tag_prefix<'a>(t: &'a Tree<'a>, id: NodeId) -> Marker<'a> {
if t[id].borrows.parent_opening_end { 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>) { pub fn write_opening_tag_start<'a>(t: &'a Tree<'a>, id: NodeId, f: &mut HtmlishFormatter<'_, 'a>) {
if !opening_start_borrowed(t, id) { 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. //! Text: filled words, or lines as written where whitespace is content.
use oxc_allocator::StringBuilder;
use oxc_formatter_core::{ use oxc_formatter_core::{
builders::{exact_line_breaks, soft_line_break_or_space, text}, FormatOptions,
write, builders::{align, dedent_to_root, exact_line_breaks, soft_line_break_or_space, text},
text_width, write,
}; };
use oxc_htmlish_parser::ast::NodeId; use oxc_htmlish_parser::ast::NodeId;
use super::{ use super::{
HtmlishFormatter, format_with, HtmlishFormatter,
embed::write_embedded_content,
format_with,
tag::{closing_tag_suffix, opening_tag_prefix}, tag::{closing_tag_suffix, opening_tag_prefix},
}; };
use crate::tree::Tree; 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); let suffix = closing_tag_suffix(t, id);
if parent.pre_like { 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; return;
} }
if parent.script_like { if parent.script_like {
write!(f, prefix); 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); write!(f, suffix);
return; return;
} }
@@ -104,14 +116,68 @@ fn trim_preserving_indentation(value: &str) -> &str {
&value[start..] &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. /// Prettier's `dedentString`: the lines with their common indentation removed.
fn dedent(value: &str) -> impl Iterator<Item = &str> { fn dedent(value: &str) -> impl Iterator<Item = &str> {
let indentation = |line: &str| line.len() - line.trim_ascii_start().len(); dedent_by(value, common_indentation(value))
let min = value }
.split('\n')
.filter(|line| !line.trim_ascii_start().is_empty()) /// The lines of `value` with `min` bytes of indentation removed.
.map(indentation) fn dedent_by(value: &str, min: usize) -> impl Iterator<Item = &str> {
.min()
.unwrap_or(0);
value.split('\n').map(move |line| line.get(min..).unwrap_or("")) 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 { fn format(source: &str, path: &Path, options: &Self::Options) -> String {
let allocator = Allocator::default(); 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) format(&allocator, source, options)
.expect("format should succeed") .expect("format should succeed")
.print() .print()
@@ -41,7 +41,7 @@ impl FixtureFormatter for HtmlishHarness {
} }
fn fingerprint(source: &str, path: &Path, options: &Self::Options) -> String { 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 allocator = Allocator::default();
let parsed = parse_for_format(&allocator, source, &options).expect("source should parse"); let parsed = parse_for_format(&allocator, source, &options).expect("source should parse");
fingerprint::fingerprint( fingerprint::fingerprint(
+1
View File
@@ -38,6 +38,7 @@ versioned_files = [
"crates/oxc_formatter/Cargo.toml", "crates/oxc_formatter/Cargo.toml",
"crates/oxc_formatter_json/Cargo.toml", "crates/oxc_formatter_json/Cargo.toml",
"crates/oxc_formatter_graphql/Cargo.toml", "crates/oxc_formatter_graphql/Cargo.toml",
"crates/oxc_formatter_htmlish/Cargo.toml",
"crates/oxc_formatter_css/Cargo.toml", "crates/oxc_formatter_css/Cargo.toml",
"crates/oxc_formatter_yaml/Cargo.toml", "crates/oxc_formatter_yaml/Cargo.toml",
"crates/oxc_formatter_markdown/Cargo.toml", "crates/oxc_formatter_markdown/Cargo.toml",