feat(oxfmt): add embedded language formatting with Prettier integration (#14820)

## Summary

Adds support for formatting embedded languages (CSS, GraphQL, HTML, Markdown) in JavaScript/TypeScript template literals using Prettier via NAPI bindings.

## Motivation

JavaScript/TypeScript developers often embed CSS, GraphQL queries, HTML, and Markdown in their code using tagged template literals. Without proper formatting support, these embedded languages remain unformatted, leading to inconsistent code style and reduced readability.

This PR enables oxfmt to format these embedded languages using Prettier, providing a seamless formatting experience similar to Prettier's own embedded language support.

## Changes

### Core Functionality
- **Embedded language detection**: Recognizes `css`, `styled`, `gql`, `graphql`, `html`, `md`, and `markdown` template tags
- **Prettier integration**: Uses `@prettier/sync` for synchronous formatting of embedded code
- **NAPI bridge**: Rust-to-JavaScript interop using NAPI ThreadsafeFunction
- **Graceful fallback**: Returns original code if formatting fails or tag is unsupported

### Implementation Details

**Rust side** (`crates/oxc_formatter/`, `apps/oxfmt/src/prettier_plugins/`):
- New `EmbeddedFormatter` trait with callback-based API
- NAPI bindings using `FnArgs` pattern for clean multi-argument calls
- Template formatter enhancement to detect and format tagged templates
- Proper indentation handling: splits Prettier output into lines, uses `hard_line_break()` separators, wraps in `indent()` for context-aware indentation
- Arena allocator pattern to avoid borrow checker conflicts

**JavaScript side** (`apps/oxfmt/src-js/`):
- TypeScript bindings with clean signatures: `formatEmbeddedCode(tagName: string, code: string)`
- Prettier configuration: 80 char width, 2 space indent, semicolons, double quotes
- Tag-to-parser mapping for supported languages
- Error handling with console logging and fallback

**Testing**:
- E2E test suite using Vitest (8 test cases)
- Test fixtures for each supported language
- Snapshot testing with timing normalization for stability
- All tests passing consistently

## Examples

**Before** (unformatted):
```js
const styles = css`.button { color: red; background: blue; }`;
```

**After** (formatted with proper indentation):
```js
const styles = css`
  .button {
    color: red;
    background: blue;
  }
`;
```

**Supported tags:**
```js
// CSS
const styles = css`...`;
const component = styled`...`;

// GraphQL
const query = gql`...`;
const schema = graphql`...`;

// HTML
const template = html`...`;

// Markdown
const docs = md`...`;
const readme = markdown`...`;
```

## Testing

```bash
# Run oxfmt tests
just oxfmt

# Watch mode for development
just watch-oxfmt
```

All 8 E2E tests pass:
- ✓ embedded_css
- ✓ embedded_graphql
- ✓ embedded_html
- ✓ embedded_markdown
- ✓ mixed_embedded
- ✓ no_embedded
- ✓ unsupported_tag
- ✓ check_mode

## Related

Related to #13427

## Future Work

Potential enhancements:
- Support for additional embedded languages (SQL, YAML, etc.)
- Custom Prettier configuration via oxfmt config
- Performance optimization for large embedded blocks
- Integration with language servers for inline diagnostics
This commit is contained in:
Boshen committed 2025-11-03 02:05:35 +00:00
1 parent 2c15353822
commit 898d6fec12
31 files changed
+1144 -31

No files matched your search

+3
View File
@@ -25,6 +25,9 @@ rustflags = ["-C", "target-feature=+crt-static"]
[target.aarch64-pc-windows-msvc]
rustflags = ["-C", "target-feature=+crt-static"]
[target.'cfg(target_os = "linux")']
rustflags = ["-C", "linker-features=-lld", "-C", "link-args=-Wl,--warn-unresolved-symbols"]
# To be able to run unit tests on Windows, support compilation to 'x86_64-pc-windows-msvc'.
# Use Hybrid CRT to reduce the size of the binary (Coming by default with Windows 10 and later versions).
[target.'cfg(target_os = "windows")']
+30 -9
View File
@@ -38,34 +38,42 @@ jobs:
- os: windows-latest
target: x86_64-pc-windows-msvc
code-target: win32-x64
build-oxfmt-args: ""
- os: windows-latest
target: aarch64-pc-windows-msvc
code-target: win32-arm64
build-oxfmt-args: ""
- os: ubuntu-latest
target: x86_64-unknown-linux-gnu
code-target: linux-x64-gnu
build-oxfmt-args: --use-napi-cross
- os: ubuntu-latest
target: aarch64-unknown-linux-gnu
code-target: linux-arm64-gnu
build-oxfmt-args: --use-napi-cross
- os: ubuntu-latest
target: x86_64-unknown-linux-musl
code-target: linux-x64-musl
build-oxfmt-args: -x
- os: ubuntu-latest
target: aarch64-unknown-linux-musl
code-target: linux-arm64-musl
build-oxfmt-args: -x
- os: macos-latest
target: x86_64-apple-darwin
code-target: darwin-x64
build-oxfmt-args: ""
- os: macos-latest
target: aarch64-apple-darwin
code-target: darwin-arm64
build-oxfmt-args: ""
name: Package ${{ matrix.code-target }}
runs-on: ${{ matrix.os }}
@@ -89,28 +97,37 @@ jobs:
- name: Add Rust Target
run: rustup target add ${{ matrix.target }}
- name: Build
- uses: oxc-project/setup-node@fdbf0dfd334c4e6d56ceeb77d91c76339c2a0885 # v1.0.4
- uses: goto-bus-stop/setup-zig@abea47f85e598557f500fa1fd2ab7464fcb39406 # v2.2.1
if: ${{ contains(matrix.target, 'musl') }}
with:
version: 0.13.0
- name: Build oxfmt
working-directory: apps/oxfmt
run: pnpm run build-napi-release --target ${{ matrix.target }} ${{ matrix.build-oxfmt-args }}
shell: bash
env:
TARGET_CC: clang # for mimalloc
run: |
cross build --release -p oxfmt --bin oxfmt --features allocator --target=${{ matrix.target }}
# The binaries are zipped to fix permission loss https://github.com/actions/upload-artifact#permission-loss
- name: Archive Binaries
if: runner.os == 'Windows'
# Windows `.node` files have `-msvc` postfix e.g. `oxfmt.win32-x64-msvc.node`. Remove the postfix.
run: |
OXFMT_BIN_NAME=oxfmt-${{ matrix.code-target }}
mv target/${{ matrix.target }}/release/oxfmt.exe $OXFMT_BIN_NAME.exe
7z a $OXFMT_BIN_NAME.zip $OXFMT_BIN_NAME.exe
OXFMT_BIN_SRC_NAME=oxfmt.${{ matrix.code-target }}-msvc.node
OXFMT_BIN_NAME=oxfmt.${{ matrix.code-target }}.node
mv apps/oxfmt/src-js/$OXFMT_BIN_SRC_NAME $OXFMT_BIN_NAME
7z a oxfmt-${{ matrix.code-target }}.zip $OXFMT_BIN_NAME
# The binaries are zipped to fix permission loss https://github.com/actions/upload-artifact#permission-loss
- name: Archive Binaries
if: runner.os != 'Windows'
run: |
OXFMT_BIN_NAME=oxfmt-${{ matrix.code-target }}
mv target/${{ matrix.target }}/release/oxfmt $OXFMT_BIN_NAME
tar czf $OXFMT_BIN_NAME.tar.gz $OXFMT_BIN_NAME
OXFMT_BIN_NAME=oxfmt.${{ matrix.code-target }}.node
mv apps/oxfmt/src-js/$OXFMT_BIN_NAME .
tar czf oxfmt-${{ matrix.code-target }}.tar.gz $OXFMT_BIN_NAME
- name: Upload Binary
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
@@ -164,6 +181,10 @@ jobs:
- uses: oxc-project/setup-node@fdbf0dfd334c4e6d56ceeb77d91c76339c2a0885 # v1.0.4
- name: Generate oxfmt JS build
working-directory: apps/oxfmt
run: pnpm run build-js
- name: Generate npm packages
run: |
node npm/oxfmt/scripts/generate-packages.js
Generated
+5
View File
@@ -1454,6 +1454,8 @@ dependencies = [
"napi-sys",
"nohash-hasher",
"rustc-hash",
"serde",
"serde_json",
"tokio",
]
@@ -2805,6 +2807,9 @@ dependencies = [
"insta",
"lazy-regex",
"mimalloc-safe",
"napi",
"napi-build",
"napi-derive",
"oxc-miette",
"oxc_allocator 0.96.0",
"oxc_diagnostics 0.96.0",
+6
View File
@@ -0,0 +1,6 @@
/node_modules/
/dist/
*.node
/src-js/bindings.js
/src-js/bindings.d.ts
/src-js/oxfmt
+9 -1
View File
@@ -16,7 +16,7 @@ description.workspace = true
workspace = true
[lib]
crate-type = ["lib"]
crate-type = ["lib", "cdylib"]
path = "src/lib.rs"
doctest = false
@@ -40,6 +40,13 @@ miette = { workspace = true }
rayon = { workspace = true }
tracing-subscriber = { workspace = true, features = [] } # Omit the `regex` feature
# NAPI dependencies (conditional on napi feature)
napi = { version = "3", features = ["napi9", "async", "serde-json"], optional = true }
napi-derive = { version = "3", optional = true }
[build-dependencies]
napi-build = "2.1.3"
[target.'cfg(not(any(target_os = "linux", target_os = "freebsd", target_arch = "arm", target_family = "wasm")))'.dependencies]
mimalloc-safe = { workspace = true, optional = true, features = ["skip_collect_on_exit"] }
@@ -55,4 +62,5 @@ lazy-regex = { workspace = true }
[features]
default = []
napi = ["dep:napi", "dep:napi-derive"]
allocator = ["dep:mimalloc-safe"]
+3
View File
@@ -0,0 +1,3 @@
fn main() {
napi_build::setup();
}
+63
View File
@@ -0,0 +1,63 @@
{
"name": "oxfmt",
"version": "0.5.0",
"type": "module",
"main": "dist/index.js",
"bin": "dist/cli.js",
"types": "dist/index.d.ts",
"scripts": {
"build": "pnpm run build-napi-release && pnpm run build-js",
"build-dev": "pnpm run build-napi && pnpm run build-js",
"build-test": "pnpm run build-napi-test && pnpm run build-js",
"build-napi": "napi build --esm --platform --js ./bindings.js --dts ./bindings.d.ts --output-dir src-js --no-dts-cache --features napi",
"build-napi-test": "pnpm run build-napi",
"build-napi-release": "pnpm run build-napi --release --features napi,allocator",
"build-js": "node scripts/build.js",
"test": "tsc && vitest --dir ./test run"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
},
"description": "Formatter for the JavaScript Oxidation Compiler",
"author": "Boshen and oxc contributors",
"license": "MIT",
"homepage": "https://oxc.rs",
"bugs": "https://github.com/oxc-project/oxc/issues",
"repository": {
"type": "git",
"url": "https://github.com/oxc-project/oxc.git",
"directory": "apps/oxfmt"
},
"publishConfig": {
"registry": "https://registry.npmjs.org/",
"access": "public"
},
"files": [
"dist"
],
"dependencies": {
"prettier": "3.6.2",
"@prettier/sync": "0.6.1"
},
"devDependencies": {
"@types/node": "^24.0.0",
"execa": "^9.6.0",
"tsdown": "^0.15.5",
"typescript": "catalog:",
"vitest": "catalog:"
},
"napi": {
"binaryName": "oxfmt",
"packageName": "@oxfmt/binding",
"targets": [
"win32-x64",
"win32-arm64",
"linux-x64-gnu",
"linux-arm64-gnu",
"linux-x64-musl",
"linux-arm64-musl",
"darwin-x64",
"darwin-arm64"
]
}
}
+32
View File
@@ -0,0 +1,32 @@
import { execSync } from 'node:child_process';
import { copyFileSync, mkdirSync, readdirSync } from 'node:fs';
import { join } from 'node:path';
const oxfmtDirPath = join(import.meta.dirname, '..'),
distDirPath = join(oxfmtDirPath, 'dist');
// Build with tsdown
console.log('Building with tsdown...');
execSync('pnpm tsdown', { stdio: 'inherit', cwd: oxfmtDirPath });
// Copy native `.node` files from `src-js`
console.log('Copying `.node` files...');
for (const filename of readdirSync(join(oxfmtDirPath, 'src-js'))) {
if (!filename.endsWith('.node')) continue;
copyFile(join(oxfmtDirPath, 'src-js', filename), join(distDirPath, filename));
}
console.log('Build complete!');
/**
* Copy a file, creating parent directories if needed.
* @param {string} srcPath - Source file path, absolute
* @param {string} destPath - Destination file path, absolute
* @returns {void}
*/
function copyFile(srcPath, destPath) {
mkdirSync(join(destPath, '..'), { recursive: true });
copyFileSync(srcPath, destPath);
console.log(`- Copied ${srcPath.split('/').pop()}`);
}
+11
View File
@@ -0,0 +1,11 @@
#!/usr/bin/env node
import { format } from './bindings.js';
import { formatEmbeddedCode } from './embedded.js';
const args = process.argv.slice(2);
// Call the Rust formatter with our JS callback
const success = await format(args, formatEmbeddedCode);
process.exit(success ? 0 : 1);
+51
View File
@@ -0,0 +1,51 @@
import prettier from '@prettier/sync';
// Map template tag names to Prettier parsers
const TAG_TO_PARSER: Record<string, string> = {
// CSS
css: 'css',
styled: 'css',
// GraphQL
gql: 'graphql',
graphql: 'graphql',
// HTML
html: 'html',
// Markdown
md: 'markdown',
markdown: 'markdown',
};
/**
* Format embedded code using Prettier (synchronous).
* Note: Called from Rust via NAPI ThreadsafeFunction with FnArgs
* @param tagName - The template tag name (e.g., "css", "gql", "html")
* @param code - The code to format
* @returns Formatted code
*/
export function formatEmbeddedCode(tagName: string, code: string): string {
const parser = TAG_TO_PARSER[tagName];
if (!parser) {
// Unknown tag, return original code
return code;
}
try {
const formatted = prettier.format(code, {
parser,
printWidth: 80,
tabWidth: 2,
semi: true,
singleQuote: false,
});
// Remove trailing newline that Prettier adds
return formatted.trimEnd();
} catch {
// If Prettier fails to format, return original code
return code;
}
}
+2
View File
@@ -0,0 +1,2 @@
export * from './bindings.js';
export { formatEmbeddedCode } from './embedded.js';
+22 -1
View File
@@ -21,6 +21,8 @@ use crate::{
pub struct FormatRunner {
options: FormatCommand,
cwd: PathBuf,
#[cfg(feature = "napi")]
external_formatter: Option<crate::prettier_plugins::ExternalFormatter>,
}
impl FormatRunner {
@@ -29,7 +31,12 @@ impl FormatRunner {
/// # Panics
/// Panics if the current working directory cannot be determined.
pub fn new(options: FormatCommand) -> Self {
Self { options, cwd: env::current_dir().expect("Failed to get current working directory") }
Self {
options,
cwd: env::current_dir().expect("Failed to get current working directory"),
#[cfg(feature = "napi")]
external_formatter: None,
}
}
#[must_use]
@@ -38,6 +45,16 @@ impl FormatRunner {
self
}
#[cfg(feature = "napi")]
#[must_use]
pub fn with_external_formatter(
mut self,
external_formatter: Option<crate::prettier_plugins::ExternalFormatter>,
) -> Self {
self.external_formatter = external_formatter;
self
}
pub fn run(self, stdout: &mut dyn Write) -> CliRunResult {
let start_time = Instant::now();
@@ -100,9 +117,13 @@ impl FormatRunner {
}
let output_options_clone = output_options.clone();
#[cfg(feature = "napi")]
let external_formatter_clone = self.external_formatter;
// Spawn a thread to run formatting service with streaming entries
rayon::spawn(move || {
let format_service = FormatService::new(cwd, output_options_clone, format_options);
#[cfg(feature = "napi")]
let format_service = format_service.with_external_formatter(external_formatter_clone);
format_service.run_streaming(rx_entry, &tx_error, tx_count);
});
+18 -10
View File
@@ -13,15 +13,23 @@ pub mod cli {
};
}
use std::io::BufWriter;
use cli::{CliRunResult, FormatRunner, format_command};
// Only include code to run formatter when the `napi` feature is enabled.
#[cfg(feature = "napi")]
mod prettier_plugins;
#[cfg(feature = "napi")]
mod run;
#[cfg(feature = "napi")]
pub use run::*;
#[cfg(all(feature = "allocator", not(miri), not(target_family = "wasm")))]
#[global_allocator]
static GLOBAL: mimalloc_safe::MiMalloc = mimalloc_safe::MiMalloc;
pub fn format() -> CliRunResult {
use std::io::BufWriter;
use crate::cli::{CliRunResult, FormatRunner, format_command};
pub fn format_cli() -> CliRunResult {
init_tracing();
init_miette();
@@ -55,9 +63,14 @@ pub fn format() -> CliRunResult {
FormatRunner::new(command).run(&mut stdout)
}
/// Initialize the data which relies on `is_atty` system calls so they don't block subsequent threads.
pub(crate) fn init_miette() {
miette::set_hook(Box::new(|_| Box::new(miette::MietteHandlerOpts::new().build()))).unwrap();
}
/// To debug `oxc_formatter`:
/// `OXC_LOG=oxc_formatter oxfmt`
fn init_tracing() {
pub(crate) fn init_tracing() {
use tracing_subscriber::{filter::Targets, prelude::*};
// Usage without the `regex` feature.
@@ -73,8 +86,3 @@ fn init_tracing() {
.with(tracing_subscriber::fmt::layer())
.init();
}
// Initialize the data which relies on `is_atty` system calls so they don't block subsequent threads.
fn init_miette() {
miette::set_hook(Box::new(|_| Box::new(miette::MietteHandlerOpts::new().build()))).unwrap();
}
+2 -2
View File
@@ -1,5 +1,5 @@
use oxfmt::{cli::CliRunResult, format};
use oxfmt::{cli::CliRunResult, format_cli};
fn main() -> CliRunResult {
format()
format_cli()
}
@@ -0,0 +1,96 @@
use std::sync::Arc;
use napi::{
Status,
bindgen_prelude::FnArgs,
threadsafe_function::{ThreadsafeFunction, ThreadsafeFunctionCallMode},
};
use oxc_formatter::EmbeddedFormatterCallback;
/// Type alias for the callback function signature.
/// Takes (tag_name, code) as separate arguments and returns formatted code.
pub type JsFormatEmbeddedCb = ThreadsafeFunction<
// Input arguments
FnArgs<(String, String)>, // (tag_name, code) as separate arguments
// Return type (what JS function returns)
String,
// Arguments (repeated)
FnArgs<(String, String)>,
// Error status
Status,
// CalleeHandled
false,
>;
/// External formatter that wraps a JS callback.
#[derive(Clone)]
pub struct ExternalFormatter {
pub format_embedded: EmbeddedFormatterCallback,
}
impl std::fmt::Debug for ExternalFormatter {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ExternalFormatter").field("format_embedded", &"<callback>").finish()
}
}
impl ExternalFormatter {
pub fn new(format_embedded: EmbeddedFormatterCallback) -> Self {
Self { format_embedded }
}
/// Convert this external formatter to the oxc_formatter::EmbeddedFormatter type
pub fn to_embedded_formatter(&self) -> oxc_formatter::EmbeddedFormatter {
let callback = Arc::clone(&self.format_embedded);
// The callback already expects &str, so just use it directly
oxc_formatter::EmbeddedFormatter::new(callback)
}
}
/// Wrap JS `formatEmbeddedCode` callback as a normal Rust function.
///
/// Uses a channel to capture the result from the JS callback.
pub fn wrap_format_embedded(cb: JsFormatEmbeddedCb) -> EmbeddedFormatterCallback {
let cb = Arc::new(cb);
Arc::new(move |tag_name: &str, code: &str| {
let cb = Arc::clone(&cb);
// Use a channel to capture the result from the JS callback
let (tx, rx) = std::sync::mpsc::channel();
let tag_name_str = tag_name.to_string();
let code_str = code.to_string();
// Call the JS function with separate arguments
let status = cb.call_with_return_value(
FnArgs::from((tag_name_str.clone(), code_str)),
ThreadsafeFunctionCallMode::Blocking,
move |result: Result<String, napi::Error>, _env| {
// Send the result through the channel
let _ = tx.send(result);
Ok(())
},
);
if status != napi::Status::Ok {
return Err(format!(
"Failed to call JS formatter for tag '{tag_name_str}': {status:?}"
));
}
// Wait for the result from the channel
match rx.recv() {
Ok(Ok(formatted)) => Ok(formatted),
Ok(Err(e)) => Err(format!("JS formatter failed for tag '{tag_name_str}': {e}")),
Err(_) => {
Err(format!("Failed to receive result from JS formatter for tag '{tag_name_str}'"))
}
}
})
}
/// Create an [`ExternalFormatter`] from JS callbacks.
pub fn create_external_formatter(format_embedded_cb: JsFormatEmbeddedCb) -> ExternalFormatter {
let rust_format_embedded = wrap_format_embedded(format_embedded_cb);
ExternalFormatter::new(rust_format_embedded)
}
+3
View File
@@ -0,0 +1,3 @@
mod external_formatter;
pub use external_formatter::{ExternalFormatter, JsFormatEmbeddedCb, create_external_formatter};
+56
View File
@@ -0,0 +1,56 @@
use std::{
io::BufWriter,
process::{ExitCode, Termination},
};
use napi_derive::napi;
use crate::{
cli::{CliRunResult, FormatRunner, format_command},
prettier_plugins::{JsFormatEmbeddedCb, create_external_formatter},
};
/// NAPI entry point.
///
/// JS side passes in:
/// 1. `args`: Command line arguments (process.argv.slice(2))
/// 2. `format_embedded_cb`: Callback to format embedded code in templates
///
/// Returns `true` if formatting succeeded without errors, `false` otherwise.
#[expect(clippy::allow_attributes)]
#[allow(clippy::trailing_empty_array, clippy::unused_async)] // https://github.com/napi-rs/napi-rs/issues/2758
#[napi]
pub async fn format(args: Vec<String>, format_embedded_cb: JsFormatEmbeddedCb) -> bool {
format_impl(&args, format_embedded_cb).report() == ExitCode::SUCCESS
}
/// Run the formatter.
fn format_impl(args: &[String], format_embedded_cb: JsFormatEmbeddedCb) -> CliRunResult {
crate::init_tracing();
crate::init_miette();
// Parse command line arguments
let command = match format_command()
.run_inner(args.iter().map(|s| s.as_ref() as &str).collect::<Vec<_>>().as_slice())
{
Ok(cmd) => cmd,
Err(e) => {
e.print_message(100);
return if e.exit_code() == 0 {
CliRunResult::None
} else {
CliRunResult::InvalidOptionConfig
};
}
};
command.handle_threads();
// Create external formatter from JS callback
let external_formatter = create_external_formatter(format_embedded_cb);
// stdio is blocked by LineWriter, use a BufWriter to reduce syscalls.
// See `https://github.com/rust-lang/rust/issues/60673`.
let mut stdout = BufWriter::new(std::io::stdout());
FormatRunner::new(command).with_external_formatter(Some(external_formatter)).run(&mut stdout)
}
+39 -2
View File
@@ -14,6 +14,8 @@ pub struct FormatService {
cwd: Box<Path>,
output_options: OutputOptions,
format_options: FormatOptions,
#[cfg(feature = "napi")]
external_formatter: Option<crate::prettier_plugins::ExternalFormatter>,
}
impl FormatService {
@@ -21,9 +23,29 @@ impl FormatService {
where
T: Into<Box<Path>>,
{
Self { cwd: cwd.into(), output_options, format_options }
Self {
cwd: cwd.into(),
output_options,
format_options,
#[cfg(feature = "napi")]
external_formatter: None,
}
}
#[cfg(feature = "napi")]
#[must_use]
pub fn with_external_formatter(
mut self,
external_formatter: Option<crate::prettier_plugins::ExternalFormatter>,
) -> Self {
self.external_formatter = external_formatter;
self
}
// TODO: Support reading from stdin for formatting, similar to `prettier --stdin-filepath <path>`
// This would allow formatting code from stdin and optionally specifying the file path for correct
// syntax detection (e.g., `echo "const x=1" | oxfmt --stdin-filepath file.js`)
/// Process entries as they are received from the channel
#[expect(clippy::needless_pass_by_value)]
pub fn run_streaming(
@@ -66,7 +88,22 @@ impl FormatService {
return;
}
let code = Formatter::new(&allocator, self.format_options.clone()).build(&ret.program);
let embedded_formatter = {
#[cfg(feature = "napi")]
{
self.external_formatter
.as_ref()
.map(crate::prettier_plugins::ExternalFormatter::to_embedded_formatter)
}
#[cfg(not(feature = "napi"))]
{
None
}
};
let code = Formatter::new(&allocator, self.format_options.clone())
.with_embedded_formatter(embedded_formatter)
.build(&ret.program);
let elapsed = start_time.elapsed();
let is_changed = source_text != code;
+49
View File
@@ -0,0 +1,49 @@
import fs from 'node:fs/promises';
import { join as pathJoin } from 'node:path';
import { describe, it } from 'vitest';
import { PACKAGE_ROOT_PATH, testFixtureWithCommand } from './utils.js';
const CLI_PATH = pathJoin(PACKAGE_ROOT_PATH, 'dist/cli.js');
// Options to pass to `testFixture`.
interface TestOptions {
// Arguments to pass to the CLI.
args?: string[];
// Name of the snapshot file.
// Defaults to `output`.
// Supply a different name when there are multiple tests for a single fixture.
snapshotName?: string;
// Function to get extra data to include in the snapshot
getExtraSnapshotData?: (dirPath: string) => Promise<{ [key: string]: string }>;
}
/**
* Run a test fixture.
* @param fixtureName - Name of the fixture directory within `test/fixtures`
* @param options - Options to customize the test (optional)
*/
async function testFixture(fixtureName: string, options?: TestOptions): Promise<void> {
const args = options?.args ?? [];
await testFixtureWithCommand({
// Use current NodeJS executable, rather than `node`, to avoid problems with a Node version manager
// installed on system resulting in using wrong NodeJS version
command: process.execPath,
args: [CLI_PATH, ...args, 'files'],
fixtureName,
snapshotName: options?.snapshotName ?? 'output',
getExtraSnapshotData: options?.getExtraSnapshotData,
});
}
describe('oxfmt NAPI - Embedded Language Formatting', () => {
// oxlint-disable-next-line vitest/expect-expect -- Assertion is inside testFixtureWithCommand helper
it('should format embedded languages (CSS, GraphQL, HTML, Markdown)', async () => {
await testFixture('embedded_languages', {
getExtraSnapshotData: async (dirPath) => {
const formattedCode = await fs.readFile(pathJoin(dirPath, 'files/index.js'), 'utf-8');
return { 'Formatted Output': formattedCode };
},
});
});
});
@@ -0,0 +1,113 @@
// ============================================================================
// CSS - Tagged template literals with css and styled tags
// ============================================================================
const styles = css`.button{color:red;background:blue;padding:10px 20px;}.container{display:flex;justify-content:center;}`;
const styledComponent = styled`background-color:#ffffff;border-radius:4px;padding:8px;`;
// ============================================================================
// GraphQL - Tagged template literals with gql and graphql tags
// ============================================================================
const query = gql`query GetUser($id:ID!){user(id:$id){name email posts{title}}}`;
const mutation = graphql`mutation CreatePost($input:PostInput!){createPost(input:$input){id title}}`;
// ============================================================================
// HTML - Tagged template literals with html tag
// ============================================================================
const template = html`<div class="container"><h1>Hello World</h1><p>This is a paragraph with <strong>bold</strong> text.</p></div>`;
const component = html`<button type="button" onclick="handleClick()">Click me</button>`;
// ============================================================================
// Markdown - Tagged template literals with md and markdown tags
// ============================================================================
const documentation = md`#Heading
This is **bold** and this is _italic_.
-Item 1
-Item 2`;
const readme = markdown`##Installation
\`\`\`bash
npm install package
\`\`\``;
// ============================================================================
// Mixed - Multiple embedded languages in one file
// ============================================================================
const mixedStyles = css`.button{color:red;}`;
const mixedQuery = gql`query{users{name}}`;
const mixedTemplate = html`<div><h1>Title</h1></div>`;
const mixedDocs = md`#Documentation
This is **important**.`;
// ============================================================================
// No Embedded Languages - Regular JavaScript (no tagged templates)
// ============================================================================
function greet(name) {
return `Hello, ${name}!`;
}
const message = `This is a regular template string`;
class Formatter {
format(text) {
return text.trim();
}
}
// ============================================================================
// prettier-ignore - Skip formatting with prettier-ignore comments
// ============================================================================
// prettier-ignore
const unformatted = css`.button{color:red;background:blue;border:1px solid green;}`;
const formattedCss = css`.container{display:flex;align-items:center;}`;
// prettier-ignore
const ignoredGql = gql`query GetUser($id:ID!){user(id:$id){name email}}`;
const normalGql = gql`query GetPosts{posts{title author}}`;
// ============================================================================
// Unsupported Tags - Tags not recognized by the formatter
// ============================================================================
const unknown = customTag`
This is some content that won't be formatted
because customTag is not recognized.
`;
const sql = sql`
SELECT * FROM users WHERE id = 1
`;
@@ -0,0 +1,200 @@
# Exit code
0
# stdout
```
files/index.js Xms
Formatted 1 files.
Finished in Xms on 1 files using X threads.
```
# stderr
```
```
# Formatted Output
```
// ============================================================================
// CSS - Tagged template literals with css and styled tags
// ============================================================================
const styles = css`
.button {
color: red;
background: blue;
padding: 10px 20px;
}
.container {
display: flex;
justify-content: center;
}
`;
const styledComponent = styled`
background-color: #ffffff;
border-radius: 4px;
padding: 8px;
`;
// ============================================================================
// GraphQL - Tagged template literals with gql and graphql tags
// ============================================================================
const query = gql`
query GetUser($id: ID!) {
user(id: $id) {
name
email
posts {
title
}
}
}
`;
const mutation = graphql`
mutation CreatePost($input: PostInput!) {
createPost(input: $input) {
id
title
}
}
`;
// ============================================================================
// HTML - Tagged template literals with html tag
// ============================================================================
const template = html`
<div class="container">
<h1>Hello World</h1>
<p>This is a paragraph with <strong>bold</strong> text.</p>
</div>
`;
const component = html`
<button type="button" onclick="handleClick()">Click me</button>
`;
// ============================================================================
// Markdown - Tagged template literals with md and markdown tags
// ============================================================================
const documentation = md`
#Heading
This is **bold** and this is _italic_.
-Item 1
-Item 2
`;
const readme = markdown`
##Installation
\`\`\`bash
npm install package
\`\`\`
`;
// ============================================================================
// Mixed - Multiple embedded languages in one file
// ============================================================================
const mixedStyles = css`
.button {
color: red;
}
`;
const mixedQuery = gql`
query {
users {
name
}
}
`;
const mixedTemplate = html`
<div><h1>Title</h1></div>
`;
const mixedDocs = md`
#Documentation
This is **important**.
`;
// ============================================================================
// No Embedded Languages - Regular JavaScript (no tagged templates)
// ============================================================================
function greet(name) {
return `Hello, ${name}!`;
}
const message = `This is a regular template string`;
class Formatter {
format(text) {
return text.trim();
}
}
// ============================================================================
// prettier-ignore - Skip formatting with prettier-ignore comments
// ============================================================================
// prettier-ignore
const unformatted = css`.button{color:red;background:blue;border:1px solid green;}`;
const formattedCss = css`
.container {
display: flex;
align-items: center;
}
`;
// prettier-ignore
const ignoredGql = gql`query GetUser($id:ID!){user(id:$id){name email}}`;
const normalGql = gql`
query GetPosts {
posts {
title
author
}
}
`;
// ============================================================================
// Unsupported Tags - Tags not recognized by the formatter
// ============================================================================
const unknown = customTag`
This is some content that won't be formatted
because customTag is not recognized.
`;
const sql = sql`
SELECT * FROM users WHERE id = 1
`;
```
+114
View File
@@ -0,0 +1,114 @@
import fs from 'node:fs/promises';
import os from 'node:os';
import { join as pathJoin } from 'node:path';
import { execa } from 'execa';
import { expect } from 'vitest';
export const PACKAGE_ROOT_PATH = pathJoin(import.meta.dirname, '..');
export const FIXTURES_DIR_PATH = pathJoin(import.meta.dirname, 'fixtures');
const REPO_ROOT_PATH = pathJoin(PACKAGE_ROOT_PATH, '../../');
// Options to pass to `testFixtureWithCommand`.
interface TestFixtureOptions {
// Command
command: string;
// Arguments to execute command with
args: string[];
// Fixture name
fixtureName: string;
// Name of the snapshot file
snapshotName: string;
// Function to get extra data to include in the snapshot
getExtraSnapshotData?: (dirPath: string) => Promise<{ [key: string]: string }>;
}
/**
* Run a test fixture.
* @param options - Options for running the test
*/
export async function testFixtureWithCommand(options: TestFixtureOptions): Promise<void> {
const fixtureDirPath = pathJoin(FIXTURES_DIR_PATH, options.fixtureName);
const sourceFilesDir = pathJoin(fixtureDirPath, 'files');
// Create a temporary directory to avoid modifying the original fixture files
const tempDir = await fs.mkdtemp(pathJoin(os.tmpdir(), 'oxfmt-test-'));
const tempFilesDir = pathJoin(tempDir, 'files');
try {
// TODO: use stdin instead of copy to tmp dir.
// Copy fixture files to temp directory
await fs.cp(sourceFilesDir, tempFilesDir, { recursive: true });
let { stdout, stderr, exitCode } = await execa(options.command, options.args, {
cwd: tempDir,
reject: false,
});
const snapshotPath = pathJoin(fixtureDirPath, `${options.snapshotName}.snap.md`);
stdout = normalizeStdout(stdout);
stderr = normalizeStdout(stderr);
let snapshot =
`# Exit code\n${exitCode}\n\n` + `# stdout\n\`\`\`\n${stdout}\`\`\`\n\n` + `# stderr\n\`\`\`\n${stderr}\`\`\`\n`;
if (options.getExtraSnapshotData) {
const extraSnapshots = await options.getExtraSnapshotData(tempDir);
for (const [name, data] of Object.entries(extraSnapshots)) {
snapshot += `\n# ${name}\n\`\`\`\n${data}\n\`\`\`\n`;
}
}
await expect(snapshot).toMatchFileSnapshot(snapshotPath);
} finally {
// Clean up temp directory
await fs.rm(tempDir, { recursive: true, force: true });
}
}
/**
* Normalize output, so it's the same on every machine.
*
* - Remove timing + thread count info.
* - Replace start of file paths with `<root>`.
* - Normalize line breaks.
*
* @param stdout Output from process
* @returns Normalized output
*/
function normalizeStdout(stdout: string): string {
// Normalize line breaks, and trim line breaks from start and end
stdout = stdout.replace(/\r\n?/g, '\n').replace(/^\n+/, '').replace(/\n+$/, '');
if (stdout === '') return '';
let lines = stdout.split('\n');
// Remove timing and thread count info which can vary between runs
// Match patterns like "Finished in 123ms on 5 files using 8 threads."
// and per-file timings like "files/index.js 23ms (unchanged)" or "files/index.js (23ms)"
lines = lines.map((line) => {
// Normalize the summary line
line = line.replace(
/^Finished in \d+(?:\.\d+)?(?:s|ms|us|ns) on (\d+) file(s?) using \d+ threads\.$/,
'Finished in Xms on $1 file$2 using X threads.',
);
// Normalize per-file timing: "files/index.js 23ms" -> "files/index.js Xms"
line = line.replace(/^(.*?)\s+\d+(?:\.\d+)?(?:s|ms|us|ns)(\s|$)/, '$1 Xms$2');
// Normalize check mode timing: "files/index.js (23ms)" -> "files/index.js (Xms)"
line = line.replace(/\((\d+(?:\.\d+)?(?:s|ms|us|ns))\)/g, '(Xms)');
return line;
});
// Replace absolute paths with <root>
lines = lines.map((line) => {
if (line.startsWith(REPO_ROOT_PATH)) {
return `<root>/${line.slice(REPO_ROOT_PATH.length).replace(/\\/g, '/')}`;
}
return line;
});
if (lines.length === 0) return '';
return lines.join('\n') + '\n';
}
+13
View File
@@ -0,0 +1,13 @@
{
"compilerOptions": {
"module": "Preserve",
"moduleResolution": "Bundler",
"noEmit": true,
"target": "ESNext",
"noImplicitAny": true,
"skipLibCheck": true
},
"include": [
"src-js"
]
}
+12
View File
@@ -0,0 +1,12 @@
import { defineConfig } from 'tsdown';
export default defineConfig({
entry: ['src-js/index.ts', 'src-js/cli.ts'],
format: 'esm',
platform: 'node',
target: 'node20',
dts: true,
clean: true,
outDir: 'dist',
shims: false,
});
+7
View File
@@ -0,0 +1,7 @@
import { configDefaults, defineConfig } from 'vitest/config';
export default defineConfig({
test: {
exclude: [...configDefaults.exclude, 'fixtures/**', 'test/fixtures/**'],
},
});
@@ -0,0 +1,48 @@
use std::sync::Arc;
/// Callback function type for formatting embedded code.
/// Takes (tag_name, code) and returns formatted code or an error.
pub type EmbeddedFormatterCallback =
Arc<dyn Fn(&str, &str) -> Result<String, String> + Send + Sync>;
/// Formatter for embedded languages in template literals.
///
/// This allows formatting code embedded in template literals like:
/// - CSS in `css\`...\``
/// - GraphQL in `gql\`...\``
/// - HTML in `html\`...\``
#[derive(Clone)]
pub struct EmbeddedFormatter {
callback: EmbeddedFormatterCallback,
}
/// See <apps/oxfmt/src-js/embedded.ts> for supported tags.
const SUPPORTED_TAGS: &[&str] = &["css", "styled", "gql", "graphql", "html", "md", "markdown"];
impl EmbeddedFormatter {
/// Create a new embedded formatter with the given callback.
pub fn new(callback: EmbeddedFormatterCallback) -> Self {
Self { callback }
}
/// Check if the given tag name is supported for embedded formatting.
pub fn is_supported_tag(tag_name: &str) -> bool {
SUPPORTED_TAGS.contains(&tag_name)
}
/// Format embedded code with the given tag name.
///
/// # Arguments
/// * `tag_name` - The template tag (e.g., "css", "gql", "html")
/// * `code` - The code to format
///
/// # Returns
/// * `Ok(String)` - The formatted code
/// * `Err(String)` - An error message if formatting failed
///
/// # Errors
/// Returns an error if the embedded formatter fails to format the code
pub fn format(&self, tag_name: &str, code: &str) -> Result<String, String> {
(self.callback)(tag_name, code)
}
}
+17 -1
View File
@@ -8,7 +8,10 @@ use oxc_ast::{
use oxc_span::{GetSpan, SourceType, Span};
use rustc_hash::FxHashMap;
use crate::{ast_nodes::AstNode, formatter::FormatElement, options::FormatOptions};
use crate::{
ast_nodes::AstNode, embedded_formatter::EmbeddedFormatter, formatter::FormatElement,
options::FormatOptions,
};
use super::{Comments, SourceText};
@@ -26,6 +29,8 @@ pub struct FormatContext<'ast> {
cached_elements: FxHashMap<Span, FormatElement<'ast>>,
allocator: &'ast Allocator,
embedded_formatter: Option<EmbeddedFormatter>,
}
impl std::fmt::Debug for FormatContext<'_> {
@@ -56,9 +61,20 @@ impl<'ast> FormatContext<'ast> {
comments: Comments::new(source_text, comments),
allocator,
cached_elements: FxHashMap::default(),
embedded_formatter: None,
}
}
/// Set the embedded formatter for handling embedded languages
pub fn set_embedded_formatter(&mut self, embedded_formatter: Option<EmbeddedFormatter>) {
self.embedded_formatter = embedded_formatter;
}
/// Get the embedded formatter if one is set
pub fn embedded_formatter(&self) -> Option<&EmbeddedFormatter> {
self.embedded_formatter.as_ref()
}
/// Returns the formatting options
pub fn options(&self) -> &FormatOptions {
&self.options
+17 -2
View File
@@ -12,6 +12,7 @@
mod ast_nodes;
#[cfg(feature = "detect_code_removal")]
mod detect_code_removal;
mod embedded_formatter;
mod formatter;
mod ir_transform;
mod options;
@@ -33,6 +34,7 @@ use oxc_ast::{AstKind, ast::*};
use rustc_hash::{FxHashMap, FxHashSet};
use write::FormatWrite;
pub use crate::embedded_formatter::{EmbeddedFormatter, EmbeddedFormatterCallback};
pub use crate::options::*;
pub use crate::service::{oxfmtrc::Oxfmtrc, parse_utils::*};
use crate::{
@@ -47,11 +49,22 @@ pub struct Formatter<'a> {
allocator: &'a Allocator,
source_text: &'a str,
options: FormatOptions,
embedded_formatter: Option<EmbeddedFormatter>,
}
impl<'a> Formatter<'a> {
pub fn new(allocator: &'a Allocator, options: FormatOptions) -> Self {
Self { allocator, source_text: "", options }
Self { allocator, source_text: "", options, embedded_formatter: None }
}
/// Set the embedded formatter for handling embedded languages in templates
#[must_use]
pub fn with_embedded_formatter(
mut self,
embedded_formatter: Option<EmbeddedFormatter>,
) -> Self {
self.embedded_formatter = embedded_formatter;
self
}
/// Formats the given AST `Program` and returns the formatted string.
@@ -69,13 +82,15 @@ impl<'a> Formatter<'a> {
let experimental_sort_imports = self.options.experimental_sort_imports;
let context = FormatContext::new(
let mut context = FormatContext::new(
program.source_text,
program.source_type,
&program.comments,
self.allocator,
self.options,
);
context.set_embedded_formatter(self.embedded_formatter);
let mut formatted = formatter::format(
context,
formatter::Arguments::new(&[formatter::Argument::new(&program_node)]),
+50 -2
View File
@@ -8,7 +8,7 @@ use oxc_span::{GetSpan, Span};
use oxc_syntax::identifier::is_line_terminator;
use crate::{
IndentWidth,
EmbeddedFormatter, IndentWidth,
ast_nodes::{AstNode, AstNodeIterator},
format, format_args,
formatter::{
@@ -43,7 +43,9 @@ impl<'a> FormatWrite<'a> for AstNode<'a, TaggedTemplateExpression<'a>> {
quasi.format_leading_comments(f);
if is_test_each_pattern(&self.tag) {
if let Some(result) = try_format_embedded_template(self, f) {
result
} else if is_test_each_pattern(&self.tag) {
let template = &EachTemplateTable::from_template(quasi, f)?;
// Use table formatting
write!(f, template)
@@ -694,3 +696,49 @@ impl<'a> Format<'a> for EachTemplateTable<'a> {
write!(f, ["`", indent(&format_args!(table_content)), hard_line_break(), "`"])
}
}
/// Try to format a tagged template with the embedded formatter if supported.
/// Returns `Some(result)` if formatting was attempted, `None` if not applicable.
fn try_format_embedded_template<'a>(
tagged: &AstNode<'a, TaggedTemplateExpression<'a>>,
f: &mut Formatter<'_, 'a>,
) -> Option<FormatResult<()>> {
let quasi = &tagged.quasi;
if !quasi.is_no_substitution_template() {
return None;
}
let Expression::Identifier(ident) = &tagged.tag else {
return None;
};
let tag_name = ident.name.as_str();
// Check if the tag is supported by the embedded formatter
if !EmbeddedFormatter::is_supported_tag(tag_name) {
return None;
}
// Get the embedded formatter from the context
let embedded_formatter = f.context().embedded_formatter()?;
let template_content = quasi.quasis[0].value.raw.as_str();
let Ok(formatted) = embedded_formatter.format(tag_name, template_content) else {
return None;
};
// Format with proper template literal structure:
// - Opening backtick
// - Hard line break (newline after backtick)
// - Indented content (each line will be indented)
// - Hard line break (newline before closing backtick)
// - Closing backtick
let format_content = format_once(|f: &mut Formatter<'_, 'a>| {
let content = f.context().allocator().alloc_str(&formatted);
for line in content.split('\n') {
write!(f, [dynamic_text(line), hard_line_break()])?;
}
Ok(())
});
Some(write!(f, ["`", block_indent(&format_content), "`"]))
}
+6 -1
View File
@@ -167,7 +167,12 @@ alias new-typescript-rule := new-ts-rule
# ==================== FORMATTER ====================
# Formatter-specific commands will be added here as needed
# Build oxfmt in release build
oxfmt:
pnpm -C apps/oxfmt run build
watch-oxfmt *args='':
just watch 'pnpm run -C apps/oxfmt build-dev && node apps/oxfmt/dist/cli.js {{args}}'
# ==================== TRANSFORMER ====================
+47
View File
@@ -57,6 +57,31 @@ importers:
specifier: 'catalog:'
version: 4.0.3(@types/node@24.9.1)(@vitest/browser-playwright@4.0.3)(jiti@2.6.1)
apps/oxfmt:
dependencies:
'@prettier/sync':
specifier: 0.6.1
version: 0.6.1(prettier@3.6.2)
prettier:
specifier: 3.6.2
version: 3.6.2
devDependencies:
'@types/node':
specifier: ^24.0.0
version: 24.9.1
execa:
specifier: ^9.6.0
version: 9.6.0
tsdown:
specifier: ^0.15.5
version: 0.15.10(@arethetypeswrong/core@0.18.2)(publint@0.3.15)(typescript@5.9.3)
typescript:
specifier: 'catalog:'
version: 5.9.3
vitest:
specifier: 'catalog:'
version: 4.0.3(@types/node@24.9.1)(@vitest/browser-playwright@4.0.3)(jiti@2.6.1)
apps/oxlint:
devDependencies:
'@types/esquery':
@@ -1579,6 +1604,11 @@ packages:
'@polka/url@1.0.0-next.29':
resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==}
'@prettier/sync@0.6.1':
resolution: {integrity: sha512-yF9G8vK/LYUTF3Cijd7VC9La3b20F20/J/fgoR4H0B8JGOWnZVZX6+I6+vODPosjmMcpdlUV+gUqJQZp3kLOcw==}
peerDependencies:
prettier: '*'
'@publint/pack@0.1.2':
resolution: {integrity: sha512-S+9ANAvUmjutrshV4jZjaiG8XQyuJIZ8a4utWmN/vW1sgQ9IfBnPndwkmQYw53QmouOIytT874u65HEmu6H5jw==}
engines: {node: '>=18'}
@@ -3064,6 +3094,9 @@ packages:
resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==}
engines: {node: '>=10'}
make-synchronized@0.8.0:
resolution: {integrity: sha512-DZu4lwc0ffoFz581BSQa/BJl+1ZqIkoRQ+VejMlH0VrP4E86StAODnZujZ4sepumQj8rcP7wUnUBGM8Gu+zKUA==}
markdown-it@14.1.0:
resolution: {integrity: sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==}
hasBin: true
@@ -3383,6 +3416,11 @@ packages:
resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
engines: {node: '>= 0.8.0'}
prettier@3.6.2:
resolution: {integrity: sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==}
engines: {node: '>=14'}
hasBin: true
pretty-ms@9.3.0:
resolution: {integrity: sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==}
engines: {node: '>=18'}
@@ -5273,6 +5311,11 @@ snapshots:
'@polka/url@1.0.0-next.29': {}
'@prettier/sync@0.6.1(prettier@3.6.2)':
dependencies:
make-synchronized: 0.8.0
prettier: 3.6.2
'@publint/pack@0.1.2': {}
'@quansync/fs@0.1.5':
@@ -6769,6 +6812,8 @@ snapshots:
dependencies:
semver: 7.7.3
make-synchronized@0.8.0: {}
markdown-it@14.1.0:
dependencies:
argparse: 2.0.1
@@ -7113,6 +7158,8 @@ snapshots:
prelude-ls@1.2.1: {}
prettier@3.6.2: {}
pretty-ms@9.3.0:
dependencies:
parse-ms: 4.0.0