mmdfmt 0.0.0-stage → 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/lib/cli.mjs ADDED
@@ -0,0 +1,794 @@
1
+ #!/usr/bin/env node
2
+ import { readFile, stat, writeFile } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { cli, define, isArgsValidationError } from "gunshi";
5
+ import { format, getFileInfo, resolveConfig } from "prettier";
6
+ import { ParseError, parse } from "@mmd-toolkit/parser";
7
+ import plugin from "prettier-plugin-mmd";
8
+ import { parsers } from "prettier/plugins/markdown";
9
+ import { convertPathToPattern, glob } from "tinyglobby";
10
+ //#region src/broken-pipe.ts
11
+ /**
12
+ * The listener of the errors of standard output. When the reader of standard
13
+ * output goes away, as `head` does once it has its lines, writing fails with
14
+ * `EPIPE`. Other command-line tools then end without a word; so does this
15
+ * tool, and it finishes the run, so that `--write` still rewrites every file
16
+ * and the exit code still tells the result (README, Usage). Any other error is
17
+ * thrown, as Node.js throws it when the stream has no listener.
18
+ */
19
+ function ignoreBrokenPipe(error) {
20
+ if (error.code !== "EPIPE") throw error;
21
+ }
22
+ //#endregion
23
+ //#region package.json
24
+ var version = "0.1.0";
25
+ var description = "Command-line formatter for Mermaid diagrams in .mmd files and in Markdown code blocks, which changes nothing else";
26
+ //#endregion
27
+ //#region src/diagram.ts
28
+ /** Prettier's name of each line ending. */
29
+ const endOfLineOptions = {
30
+ "\n": "lf",
31
+ "\r\n": "crlf",
32
+ "\r": "cr"
33
+ };
34
+ /**
35
+ * Formats the text of a diagram with Prettier and prettier-plugin-mmd, as
36
+ * Prettier formats a `.mmd` file with the plugin. The text has no byte order
37
+ * mark and its lines end with `\n`, as Prettier leaves a text before parsing
38
+ * it; the lines of the result end with `lineEnding`.
39
+ *
40
+ * A text that the plugin rejects is parsed again with the parser that the
41
+ * plugin uses, for the message and the offset of the syntax error: the error
42
+ * that Prettier throws has a line and a column counted for Prettier's code
43
+ * frame, which also ends lines at U+2028 and U+2029, and no offset.
44
+ */
45
+ async function formatDiagram(text, indentation, lineEnding) {
46
+ try {
47
+ return {
48
+ kind: "formatted",
49
+ text: await format(text, {
50
+ parser: "mermaid",
51
+ plugins: [plugin],
52
+ tabWidth: indentation.tabWidth,
53
+ useTabs: indentation.useTabs,
54
+ endOfLine: endOfLineOptions[lineEnding]
55
+ })
56
+ };
57
+ } catch (error) {
58
+ const syntaxError = syntaxErrorOf(text);
59
+ if (syntaxError === void 0) throw error;
60
+ return {
61
+ kind: "rejected",
62
+ message: syntaxError.message,
63
+ offset: syntaxError.range[0]
64
+ };
65
+ }
66
+ }
67
+ /** The syntax error of a text, or `undefined` when the parser accepts it. */
68
+ function syntaxErrorOf(text) {
69
+ try {
70
+ parse(text);
71
+ } catch (error) {
72
+ if (error instanceof ParseError) return error;
73
+ throw error;
74
+ }
75
+ }
76
+ //#endregion
77
+ //#region src/lines.ts
78
+ const byteOrderMark = "";
79
+ /** Reads a text into its byte order mark, its lines, and its normalized form. */
80
+ function readText(text) {
81
+ const mark = text.startsWith(byteOrderMark) ? byteOrderMark : "";
82
+ const lines = splitLines(text.slice(mark.length));
83
+ return {
84
+ byteOrderMark: mark,
85
+ lines,
86
+ normalized: lines.map((line) => line.text).join("\n")
87
+ };
88
+ }
89
+ /**
90
+ * Splits a text into lines at `\n`, `\r\n`, and `\r`. Joining the lines with
91
+ * their endings gives the text back; the last line is `""` when the text ends
92
+ * with a line terminator.
93
+ */
94
+ function splitLines(text) {
95
+ const lines = [];
96
+ let start = 0;
97
+ for (let index = 0; index < text.length; index++) {
98
+ const character = text[index];
99
+ if (character !== "\n" && character !== "\r") continue;
100
+ const ending = character === "\r" && text[index + 1] === "\n" ? "\r\n" : character;
101
+ lines.push({
102
+ text: text.slice(start, index),
103
+ ending
104
+ });
105
+ index += ending.length - 1;
106
+ start = index + 1;
107
+ }
108
+ lines.push({
109
+ text: text.slice(start),
110
+ ending: ""
111
+ });
112
+ return lines;
113
+ }
114
+ /**
115
+ * The line ending that the tool writes in a file: the first line ending of the
116
+ * file, or `\n` in a file without one (README, Code blocks in detail).
117
+ */
118
+ function lineEndingOf(text) {
119
+ for (let index = 0; index < text.length; index++) {
120
+ if (text[index] === "\n") return "\n";
121
+ if (text[index] === "\r") return text[index + 1] === "\n" ? "\r\n" : "\r";
122
+ }
123
+ return "\n";
124
+ }
125
+ /** The position of an offset in a text whose lines end with `\n`. */
126
+ function positionIn(text, offset) {
127
+ let line = 1;
128
+ let lineStart = 0;
129
+ for (let index = 0; index < offset; index++) if (text[index] === "\n") {
130
+ line++;
131
+ lineStart = index + 1;
132
+ }
133
+ return {
134
+ line,
135
+ column: offset - lineStart + 1
136
+ };
137
+ }
138
+ //#endregion
139
+ //#region src/markdown.ts
140
+ /**
141
+ * The first words of an info string that mark a diagram: the `aliases` of the
142
+ * language of prettier-plugin-mmd, which are the code blocks that Prettier
143
+ * formats with the plugin. Prettier compares them case-sensitively.
144
+ */
145
+ const diagramLanguages = /* @__PURE__ */ new Set(["mermaid", "mmd"]);
146
+ /**
147
+ * Formats the diagrams in the code blocks of a Markdown file and changes
148
+ * nothing else (README, Code blocks in detail). The warnings give their
149
+ * positions in the text that `positionsIn` names.
150
+ */
151
+ async function formatMarkdown(text, indentation, positionsIn) {
152
+ const { byteOrderMark, lines, normalized } = readText(text);
153
+ const lineEnding = lineEndingOf(text);
154
+ const diagnostics = [];
155
+ let output = byteOrderMark;
156
+ let next = 0;
157
+ let addedLines = 0;
158
+ for (const block of diagramBlocksOf(await parseMarkdown(normalized))) {
159
+ const result = await formatBlock(block, lines, indentation, lineEnding);
160
+ if (result.kind === "warning") {
161
+ const { diagnostic } = result;
162
+ const { line, column } = diagnostic.position;
163
+ diagnostics.push(positionsIn === "input" ? diagnostic : {
164
+ ...diagnostic,
165
+ position: {
166
+ line: line + addedLines,
167
+ column
168
+ }
169
+ });
170
+ continue;
171
+ }
172
+ if (result.kind === "unchanged") continue;
173
+ output += joinLines(lines.slice(next, result.start)) + result.text;
174
+ next = result.start + result.count;
175
+ addedLines += result.textLineCount - result.count;
176
+ }
177
+ output += joinLines(lines.slice(next));
178
+ return {
179
+ text: output,
180
+ diagnostics
181
+ };
182
+ }
183
+ /**
184
+ * Parses Markdown with Prettier's Markdown parser, so that the tool finds the
185
+ * code blocks that Prettier finds. Its `parse` reads only the text (it takes
186
+ * one parameter), so it is called without Prettier's options.
187
+ */
188
+ async function parseMarkdown(text) {
189
+ const parse = parsers.markdown.parse;
190
+ return parse(text);
191
+ }
192
+ /**
193
+ * The code blocks of diagrams in a Markdown tree, in the order of the file,
194
+ * without those that Prettier leaves as written for a `prettier-ignore`
195
+ * comment (README, Excluding files): the node right after
196
+ * `<!-- prettier-ignore -->` among the children of any node, and the nodes
197
+ * between `<!-- prettier-ignore-start -->` and the next
198
+ * `<!-- prettier-ignore-end -->` among the children of the root.
199
+ */
200
+ function diagramBlocksOf(root) {
201
+ const blocks = [];
202
+ /** Collects the blocks of a node and of its descendants. */
203
+ function visit(node, footnoteLines) {
204
+ if (node.type === "code" && typeof node.lang === "string" && diagramLanguages.has(node.lang) && typeof node.value === "string") {
205
+ blocks.push({
206
+ value: node.value,
207
+ position: node.position,
208
+ followsFootnoteLabel: footnoteLines.includes(node.position.start.line)
209
+ });
210
+ return;
211
+ }
212
+ const lines = node.type === "footnoteDefinition" ? [...footnoteLines, node.position.start.line] : footnoteLines;
213
+ const children = node.children ?? [];
214
+ const ignored = node === root ? nodesInIgnoredRanges(children) : /* @__PURE__ */ new Set();
215
+ for (const [index, child] of children.entries()) {
216
+ if (ignored.has(child)) continue;
217
+ if (index > 0 && prettierIgnoreOf(children[index - 1]) === "next") continue;
218
+ visit(child, lines);
219
+ }
220
+ }
221
+ visit(root, []);
222
+ return blocks;
223
+ }
224
+ /**
225
+ * The nodes between a `prettier-ignore-start` comment and the next
226
+ * `prettier-ignore-end` comment, as Prettier finds them among the children of
227
+ * the root: a start without an end leaves nothing as written.
228
+ */
229
+ function nodesInIgnoredRanges(children) {
230
+ const ignored = /* @__PURE__ */ new Set();
231
+ let start;
232
+ for (const [index, child] of children.entries()) {
233
+ const ignore = prettierIgnoreOf(child);
234
+ if (ignore === "start" && start === void 0) start = index;
235
+ else if (ignore === "end" && start !== void 0) {
236
+ for (const node of children.slice(start + 1, index)) ignored.add(node);
237
+ start = void 0;
238
+ }
239
+ }
240
+ return ignored;
241
+ }
242
+ /**
243
+ * What a node asks as a `prettier-ignore` comment, as Prettier's Markdown
244
+ * printer reads it: an HTML node of `<!--`, `prettier-ignore` with
245
+ * `-start` or `-end` or nothing, and `-->`, with whitespace around the word.
246
+ */
247
+ function prettierIgnoreOf(node) {
248
+ const { value } = node;
249
+ if (node.type !== "html" || value === void 0 || !value.startsWith("<!--") || !value.endsWith("-->")) return;
250
+ switch (value.slice(4, -3).trim()) {
251
+ case "prettier-ignore": return "next";
252
+ case "prettier-ignore-start": return "start";
253
+ case "prettier-ignore-end": return "end";
254
+ default: return;
255
+ }
256
+ }
257
+ /**
258
+ * Formats the diagram of a code block. Only the content lines change: the
259
+ * lines between the opening fence and the closing fence, or the end of the
260
+ * block, without its blank lines there, when it is not closed. The line of the
261
+ * opening fence keeps its text, and its line ending too unless the diagram
262
+ * becomes empty.
263
+ */
264
+ async function formatBlock(block, lines, indentation, lineEnding) {
265
+ const start = block.position.start.line;
266
+ if (block.followsFootnoteLabel) return {
267
+ kind: "warning",
268
+ diagnostic: {
269
+ severity: "warning",
270
+ message: "not formatted: the opening fence stands on the line of a footnote label",
271
+ position: {
272
+ line: start,
273
+ column: block.position.start.column
274
+ }
275
+ }
276
+ };
277
+ const contentValues = valueLinesOf(block, lines);
278
+ const values = isClosed(block, lines, contentValues.length) ? contentValues : withoutTrailingBlankLines(contentValues);
279
+ const prefixes = [];
280
+ for (const [index, value] of values.entries()) {
281
+ const line = lines[start + index].text;
282
+ if (!line.endsWith(value)) return {
283
+ kind: "warning",
284
+ diagnostic: {
285
+ severity: "warning",
286
+ message: "not formatted: a tab in the indentation of this line is read partly as the text of the diagram",
287
+ position: {
288
+ line: start + index + 1,
289
+ column: line.length - commonSuffixLength(line, value)
290
+ }
291
+ }
292
+ };
293
+ prefixes.push(line.slice(0, line.length - value.length));
294
+ }
295
+ const endsWithLineEnding = values.length > 0 && lines[start + values.length - 1].ending !== "";
296
+ const text = values.join("\n") + (endsWithLineEnding ? "\n" : "");
297
+ const result = await formatDiagram(text, indentation, "\n");
298
+ if (result.kind === "rejected") {
299
+ const { line, column } = positionIn(text, result.offset);
300
+ return {
301
+ kind: "warning",
302
+ diagnostic: {
303
+ severity: "warning",
304
+ message: result.message,
305
+ position: {
306
+ line: start + line,
307
+ column: (prefixes[line - 1]?.length ?? 0) + column
308
+ }
309
+ }
310
+ };
311
+ }
312
+ const formatted = !endsWithLineEnding && result.text.endsWith("\n") ? result.text.slice(0, -1) : result.text;
313
+ if (formatted === text) return { kind: "unchanged" };
314
+ const fenceLine = lines[start - 1];
315
+ const prefix = newLinePrefixOf(fenceLine.text.slice(0, block.position.start.column - 1));
316
+ const written = [fenceLine];
317
+ for (const line of splitLines(formatted)) {
318
+ if (line.ending === "" && line.text === "") break;
319
+ written.push({
320
+ text: line.text === "" ? prefix.trimEnd() : prefix + line.text,
321
+ ending: lineEnding
322
+ });
323
+ }
324
+ const last = written.length - 1;
325
+ written[last] = {
326
+ text: written[last].text,
327
+ ending: lines[start + values.length - 1].ending
328
+ };
329
+ return {
330
+ kind: "replaced",
331
+ start: start - 1,
332
+ count: values.length + 1,
333
+ text: joinLines(written),
334
+ textLineCount: written.length
335
+ };
336
+ }
337
+ /**
338
+ * The lines of the text of a block, one for each content line. The parser
339
+ * joins the content lines with `\n`, without a line ending after the last, so
340
+ * that the text `""` stands both for no content line and for one empty
341
+ * content line; see {@link hasOneEmptyLine}.
342
+ */
343
+ function valueLinesOf(block, lines) {
344
+ if (block.value !== "") return block.value.split("\n");
345
+ return hasOneEmptyLine(block, lines) ? [""] : [];
346
+ }
347
+ /**
348
+ * Whether a block whose text is `""` has one content line. The line after the
349
+ * opening fence is then that line, which the block reaches into and which
350
+ * holds only the indentation and the `>` of the block's container. Otherwise
351
+ * that line is the closing fence, which holds the characters of a fence, or it
352
+ * is outside the block.
353
+ */
354
+ function hasOneEmptyLine(block, lines) {
355
+ const next = block.position.start.line;
356
+ const { end } = block.position;
357
+ if (!(end.line - 1 > next || end.line - 1 === next && end.column > 1)) return false;
358
+ const line = lines[next].text;
359
+ return !line.includes("`") && !line.includes("~");
360
+ }
361
+ /**
362
+ * Whether a block has a closing fence: the line after its content lines, which
363
+ * the block reaches into with the characters of a fence. A block that is not
364
+ * closed ends where its container or the file ends, on its last content line,
365
+ * or on the next line with no more of it than the indentation and the `>` of
366
+ * its container.
367
+ */
368
+ function isClosed(block, lines, contentLineCount) {
369
+ const next = block.position.start.line + contentLineCount;
370
+ const { end } = block.position;
371
+ if (end.line - 1 !== next) return false;
372
+ const reached = lines[next].text.slice(0, end.column - 1);
373
+ return reached.includes("`") || reached.includes("~");
374
+ }
375
+ /**
376
+ * The lines without the lines at their end that are blank as Markdown reads
377
+ * them: empty, or of only spaces and tabs.
378
+ */
379
+ function withoutTrailingBlankLines(values) {
380
+ let count = values.length;
381
+ while (count > 0 && isBlank(values[count - 1])) count--;
382
+ return values.slice(0, count);
383
+ }
384
+ /** Whether a line is empty or holds only spaces and tabs. */
385
+ function isBlank(text) {
386
+ for (const character of text) if (character !== " " && character !== " ") return false;
387
+ return true;
388
+ }
389
+ /**
390
+ * What starts a new content line of a block: what stands before the opening
391
+ * fence on its line, with the characters of a list marker turned into spaces,
392
+ * so that the line takes the indentation and the `>` of the block's container
393
+ * and the indentation of the fence (README, Code blocks in detail). Tabs stay as
394
+ * written, and with them the columns. A prefix that ends with `>` takes a
395
+ * space: a block quote reads one space after its `>` as part of its marker, so
396
+ * without it a line of the diagram that starts with a space would lose it.
397
+ */
398
+ function newLinePrefixOf(beforeFence) {
399
+ let prefix = "";
400
+ for (const character of beforeFence) prefix += character === " " || character === " " || character === ">" ? character : " ";
401
+ return prefix.endsWith(">") ? `${prefix} ` : prefix;
402
+ }
403
+ /** The number of characters at the end of `a` that `b` ends with too. */
404
+ function commonSuffixLength(a, b) {
405
+ let length = 0;
406
+ while (length < a.length && length < b.length && a[a.length - 1 - length] === b[b.length - 1 - length]) length++;
407
+ return length;
408
+ }
409
+ /** Joins lines with their own line endings. */
410
+ function joinLines(lines) {
411
+ let text = "";
412
+ for (const line of lines) text += line.text + line.ending;
413
+ return text;
414
+ }
415
+ //#endregion
416
+ //#region src/format-file.ts
417
+ /**
418
+ * The files that the tool formats, by the ending of their names (README,
419
+ * Patterns).
420
+ */
421
+ const fileKinds = /* @__PURE__ */ new Map([
422
+ [".md", "markdown"],
423
+ [".markdown", "markdown"],
424
+ [".mmd", "diagram"],
425
+ [".mermaid", "diagram"]
426
+ ]);
427
+ /** The endings of the names of the files that the tool formats, for messages. */
428
+ const fileEndings = [...fileKinds.keys()];
429
+ /** How the tool reads a file, or `undefined` for a file it does not format. */
430
+ function kindOf(file) {
431
+ return fileKinds.get(path.extname(file));
432
+ }
433
+ /**
434
+ * Formats a file of the given kind. The indentation comes from the Prettier
435
+ * configuration of the file, found as Prettier's CLI finds it,
436
+ * `.editorconfig` included (README, Indentation). The diagnostics give their
437
+ * positions in the text that `positionsIn` names.
438
+ */
439
+ async function formatFile(text, file, kind, positionsIn) {
440
+ const config = await resolveConfig(file, { editorconfig: true });
441
+ const indentation = {
442
+ tabWidth: config?.tabWidth,
443
+ useTabs: config?.useTabs
444
+ };
445
+ return kind === "diagram" ? formatDiagramFile(text, indentation) : formatMarkdown(text, indentation, positionsIn);
446
+ }
447
+ /**
448
+ * Formats a `.mmd` or `.mermaid` file as a whole. Prettier keeps a byte order
449
+ * mark apart from the text it formats and puts it back; so does this. A
450
+ * rejected diagram leaves the text as it was read, so its position is the same
451
+ * in the text as read and after formatting.
452
+ */
453
+ async function formatDiagramFile(text, indentation) {
454
+ const { byteOrderMark, normalized } = readText(text);
455
+ const result = await formatDiagram(normalized, indentation, lineEndingOf(text));
456
+ if (result.kind === "rejected") return {
457
+ text,
458
+ diagnostics: [{
459
+ severity: "error",
460
+ message: result.message,
461
+ position: positionIn(normalized, result.offset)
462
+ }]
463
+ };
464
+ return {
465
+ text: byteOrderMark + result.text,
466
+ diagnostics: []
467
+ };
468
+ }
469
+ //#endregion
470
+ //#region src/patterns.ts
471
+ /**
472
+ * The directories whose files are skipped, those that Prettier's CLI skips
473
+ * (README, Excluding files).
474
+ */
475
+ const skippedDirectories = [
476
+ "node_modules",
477
+ ".git",
478
+ ".hg",
479
+ ".jj",
480
+ ".sl",
481
+ ".svn"
482
+ ];
483
+ /** The globs of the files under the skipped directories. */
484
+ const skippedDirectoryGlobs = skippedDirectories.map((directory) => `**/${directory}/**`);
485
+ /**
486
+ * Selects the files that the patterns name, of those the tool formats, as
487
+ * Prettier's CLI reads the patterns (README, Patterns). A pattern that names a
488
+ * path in a skipped directory, a file, a directory, or a glob, is skipped. A
489
+ * pattern is then first the path of a file or of a directory. Otherwise, a
490
+ * pattern that starts with `!` excludes the files that the glob after it
491
+ * matches from what every other pattern selects, and any other pattern is a
492
+ * glob.
493
+ */
494
+ async function selectFiles(patterns, cwd) {
495
+ const selecting = [];
496
+ const excluding = [];
497
+ const skipped = [];
498
+ for (const pattern of patterns) {
499
+ const target = path.resolve(cwd, pattern);
500
+ if (passesSkippedDirectory(target, cwd)) {
501
+ skipped.push(pattern);
502
+ continue;
503
+ }
504
+ const stats = await stat(target).catch(() => void 0);
505
+ if (stats?.isFile()) selecting.push({
506
+ kind: "file",
507
+ text: pattern,
508
+ path: target
509
+ });
510
+ else if (stats?.isDirectory()) selecting.push({
511
+ kind: "directory",
512
+ text: pattern,
513
+ path: target
514
+ });
515
+ else if (pattern.startsWith("!")) excluding.push(globOf(pattern.slice(1)));
516
+ else selecting.push({
517
+ kind: "glob",
518
+ text: pattern
519
+ });
520
+ }
521
+ const files = /* @__PURE__ */ new Map();
522
+ const unmatched = [];
523
+ const excluded = [];
524
+ for (const pattern of selecting) {
525
+ if (pattern.kind === "file" && await isExcluded(pattern.path, cwd, excluding)) {
526
+ excluded.push(pattern.text);
527
+ continue;
528
+ }
529
+ const selected = [];
530
+ for (const file of (await filesOf(pattern, cwd, excluding)).sort()) {
531
+ const kind = kindOf(file);
532
+ if (kind !== void 0) selected.push({
533
+ path: file,
534
+ kind
535
+ });
536
+ }
537
+ if (selected.length === 0) unmatched.push(pattern.text);
538
+ for (const file of selected) if (!files.has(file.path)) files.set(file.path, file);
539
+ }
540
+ return {
541
+ files: [...files.values()],
542
+ noSelectingPattern: selecting.length === 0,
543
+ skipped,
544
+ unmatched,
545
+ excluded
546
+ };
547
+ }
548
+ /**
549
+ * A pattern as a glob. On Windows, Prettier's CLI reads a `\` of a glob as
550
+ * `/`, the separator of globs; so does this (README, Patterns).
551
+ */
552
+ function globOf(pattern) {
553
+ return process.platform === "win32" ? pattern.replaceAll("\\", "/") : pattern;
554
+ }
555
+ /**
556
+ * Whether the globs of the excluding patterns match a file, given by its
557
+ * absolute path. As in Prettier's CLI, they are matched against its path from
558
+ * `cwd`.
559
+ */
560
+ async function isExcluded(file, cwd, excluding) {
561
+ return (await globFiles(convertPathToPattern(path.relative(cwd, file)), cwd, excluding)).length === 0;
562
+ }
563
+ /**
564
+ * The files that a pattern names, as absolute paths, apart from those that the
565
+ * globs of the excluding patterns match. The path of a file names the file
566
+ * wherever it is; a directory or a glob names no file under a skipped
567
+ * directory, as Prettier's CLI does not look into one.
568
+ */
569
+ async function filesOf(pattern, cwd, excluding) {
570
+ if (pattern.kind === "file") return [pattern.path];
571
+ const ignore = [...skippedDirectoryGlobs, ...excluding];
572
+ return (pattern.kind === "directory" ? await globFiles(`${convertPathToPattern(path.relative(cwd, pattern.path) || ".")}/**/*`, cwd, ignore) : await globFiles(globOf(pattern.text), cwd, ignore)).filter((file) => !isUnderSkippedDirectory(file, cwd));
573
+ }
574
+ /**
575
+ * The files that a glob matches from `cwd`, as absolute paths, apart from
576
+ * those that the globs of `ignore` match. Like Prettier's CLI, it matches names
577
+ * that start with a dot and follows no symbolic link.
578
+ */
579
+ async function globFiles(pattern, cwd, ignore) {
580
+ return (await glob(pattern, {
581
+ cwd,
582
+ absolute: true,
583
+ dot: true,
584
+ expandDirectories: false,
585
+ followSymbolicLinks: false,
586
+ ignore: [...ignore]
587
+ })).map((file) => path.resolve(file));
588
+ }
589
+ /**
590
+ * Whether a path, taken from `cwd`, is one of the skipped directories or passes
591
+ * through one, as Prettier's CLI decides it for a pattern.
592
+ */
593
+ function passesSkippedDirectory(target, cwd) {
594
+ return path.relative(cwd, target).split(path.sep).some((name) => skippedDirectories.includes(name));
595
+ }
596
+ /** Whether a file is under one of the skipped directories of `cwd`. */
597
+ function isUnderSkippedDirectory(file, cwd) {
598
+ return passesSkippedDirectory(path.dirname(file), cwd);
599
+ }
600
+ /**
601
+ * Whether a file is skipped: it is under one of the skipped directories, or
602
+ * the `.gitignore` of the current directory lists it (README, Excluding files).
603
+ *
604
+ * Unlike Prettier's CLI, the tool does not read `.prettierignore`. It is used
605
+ * where Prettier does not format the Markdown, and the `.prettierignore` there
606
+ * often lists the Markdown files: reading it would leave their diagrams
607
+ * unformatted without a word. If users ask for a way to keep files from this
608
+ * tool alone, other than a pattern that starts with `!`, read a file of its
609
+ * own, such as `.mmdfmtignore`, not `.prettierignore`.
610
+ */
611
+ async function isSkipped(file, cwd) {
612
+ if (isUnderSkippedDirectory(file, cwd)) return true;
613
+ const { ignored } = await getFileInfo(file, {
614
+ ignorePath: path.join(cwd, ".gitignore"),
615
+ resolveConfig: false
616
+ });
617
+ return ignored;
618
+ }
619
+ //#endregion
620
+ //#region src/main.ts
621
+ /** The exit codes (README, Errors and exit codes). */
622
+ const exitCodes = {
623
+ ok: 0,
624
+ changed: 1,
625
+ error: 2
626
+ };
627
+ /** The examples in the usage, one on a line (README, Usage). */
628
+ const examples = `mmdfmt --write "**/*.{md,mmd,mermaid}"
629
+ mmdfmt --check "**/*.{md,mmd,mermaid}"
630
+ mmdfmt --stdin-filepath diagram.mmd < diagram.mmd`;
631
+ /** The endings of the files that the tool formats, as a message names them. */
632
+ const endingsInWords = `${fileEndings.slice(0, -1).join(", ")}, or ${fileEndings.at(-1)}`;
633
+ /** Runs the tool with its arguments, and returns the exit code (README, Usage). */
634
+ async function main(args, io) {
635
+ let code = exitCodes.ok;
636
+ const command = define({
637
+ toKebab: true,
638
+ args: {
639
+ write: {
640
+ type: "boolean",
641
+ description: "Rewrite the files that change, and list them",
642
+ conflicts: ["check", "stdinFilepath"]
643
+ },
644
+ check: {
645
+ type: "boolean",
646
+ description: "List the files that --write would change",
647
+ conflicts: ["write", "stdinFilepath"]
648
+ },
649
+ stdinFilepath: {
650
+ type: "string",
651
+ description: "Format standard input as the file at this path, and write the result to standard output",
652
+ conflicts: ["write", "check"]
653
+ },
654
+ patterns: {
655
+ type: "positional",
656
+ multiple: true,
657
+ required: false,
658
+ description: "The files, directories, or globs of the files to format"
659
+ }
660
+ },
661
+ examples,
662
+ /** Runs the mode that the arguments select, and keeps its exit code. */
663
+ async run(ctx) {
664
+ if (args.length === 0) {
665
+ const usage = await ctx.env.renderUsage?.(ctx);
666
+ io.stderr(usage ?? "");
667
+ code = exitCodes.error;
668
+ return;
669
+ }
670
+ const { write, check, stdinFilepath } = ctx.values;
671
+ const patterns = [...ctx.values.patterns ?? [], ...ctx.rest];
672
+ if (stdinFilepath !== void 0) code = patterns.length > 0 ? argumentError(io, ["--stdin-filepath takes no patterns"]) : await formatStandardInput(stdinFilepath, io);
673
+ else if (ctx.explicit.stdinFilepath) code = argumentError(io, ["--stdin-filepath needs a path"]);
674
+ else if (patterns.length === 0) code = argumentError(io, ["no patterns of the files to format"]);
675
+ else if (!write && !check) code = argumentError(io, ["give --write or --check with the patterns"]);
676
+ else code = await formatFiles(patterns, write ? "write" : "check", io);
677
+ }
678
+ });
679
+ try {
680
+ const printed = await cli([...args], command, {
681
+ name: "mmdfmt",
682
+ version,
683
+ description,
684
+ strict: true,
685
+ usageSilent: true
686
+ });
687
+ if (printed !== void 0) io.stdout(printed.endsWith("\n") ? printed : `${printed}\n`);
688
+ } catch (error) {
689
+ if (error instanceof AggregateError && error.errors.every(isArgsValidationError)) return argumentError(io, error.errors.map(messageOf));
690
+ throw error;
691
+ }
692
+ return code;
693
+ }
694
+ /**
695
+ * Formats the files that the patterns select. With `write`, a file that
696
+ * changes is rewritten; with `check`, it is only listed.
697
+ */
698
+ async function formatFiles(patterns, mode, io) {
699
+ const { files, noSelectingPattern, skipped, unmatched, excluded } = await selectFiles(patterns, io.cwd);
700
+ if (noSelectingPattern) return argumentError(io, [skipped.length > 0 ? "no patterns of the files to format; a pattern in node_modules or a directory of version control is skipped" : "no patterns of the files to format; a pattern that starts with ! only excludes files"]);
701
+ let failed = unmatched.length > 0 || excluded.length > 0;
702
+ let changed = false;
703
+ for (const pattern of unmatched) io.stderr(`mmdfmt: error: no file ending in ${endingsInWords} matches ${JSON.stringify(pattern)}\n`);
704
+ for (const pattern of excluded) io.stderr(`mmdfmt: error: a pattern that starts with ! excludes the file ${JSON.stringify(pattern)}\n`);
705
+ for (const file of files) {
706
+ const name = path.relative(io.cwd, file.path);
707
+ try {
708
+ if (await isSkipped(file.path, io.cwd)) continue;
709
+ const text = await readFile(file.path, "utf8");
710
+ const result = await formatFile(text, file.path, file.kind, mode === "write" ? "output" : "input");
711
+ report(io, name, result.diagnostics);
712
+ if (result.diagnostics.some(isError)) {
713
+ failed = true;
714
+ continue;
715
+ }
716
+ if (result.text === text) continue;
717
+ if (mode === "write") await writeFile(file.path, result.text);
718
+ changed = true;
719
+ io.stdout(`${name}\n`);
720
+ } catch (error) {
721
+ io.stderr(`${name}: error: ${messageOf(error)}\n`);
722
+ failed = true;
723
+ }
724
+ }
725
+ if (failed) return exitCodes.error;
726
+ return mode === "check" && changed ? exitCodes.changed : exitCodes.ok;
727
+ }
728
+ /**
729
+ * Formats standard input as the file at `filepath`, and writes the result to
730
+ * standard output. A skipped path gets its input back unchanged, and a
731
+ * rejected diagram file gets nothing.
732
+ */
733
+ async function formatStandardInput(filepath, io) {
734
+ const file = path.resolve(io.cwd, filepath);
735
+ const kind = kindOf(file);
736
+ if (kind === void 0) {
737
+ io.stderr(`mmdfmt: error: the path of --stdin-filepath does not end in ${endingsInWords}: ${JSON.stringify(filepath)}\n`);
738
+ return exitCodes.error;
739
+ }
740
+ const text = await io.readStdin();
741
+ try {
742
+ if (await isSkipped(file, io.cwd)) {
743
+ io.stdout(text);
744
+ return exitCodes.ok;
745
+ }
746
+ const result = await formatFile(text, file, kind, "output");
747
+ report(io, filepath, result.diagnostics);
748
+ if (result.diagnostics.some(isError)) return exitCodes.error;
749
+ io.stdout(result.text);
750
+ return exitCodes.ok;
751
+ } catch (error) {
752
+ io.stderr(`${filepath}: error: ${messageOf(error)}\n`);
753
+ return exitCodes.error;
754
+ }
755
+ }
756
+ /** Writes the diagnostics of a file to standard error. */
757
+ function report(io, name, diagnostics) {
758
+ for (const { severity, message, position } of diagnostics) io.stderr(`${name}:${position.line}:${position.column}: ${severity}: ${message}\n`);
759
+ }
760
+ /** Whether a diagnostic makes the run fail. */
761
+ function isError(diagnostic) {
762
+ return diagnostic.severity === "error";
763
+ }
764
+ /** Reports wrong uses of the arguments, and returns 2 (README, Usage). */
765
+ function argumentError(io, messages) {
766
+ for (const message of messages) io.stderr(`mmdfmt: error: ${message}\n`);
767
+ return exitCodes.error;
768
+ }
769
+ /** The message of a thrown value. */
770
+ function messageOf(error) {
771
+ return error instanceof Error ? error.message : String(error);
772
+ }
773
+ //#endregion
774
+ //#region src/cli.ts
775
+ /** Reads standard input to its end, as UTF-8. */
776
+ async function readStdin() {
777
+ process.stdin.setEncoding("utf8");
778
+ let text = "";
779
+ for await (const chunk of process.stdin) text += chunk;
780
+ return text;
781
+ }
782
+ process.stdout.on("error", ignoreBrokenPipe);
783
+ process.exitCode = await main(process.argv.slice(2), {
784
+ cwd: process.cwd(),
785
+ readStdin,
786
+ stdout: (text) => {
787
+ process.stdout.write(text);
788
+ },
789
+ stderr: (text) => {
790
+ process.stderr.write(text);
791
+ }
792
+ });
793
+ //#endregion
794
+ export {};