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/LICENSE +21 -0
- package/README.ja.md +429 -0
- package/README.md +394 -2
- package/lib/cli.mjs +794 -0
- package/package.json +47 -3
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 {};
|