Skip to content

Commit 6e8e818

Browse files
committed
feat(oxfmt): Experimental .svelte support (#21700)
Part of #19715 Note that: > Users are required to perform 2 steps below > 1️⃣ Install svelte/compiler@^5.0.0 and/or @astrojs/compiler@^2.9.1 manually > 2️⃣ Enable option in config
1 parent e2a20b6 commit 6e8e818

23 files changed

Lines changed: 640 additions & 39 deletions

apps/oxfmt/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
},
2222
"dependencies": {
2323
"prettier": "3.8.3",
24+
"prettier-plugin-svelte": "3.5.1",
2425
"prettier-plugin-tailwindcss": "0.0.0-insiders.3997fbd",
2526
"tinypool": "2.1.0"
2627
},
@@ -32,6 +33,7 @@
3233
"diff": "^9.0.0",
3334
"execa": "^9.6.0",
3435
"json-schema-to-typescript": "catalog:",
36+
"svelte": "^5.55.5",
3537
"tsdown": "catalog:",
3638
"vitest": "catalog:",
3739
"vscode-languageserver-protocol": "^3.17.5",

apps/oxfmt/src-js/config.generated.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ export type SortGroupItemConfig = NewlinesBetweenMarker | string | string[];
2121
export type SortOrderConfig = "asc" | "desc";
2222
export type SortPackageJsonUserConfig = boolean | SortPackageJsonConfig;
2323
export type SortTailwindcssUserConfig = boolean | SortTailwindcssConfig;
24+
export type SvelteUserConfig = boolean | SvelteConfig;
2425
export type TrailingCommaConfig = "all" | "es5" | "none";
2526

2627
/**
@@ -199,6 +200,20 @@ export interface Oxfmtrc {
199200
* - Default: Disabled
200201
*/
201202
sortTailwindcss?: SortTailwindcssUserConfig;
203+
/**
204+
* Options for `prettier-plugin-svelte`.
205+
*
206+
* Pass `true` or an object to enable `.svelte` file formatting,
207+
* or `false` (handy in overrides) / omit to disable.
208+
* Setting `true` resets to defaults — any options inherited from a parent scope are dropped.
209+
*
210+
* NOTE: `prettier-plugin-svelte` requires the `svelte` package (`svelte/compiler`) at runtime,
211+
* but Oxfmt does NOT bundle or auto-install it.
212+
* You must install `svelte` yourself in your project, formatting will fail at runtime otherwise.
213+
*
214+
* - Default: Disabled
215+
*/
216+
svelte?: SvelteUserConfig;
202217
/**
203218
* Specify the number of spaces per indentation-level.
204219
*
@@ -476,6 +491,20 @@ export interface FormatConfig {
476491
* - Default: Disabled
477492
*/
478493
sortTailwindcss?: SortTailwindcssUserConfig;
494+
/**
495+
* Options for `prettier-plugin-svelte`.
496+
*
497+
* Pass `true` or an object to enable `.svelte` file formatting,
498+
* or `false` (handy in overrides) / omit to disable.
499+
* Setting `true` resets to defaults — any options inherited from a parent scope are dropped.
500+
*
501+
* NOTE: `prettier-plugin-svelte` requires the `svelte` package (`svelte/compiler`) at runtime,
502+
* but Oxfmt does NOT bundle or auto-install it.
503+
* You must install `svelte` yourself in your project, formatting will fail at runtime otherwise.
504+
*
505+
* - Default: Disabled
506+
*/
507+
svelte?: SvelteUserConfig;
479508
/**
480509
* Specify the number of spaces per indentation-level.
481510
*
@@ -734,3 +763,26 @@ export interface SortTailwindcssConfig {
734763
stylesheet?: string;
735764
[k: string]: unknown;
736765
}
766+
export interface SvelteConfig {
767+
/**
768+
* Whether to allow attribute shorthand if attribute name and expression are same.
769+
*
770+
* - Default: `true`
771+
*/
772+
allowShorthand?: boolean;
773+
/**
774+
* Whether to indent code inside `<script>` and `<style>` tags.
775+
*
776+
* - Default: `true`
777+
*/
778+
indentScriptAndStyle?: boolean;
779+
/**
780+
* The order in which Svelte component sections are printed.
781+
* Format: join the keywords `options`, `scripts`, `markup`, `styles` with a `-` in the order you want;
782+
* or `none` if you don't want to reorder anything.
783+
*
784+
* - Default: `"options-scripts-markup-styles"`
785+
*/
786+
sortOrder?: string;
787+
[k: string]: unknown;
788+
}

