mmdfmt 0.0.0 → 0.2.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,433 @@
1
+ #!/usr/bin/env node
2
+ import { a as kindOf, i as formatFile, n as endingsInWords } from "./format-file-DSbEElSV.mjs";
3
+ import path from "node:path";
4
+ import { getFileInfo } from "prettier";
5
+ import { text } from "node:stream/consumers";
6
+ import { readFile, stat, writeFile } from "node:fs/promises";
7
+ import { styleText } from "node:util";
8
+ import { cli, define, isArgsValidationError } from "gunshi";
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.2.0";
25
+ var description = "Formatter for Mermaid diagrams in .mmd files and in Markdown code blocks, which changes nothing else, as a command and as a function";
26
+ //#endregion
27
+ //#region src/patterns.ts
28
+ /**
29
+ * The directories whose files are skipped, those that Prettier's CLI skips
30
+ * (README, Excluding files).
31
+ */
32
+ const skippedDirectories = [
33
+ "node_modules",
34
+ ".git",
35
+ ".hg",
36
+ ".jj",
37
+ ".sl",
38
+ ".svn"
39
+ ];
40
+ /** The globs of the files under the skipped directories. */
41
+ const skippedDirectoryGlobs = skippedDirectories.map((directory) => `**/${directory}/**`);
42
+ /**
43
+ * Selects the files that the patterns name, of those the tool formats, as
44
+ * Prettier's CLI reads the patterns (README, Patterns). A pattern that names a
45
+ * path in a skipped directory, a file, a directory, or a glob, is skipped. A
46
+ * pattern is then first the path of a file or of a directory. Otherwise, a
47
+ * pattern that starts with `!` excludes the files that the glob after it
48
+ * matches from what every other pattern selects, and any other pattern is a
49
+ * glob.
50
+ */
51
+ async function selectFiles(patterns, cwd) {
52
+ const selecting = [];
53
+ const excluding = [];
54
+ const skipped = [];
55
+ for (const pattern of patterns) {
56
+ const target = path.resolve(cwd, pattern);
57
+ if (passesSkippedDirectory(target, cwd)) {
58
+ skipped.push(pattern);
59
+ continue;
60
+ }
61
+ const stats = await stat(target).catch(() => void 0);
62
+ if (stats?.isFile()) selecting.push({
63
+ kind: "file",
64
+ text: pattern,
65
+ path: target
66
+ });
67
+ else if (stats?.isDirectory()) selecting.push({
68
+ kind: "directory",
69
+ text: pattern,
70
+ path: target
71
+ });
72
+ else if (pattern.startsWith("!")) excluding.push(globOf(pattern.slice(1)));
73
+ else selecting.push({
74
+ kind: "glob",
75
+ text: pattern
76
+ });
77
+ }
78
+ const files = /* @__PURE__ */ new Map();
79
+ const unmatched = [];
80
+ const excluded = [];
81
+ for (const pattern of selecting) {
82
+ if (pattern.kind === "file" && await isExcluded(pattern.path, cwd, excluding)) {
83
+ excluded.push(pattern.text);
84
+ continue;
85
+ }
86
+ const selected = [];
87
+ for (const file of (await filesOf(pattern, cwd, excluding)).sort()) {
88
+ const kind = kindOf(file);
89
+ if (kind !== void 0) selected.push({
90
+ path: file,
91
+ kind
92
+ });
93
+ }
94
+ if (selected.length === 0) unmatched.push(pattern.text);
95
+ for (const file of selected) if (!files.has(file.path)) files.set(file.path, file);
96
+ }
97
+ return {
98
+ files: [...files.values()],
99
+ noSelectingPattern: selecting.length === 0,
100
+ skipped,
101
+ unmatched,
102
+ excluded
103
+ };
104
+ }
105
+ /**
106
+ * A pattern as a glob. On Windows, Prettier's CLI reads a `\` of a glob as
107
+ * `/`, the separator of globs; so does this (README, Patterns).
108
+ */
109
+ function globOf(pattern) {
110
+ return process.platform === "win32" ? pattern.replaceAll("\\", "/") : pattern;
111
+ }
112
+ /**
113
+ * Whether the globs of the excluding patterns match a file, given by its
114
+ * absolute path. As in Prettier's CLI, they are matched against its path from
115
+ * `cwd`.
116
+ */
117
+ async function isExcluded(file, cwd, excluding) {
118
+ return (await globFiles(convertPathToPattern(path.relative(cwd, file)), cwd, excluding)).length === 0;
119
+ }
120
+ /**
121
+ * The files that a pattern names, as absolute paths, apart from those that the
122
+ * globs of the excluding patterns match. The path of a file names the file
123
+ * wherever it is; a directory or a glob names no file under a skipped
124
+ * directory, as Prettier's CLI does not look into one.
125
+ */
126
+ async function filesOf(pattern, cwd, excluding) {
127
+ if (pattern.kind === "file") return [pattern.path];
128
+ const ignore = [...skippedDirectoryGlobs, ...excluding];
129
+ 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));
130
+ }
131
+ /**
132
+ * The files that a glob matches from `cwd`, as absolute paths, apart from
133
+ * those that the globs of `ignore` match. Like Prettier's CLI, it matches names
134
+ * that start with a dot and follows no symbolic link.
135
+ */
136
+ async function globFiles(pattern, cwd, ignore) {
137
+ return (await glob(pattern, {
138
+ cwd,
139
+ absolute: true,
140
+ dot: true,
141
+ expandDirectories: false,
142
+ followSymbolicLinks: false,
143
+ ignore: [...ignore]
144
+ })).map((file) => path.resolve(file));
145
+ }
146
+ /**
147
+ * Whether a path, taken from `cwd`, is one of the skipped directories or passes
148
+ * through one, as Prettier's CLI decides it for a pattern.
149
+ */
150
+ function passesSkippedDirectory(target, cwd) {
151
+ return path.relative(cwd, target).split(path.sep).some((name) => skippedDirectories.includes(name));
152
+ }
153
+ /** Whether a file is under one of the skipped directories of `cwd`. */
154
+ function isUnderSkippedDirectory(file, cwd) {
155
+ return passesSkippedDirectory(path.dirname(file), cwd);
156
+ }
157
+ /**
158
+ * Whether a file is skipped: it is under one of the skipped directories, or
159
+ * the `.gitignore` of the current directory lists it (README, Excluding files).
160
+ *
161
+ * Unlike Prettier's CLI, the tool does not read `.prettierignore`. It is used
162
+ * where Prettier does not format the Markdown, and the `.prettierignore` there
163
+ * often lists the Markdown files: reading it would leave their diagrams
164
+ * unformatted without a word. If users ask for a way to keep files from this
165
+ * tool alone, other than a pattern that starts with `!`, read a file of its
166
+ * own, such as `.mmdfmtignore`, not `.prettierignore`.
167
+ */
168
+ async function isSkipped(file, cwd) {
169
+ if (isUnderSkippedDirectory(file, cwd)) return true;
170
+ const { ignored } = await getFileInfo(file, {
171
+ ignorePath: path.join(cwd, ".gitignore"),
172
+ resolveConfig: false
173
+ });
174
+ return ignored;
175
+ }
176
+ //#endregion
177
+ //#region src/main.ts
178
+ /** The exit codes (README, Errors and exit codes). */
179
+ const exitCodes = {
180
+ ok: 0,
181
+ changed: 1,
182
+ error: 2
183
+ };
184
+ /** The examples in the usage, one on a line (README, Usage). */
185
+ const examples = `mmdfmt --write "**/*.{md,mmd,mermaid}"
186
+ mmdfmt --check "**/*.{md,mmd,mermaid}"
187
+ mmdfmt --stdin-filepath diagram.mmd < diagram.mmd`;
188
+ /** Runs the tool with its arguments, and returns the exit code (README, Usage). */
189
+ async function main(args, io) {
190
+ let code = exitCodes.ok;
191
+ const command = define({
192
+ toKebab: true,
193
+ args: {
194
+ write: {
195
+ type: "boolean",
196
+ short: "w",
197
+ negatable: true,
198
+ description: "Rewrite the files that change, and list each file with its time"
199
+ },
200
+ check: {
201
+ type: "boolean",
202
+ short: "c",
203
+ negatable: true,
204
+ description: "Check whether the files are formatted, and print a summary and the files that are not",
205
+ conflicts: ["listDifferent"]
206
+ },
207
+ listDifferent: {
208
+ type: "boolean",
209
+ short: "l",
210
+ negatable: true,
211
+ description: "List the files that --write would change",
212
+ conflicts: ["check"]
213
+ },
214
+ stdinFilepath: {
215
+ type: "string",
216
+ description: "Format standard input as the file at this path, and write the result to standard output"
217
+ },
218
+ patterns: {
219
+ type: "positional",
220
+ multiple: true,
221
+ required: false,
222
+ description: "The files, directories, or globs of the files to format"
223
+ }
224
+ },
225
+ examples,
226
+ /** Runs the mode that the arguments select, and keeps its exit code. */
227
+ async run(ctx) {
228
+ if (args.length === 0) {
229
+ const usage = await ctx.env.renderUsage?.(ctx);
230
+ io.stderr(usage ?? "");
231
+ code = exitCodes.error;
232
+ return;
233
+ }
234
+ const { write, check, listDifferent, stdinFilepath } = ctx.values;
235
+ const patterns = [...ctx.values.patterns ?? [], ...ctx.rest];
236
+ if (stdinFilepath !== void 0) code = patterns.length > 0 ? argumentError(io, ["--stdin-filepath takes no patterns"]) : await formatStandardInput(stdinFilepath, {
237
+ write: Boolean(write),
238
+ listsDifference: Boolean(check) || Boolean(listDifferent)
239
+ }, io);
240
+ else if (ctx.explicit.stdinFilepath) code = argumentError(io, ["--stdin-filepath needs a path"]);
241
+ else if (patterns.length === 0) code = argumentError(io, ["no patterns of the files to format"]);
242
+ else if (!write && !check && !listDifferent) code = argumentError(io, ["give --write, --check, or --list-different with the patterns"]);
243
+ else code = await formatFiles(patterns, {
244
+ write: Boolean(write),
245
+ check: Boolean(check),
246
+ listDifferent: Boolean(listDifferent)
247
+ }, io);
248
+ }
249
+ });
250
+ try {
251
+ const printed = await cli([...args], command, {
252
+ name: "mmdfmt",
253
+ version,
254
+ description,
255
+ strict: true,
256
+ usageSilent: true
257
+ });
258
+ if (printed !== void 0) io.stdout(printed.endsWith("\n") ? printed : `${printed}\n`);
259
+ } catch (error) {
260
+ if (error instanceof AggregateError && error.errors.every(isArgsValidationError)) return argumentError(io, error.errors.map(messageOf));
261
+ throw error;
262
+ }
263
+ return code;
264
+ }
265
+ /**
266
+ * Formats the files that the patterns select, following Prettier's counts
267
+ * and reports for `write`, `check`, and `listDifferent`.
268
+ */
269
+ async function formatFiles(patterns, mode, io) {
270
+ if (mode.check) io.stdout("Checking formatting...\n");
271
+ const { files, noSelectingPattern, skipped, unmatched, excluded } = await selectFiles(patterns, io.cwd);
272
+ let failed = noSelectingPattern || unmatched.length > 0 || excluded.length > 0;
273
+ let numberOfUnformattedFilesFound = 0;
274
+ let numberOfFilesWithError = 0;
275
+ if (noSelectingPattern) 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"]);
276
+ for (const pattern of unmatched) io.stderr(`mmdfmt: error: no file ending in ${endingsInWords} matches ${JSON.stringify(pattern)}\n`);
277
+ for (const pattern of excluded) io.stderr(`mmdfmt: error: a pattern that starts with ! excludes the file ${JSON.stringify(pattern)}\n`);
278
+ for (const file of files) {
279
+ const name = displayName(file.path, io.cwd);
280
+ let text, result;
281
+ try {
282
+ if (await isSkipped(file.path, io.cwd)) continue;
283
+ text = await readFile(file.path, "utf8");
284
+ } catch (error) {
285
+ io.stderr(`${name}: error: ${messageOf(error)}\n`);
286
+ failed = true;
287
+ continue;
288
+ }
289
+ const start = performance.now();
290
+ try {
291
+ result = await formatFile(text, file.path, file.kind, mode.write ? "output" : "input");
292
+ report(io, name, result.diagnostics);
293
+ if (result.diagnostics.some(isError)) {
294
+ failed = true;
295
+ numberOfFilesWithError += 1;
296
+ continue;
297
+ }
298
+ } catch (error) {
299
+ io.stderr(`${name}: error: ${messageOf(error)}\n`);
300
+ failed = true;
301
+ numberOfFilesWithError += 1;
302
+ continue;
303
+ }
304
+ const listsTimes = mode.write && !mode.check && !mode.listDifferent;
305
+ const time = `${Math.round(performance.now() - start)}ms`;
306
+ if (result.text === text) {
307
+ if (listsTimes) io.stdout(`${style(io.stdoutStream, "gray", name)} ${time} (unchanged)\n`);
308
+ continue;
309
+ }
310
+ if (mode.write) {
311
+ if (listsTimes) io.stdout(`${name} ${time}\n`);
312
+ try {
313
+ await writeFile(file.path, result.text);
314
+ } catch (error) {
315
+ io.stderr(`${name}: error: ${messageOf(error)}\n`);
316
+ failed = true;
317
+ }
318
+ }
319
+ if (mode.check) warn(io, name);
320
+ else if (mode.listDifferent) io.stdout(`${name}\n`);
321
+ numberOfUnformattedFilesFound += 1;
322
+ }
323
+ if (mode.check) {
324
+ if (numberOfFilesWithError > 0) {
325
+ const filesDescription = numberOfFilesWithError === 1 ? "the above file" : `${numberOfFilesWithError} files`;
326
+ io.stdout(`Error occurred when checking code style in ${filesDescription}.\n`);
327
+ } else if (numberOfUnformattedFilesFound === 0) io.stdout("All diagrams in the matched files use Prettier code style!\n");
328
+ else {
329
+ const filesDescription = numberOfUnformattedFilesFound === 1 ? "the above file" : `${numberOfUnformattedFilesFound} files`;
330
+ warn(io, mode.write ? `Code style issues fixed in ${filesDescription}.` : `Code style issues found in ${filesDescription}. Run mmdfmt with --write to fix.`);
331
+ }
332
+ }
333
+ if (failed) return exitCodes.error;
334
+ return numberOfUnformattedFilesFound > 0 && !mode.write ? exitCodes.changed : exitCodes.ok;
335
+ }
336
+ /**
337
+ * Formats standard input as the file at `filepath`, and writes the result to
338
+ * standard output. A skipped path gets its input back unchanged, and a
339
+ * rejected diagram file gets nothing. With `listsDifference` (`--check` or
340
+ * `--list-different`), the result is not written: `(stdin)` is listed when it
341
+ * differs from the input, unless `write` is given too, as Prettier lists it.
342
+ * `write` alone changes nothing: no file is written.
343
+ */
344
+ async function formatStandardInput(filepath, mode, io) {
345
+ const file = path.resolve(io.cwd, filepath);
346
+ const kind = kindOf(file);
347
+ if (kind === void 0) {
348
+ io.stderr(`mmdfmt: error: the path of --stdin-filepath does not end in ${endingsInWords}: ${JSON.stringify(filepath)}\n`);
349
+ return exitCodes.error;
350
+ }
351
+ const text = await io.readStdin();
352
+ try {
353
+ if (await isSkipped(file, io.cwd)) {
354
+ io.stdout(text);
355
+ return exitCodes.ok;
356
+ }
357
+ const result = await formatFile(text, file, kind, mode.listsDifference ? "input" : "output");
358
+ report(io, filepath, result.diagnostics);
359
+ if (result.diagnostics.some(isError)) return exitCodes.error;
360
+ if (!mode.listsDifference) {
361
+ io.stdout(result.text);
362
+ return exitCodes.ok;
363
+ }
364
+ if (result.text === text || mode.write) return exitCodes.ok;
365
+ io.stdout("(stdin)\n");
366
+ return exitCodes.changed;
367
+ } catch (error) {
368
+ io.stderr(`${filepath}: error: ${messageOf(error)}\n`);
369
+ return exitCodes.error;
370
+ }
371
+ }
372
+ /**
373
+ * Writes a warning of `--check` to standard error, as Prettier's logger
374
+ * writes one: `[warn] ` before the text, with the word `warn` in yellow where
375
+ * the stream shows colors.
376
+ */
377
+ function warn(io, text) {
378
+ io.stderr(`[${style(io.stderrStream, "yellow", "warn")}] ${text}\n`);
379
+ }
380
+ /**
381
+ * `text` in `color` where Node's `util.styleText` colors text for `stream`,
382
+ * and as it is without a stream.
383
+ */
384
+ function style(stream, color, text) {
385
+ return stream === void 0 ? text : styleText(color, text, { stream });
386
+ }
387
+ /**
388
+ * The name of a file in what the tool prints: its path relative to `cwd`, with
389
+ * `/` between its parts on every system, as Prettier's CLI writes the name of a
390
+ * file (README, Usage). `pathOf` is `node:path` of the system the tool runs on;
391
+ * a test gives `path.win32` or `path.posix`.
392
+ */
393
+ function displayName(file, cwd, pathOf = path) {
394
+ return pathOf.relative(cwd, file).replaceAll(pathOf.sep, "/");
395
+ }
396
+ /** Writes the diagnostics of a file to standard error. */
397
+ function report(io, name, diagnostics) {
398
+ for (const { severity, message, position } of diagnostics) io.stderr(`${name}:${position.line}:${position.column}: ${severity}: ${message}\n`);
399
+ }
400
+ /** Whether a diagnostic makes the run fail. */
401
+ function isError(diagnostic) {
402
+ return diagnostic.severity === "error";
403
+ }
404
+ /** Reports wrong uses of the arguments, and returns 2 (README, Usage). */
405
+ function argumentError(io, messages) {
406
+ for (const message of messages) io.stderr(`mmdfmt: error: ${message}\n`);
407
+ return exitCodes.error;
408
+ }
409
+ /** The message of a thrown value. */
410
+ function messageOf(error) {
411
+ return error instanceof Error ? error.message : String(error);
412
+ }
413
+ //#endregion
414
+ //#region src/cli.ts
415
+ /** Reads standard input to its end, as UTF-8. */
416
+ async function readStdin() {
417
+ return text(process.stdin.setEncoding("utf8"));
418
+ }
419
+ process.stdout.on("error", ignoreBrokenPipe);
420
+ process.exitCode = await main(process.argv.slice(2), {
421
+ cwd: process.cwd(),
422
+ readStdin,
423
+ stdout: (output) => {
424
+ process.stdout.write(output);
425
+ },
426
+ stderr: (output) => {
427
+ process.stderr.write(output);
428
+ },
429
+ stdoutStream: process.stdout,
430
+ stderrStream: process.stderr
431
+ });
432
+ //#endregion
433
+ export {};