Skip to content

Commit 8ce1890

Browse files
authored
feat: easier API for using multiple plugins (#18)
1 parent d6d3997 commit 8ce1890

9 files changed

Lines changed: 454 additions & 86 deletions

File tree

.github/workflows/ci.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ jobs:
1010
- uses: actions/checkout@v6
1111
- uses: denoland/setup-deno@v2
1212
- name: Test
13-
run: deno test --allow-net --allow-read=.
13+
run: deno test -P
1414
- name: Publish on tag
1515
run: deno run -A jsr:@david/publish-on-tag@0.2.0
1616
- name: Get tag version

README.md

Lines changed: 26 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ JS formatter for dprint Wasm plugins.
1111
Deno:
1212

1313
```sh
14-
deno add @dprint/formatter
14+
deno add npm:@dprint/formatter
1515
```
1616

1717
Node.js:
@@ -22,44 +22,41 @@ npm i @dprint/formatter
2222

2323
## Use
2424

25-
```ts
26-
import { createStreaming, GlobalConfiguration } from "@dprint/formatter";
25+
The context API allows you to manage multiple plugins with shared configuration and automatic plugin selection based on file type:
2726

28-
const globalConfig: GlobalConfiguration = {
27+
```ts
28+
import { createContext } from "@dprint/formatter";
29+
import * as json from "@dprint/json";
30+
import * as markdown from "@dprint/markdown";
31+
import * as typescript from "@dprint/typescript";
32+
import fs from "node:fs";
33+
34+
const context = createContext({
35+
// global config
2936
indentWidth: 2,
3037
lineWidth: 80,
31-
};
32-
const tsFormatter = await createStreaming(
33-
// check https://plugins.dprint.dev/ for latest plugin versions
34-
fetch("https://plugins.dprint.dev/typescript-0.57.0.wasm"),
35-
);
38+
});
3639