apps/oxfmt/src-js/libs/apis.ts

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import type { Options, Plugin } from "prettier";
1616

1717
const CACHES = {
1818
prettier: null as typeof import("prettier") | null,
19+
sveltePlugin: null as Plugin | null,
1920
tailwindPlugin: null as typeof import("prettier-plugin-tailwindcss") | null,
2021
tailwindSorter: null as typeof import("prettier-plugin-tailwindcss/sorter") | null,
2122
oxfmtPlugin: null as Plugin | null,
@@ -97,10 +98,12 @@ export type FormatFileParam = {
9798
export async function formatFile({ code, options }: FormatFileParam): Promise<string> {
9899
const prettier = CACHES.prettier ?? (await loadPrettier());
99100

100-
// Enable Tailwind CSS plugin for non-JS files if needed
101+
// NOTE: Plugins order matters here!
102+
// This plugin add `svelte` parser to support for `.svelte` files, and is also needed for `svelte-in-md` to work
103+
if ("_useSveltePlugin" in options) await setupSveltePlugin(options);
104+
// Enable Tailwind CSS plugin, this plugin transforms `parsers` already installed by prior plugins
101105
if ("_useTailwindPlugin" in options) await setupTailwindPlugin(options);
102-
// Add oxfmt plugin for (j|t)-in-xxx files to use `oxc_formatter` instead of built-in formatter.
103-
// NOTE: This must be last since Prettier plugins are applied in order
106+
// This plugin overrides `babel(-ts)` and `typescript` parsers to use `oxc_formatter` instead of built-in parsers
104107
if ("_oxfmtPluginOptionsJson" in options) await setupOxfmtPlugin(options);
105108

106109
return prettier.format(code, options);
@@ -262,6 +265,22 @@ export async function sortTailwindClasses({
262265
return sorter.sortClassAttributes(classes);
263266
}
264267

268+
// ---
269+
// Svelte plugin support
270+
// ---
271+
272+
/**
273+
* Load prettier-plugin-svelte to provide the `svelte` parser.
274+
*/
275+
async function setupSveltePlugin(options: Options): Promise<void> {
276+
CACHES.sveltePlugin ??= await loadCached(
277+
"sveltePlugin",
278+
async () => (await import("prettier-plugin-svelte")) as Plugin,
279+
);
280+
options.plugins ??= [];
281+
options.plugins.push(CACHES.sveltePlugin);
282+
}
283+
265284
// ---
266285
// Oxfmt plugin support for (j|t)-in-xxx files
267286
// ---

apps/oxfmt/src-js/libs/prettier-plugin-oxfmt/text-to-doc.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,9 @@ function detectParentContext(parentParser: string, options: Record<string, unkno
4949
if ("__isEmbeddedTypescriptGenericParameters" in options) return "vue-script-generic";
5050
return "vue-script";
5151
}
52+
if (parentParser === "svelte") {
53+
return "svelte-script";
54+
}
5255

5356
return parentParser;
5457
}

apps/oxfmt/src/api/format_api.rs

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,8 @@ use oxc_napi::OxcError;
66

77
use crate::core::{
88
ExternalFormatter, FormatResult, JsFormatEmbeddedCb, JsFormatEmbeddedDocCb, JsFormatFileCb,
9-
JsInitExternalFormatterCb, JsSortTailwindClassesCb, SourceFormatter, classify_file_kind,
10-
resolve_for_api, utils,
9+
JsInitExternalFormatterCb, JsSortTailwindClassesCb, ResolveOutcome, SourceFormatter,
10+
classify_file_kind, resolve_for_api, utils,
1111
};
1212

1313
pub struct ApiFormatResult {
@@ -65,7 +65,16 @@ pub fn run(
6565
};
6666
};
6767
let strategy = match resolve_for_api(options.unwrap_or_default(), kind, &cwd) {
68-
Ok(strategy) => strategy,
68+
Ok(ResolveOutcome::Format(strategy)) => strategy,
69+
Ok(ResolveOutcome::MissingPlugin(plugin)) => {
70+
external_formatter.cleanup();
71+
return ApiFormatResult {
72+
code: source_text,
73+
errors: vec![OxcError::new(format!(
74+
"Cannot format `.{plugin}`: `{plugin}` plugin is not enabled in resolved config: {filename}"
75+
))],
76+
};
77+
}
6978
Err(err) => {
7079
external_formatter.cleanup();
7180
return ApiFormatResult {

apps/oxfmt/src/api/text_to_doc_api.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ pub fn run(
6363
"vue-for-binding-left" => Some(FragmentKind::VueForBindingLeft),
6464
"vue-bindings" => Some(FragmentKind::VueBindings),
6565
"vue-script-generic" => Some(FragmentKind::VueScriptGeneric),
66-
// "vue-script"
66+
// "vue-script", "svelte-script"
6767
_ => None,
6868
};
6969

apps/oxfmt/src/cli/stdin_runner.rs

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,8 @@ use super::{
1212
},
1313
};
1414
use crate::core::{
15-
ConfigResolver, ExternalFormatter, FormatResult, JsConfigLoaderCb, SourceFormatter,
16-
classify_file_kind, resolve_editorconfig_path, utils,
15+
ConfigResolver, ExternalFormatter, FormatResult, JsConfigLoaderCb, ResolveOutcome,
16+
SourceFormatter, classify_file_kind, resolve_editorconfig_path, utils,
1717
};
1818

1919
pub struct StdinRunner {
@@ -144,7 +144,11 @@ impl StdinRunner {
144144
return CliRunResult::InvalidOptionConfig;
145145
};
146146
let strategy = match config_resolver.resolve(kind) {
147-
Ok(strategy) => strategy,
147+
Ok(ResolveOutcome::Format(strategy)) => strategy,
148+
Ok(ResolveOutcome::MissingPlugin(_)) => {
149+
utils::print_and_flush(stdout, &source_text);
150+
return CliRunResult::FormatSucceeded;
151+
}
148152
Err(err) => {
149153
utils::print_and_flush(stderr, &format!("{err}\n"));
150154
return CliRunResult::InvalidOptionConfig;

apps/oxfmt/src/cli/walk.rs

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,9 @@ use oxc_diagnostics::{DiagnosticSender, DiagnosticService, OxcDiagnostic};
1515
use super::resolve::{build_global_ignore_matchers, is_ignored, resolve_file_scope_config};
1616
#[cfg(feature = "napi")]
1717
use crate::core::JsConfigLoaderCb;
18-
use crate::core::{ConfigResolver, FormatStrategy, classify_file_kind, config_discovery};
18+
use crate::core::{
19+
ConfigResolver, FormatStrategy, ResolveOutcome, classify_file_kind, config_discovery,
20+
};
1921

2022
/// Orchestrates file discovery with nested config and ignore handling.
2123
///
@@ -221,7 +223,8 @@ impl ScopedWalker {
221223
continue;
222224
};
223225
let strategy = match file_config.resolve(kind) {
224-
Ok(strategy) => strategy,
226+
Ok(ResolveOutcome::Format(strategy)) => strategy,
227+
Ok(ResolveOutcome::MissingPlugin(_)) => continue,
225228
Err(err) => {
226229
report_resolve_error(tx_error, &self.cwd, file, err);
227230
continue;
@@ -762,7 +765,10 @@ impl ignore::ParallelVisitor for WalkVisitor {
762765
return ignore::WalkState::Continue;
763766
};
764767
let strategy = match resolver.resolve(kind) {
765-
Ok(strategy) => strategy,
768+
Ok(ResolveOutcome::Format(strategy)) => strategy,
769+
Ok(ResolveOutcome::MissingPlugin(_)) => {
770+
return ignore::WalkState::Continue;
771+
}
766772
Err(err) => {
767773
report_resolve_error(&self.tx_error, &self.cwd, &path, err);
768774
return ignore::WalkState::Continue;

apps/oxfmt/src/core/config.rs

Lines changed: 30 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,22 @@ pub fn resolve_editorconfig_path(cwd: &Path) -> Option<PathBuf> {
4343
cwd.ancestors().map(|dir| dir.join(".editorconfig")).find(|p| p.exists())
4444
}
4545

46-
/// Resolve options for a pre-classified file and build a [`FormatStrategy`].
46+
// ---
47+
48+
/// Outcome of resolving a [`FileKind`] against a [`FormatConfig`],
49+
/// constructed by [`ConfigResolver::resolve`] / [`resolve_for_api`].
50+
#[derive(Debug)]
51+
pub enum ResolveOutcome {
52+
/// Ready to format with this strategy.
53+
Format(FormatStrategy),
54+
/// The file's parser requires a plugin that the resolved config did NOT enable.
55+
/// The payload carries the missing config key (e.g. `"svelte"`)
56+
/// so callers can construct a friendly error or log message.
57+
#[cfg_attr(not(feature = "napi"), expect(dead_code))]
58+
MissingPlugin(&'static str),
59+
}
60+
61+
/// Resolve options for a pre-classified file and build a [`ResolveOutcome`].
4762
///
4863
/// This is the simplified path for the NAPI `format()` API,
4964
/// which doesn't need `.oxfmtrc` overrides, `.editorconfig`, or ignore patterns.
@@ -56,14 +71,17 @@ pub fn resolve_for_api(
5671
raw_config: Value,
5772
kind: FileKind,
5873
cwd: &Path,
59-
) -> Result<FormatStrategy, String> {
74+
) -> Result<ResolveOutcome, String> {
6075
let mut format_config: FormatConfig =
6176
serde_json::from_value(raw_config).map_err(|err| err.to_string())?;
6277
format_config.resolve_tailwind_paths(cwd);
6378
// Validate eagerly: `from_format_config` skips validation for `ExternalFormatter*` kinds,
6479
// so range-out values (e.g., `printWidth: 1000`) would otherwise silently reach Prettier.
6580
let _ = to_oxc_formatter(&format_config)?;
66-
FormatStrategy::from_format_config(format_config, kind)
81+
if let Some(plugin) = kind.requires_plugin(&format_config) {
82+
return Ok(ResolveOutcome::MissingPlugin(plugin));
83+
}
84+
FormatStrategy::from_format_config(format_config, kind).map(ResolveOutcome::Format)
6785
}
6886

6987
/// Resolved options ready for the embedded callback to drive `oxc_formatter`.
@@ -389,13 +407,17 @@ impl ConfigResolver {
389407
Ok(())
390408
}
391409

392-
/// Resolve options for a pre-classified file and build a [`FormatStrategy`].
410+
/// Resolve options for a pre-classified file and build a [`ResolveOutcome`].
393411
///
394412
/// Returns `Err` only when the merged config (after override application) fails validation.
395413
#[instrument(level = "debug", name = "oxfmt::config::resolve", skip_all, fields(path = %kind.path().display()))]
396-
pub fn resolve(&self, kind: FileKind) -> Result<FormatStrategy, String> {
414+
pub fn resolve(&self, kind: FileKind) -> Result<ResolveOutcome, String> {
397415
let format_config = self.resolve_options(kind.path())?;
398-
FormatStrategy::from_format_config(format_config, kind)
416+
#[cfg(feature = "napi")]
417+
if let Some(plugin) = kind.requires_plugin(&format_config) {
418+
return Ok(ResolveOutcome::MissingPlugin(plugin));
419+
}
420+
FormatStrategy::from_format_config(format_config, kind).map(ResolveOutcome::Format)
399421
}
400422

401423
/// Resolve `FormatConfig` for a specific file path.
@@ -731,6 +753,7 @@ mod tests_slow_path_validation {
731753
parser_name: "json",
732754
supports_tailwind: false,
733755
supports_oxfmt: false,
756+
supports_svelte: false,
734757
};
735758
let err = resolver.resolve(kind).unwrap_err();
736759
assert!(err.contains("printWidth"), "expected printWidth validation error, got: {err}");
@@ -779,6 +802,7 @@ mod tests_slow_path_validation {
779802
parser_name: "css",
780803
supports_tailwind: true,
781804
supports_oxfmt: false,
805+
supports_svelte: false,
782806
};
783807
let err = resolve_for_api(serde_json::json!({ "printWidth": 1000 }), kind, Path::new("."))
784808
.unwrap_err();

0 commit comments

Comments
 (0)