prettier-plugin-razor2 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Robert Macfie
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,109 @@
1
+ # prettier-plugin-razor2
2
+
3
+ ## Table of Contents
4
+
5
+ - [Getting Started](#getting-started)
6
+ - [Known limitations](#known-limitations)
7
+ - [Composing with other plugins](#composing-with-other-plugins)
8
+ - [Configuration](#configuration)
9
+ - [Development](#development)
10
+
11
+ ## Description
12
+
13
+ An opinionated formatter plugin for [Prettier](https://prettier.io) that adds
14
+ support for Razor files — `.razor` (Blazor components) and `.cshtml` (MVC views
15
+ and Razor Pages).
16
+
17
+ It formats the HTML in a Razor file by delegating to Prettier's own HTML
18
+ formatter, and the C# in `@code`/`@functions`/`@{ }` blocks by delegating to
19
+ [CSharpier](https://csharpier.com). Control-flow blocks (`@if`, `@foreach`, …)
20
+ are re-indented in Allman style. Please try it out and provide feedback.
21
+
22
+ # Getting Started
23
+
24
+ Install the plugin and Prettier itself.
25
+
26
+ ```sh
27
+ npm install --save-dev prettier prettier-plugin-razor2
28
+ ```
29
+
30
+ Add to your Prettier config file:
31
+
32
+ ```json
33
+ {
34
+ "plugins": ["prettier-plugin-razor2"]
35
+ }
36
+ ```
37
+
38
+ Optionally, disable the CSharpier integration to only format the HTML parts.
39
+
40
+ ```json
41
+ {
42
+ "plugins": ["prettier-plugin-razor2"],
43
+ "csharpierEnabled": false
44
+ }
45
+ ```
46
+
47
+ ## C# formatting
48
+
49
+ C# formatting requires the [CSharpier](https://csharpier.com) CLI on your `PATH`
50
+ (`dotnet tool install csharpier`, invoked as `dotnet csharpier`). If it is not
51
+ available, C# is left untouched and a one-time warning is printed. Set
52
+ `csharpierEnabled: false` to disable C# formatting (and silence the warning), or
53
+ override the command with `csharpierCommand`.
54
+
55
+ # Known limitations
56
+
57
+ - `@switch` blocks with several cases: the bare `case X:` / `break;` lines are
58
+ plain text to the HTML formatter, so consecutive arms can end up glued onto
59
+ one line (`break; case Y:`). The output is stable and nothing is lost — it
60
+ just isn't pretty yet.
61
+ - Templated Razor delegates (`.cshtml`), e.g.
62
+ `@Repeat(items, @<li>@item.Name</li>)`, are preserved but not reformatted.
63
+
64
+ # Composing with other plugins
65
+
66
+ The HTML in your Razor files is formatted by Prettier's own HTML formatter, so
67
+ other plugins that hook the `html` parser work on it too — including inside
68
+ control blocks. For example,
69
+ [prettier-plugin-tailwindcss](https://github.com/tailwindlabs/prettier-plugin-tailwindcss)
70
+ sorts `class="…"` attributes. List both plugins (Tailwind last, per its docs):
71
+
72
+ ```json
73
+ { "plugins": ["prettier-plugin-razor2", "prettier-plugin-tailwindcss"] }
74
+ ```
75
+
76
+ This applies only to the HTML: classes written inside C# (a `@code`/`@{ }` block
77
+ or a dynamic `class="@GetCss()"` value) are not sorted.
78
+
79
+ # Configuration
80
+
81
+ This library follows the same configuration format as Prettier, which is
82
+ documented [here](https://prettier.io/docs/en/configuration.html). It adds two
83
+ options:
84
+
85
+ | Option | Default | Description |
86
+ | ------------------ | -------------------- | --------------------------------------------------------------------- |
87
+ | `csharpierEnabled` | `true` | Format embedded C# with CSharpier. When `false`, C# is kept verbatim. |
88
+ | `csharpierCommand` | `"dotnet csharpier"` | Command used to invoke the CSharpier CLI. |
89
+
90
+ # Development
91
+
92
+ The plugin is written in TypeScript in `src/` and published as a single
93
+ [tsdown](https://tsdown.dev)-bundled file in `dist/`.
94
+
95
+ pnpm install # install dependencies
96
+ pnpm test # run the test suite (node --test)
97
+ pnpm typecheck # type-check without emitting
98
+ pnpm build # bundle src/ to dist/ with tsdown
99
+
100
+ Tests run the TypeScript sources directly via Node's native type stripping, so
101
+ no build step is required to develop or test. The C# tests need the CSharpier
102
+ CLI (`dotnet tool restore`); they skip automatically when it isn't available.
103
+
104
+ Releases: bump `version` in package.json, commit, tag `vX.Y.Z`, and
105
+ `git push --follow-tags` — GitHub Actions runs the checks and publishes to npm.
106
+
107
+ > **Fork notice** This project started as a fork of
108
+ > [prettier-plugin-razor](https://github.com/KristinaPlusPlus/prettier-plugin-razor),
109
+ > but has since been mostly rewritten.
@@ -0,0 +1,14 @@
1
+ import { Parser, Plugin, Printer, SupportLanguage } from "prettier";
2
+
3
+ //#region src/index.d.ts
4
+ /** Trivial AST: the whole document is formatted in one embedded pass. */
5
+ interface RazorRoot {
6
+ type: 'razor-root';
7
+ source: string;
8
+ }
9
+ declare const languages: SupportLanguage[];
10
+ declare const parsers: Record<string, Parser<RazorRoot>>;
11
+ declare const options: Plugin['options'];
12
+ declare const printers: Record<string, Printer<RazorRoot>>;
13
+ //#endregion
14
+ export { languages, options, parsers, printers };
package/dist/index.js ADDED
@@ -0,0 +1,715 @@
1
+ import { doc } from "prettier";
2
+ import { spawn } from "node:child_process";
3
+ import * as path from "node:path";
4
+ /** Build the block-level placeholder element for block index `n`. */
5
+ function blockPlaceholderFor(n) {
6
+ return `<div data-razor="${n}"></div>`;
7
+ }
8
+ /**
9
+ * Matcher for {@link blockPlaceholderFor} output in formatted HTML; group 1 is
10
+ * the preceding indentation, group 2 the block index. Constructed fresh per
11
+ * call \u2014 restore recurses, so a shared /g regex's lastIndex would clash.
12
+ */
13
+ function blockPlaceholderRE() {
14
+ return /([ \t]*)<div data-razor="(\d+)"><\/div>/g;
15
+ }
16
+ const HTML_ELEMENTS = new Set("a abbr address area article aside audio b base bdi bdo blockquote body br button canvas caption cite code col colgroup data datalist dd del details dfn dialog div dl dt em embed fieldset figcaption figure footer form h1 h2 h3 h4 h5 h6 head header hgroup hr html i iframe img input ins kbd label legend li link main map mark menu meta meter nav noscript object ol optgroup option output p param picture pre progress q rp rt ruby s samp script search section select slot small source span strong style sub summary sup table tbody td template textarea tfoot th thead time title tr track u ul var video wbr".split(" "));
17
+ const DIRECTIVES = /* @__PURE__ */ new Set([
18
+ "page",
19
+ "using",
20
+ "inject",
21
+ "inherits",
22
+ "namespace",
23
+ "implements",
24
+ "attribute",
25
+ "layout",
26
+ "typeparam",
27
+ "model",
28
+ "rendermode",
29
+ "addTagHelper",
30
+ "removeTagHelper",
31
+ "tagHelperPrefix",
32
+ "preservewhitespace"
33
+ ]);
34
+ const CONTROL = /* @__PURE__ */ new Set([
35
+ "if",
36
+ "for",
37
+ "foreach",
38
+ "while",
39
+ "switch",
40
+ "using",
41
+ "lock",
42
+ "do",
43
+ "try",
44
+ "section"
45
+ ]);
46
+ const isWs = (c) => c === " " || c === " " || c === "\r" || c === "\n";
47
+ const isLetter = (c) => c !== void 0 && /[A-Za-z]/.test(c);
48
+ function skipWs(src, i) {
49
+ while (i < src.length && isWs(src[i])) i++;
50
+ return i;
51
+ }
52
+ function readIdent(src, i) {
53
+ let j = i;
54
+ while (j < src.length && isLetter(src[j])) j++;
55
+ return src.slice(i, j);
56
+ }
57
+ function readTagName(src, i) {
58
+ let j = i;
59
+ while (j < src.length && /[A-Za-z0-9]/.test(src[j])) j++;
60
+ return src.slice(i, j);
61
+ }
62
+ function atLineStart(src, i) {
63
+ let j = i - 1;
64
+ while (j >= 0 && (src[j] === " " || src[j] === " ")) j--;
65
+ return j < 0 || src[j] === "\n";
66
+ }
67
+ function skipUntil(src, from, needle) {
68
+ const idx = src.indexOf(needle, from);
69
+ return idx === -1 ? src.length : idx + needle.length;
70
+ }
71
+ function skipCSharpToken(src, i) {
72
+ const c = src[i];
73
+ if (c === "\"" && src[i + 1] === "\"" && src[i + 2] === "\"") {
74
+ let openLen = 0;
75
+ while (src[i + openLen] === "\"") openLen++;
76
+ let j = i + openLen;
77
+ while (j < src.length) {
78
+ if (src[j] !== "\"") {
79
+ j++;
80
+ continue;
81
+ }
82
+ let runLen = 0;
83
+ while (src[j + runLen] === "\"") runLen++;
84
+ if (runLen >= openLen) return j + openLen;
85
+ j += runLen;
86
+ }
87
+ return src.length;
88
+ }
89
+ if (c === "@" && src[i + 1] === "\"") {
90
+ let j = i + 2;
91
+ while (j < src.length) {
92
+ if (src[j] === "\"") {
93
+ if (src[j + 1] === "\"") {
94
+ j += 2;
95
+ continue;
96
+ }
97
+ return j + 1;
98
+ }
99
+ j++;
100
+ }
101
+ return src.length;
102
+ }
103
+ if (c === "\"" || c === "'") {
104
+ let j = i + 1;
105
+ while (j < src.length) {
106
+ if (src[j] === "\\") {
107
+ j += 2;
108
+ continue;
109
+ }
110
+ if (src[j] === c) return j + 1;
111
+ j++;
112
+ }
113
+ return src.length;
114
+ }
115
+ if (c === "/" && src[i + 1] === "/") {
116
+ let j = i + 2;
117
+ while (j < src.length && src[j] !== "\n") j++;
118
+ return j;
119
+ }
120
+ if (c === "/" && src[i + 1] === "*") return skipUntil(src, i + 2, "*/");
121
+ return -1;
122
+ }
123
+ /**
124
+ * 0-based indices of the lines in `text` that _start inside_ a multi-line C#
125
+ * string literal (verbatim or raw). Their leading whitespace is string content,
126
+ * so re-indentation must leave them untouched. Multi-line comments are not
127
+ * protected — shifting their interior is cosmetic, not a content change.
128
+ */
129
+ function linesInsideCSharpStrings(text) {
130
+ const inside = /* @__PURE__ */ new Set();
131
+ let line = 0;
132
+ let i = 0;
133
+ while (i < text.length) {
134
+ if (text[i] === "\n") {
135
+ line++;
136
+ i++;
137
+ continue;
138
+ }
139
+ const end = skipCSharpToken(text, i);
140
+ if (end === -1) {
141
+ i++;
142
+ continue;
143
+ }
144
+ const isString = text[i] === "\"" || text[i] === "'" || text[i] === "@" && text[i + 1] === "\"";
145
+ for (let j = i; j < end; j++) if (text[j] === "\n") {
146
+ line++;
147
+ if (isString) inside.add(line);
148
+ }
149
+ i = end;
150
+ }
151
+ return inside;
152
+ }
153
+ function matchDelimited(src, i, open, close) {
154
+ let depth = 0;
155
+ let j = i;
156
+ while (j < src.length) {
157
+ const skipped = skipCSharpToken(src, j);
158
+ if (skipped !== -1) {
159
+ j = skipped;
160
+ continue;
161
+ }
162
+ const c = src[j];
163
+ if (c === open) depth++;
164
+ else if (c === close && --depth === 0) return j + 1;
165
+ j++;
166
+ }
167
+ return src.length;
168
+ }
169
+ const matchBraceCSharp = (src, i) => matchDelimited(src, i, "{", "}");
170
+ const matchParen = (src, i) => matchDelimited(src, i, "(", ")");
171
+ function skipMarkupQuote(src, i) {
172
+ const q = src[i];
173
+ let j = i + 1;
174
+ while (j < src.length && src[j] !== q) j++;
175
+ return j < src.length ? j + 1 : src.length;
176
+ }
177
+ function matchBraceMarkup(src, i) {
178
+ let depth = 0;
179
+ let j = i;
180
+ while (j < src.length) {
181
+ if (src[j] === "@" && src[j + 1] === "*") {
182
+ j = skipUntil(src, j + 2, "*@");
183
+ continue;
184
+ }
185
+ if (src.startsWith("<!--", j)) {
186
+ j = skipUntil(src, j + 4, "-->");
187
+ continue;
188
+ }
189
+ if (src[j] === "@" && src[j + 1] === "{") {
190
+ j = matchBraceCSharp(src, j + 1);
191
+ continue;
192
+ }
193
+ if (src[j] === "@" && src[j + 1] === "(") {
194
+ j = matchParen(src, j + 1);
195
+ continue;
196
+ }
197
+ if (src[j] === "\"" || src[j] === "'") {
198
+ j = skipMarkupQuote(src, j);
199
+ continue;
200
+ }
201
+ const c = src[j];
202
+ if (c === "{") depth++;
203
+ else if (c === "}" && --depth === 0) return j + 1;
204
+ j++;
205
+ }
206
+ return src.length;
207
+ }
208
+ function parseClause(src, headerStart, afterKeyword, hasCondition, requireCondition) {
209
+ let pos = skipWs(src, afterKeyword);
210
+ if (hasCondition) if (src[pos] !== "(") {
211
+ if (requireCondition) return null;
212
+ } else {
213
+ pos = matchParen(src, pos);
214
+ pos = skipWs(src, pos);
215
+ }
216
+ if (src[pos] !== "{") return null;
217
+ const bodyEnd = matchBraceMarkup(src, pos);
218
+ return {
219
+ clause: {
220
+ header: src.slice(headerStart, pos).trimEnd(),
221
+ body: src.slice(pos + 1, bodyEnd - 1)
222
+ },
223
+ end: bodyEnd
224
+ };
225
+ }
226
+ function parseControl(src, at, keyword) {
227
+ const afterKw = at + 1 + keyword.length;
228
+ if (keyword === "section") {
229
+ let pos = skipWs(src, afterKw);
230
+ pos += readIdent(src, pos).length;
231
+ pos = skipWs(src, pos);
232
+ if (src[pos] !== "{") return null;
233
+ const bodyEnd = matchBraceMarkup(src, pos);
234
+ return {
235
+ block: {
236
+ kind: "control",
237
+ clauses: [{
238
+ header: src.slice(at, pos).trimEnd(),
239
+ body: src.slice(pos + 1, bodyEnd - 1)
240
+ }]
241
+ },
242
+ end: bodyEnd
243
+ };
244
+ }
245
+ const first = parseClause(src, at, afterKw, keyword !== "do" && keyword !== "try", keyword !== "using" && keyword !== "lock");
246
+ if (!first) return null;
247
+ const clauses = [first.clause];
248
+ let end = first.end;
249
+ if (keyword === "do") {
250
+ let pos = skipWs(src, end);
251
+ if (readIdent(src, pos) === "while") {
252
+ pos = skipWs(src, pos + 5);
253
+ if (src[pos] === "(") {
254
+ pos = matchParen(src, pos);
255
+ let trailer = src.slice(skipWs(src, first.end), pos);
256
+ if (src[pos] === ";") {
257
+ pos++;
258
+ trailer += ";";
259
+ }
260
+ return {
261
+ block: {
262
+ kind: "control",
263
+ clauses,
264
+ trailer
265
+ },
266
+ end: pos
267
+ };
268
+ }
269
+ }
270
+ return {
271
+ block: {
272
+ kind: "control",
273
+ clauses
274
+ },
275
+ end
276
+ };
277
+ }
278
+ const chained = keyword === "if" ? ["else"] : keyword === "try" ? ["catch", "finally"] : [];
279
+ for (;;) {
280
+ const pos = skipWs(src, end);
281
+ const next = readIdent(src, pos);
282
+ if (!chained.includes(next)) break;
283
+ if (next === "else") {
284
+ const afterElse = skipWs(src, pos + 4);
285
+ if (readIdent(src, afterElse) === "if") {
286
+ const arm = parseClause(src, pos, afterElse + 2, true, true);
287
+ if (!arm) break;
288
+ clauses.push(arm.clause);
289
+ end = arm.end;
290
+ continue;
291
+ }
292
+ const arm = parseClause(src, pos, pos + 4, false, false);
293
+ if (!arm) break;
294
+ clauses.push(arm.clause);
295
+ end = arm.end;
296
+ break;
297
+ }
298
+ const arm = parseClause(src, pos, pos + next.length, next === "catch", false);
299
+ if (!arm) break;
300
+ clauses.push(arm.clause);
301
+ end = arm.end;
302
+ }
303
+ return {
304
+ block: {
305
+ kind: "control",
306
+ clauses
307
+ },
308
+ end
309
+ };
310
+ }
311
+ /** Replace Razor block constructs with placeholders; see {@link MaskResult}. */
312
+ function mask(src) {
313
+ const blocks = [];
314
+ const tagAliases = [];
315
+ const blockPlaceholder = (block) => blockPlaceholderFor(blocks.push(block) - 1);
316
+ const inlinePlaceholder = (block) => `${blocks.push(block) - 1}`;
317
+ let out = "";
318
+ let i = 0;
319
+ const n = src.length;
320
+ while (i < n) {
321
+ const c = src[i];
322
+ if (c === "<" && !src.startsWith("<!--", i)) {
323
+ const nameStart = src[i + 1] === "/" ? i + 2 : i + 1;
324
+ const name = readTagName(src, nameStart);
325
+ const after = src[nameStart + name.length];
326
+ const nameEnds = after === void 0 || after === ">" || after === "/" || isWs(after);
327
+ if (name !== "" && nameEnds && /^[A-Z]/.test(name) && HTML_ELEMENTS.has(name.toLowerCase())) {
328
+ let idx = tagAliases.indexOf(name);
329
+ if (idx === -1) idx = tagAliases.push(name) - 1;
330
+ out += src.slice(i, nameStart) + `rz-${idx}`;
331
+ i = nameStart + name.length;
332
+ continue;
333
+ }
334
+ }
335
+ if (c === "@" && src[i + 1] === "*") {
336
+ const end = skipUntil(src, i + 2, "*@");
337
+ out += inlinePlaceholder({
338
+ kind: "inline",
339
+ text: src.slice(i, end)
340
+ });
341
+ i = end;
342
+ continue;
343
+ }
344
+ if (src.startsWith("<!--", i)) {
345
+ const end = skipUntil(src, i + 4, "-->");
346
+ out += src.slice(i, end);
347
+ i = end;
348
+ continue;
349
+ }
350
+ if (c !== "@") {
351
+ out += c;
352
+ i++;
353
+ continue;
354
+ }
355
+ if (src[i + 1] === "@") {
356
+ out += "@@";
357
+ i += 2;
358
+ continue;
359
+ }
360
+ if (src[i + 1] === "(") {
361
+ const end = matchParen(src, i + 1);
362
+ out += inlinePlaceholder({
363
+ kind: "inline",
364
+ text: src.slice(i, end)
365
+ });
366
+ i = end;
367
+ continue;
368
+ }
369
+ if (src[i + 1] === "{") {
370
+ const end = matchBraceCSharp(src, i + 1);
371
+ out += blockPlaceholder({
372
+ kind: "verbatim",
373
+ opener: "@{",
374
+ body: src.slice(i + 2, end - 1),
375
+ csharp: "statements",
376
+ raw: src.slice(i, end)
377
+ });
378
+ i = end;
379
+ continue;
380
+ }
381
+ const kw = readIdent(src, i + 1);
382
+ if (kw === "code" || kw === "functions") {
383
+ const bracePos = skipWs(src, i + 1 + kw.length);
384
+ if (src[bracePos] === "{") {
385
+ const end = matchBraceCSharp(src, bracePos);
386
+ out += blockPlaceholder({
387
+ kind: "verbatim",
388
+ opener: `@${kw} {`,
389
+ body: src.slice(bracePos + 1, end - 1),
390
+ csharp: "members",
391
+ raw: src.slice(i, end)
392
+ });
393
+ i = end;
394
+ continue;
395
+ }
396
+ }
397
+ if (CONTROL.has(kw)) {
398
+ const parsed = parseControl(src, i, kw);
399
+ if (parsed) {
400
+ out += blockPlaceholder(parsed.block);
401
+ i = parsed.end;
402
+ continue;
403
+ }
404
+ }
405
+ if (DIRECTIVES.has(kw) && atLineStart(src, i) && src[i + 1 + kw.length] !== "=") {
406
+ let end = src.indexOf("\n", i);
407
+ if (end === -1) end = n;
408
+ out += blockPlaceholder({
409
+ kind: "directive",
410
+ text: src.slice(i, end).trimEnd()
411
+ });
412
+ i = end;
413
+ continue;
414
+ }
415
+ out += c;
416
+ i++;
417
+ }
418
+ return {
419
+ masked: out,
420
+ blocks,
421
+ tagAliases
422
+ };
423
+ }
424
+ //#endregion
425
+ //#region src/csharp.ts
426
+ const DEFAULT_COMMAND = "dotnet csharpier";
427
+ const WRAPPER = "__CSharpierWrapper__";
428
+ const EOT = "";
429
+ const REQUEST_TIMEOUT_MS = 1e4;
430
+ /**
431
+ * A persistent `csharpier pipe-files` process. Spawning `dotnet csharpier`
432
+ * costs ~200 ms (runtime + Roslyn startup); keeping one warm process reduces
433
+ * every call after the first to a couple of milliseconds. Requests are
434
+ * serialized (the protocol is sequential over one pipe), and the child is
435
+ * unref'ed while idle so it never keeps the Node process alive.
436
+ */
437
+ var CSharpierServer = class {
438
+ command;
439
+ child = null;
440
+ available = true;
441
+ stdoutBuf = "";
442
+ pendingResolve = null;
443
+ queue = Promise.resolve();
444
+ constructor(command) {
445
+ this.command = command;
446
+ }
447
+ /** Format one snippet; null when unavailable or the input was rejected. */
448
+ format(filePath, content) {
449
+ const task = this.queue.then(() => this.request(filePath, content));
450
+ this.queue = task.catch(() => null);
451
+ return task;
452
+ }
453
+ spawnChild() {
454
+ const parts = this.command.split(/\s+/);
455
+ const child = spawn(parts[0], [...parts.slice(1), "pipe-files"], { stdio: "pipe" });
456
+ this.child = child;
457
+ this.stdoutBuf = "";
458
+ child.stdout.on("data", (chunk) => {
459
+ this.stdoutBuf += chunk;
460
+ const end = this.stdoutBuf.indexOf(EOT);
461
+ if (end !== -1 && this.pendingResolve) {
462
+ const payload = this.stdoutBuf.slice(0, end);
463
+ this.stdoutBuf = this.stdoutBuf.slice(end + 1);
464
+ this.settle(payload === "" ? null : payload);
465
+ }
466
+ });
467
+ child.stderr.on("data", () => {});
468
+ child.stdin.on("error", () => {});
469
+ child.on("error", () => {
470
+ this.available = false;
471
+ this.child = null;
472
+ warnUnavailable(this.command);
473
+ this.settle(null);
474
+ });
475
+ child.on("close", () => {
476
+ this.child = null;
477
+ this.settle(null);
478
+ });
479
+ this.idle();
480
+ process.on("exit", () => child.kill());
481
+ }
482
+ request(filePath, content) {
483
+ if (!this.available) return Promise.resolve(null);
484
+ if (!this.child) this.spawnChild();
485
+ const child = this.child;
486
+ if (!child || !this.available) return Promise.resolve(null);
487
+ return new Promise((resolve) => {
488
+ this.pendingResolve = resolve;
489
+ this.busy();
490
+ const timeout = setTimeout(() => {
491
+ child.kill();
492
+ }, REQUEST_TIMEOUT_MS);
493
+ timeout.unref();
494
+ const done = (payload) => {
495
+ clearTimeout(timeout);
496
+ resolve(payload);
497
+ };
498
+ this.pendingResolve = done;
499
+ child.stdin.write(filePath + EOT + content + EOT);
500
+ });
501
+ }
502
+ settle(payload) {
503
+ const resolve = this.pendingResolve;
504
+ this.pendingResolve = null;
505
+ this.idle();
506
+ if (resolve) resolve(payload);
507
+ }
508
+ setLoopHold(hold) {
509
+ const child = this.child;
510
+ if (!child) return;
511
+ const handles = [
512
+ child,
513
+ child.stdout,
514
+ child.stdin,
515
+ child.stderr
516
+ ];
517
+ for (const handle of handles) if (hold) handle.ref?.();
518
+ else handle.unref?.();
519
+ }
520
+ busy() {
521
+ this.setLoopHold(true);
522
+ }
523
+ idle() {
524
+ this.setLoopHold(false);
525
+ }
526
+ };
527
+ const servers = /* @__PURE__ */ new Map();
528
+ function serverFor(command) {
529
+ let server = servers.get(command);
530
+ if (!server) {
531
+ server = new CSharpierServer(command);
532
+ servers.set(command, server);
533
+ }
534
+ return server;
535
+ }
536
+ const warnedCommands = /* @__PURE__ */ new Set();
537
+ function warnUnavailable(command) {
538
+ if (warnedCommands.has(command)) return;
539
+ warnedCommands.add(command);
540
+ console.warn(`[prettier-plugin-razor2] Could not run CSharpier ("${command}"); leaving embedded C# unformatted. Install it (\`dotnet tool install csharpier\`) or set the "csharpierEnabled" option to false to disable C# formatting and silence this warning.`);
541
+ }
542
+ function detectIndentUnit(text) {
543
+ for (const line of text.split("\n")) {
544
+ const m = /^([ \t]+)\S/.exec(line);
545
+ if (m) return m[1];
546
+ }
547
+ return " ";
548
+ }
549
+ function resolveCommand(options) {
550
+ if (options.csharpierEnabled === false) return null;
551
+ const command = (options.csharpierCommand ?? DEFAULT_COMMAND).trim();
552
+ return command === "" ? null : command;
553
+ }
554
+ async function runCSharpier(code, options) {
555
+ const command = resolveCommand(options);
556
+ if (command === null) return null;
557
+ const dir = options.filepath ? path.resolve(path.dirname(options.filepath)) : process.cwd();
558
+ return serverFor(command).format(path.join(dir, "__csharpier__.cs"), code);
559
+ }
560
+ /**
561
+ * Format a C# snippet. Returns the formatted code (indented from column zero),
562
+ * or `null` if CSharpier is unavailable/disabled or the code can't be parsed.
563
+ */
564
+ async function formatCSharp(code, kind, options) {
565
+ const trimmed = code.trim();
566
+ if (trimmed === "") return "";
567
+ if (kind === "statements") {
568
+ const out = await runCSharpier(trimmed, options);
569
+ return out === null ? null : out.replace(/\s+$/, "");
570
+ }
571
+ const out = await runCSharpier(`class ${WRAPPER}\n{\n${trimmed}\n}\n`, options);
572
+ if (out === null) return null;
573
+ const trimmedOut = out.replace(/\n+$/, "");
574
+ const lines = trimmedOut.split("\n");
575
+ const open = lines.findIndex((line, i) => i > 0 && line.trim() === "{");
576
+ if (open === -1) return null;
577
+ let close = lines.length - 1;
578
+ while (close > open && lines[close].trim() !== "}") close--;
579
+ const insideString = linesInsideCSharpStrings(trimmedOut);
580
+ const unit = detectIndentUnit(out);
581
+ const body = lines.slice(open + 1, close).map((line, k) => !insideString.has(open + 1 + k) && line.startsWith(unit) ? line.slice(unit.length) : line);
582
+ while (body.length && body[0].trim() === "") body.shift();
583
+ while (body.length && body[body.length - 1].trim() === "") body.pop();
584
+ return body.join("\n");
585
+ }
586
+ //#endregion
587
+ //#region src/format.ts
588
+ const inlineRE = () => new RegExp(`(\\d+)`, "g");
589
+ function indentUnit(options) {
590
+ return options.useTabs ? " " : " ".repeat(options.tabWidth ?? 2);
591
+ }
592
+ function indentLines(text, prefix) {
593
+ return text.split("\n").map((line) => line === "" ? "" : prefix + line).join("\n");
594
+ }
595
+ function indentCSharpLines(text, prefix) {
596
+ const insideString = linesInsideCSharpStrings(text);
597
+ return text.split("\n").map((line, i) => line === "" || insideString.has(i) ? line : prefix + line).join("\n");
598
+ }
599
+ function reindentVerbatim(text, indent) {
600
+ if (indent === "") return text;
601
+ return indentCSharpLines(text, indent);
602
+ }
603
+ async function renderBlock(block, indent, textToDoc, options) {
604
+ switch (block.kind) {
605
+ case "inline": return indent + block.text;
606
+ case "directive": return indent + block.text;
607
+ case "verbatim": {
608
+ const formatted = await formatCSharp(block.body, block.csharp, options);
609
+ if (formatted === null) return reindentVerbatim(block.raw, indent);
610
+ if (formatted === "") return `${indent}${block.opener}\n${indent}}`;
611
+ const body = indentCSharpLines(formatted, indent + indentUnit(options));
612
+ return `${indent}${block.opener}\n${body}\n${indent}}`;
613
+ }
614
+ case "control": {
615
+ const inner = indent + indentUnit(options);
616
+ const parts = [];
617
+ for (const clause of block.clauses) {
618
+ parts.push(indent + clause.header);
619
+ parts.push(indent + "{");
620
+ const body = (await formatDocument(clause.body, textToDoc, options)).trimEnd();
621
+ if (body !== "") parts.push(indentLines(body, inner));
622
+ parts.push(indent + "}");
623
+ }
624
+ let result = parts.join("\n");
625
+ if (block.trailer) result += "\n" + indent + block.trailer;
626
+ return result;
627
+ }
628
+ }
629
+ }
630
+ async function restore(html, blocks, textToDoc, options) {
631
+ const re = blockPlaceholderRE();
632
+ let out = "";
633
+ let last = 0;
634
+ let m;
635
+ while ((m = re.exec(html)) !== null) {
636
+ out += html.slice(last, m.index);
637
+ out += await renderBlock(blocks[Number(m[2])], m[1], textToDoc, options);
638
+ last = m.index + m[0].length;
639
+ }
640
+ out += html.slice(last);
641
+ return out;
642
+ }
643
+ /**
644
+ * Format Razor source into a formatted string: mask the Razor constructs,
645
+ * delegate the remaining markup to Prettier's HTML printer, then restore the
646
+ * constructs (recursing into control-block bodies).
647
+ */
648
+ async function formatDocument(source, textToDoc, options) {
649
+ if (source.trim() === "") return "";
650
+ const { masked, blocks, tagAliases } = mask(source);
651
+ const htmlDoc = await textToDoc(masked, { parser: "html" });
652
+ let html = doc.printer.printDocToString(htmlDoc, {
653
+ printWidth: options.printWidth ?? 80,
654
+ tabWidth: options.tabWidth ?? 2,
655
+ useTabs: options.useTabs ?? false
656
+ }).formatted;
657
+ if (blocks.length > 0) {
658
+ html = await restore(html, blocks, textToDoc, options);
659
+ html = resolveInline(html, blocks);
660
+ }
661
+ return restoreTagAliases(html, tagAliases);
662
+ }
663
+ function restoreTagAliases(html, tagAliases) {
664
+ if (tagAliases.length === 0) return html;
665
+ return html.replace(/(<\/?)rz-(\d+)/g, (whole, open, id) => open + (tagAliases[Number(id)] ?? whole));
666
+ }
667
+ function resolveInline(text, blocks) {
668
+ return text.replace(inlineRE(), (whole, id) => {
669
+ const block = blocks[Number(id)];
670
+ return block?.kind === "inline" ? block.text : whole;
671
+ });
672
+ }
673
+ //#endregion
674
+ //#region src/index.ts
675
+ const languages = [{
676
+ name: "Razor",
677
+ parsers: ["razor"],
678
+ extensions: [".razor", ".cshtml"],
679
+ vscodeLanguageIds: ["razor", "aspnetcorerazor"]
680
+ }];
681
+ const parsers = { razor: {
682
+ parse: (text) => ({
683
+ type: "razor-root",
684
+ source: text
685
+ }),
686
+ astFormat: "razor-ast",
687
+ locStart: () => 0,
688
+ locEnd: () => 0
689
+ } };
690
+ const options = {
691
+ csharpierEnabled: {
692
+ type: "boolean",
693
+ category: "Razor",
694
+ default: true,
695
+ description: "Format embedded C# (@code/@functions/@{ } blocks) with CSharpier. When false, C# is kept verbatim."
696
+ },
697
+ csharpierCommand: {
698
+ type: "string",
699
+ category: "Razor",
700
+ default: "dotnet csharpier",
701
+ description: "Command used to invoke the CSharpier CLI."
702
+ }
703
+ };
704
+ const printers = { "razor-ast": {
705
+ print: () => "",
706
+ embed(path) {
707
+ const node = path.node;
708
+ if (node.type !== "razor-root") return null;
709
+ return async (textToDoc, _print, _embedPath, options) => {
710
+ return (await formatDocument(node.source, textToDoc, options)).replace(/\s+$/, "") + "\n";
711
+ };
712
+ }
713
+ } };
714
+ //#endregion
715
+ export { languages, options, parsers, printers };
package/package.json ADDED
@@ -0,0 +1,73 @@
1
+ {
2
+ "name": "prettier-plugin-razor2",
3
+ "version": "0.1.0",
4
+ "description": "Prettier plugin for Razor (Blazor) files — a fork of prettier-plugin-razor",
5
+ "keywords": [
6
+ "prettier",
7
+ "razor",
8
+ "blazor",
9
+ "plugin"
10
+ ],
11
+ "homepage": "https://github.com/rmacfie/prettier-plugin-razor2#readme",
12
+ "bugs": {
13
+ "url": "https://github.com/rmacfie/prettier-plugin-razor2/issues"
14
+ },
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "https://github.com/rmacfie/prettier-plugin-razor2.git"
18
+ },
19
+ "license": "MIT",
20
+ "author": {
21
+ "name": "Robert Macfie",
22
+ "email": "robert@macfie.se"
23
+ },
24
+ "type": "module",
25
+ "exports": {
26
+ ".": {
27
+ "types": "./dist/index.d.ts",
28
+ "default": "./dist/index.js"
29
+ }
30
+ },
31
+ "main": "dist/index.js",
32
+ "types": "dist/index.d.ts",
33
+ "files": [
34
+ "dist"
35
+ ],
36
+ "devDependencies": {
37
+ "@types/node": "26.1.0",
38
+ "prettier": "3.9.4",
39
+ "prettier-plugin-jsdoc": "1.8.1",
40
+ "prettier-plugin-packagejson": "3.0.2",
41
+ "tsdown": "^0.22.3",
42
+ "typescript": "6.0.3",
43
+ "node": "runtime:26.4.0"
44
+ },
45
+ "peerDependencies": {
46
+ "prettier": "^3.0.0"
47
+ },
48
+ "engines": {
49
+ "node": ">=20"
50
+ },
51
+ "devEngines": {
52
+ "runtime": {
53
+ "name": "node",
54
+ "version": "26.4.0",
55
+ "onFail": "download"
56
+ },
57
+ "packageManager": {
58
+ "name": "pnpm",
59
+ "version": "11.9.0",
60
+ "onFail": "download"
61
+ }
62
+ },
63
+ "scripts": {
64
+ "bench": "node bench/bench.ts",
65
+ "build": "tsdown",
66
+ "clean": "rm -rf dist",
67
+ "csharpier": "dotnet csharpier",
68
+ "format": "prettier --write .",
69
+ "node": "node",
70
+ "test": "node --test \"tests/**/*.test.ts\"",
71
+ "typecheck": "tsc"
72
+ }
73
+ }