37-
tsFormatter.setConfig(globalConfig, {
40+
// note: some plugins might have a getBuffer() export instead
41+
context.addPlugin(fs.readFileSync(typescript.getPath()), {
3842
semiColons: "asi",
3943
});
44+
context.addPlugin(fs.readFileSync(json.getPath()));
45+
context.addPlugin(fs.readFileSync(markdown.getPath()));
4046

41-
// outputs: "const t = 5\n"
42-
console.log(tsFormatter.formatText({
43-
filePath: "file.ts",
44-
fileText: "const t = 5;",
47+
console.log(context.formatText({
48+
filePath: "config.json",
49+
fileText: "{\"a\":1}",
4550
}));
46-
```
47-
48-
Using with plugins on npm (ex. [@dprint/json](https://www.npmjs.com/package/@dprint/json)):
4951

50-
```ts
51-
import { createFromBuffer } from "@dprint/formatter";
52-
import { getBuffer } from "@dprint/json";
53-
import * as fs from "node:fs";
54-
55-
const formatter = createFromBuffer(getBuffer());
56-
57-
console.log(formatter.formatText({
58-
filePath: "test.json",
59-
fileText: "{test: 5}",
52+
console.log(context.formatText({
53+
filePath: "app.ts",
54+
fileText: "const x=1",
6055
}));
6156
```
6257

58+
The context also handles host formatting automatically, so embedded code blocks (like JSON in Markdown) will be formatted by the appropriate plugin.
59+
6360
### Plugin NPM Packages
6461

6562
Note: In the future I will ensure plugins are published to JSR as well.
@@ -70,4 +67,6 @@ Note: In the future I will ensure plugins are published to JSR as well.
7067
- [@dprint/toml](https://www.npmjs.com/package/@dprint/toml)
7168
- [@dprint/dockerfile](https://www.npmjs.com/package/@dprint/dockerfile)
7269
- [@dprint/biome](https://www.npmjs.com/package/@dprint/biome)
70+
- [@dprint/oxc](https://www.npmjs.com/package/@dprint/oxc)
71+
- [@dprint/mago](https://www.npmjs.com/package/@dprint/mago)
7372
- [@dprint/ruff](https://www.npmjs.com/package/@dprint/ruff)

common.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,14 +47,18 @@ export interface Formatter {
4747
* Gets the license text of the plugin.
4848
*/
4949
getLicenseText(): string;
50+
/**
51+
* Sets the host formatter for formatting embedded code.
52+
* @param formatWithHost - A function that formats embedded code blocks.
53+
*/
54+
setHostFormatter(formatWithHost: ((request: FormatRequest) => string) | undefined): void;
5055
/**
5156
* Formats the specified file text.
5257
* @param request - Data to format.
53-
* @param formatWithHost - Host formatter.
5458
* @returns The formatted text.
5559
* @throws If there is an error formatting.
5660
*/
57-
formatText(request: FormatRequest, formatWithHost?: (request: FormatRequest) => string): string;
61+
formatText(request: FormatRequest): string;
5862
}
5963

6064
/** Configuration specified for use across plugins. */

deno.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,16 @@
44
"tasks": {
55
"build:npm": "deno run -A ./scripts/build_npm.ts"
66
},
7+
"test": {
8+
"permissions": {
9+
"read": true
10+
}
11+
},
712
"imports": {
813
"@deno/dnt": "jsr:@deno/dnt@^0.42.3",
14+
"@dprint/json": "npm:@dprint/json@^0.21.1",
15+
"@dprint/markdown": "npm:@dprint/markdown@^0.20.0",
16+
"@dprint/typescript": "npm:@dprint/typescript@^0.95.13",
917
"@std/assert": "jsr:@std/assert@1"
1018
},
1119
"publish": {

deno.lock

Lines changed: 19 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

mod.ts

Lines changed: 197 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { Formatter } from "./common.ts";
1+
import type { ConfigurationDiagnostic, FormatRequest, Formatter, GlobalConfiguration } from "./common.ts";
22
import * as v3 from "./v3.ts";
33
import * as v4 from "./v4.ts";
44

@@ -12,6 +12,195 @@ export type {
1212
PluginInfo,
1313
} from "./common.ts";
1414

15+
/** A registered plugin with its configuration. */
16+
interface RegisteredPlugin {
17+
formatter: Formatter;
18+
pluginConfig: Record<string, unknown>;
19+
fileExtensions: Set<string>;
20+
fileNames: Set<string>;
21+
}
22+
23+
/** A formatter returned from adding a plugin to a context. */
24+
export interface ContextFormatter {
25+
/** Formats the specified file text using this plugin. */
26+
formatText(request: FormatRequest): string;
27+
/** Gets the resolved configuration for this plugin. */
28+
getResolvedConfig(): Record<string, unknown>;
29+
/** Gets the configuration diagnostics for this plugin. */
30+
getConfigDiagnostics(): ConfigurationDiagnostic[];
31+
}
32+
33+
/** A context for managing multiple formatters with shared configuration. */
34+
export interface FormatterContext {
35+
/**
36+
* Adds a plugin to the context.
37+
* @param source - The buffer or Wasm module of the plugin (e.g., from `@dprint/json` getBuffer()).
38+
* @param param - Plugin config.
39+
* @returns A formatter for directly formatting with this plugin.
40+
*/
41+
addPlugin(
42+
source: BufferSource | WebAssembly.Module,
43+
pluginConfig?: Record<string, unknown>,
44+
): ContextFormatter;
45+
46+
/**
47+
* Adds a plugin to the context.
48+
* @param source - Source response object.
49+
* @param pluginConfig - Plugin config.
50+
* @returns A formatter for directly formatting with this plugin.
51+
*/
52+
addPluginStreaming(
53+
source: ResponseLike,
54+
pluginConfig?: Record<string, unknown>,
55+
): Promise<ContextFormatter>;
56+
57+
/**
58+
* Formats the specified file text, automatically selecting the appropriate plugin.
59+
* @param request - Data to format.
60+
* @returns The formatted text.
61+
* @throws If no plugin matches the file or there is an error formatting.
62+
*/
63+
formatText(request: FormatRequest): string;
64+
65+
/**
66+
* Gets all configuration diagnostics from all plugins.
67+
*/
68+
getConfigDiagnostics(): ConfigurationDiagnostic[];
69+
}
70+
71+
/**
72+
* Creates a formatter context for managing multiple plugins with shared configuration.
73+
* @param globalConfig - Global configuration shared across all plugins.
74+
*/
75+
export function createContext(globalConfig: GlobalConfiguration = {}): FormatterContext {
76+
const plugins: RegisteredPlugin[] = [];
77+
78+
function findPluginForFile(filePath: string): RegisteredPlugin | undefined {
79+
const fileName = getFileName(filePath);
80+
const ext = getFileExtension(filePath);
81+
82+
// First try to match by exact file name
83+
for (const plugin of plugins) {
84+
if (plugin.fileNames.has(fileName)) {
85+
return plugin;
86+
}
87+
}
88+
89+
// Then try to match by extension
90+
if (ext) {
91+
for (const plugin of plugins) {
92+
if (plugin.fileExtensions.has(ext)) {
93+
return plugin;
94+
}
95+
}
96+
}
97+
98+
return undefined;
99+
}
100+
101+
function createHostFormatter(
102+
currentPlugin: RegisteredPlugin,
103+
): (request: FormatRequest) => string {
104+
return (request: FormatRequest) => {
105+
const plugin = findPluginForFile(request.filePath);
106+
if (plugin && plugin !== currentPlugin) {
107+
return plugin.formatter.formatText(request);
108+
}
109+
// Return unchanged if no other plugin matches
110+
return request.fileText;
111+
};
112+
}
113+
114+
return {
115+
async addPluginStreaming(source: ResponseLike, pluginConfig?: Record<string, unknown>) {
116+
const wasmModule = await createWasmModuleFromStreaming(source);
117+
return this.addPlugin(wasmModule, pluginConfig);
118+
},
119+
addPlugin(
120+
source: BufferSource | WebAssembly.Module,
121+
pluginConfig: Record<string, unknown> = {},
122+
): ContextFormatter {
123+
const formatter = source instanceof WebAssembly.Module
124+
? createFromWasmModule(source)
125+
: createFromBuffer(source);
126+
127+
// Set configuration
128+
formatter.setConfig(globalConfig, pluginConfig);
129+
130+
// Get file matching info
131+
const matchingInfo = formatter.getFileMatchingInfo();
132+
const fileExtensions = new Set(
133+
matchingInfo.fileExtensions.map((ext) => ext.toLowerCase()),
134+
);
135+
const fileNames = new Set(
136+
matchingInfo.fileNames.map((name) => name.toLowerCase()),
137+
);
138+
139+
const registered: RegisteredPlugin = {
140+
formatter,
141+
pluginConfig,
142+
fileExtensions,
143+
fileNames,
144+
};
145+
146+
plugins.push(registered);
147+
148+
// Set up host formatter for this plugin
149+
formatter.setHostFormatter(createHostFormatter(registered));
150+
151+
// Return a context-aware formatter
152+
return {
153+
formatText(request: FormatRequest): string {
154+
return formatter.formatText(request);
155+
},
156+
getResolvedConfig(): Record<string, unknown> {
157+
return formatter.getResolvedConfig();
158+
},
159+
getConfigDiagnostics(): ConfigurationDiagnostic[] {
160+
return formatter.getConfigDiagnostics();
161+
},
162+
};
163+
},
164+
165+
formatText(request: FormatRequest): string {
166+
const plugin = findPluginForFile(request.filePath);
167+
if (!plugin) {
168+
throw new Error(
169+
`No plugin found for file: ${request.filePath}. `
170+
+ `Registered plugins handle: ${
171+
plugins
172+
.map((p) => [...p.fileExtensions].join(", "))
173+
.join("; ")
174+
}`,
175+
);
176+
}
177+
return plugin.formatter.formatText(request);
178+
},
179+
180+
getConfigDiagnostics(): ConfigurationDiagnostic[] {
181+
return plugins.flatMap((p) => p.formatter.getConfigDiagnostics());
182+
},
183+
};
184+
}
185+
186+
function getFileName(filePath: string): string {
187+
const lastSlash = Math.max(
188+
filePath.lastIndexOf("/"),
189+
filePath.lastIndexOf("\\"),
190+
);
191+
return (lastSlash >= 0 ? filePath.slice(lastSlash + 1) : filePath)
192+
.toLowerCase();
193+
}
194+
195+
function getFileExtension(filePath: string): string | undefined {
196+
const fileName = getFileName(filePath);
197+
const lastDot = fileName.lastIndexOf(".");
198+
if (lastDot > 0) {
199+
return fileName.slice(lastDot + 1);
200+
}
201+
return undefined;
202+
}
203+
15204
export interface ResponseLike {
16205
status: number;
17206
arrayBuffer(): Promise<BufferSource>;
@@ -29,6 +218,11 @@ export interface ResponseLike {
29218
export async function createStreaming(
30219
responsePromise: Promise<ResponseLike> | ResponseLike,
31220
): Promise<Formatter> {
221+
const wasmModule = await createWasmModuleFromStreaming(responsePromise);
222+
return createFromWasmModule(wasmModule);
223+
}
224+
225+
async function createWasmModuleFromStreaming(responsePromise: Promise<ResponseLike> | ResponseLike) {
32226
const response = await responsePromise;
33227
if (response.status !== 200) {
34228
throw new Error(
@@ -40,12 +234,11 @@ export async function createStreaming(
40234
&& response.headers.get("content-type") === "application/wasm"
41235
) {
42236
// deno-lint-ignore no-explicit-any
43-
const module = await WebAssembly.compileStreaming(response as any);
44-
return createFromWasmModule(module);
237+
return await WebAssembly.compileStreaming(response as any);
45238
} else {
46239
// fallback for node.js or when the content type isn't application/wasm
47240
return response.arrayBuffer()
48-
.then((buffer) => createFromBuffer(buffer));
241+
.then((buffer) => new WebAssembly.Module(buffer));
49242
}
50243
}
51244

0 commit comments

Comments
 (0)