remark-mdat 1.2.3 → 1.2.5
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/dist/index.js +714 -3
- package/package.json +10 -9
- package/readme.md +9 -1
package/dist/index.js
CHANGED
|
@@ -1,3 +1,714 @@
|
|
|
1
|
-
import
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
import Table from "cli-table3";
|
|
2
|
+
import picocolors from "picocolors";
|
|
3
|
+
import { CONTINUE, SKIP, visit } from "unist-util-visit";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import json5 from "json5";
|
|
6
|
+
import { VFileMessage } from "vfile-message";
|
|
7
|
+
import { z } from "zod";
|
|
8
|
+
import { remark } from "remark";
|
|
9
|
+
import remarkGfm from "remark-gfm";
|
|
10
|
+
import { fromHtml } from "hast-util-from-html";
|
|
11
|
+
import { deepmerge } from "deepmerge-ts";
|
|
12
|
+
//#region src/lib/mdat/log.ts
|
|
13
|
+
const isNode = process?.versions?.node !== void 0;
|
|
14
|
+
const log = {
|
|
15
|
+
verbose: false,
|
|
16
|
+
log(...data) {
|
|
17
|
+
if (!this.verbose) return;
|
|
18
|
+
const levelPrefix = picocolors.gray("[Log]");
|
|
19
|
+
if (isNode) console.warn(levelPrefix, ...data);
|
|
20
|
+
else console.log(levelPrefix, ...data);
|
|
21
|
+
},
|
|
22
|
+
logPrefixed(prefix, ...data) {
|
|
23
|
+
this.info(picocolors.blue(`[${prefix}]`), ...data);
|
|
24
|
+
},
|
|
25
|
+
info(...data) {
|
|
26
|
+
if (!this.verbose) return;
|
|
27
|
+
const levelPrefix = picocolors.green("[Info]");
|
|
28
|
+
if (isNode) console.warn(levelPrefix, ...data);
|
|
29
|
+
else console.info(levelPrefix, ...data);
|
|
30
|
+
},
|
|
31
|
+
infoPrefixed(prefix, ...data) {
|
|
32
|
+
this.info(picocolors.blue(`[${prefix}]`), ...data);
|
|
33
|
+
},
|
|
34
|
+
warn(...data) {
|
|
35
|
+
console.warn(picocolors.yellow("[Warning]"), ...data);
|
|
36
|
+
},
|
|
37
|
+
warnPrefixed(prefix, ...data) {
|
|
38
|
+
this.warn(picocolors.blue(`[${prefix}]`), ...data);
|
|
39
|
+
},
|
|
40
|
+
error(...data) {
|
|
41
|
+
console.error(picocolors.red("[Error]"), ...data);
|
|
42
|
+
},
|
|
43
|
+
errorPrefixed(prefix, ...data) {
|
|
44
|
+
this.error(picocolors.blue(`[${prefix}]`), ...data);
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
//#endregion
|
|
48
|
+
//#region src/lib/mdat/mdat-log.ts
|
|
49
|
+
function saveLog(file, level, source, message, lineOrNode, maybeColumn) {
|
|
50
|
+
let line;
|
|
51
|
+
let column;
|
|
52
|
+
if (lineOrNode === void 0 || typeof lineOrNode === "number") {
|
|
53
|
+
line = lineOrNode ?? 0;
|
|
54
|
+
column = maybeColumn ?? 0;
|
|
55
|
+
} else {
|
|
56
|
+
line = lineOrNode?.position?.start.line ?? 0;
|
|
57
|
+
column = lineOrNode?.position?.start.column ?? 0;
|
|
58
|
+
}
|
|
59
|
+
const options = {
|
|
60
|
+
place: {
|
|
61
|
+
start: {
|
|
62
|
+
column,
|
|
63
|
+
line
|
|
64
|
+
},
|
|
65
|
+
end: {
|
|
66
|
+
column,
|
|
67
|
+
line
|
|
68
|
+
}
|
|
69
|
+
},
|
|
70
|
+
source
|
|
71
|
+
};
|
|
72
|
+
const vFileMessage = file.message(message, options);
|
|
73
|
+
vFileMessage.fatal = level === "error" ? true : level === "warn" ? false : void 0;
|
|
74
|
+
}
|
|
75
|
+
function vFileMessageToMdatMessage(vFileMessage) {
|
|
76
|
+
return {
|
|
77
|
+
column: vFileMessage.column,
|
|
78
|
+
level: vFileMessage.fatal ? "error" : vFileMessage.fatal === false ? "warn" : "info",
|
|
79
|
+
line: vFileMessage.line,
|
|
80
|
+
message: vFileMessage.reason,
|
|
81
|
+
source: vFileMessage.source
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
function getMdatReports(files) {
|
|
85
|
+
return files.map((file) => getMdatReport(file));
|
|
86
|
+
}
|
|
87
|
+
function getMdatReport(file) {
|
|
88
|
+
const mdatFileReport = {
|
|
89
|
+
destinationPath: file.history.length > 0 ? file.history.at(-1) : void 0,
|
|
90
|
+
errors: [],
|
|
91
|
+
infos: [],
|
|
92
|
+
sourcePath: file.history.at(0) ?? file.path,
|
|
93
|
+
warnings: []
|
|
94
|
+
};
|
|
95
|
+
if (mdatFileReport.sourcePath !== void 0) mdatFileReport.sourcePath = path.normalize(mdatFileReport.sourcePath);
|
|
96
|
+
for (const message of file.messages) {
|
|
97
|
+
const mdatMessage = vFileMessageToMdatMessage(message);
|
|
98
|
+
if (mdatMessage.level === "error") mdatFileReport.errors.push(mdatMessage);
|
|
99
|
+
else if (mdatMessage.level === "warn") mdatFileReport.warnings.push(mdatMessage);
|
|
100
|
+
else mdatFileReport.infos.push(mdatMessage);
|
|
101
|
+
}
|
|
102
|
+
return mdatFileReport;
|
|
103
|
+
}
|
|
104
|
+
function reporterMdat(files) {
|
|
105
|
+
for (const file of files) {
|
|
106
|
+
const { destinationPath, errors, infos, sourcePath, warnings } = getMdatReport(file);
|
|
107
|
+
log.info(picocolors.bold("MDAT Report:"));
|
|
108
|
+
log.info(`\tFrom: ${picocolors.blue(picocolors.bold(sourcePath))}`);
|
|
109
|
+
if (destinationPath !== void 0) log.info(`\tTo: ${picocolors.blue(picocolors.bold(destinationPath))}`);
|
|
110
|
+
for (const message of errors) log.error(mdatMessageToLogString(sourcePath, message));
|
|
111
|
+
for (const message of warnings) log.warn(mdatMessageToLogString(sourcePath, message));
|
|
112
|
+
for (const message of infos) log.info(mdatMessageToLogString(sourcePath, message));
|
|
113
|
+
if (errors.length === 0 && warnings.length === 0) log.info(`No issues found in ${sourcePath}`);
|
|
114
|
+
else log.error(`${errors.length} errors, ${warnings.length} warnings found in ${sourcePath}`);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
function mdatMessageToLogString(sourcePath, mdatMessage) {
|
|
118
|
+
const { column, level, line, message, source } = mdatMessage;
|
|
119
|
+
const resolvedSource = source ? picocolors.gray(`[${source}] `) : "";
|
|
120
|
+
const lineColumn = line && column ? `:${line}:${column}` : "";
|
|
121
|
+
return `${resolvedSource}${highlightComments(message, level)} ${picocolors.whiteBright(sourcePath + lineColumn)}`;
|
|
122
|
+
}
|
|
123
|
+
function highlightComments(text, level) {
|
|
124
|
+
return text.replaceAll(/<!--.+-->/g, (match) => level === "info" ? picocolors.green(match) : level === "warn" ? picocolors.yellow(match) : picocolors.red(match));
|
|
125
|
+
}
|
|
126
|
+
//#endregion
|
|
127
|
+
//#region src/lib/mdat/parse.ts
|
|
128
|
+
/**
|
|
129
|
+
* Parse an Mdast HTML comment node into structured data.
|
|
130
|
+
* @returns A discriminated union of CommentMarkerNode based on comment type, or
|
|
131
|
+
* undefined if the node is not a comment.
|
|
132
|
+
*/
|
|
133
|
+
function parseCommentNode(node, parent, options) {
|
|
134
|
+
try {
|
|
135
|
+
const result = parseComment(node.value, options);
|
|
136
|
+
if (result === void 0) return;
|
|
137
|
+
return {
|
|
138
|
+
...result,
|
|
139
|
+
node,
|
|
140
|
+
parent
|
|
141
|
+
};
|
|
142
|
+
} catch (error) {
|
|
143
|
+
if (error instanceof VFileMessage) {
|
|
144
|
+
error.line = node.position?.start.line;
|
|
145
|
+
throw error;
|
|
146
|
+
} else if (error instanceof Error) throw new VFileMessage(error.message, node);
|
|
147
|
+
else throw new VFileMessage("Unknown error", node);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Parse any comment string into structured data.
|
|
152
|
+
* @returns A discriminated union of CommentMarker based on comment type, or
|
|
153
|
+
* undefined if the node is not a comment.
|
|
154
|
+
*/
|
|
155
|
+
function parseComment(text, options) {
|
|
156
|
+
if (!isComment(text)) return;
|
|
157
|
+
const { closingPrefix, keywordPrefix, metaCommentIdentifier } = options;
|
|
158
|
+
if (closingPrefix === "") throw new VFileMessage("closingPrefix must not be an empty string");
|
|
159
|
+
const commentHtml = text.trim();
|
|
160
|
+
const commentBody = commentHtml.replace(/^\s*<!-{2,}\s*/, "").replace(/\s*-{2,}>\s*$/, "");
|
|
161
|
+
const [rawKeyword, ...argumentParts] = commentBody.split(/(\s+|\(|\{)/);
|
|
162
|
+
const type = rawKeyword.startsWith(metaCommentIdentifier) ? "meta" : keywordPrefix !== "" && !rawKeyword.startsWith(keywordPrefix) && !rawKeyword.startsWith(`${closingPrefix}${keywordPrefix}`) ? "native" : rawKeyword.startsWith(closingPrefix) ? "close" : "open";
|
|
163
|
+
if (type === "meta") return {
|
|
164
|
+
content: trimMetaIdentifiers(commentBody, metaCommentIdentifier),
|
|
165
|
+
html: commentHtml,
|
|
166
|
+
type
|
|
167
|
+
};
|
|
168
|
+
if (type === "native") return {
|
|
169
|
+
content: commentBody,
|
|
170
|
+
html: commentHtml,
|
|
171
|
+
type
|
|
172
|
+
};
|
|
173
|
+
let keyword = rawKeyword;
|
|
174
|
+
if (keyword.startsWith(closingPrefix)) keyword = keyword.slice(closingPrefix.length);
|
|
175
|
+
if (keyword.startsWith(keywordPrefix)) keyword = keyword.slice(keywordPrefix.length);
|
|
176
|
+
const optionText = makeValidJson(argumentParts.join(""));
|
|
177
|
+
if (type === "open" || type === "close") {
|
|
178
|
+
let options = {};
|
|
179
|
+
try {
|
|
180
|
+
options = json5.parse(optionText);
|
|
181
|
+
} catch (error) {
|
|
182
|
+
if (error instanceof Error) throw new VFileMessage(`Failed to parse comment options "${optionText}" for keyword "${keyword}": ${error.message}`);
|
|
183
|
+
}
|
|
184
|
+
return {
|
|
185
|
+
closingPrefix,
|
|
186
|
+
html: commentHtml,
|
|
187
|
+
keyword,
|
|
188
|
+
keywordPrefix,
|
|
189
|
+
options,
|
|
190
|
+
type
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
function isComment(text) {
|
|
195
|
+
const trimmed = text.trim();
|
|
196
|
+
return trimmed.startsWith("<!--") && trimmed.endsWith("-->");
|
|
197
|
+
}
|
|
198
|
+
function makeValidJson(text) {
|
|
199
|
+
text = text.trim();
|
|
200
|
+
text = text.startsWith("(") ? text.slice(1) : text;
|
|
201
|
+
text = text.endsWith(")") ? text.slice(0, -1) : text;
|
|
202
|
+
text = text.trim();
|
|
203
|
+
if (!text.startsWith("{") && !text.startsWith("[")) text = "{" + text;
|
|
204
|
+
if (!text.endsWith("}") && !text.endsWith("]")) text += "}";
|
|
205
|
+
return text;
|
|
206
|
+
}
|
|
207
|
+
function trimMetaIdentifiers(text, metaCommentIdentifier) {
|
|
208
|
+
text = text.trim();
|
|
209
|
+
text = text.startsWith(metaCommentIdentifier) ? text.slice(metaCommentIdentifier.length) : text;
|
|
210
|
+
text = text.endsWith(metaCommentIdentifier) ? text.slice(0, -metaCommentIdentifier.length) : text;
|
|
211
|
+
return text;
|
|
212
|
+
}
|
|
213
|
+
//#endregion
|
|
214
|
+
//#region src/lib/mdat/rules.ts
|
|
215
|
+
function normalizeRules(rules) {
|
|
216
|
+
const normalizedRules = {};
|
|
217
|
+
for (const [keyword, rule] of Object.entries(rules)) if (typeof rule === "string") normalizedRules[keyword] = {
|
|
218
|
+
applicationOrder: 0,
|
|
219
|
+
content: async () => rule,
|
|
220
|
+
order: void 0,
|
|
221
|
+
required: false
|
|
222
|
+
};
|
|
223
|
+
else if (typeof rule === "function") normalizedRules[keyword] = {
|
|
224
|
+
applicationOrder: 0,
|
|
225
|
+
content: async (options, tree) => rule(options, tree),
|
|
226
|
+
order: void 0,
|
|
227
|
+
required: false
|
|
228
|
+
};
|
|
229
|
+
else if (Array.isArray(rule)) normalizedRules[keyword] = {
|
|
230
|
+
applicationOrder: 0,
|
|
231
|
+
content: Object.values(normalizeRules(Object.fromEntries(rule.entries()))),
|
|
232
|
+
order: void 0,
|
|
233
|
+
required: false
|
|
234
|
+
};
|
|
235
|
+
else if (typeof rule.content === "string") {
|
|
236
|
+
const ruleContent = rule.content;
|
|
237
|
+
normalizedRules[keyword] = {
|
|
238
|
+
applicationOrder: rule.applicationOrder ?? 0,
|
|
239
|
+
content: async () => ruleContent,
|
|
240
|
+
order: rule.order ?? void 0,
|
|
241
|
+
required: rule.required ?? false
|
|
242
|
+
};
|
|
243
|
+
} else if (Array.isArray(rule.content)) normalizedRules[keyword] = {
|
|
244
|
+
applicationOrder: rule.applicationOrder ?? 0,
|
|
245
|
+
content: Object.values(normalizeRules(Object.fromEntries(rule.content.entries()))),
|
|
246
|
+
order: rule.order ?? void 0,
|
|
247
|
+
required: rule.required ?? false
|
|
248
|
+
};
|
|
249
|
+
else {
|
|
250
|
+
const ruleContent = rule.content;
|
|
251
|
+
normalizedRules[keyword] = {
|
|
252
|
+
applicationOrder: rule.applicationOrder ?? 0,
|
|
253
|
+
content: async (options, tree) => ruleContent(options, tree),
|
|
254
|
+
order: rule.order ?? void 0,
|
|
255
|
+
required: rule.required ?? false
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
validateNormalizedRules(normalizedRules);
|
|
259
|
+
return normalizedRules;
|
|
260
|
+
}
|
|
261
|
+
function validateRules(rules) {
|
|
262
|
+
try {
|
|
263
|
+
rulesSchema.parse(rules);
|
|
264
|
+
} catch (error) {
|
|
265
|
+
if (error instanceof Error) throw new TypeError(`Error validating rules: ${error.message}`);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
function validateNormalizedRules(rules) {
|
|
269
|
+
try {
|
|
270
|
+
normalizedRulesSchema.parse(rules);
|
|
271
|
+
} catch (error) {
|
|
272
|
+
if (error instanceof Error) throw new TypeError(`Error validating rules: ${error.message}`);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
const jsonValueSchema = z.any();
|
|
276
|
+
const rootSchema = z.any();
|
|
277
|
+
const normalizedRuleSchema = z.lazy(() => z.object({
|
|
278
|
+
applicationOrder: z.number(),
|
|
279
|
+
content: z.union([z.function().args(jsonValueSchema.optional(), rootSchema.optional()).returns(z.promise(z.string())), z.array(normalizedRuleSchema)]),
|
|
280
|
+
order: z.number().optional(),
|
|
281
|
+
required: z.boolean().default(false)
|
|
282
|
+
}));
|
|
283
|
+
const ruleContentFunctionSchema = z.function().args(jsonValueSchema.optional(), rootSchema.optional()).returns(z.union([z.string(), z.promise(z.string())]));
|
|
284
|
+
const ruleSchema = z.lazy(() => z.union([
|
|
285
|
+
ruleContentFunctionSchema,
|
|
286
|
+
z.array(ruleSchema),
|
|
287
|
+
z.string(),
|
|
288
|
+
z.object({
|
|
289
|
+
applicationOrder: z.number().optional(),
|
|
290
|
+
content: z.union([
|
|
291
|
+
ruleContentFunctionSchema,
|
|
292
|
+
z.array(ruleSchema),
|
|
293
|
+
z.string()
|
|
294
|
+
]),
|
|
295
|
+
order: z.number().optional(),
|
|
296
|
+
required: z.boolean().optional()
|
|
297
|
+
})
|
|
298
|
+
]));
|
|
299
|
+
const rulesSchema = z.record(ruleSchema).describe("MDAT Rules");
|
|
300
|
+
const normalizedRulesSchema = z.record(normalizedRuleSchema).describe("MDAT Rules");
|
|
301
|
+
/**
|
|
302
|
+
* Compound rule helpers, used in both "expand" and "check" utilities
|
|
303
|
+
*/
|
|
304
|
+
async function getRuleContent(rule, options, tree, check = false) {
|
|
305
|
+
if (Array.isArray(rule.content)) {
|
|
306
|
+
const subruleContent = [];
|
|
307
|
+
for (const [index, subrule] of rule.content.entries()) {
|
|
308
|
+
const subruleOptions = Array.isArray(options) ? options.at(index) : void 0;
|
|
309
|
+
try {
|
|
310
|
+
subruleContent.push(await getRuleContent(subrule, subruleOptions ?? {}, tree));
|
|
311
|
+
} catch (error) {
|
|
312
|
+
if (check) throw error;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
return subruleContent.join("\n\n");
|
|
316
|
+
}
|
|
317
|
+
try {
|
|
318
|
+
return await rule.content(options, tree);
|
|
319
|
+
} catch (error) {
|
|
320
|
+
if (check) throw error;
|
|
321
|
+
throw new Error("Failed to expand content", { cause: error });
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
/**
|
|
325
|
+
* Returns the rule value from a single-rule record.
|
|
326
|
+
* Useful when aliasing rules or invoking them programmatically.
|
|
327
|
+
*
|
|
328
|
+
* Throws if there are no entries or more than one entry.
|
|
329
|
+
*/
|
|
330
|
+
function getSoleRule(rules) {
|
|
331
|
+
return getSoleRecord(rules);
|
|
332
|
+
}
|
|
333
|
+
/**
|
|
334
|
+
* Returns the rule key from a single-rule record.
|
|
335
|
+
* Useful for comment placeholder validation.
|
|
336
|
+
*
|
|
337
|
+
* Throws if there are no entries or more than one entry.
|
|
338
|
+
*/
|
|
339
|
+
function getSoleRuleKey(rules) {
|
|
340
|
+
const keys = Object.keys(rules);
|
|
341
|
+
if (keys.length !== 1) throw new Error(`Expected exactly one rule, found ${keys.length}`);
|
|
342
|
+
return keys[0];
|
|
343
|
+
}
|
|
344
|
+
/**
|
|
345
|
+
* Get the sole entry in a record.
|
|
346
|
+
*
|
|
347
|
+
* Useful for working with Rules records
|
|
348
|
+
* that are only supposed to contain a single rule.
|
|
349
|
+
* @param record The record to get the sole entry from
|
|
350
|
+
* @returns The value of the sole entry in the record
|
|
351
|
+
* @throws {Error} If there are no entries or more than one entry
|
|
352
|
+
*/
|
|
353
|
+
function getSoleRecord(record) {
|
|
354
|
+
const recordValues = Object.values(record);
|
|
355
|
+
if (recordValues.length === 0) throw new Error("Found no entries in a \"sole record\" record. This should never happen");
|
|
356
|
+
if (recordValues.length > 1) throw new Error("Found multiple entries in \"sole record\" record. This should never happen");
|
|
357
|
+
return recordValues[0];
|
|
358
|
+
}
|
|
359
|
+
//#endregion
|
|
360
|
+
//#region src/lib/mdast-utils/mdast-util-mdat-check.ts
|
|
361
|
+
/**
|
|
362
|
+
* Mdast utility function to check mdat source document, and output.
|
|
363
|
+
*/
|
|
364
|
+
async function mdatCheck(tree, file, options) {
|
|
365
|
+
const { closingPrefix, keywordPrefix, metaCommentIdentifier, paranoid, rules: rawRules } = options;
|
|
366
|
+
validateRules(rawRules);
|
|
367
|
+
const rules = normalizeRules(rawRules);
|
|
368
|
+
const commentMarkers = [];
|
|
369
|
+
visit(tree, "html", (node, index, parent) => {
|
|
370
|
+
if (parent === void 0 || index === void 0) return CONTINUE;
|
|
371
|
+
const commentMarker = parseCommentNode(node, parent, {
|
|
372
|
+
closingPrefix,
|
|
373
|
+
keywordPrefix,
|
|
374
|
+
metaCommentIdentifier
|
|
375
|
+
});
|
|
376
|
+
if (commentMarker !== void 0) {
|
|
377
|
+
const rule = commentMarker.type === "open" || commentMarker.type === "close" ? rules[commentMarker.keyword] : void 0;
|
|
378
|
+
commentMarkers.push({
|
|
379
|
+
...commentMarker,
|
|
380
|
+
rule
|
|
381
|
+
});
|
|
382
|
+
}
|
|
383
|
+
});
|
|
384
|
+
checkMissingRequiredComments(file, commentMarkers, rules, rawRules);
|
|
385
|
+
checkCommentOrder(file, commentMarkers);
|
|
386
|
+
checkMetaCommentPresence(file, commentMarkers, options);
|
|
387
|
+
await checkRulesReturnedContent(file, commentMarkers, tree);
|
|
388
|
+
if (paranoid) checkMissingOptionalComments(file, commentMarkers, rules, rawRules);
|
|
389
|
+
checkMissingRules(file, commentMarkers);
|
|
390
|
+
checkMissingPrefix(file, commentMarkers, rules, options);
|
|
391
|
+
}
|
|
392
|
+
/**
|
|
393
|
+
* Check that all the rules are working by getting their content
|
|
394
|
+
*/
|
|
395
|
+
async function checkRulesReturnedContent(file, comments, tree) {
|
|
396
|
+
for (const comment of comments) if (comment.type === "open" && comment.rule !== void 0) try {
|
|
397
|
+
if ((await getRuleContent(comment.rule, comment.options, tree, true)).trim() === "") saveLog(file, comment.rule.required ? "error" : "warn", "check", `${comment.html} returned an empty string.`, comment.node);
|
|
398
|
+
} catch (error) {
|
|
399
|
+
if (error instanceof Error) saveLog(file, comment.rule.required ? "error" : "warn", "check", `Could not get content for ${comment.html}. ${error.message}`, comment.node);
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
/**
|
|
403
|
+
* Check for comments with missing prefix (have an un-prefixed comment that matches a rule)
|
|
404
|
+
*/
|
|
405
|
+
function checkMissingPrefix(file, comments, rules, options) {
|
|
406
|
+
if (options.keywordPrefix === "") return;
|
|
407
|
+
const ruleKeywords = Object.keys(rules);
|
|
408
|
+
for (const comment of comments) if (comment.type === "native" && ruleKeywords.includes(comment.content)) saveLog(file, "warn", "check", `Missing prefix: ${comment.html}`, comment.node);
|
|
409
|
+
}
|
|
410
|
+
/**
|
|
411
|
+
* Check for missing "optional" rules. These are instances where we have the comment, but not the rule
|
|
412
|
+
*/
|
|
413
|
+
function checkMissingRules(file, comments) {
|
|
414
|
+
for (const comment of comments) if (comment.type === "open" && comment.rule === void 0) saveLog(file, "warn", "check", `Missing rule for: ${comment.html}`, comment.node);
|
|
415
|
+
}
|
|
416
|
+
/**
|
|
417
|
+
* Check for missing optional comments. We have defined the rule, but not written a matching comment.
|
|
418
|
+
*/
|
|
419
|
+
function checkMissingOptionalComments(file, comments, rules, rawRules) {
|
|
420
|
+
for (const [keyword, rule] of Object.entries(rules)) if (!rule.required && !comments.some((comment) => comment.type === "open" && comment.keyword === keyword) && !satisfiedByCompoundRule(keyword, rawRules)) saveLog(file, "warn", "check", `Missing optional: <!-- ${keyword} -->`);
|
|
421
|
+
}
|
|
422
|
+
/**
|
|
423
|
+
* Check for missing required comments.
|
|
424
|
+
* The rule set includes a rule with `required: true`, but no matching comment was found in the document.
|
|
425
|
+
*/
|
|
426
|
+
function checkMissingRequiredComments(file, comments, rules, rawRules) {
|
|
427
|
+
for (const [keyword, rule] of Object.entries(rules)) if (rule.required && !comments.some((comment) => comment.type === "open" && comment.keyword === keyword) && !satisfiedByCompoundRule(keyword, rawRules)) saveLog(file, "error", "check", `Missing required: <!-- ${keyword} -->`);
|
|
428
|
+
}
|
|
429
|
+
/**
|
|
430
|
+
* Extract the content value from a raw rule, unwrapping object-form rules.
|
|
431
|
+
*/
|
|
432
|
+
function getRawRuleContent(rule) {
|
|
433
|
+
if (typeof rule === "object" && !Array.isArray(rule)) return rule.content;
|
|
434
|
+
return rule;
|
|
435
|
+
}
|
|
436
|
+
/**
|
|
437
|
+
* Get the sub-rule array from a compound rule, if it is one.
|
|
438
|
+
*/
|
|
439
|
+
function getCompoundSubRules(rule) {
|
|
440
|
+
if (Array.isArray(rule)) return rule;
|
|
441
|
+
if (typeof rule === "object" && !Array.isArray(rule) && Array.isArray(rule.content)) return rule.content;
|
|
442
|
+
}
|
|
443
|
+
/**
|
|
444
|
+
* Helper to see if a rule keyword is covered by a compound rule in the rule set.
|
|
445
|
+
* Checks whether any compound rule contains the same raw content value (by reference)
|
|
446
|
+
* as the rule for the given keyword. This works when the sub-rule was imported
|
|
447
|
+
* directly into the compound rule definition.
|
|
448
|
+
*/
|
|
449
|
+
function satisfiedByCompoundRule(keyword, rawRules) {
|
|
450
|
+
const rawRule = rawRules[keyword];
|
|
451
|
+
const ruleContent = getRawRuleContent(rawRule);
|
|
452
|
+
for (const otherRule of Object.values(rawRules)) {
|
|
453
|
+
const subRules = getCompoundSubRules(otherRule);
|
|
454
|
+
if (subRules === void 0) continue;
|
|
455
|
+
if (subRules.some((subRule) => getRawRuleContent(subRule) === ruleContent)) return true;
|
|
456
|
+
}
|
|
457
|
+
return false;
|
|
458
|
+
}
|
|
459
|
+
/**
|
|
460
|
+
* Check if comment order in document is different from order specified in the rules
|
|
461
|
+
*/
|
|
462
|
+
function checkCommentOrder(file, comments) {
|
|
463
|
+
const commentsInOrderOfAppearance = comments.filter((commentMarker) => commentMarker.type === "open" && commentMarker.rule?.order !== void 0);
|
|
464
|
+
const commentsInCorrectOrder = [...commentsInOrderOfAppearance].toSorted((a, b) => {
|
|
465
|
+
const orderA = a.rule?.order;
|
|
466
|
+
const orderB = b.rule?.order;
|
|
467
|
+
if (orderA === void 0 || orderB === void 0) throw new Error("Unexpected undefined rule order");
|
|
468
|
+
return orderA - orderB;
|
|
469
|
+
});
|
|
470
|
+
const currentOrderList = commentOrderList(commentsInOrderOfAppearance);
|
|
471
|
+
const correctOrderList = commentOrderList(commentsInCorrectOrder);
|
|
472
|
+
const table = new Table({
|
|
473
|
+
head: [picocolors.red(picocolors.bold("Current Order")), picocolors.green(picocolors.bold("Required Order"))],
|
|
474
|
+
style: { compact: true }
|
|
475
|
+
});
|
|
476
|
+
if (currentOrderList.join(",") !== correctOrderList.join(",")) {
|
|
477
|
+
table.push(...currentOrderList.map((currentOrder, index) => [currentOrder, correctOrderList[index]]));
|
|
478
|
+
saveLog(file, "error", "check", `Out of order:\n${table.toString()}`);
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
/**
|
|
482
|
+
* Check that meta presence / absence comment matches options.
|
|
483
|
+
*/
|
|
484
|
+
function checkMetaCommentPresence(file, comments, options) {
|
|
485
|
+
const { addMetaComment } = options;
|
|
486
|
+
const metaCommentCount = comments.filter((comment) => comment.type === "meta").length;
|
|
487
|
+
const shouldHaveMetaComment = typeof addMetaComment === "string" ? true : addMetaComment;
|
|
488
|
+
if (shouldHaveMetaComment && metaCommentCount !== 1) saveLog(file, "error", "check", `Missing meta comment`);
|
|
489
|
+
if (!shouldHaveMetaComment && metaCommentCount !== 0) saveLog(file, "error", "check", `Unexpected meta comment`);
|
|
490
|
+
if (metaCommentCount > 1) saveLog(file, "error", "check", `Multiple meta comments`);
|
|
491
|
+
}
|
|
492
|
+
function commentOrderList(comments) {
|
|
493
|
+
return comments.map((comment, index) => {
|
|
494
|
+
if (comment.type === "open" || comment.type === "close") return `${index + 1}. ${comment.html}`;
|
|
495
|
+
throw new Error("Unexpected comment type");
|
|
496
|
+
});
|
|
497
|
+
}
|
|
498
|
+
//#endregion
|
|
499
|
+
//#region src/lib/mdast-utils/mdast-util-mdat-clean.ts
|
|
500
|
+
/**
|
|
501
|
+
* Collapses any expanded mdat comments and removes meta comments,
|
|
502
|
+
* effectively resetting the document to its pre-expansion state. No-op if no
|
|
503
|
+
* mdat comments are found.
|
|
504
|
+
*/
|
|
505
|
+
function mdatClean(tree, file, options) {
|
|
506
|
+
let lastOpenMarker;
|
|
507
|
+
visit(tree, "html", (node, index, parent) => {
|
|
508
|
+
if (parent === void 0 || index === void 0) return CONTINUE;
|
|
509
|
+
const marker = parseCommentNode(node, parent, options);
|
|
510
|
+
if (marker === void 0 || marker.type === "native") return CONTINUE;
|
|
511
|
+
if (marker.type === "meta") {
|
|
512
|
+
parent.children.splice(index, 1);
|
|
513
|
+
return [CONTINUE, index];
|
|
514
|
+
}
|
|
515
|
+
if (marker.type === "open") {
|
|
516
|
+
lastOpenMarker = marker;
|
|
517
|
+
return CONTINUE;
|
|
518
|
+
}
|
|
519
|
+
if (marker.type === "close") {
|
|
520
|
+
if (lastOpenMarker === void 0) {
|
|
521
|
+
saveLog(file, "error", "clean", "Found closing marker without opening marker", node);
|
|
522
|
+
return CONTINUE;
|
|
523
|
+
}
|
|
524
|
+
if (lastOpenMarker.parent !== marker.parent) {
|
|
525
|
+
saveLog(file, "error", "clean", "Opening marker doesn't share a parent", node);
|
|
526
|
+
return CONTINUE;
|
|
527
|
+
}
|
|
528
|
+
if (lastOpenMarker.keyword !== marker.keyword) {
|
|
529
|
+
saveLog(file, "error", "clean", "Opening marker doesn't share a keyword", node);
|
|
530
|
+
return CONTINUE;
|
|
531
|
+
}
|
|
532
|
+
const openMarkerIndex = parent.children.indexOf(lastOpenMarker.node);
|
|
533
|
+
const nodesToRemove = parent.children.indexOf(marker.node) - openMarkerIndex + 1;
|
|
534
|
+
parent.children.splice(openMarkerIndex + 1, nodesToRemove - 1);
|
|
535
|
+
lastOpenMarker = void 0;
|
|
536
|
+
return [CONTINUE, index - nodesToRemove + 1];
|
|
537
|
+
}
|
|
538
|
+
});
|
|
539
|
+
}
|
|
540
|
+
//#endregion
|
|
541
|
+
//#region src/lib/mdast-utils/mdast-util-mdat-expand.ts
|
|
542
|
+
async function mdatExpand(tree, file, options) {
|
|
543
|
+
const { addMetaComment, closingPrefix, keywordPrefix, metaCommentIdentifier, rules: rawRules } = options;
|
|
544
|
+
validateRules(rawRules);
|
|
545
|
+
const rules = normalizeRules(rawRules);
|
|
546
|
+
const commentMarkers = [];
|
|
547
|
+
visit(tree, "html", (node, index, parent) => {
|
|
548
|
+
if (parent === void 0 || index === void 0) return CONTINUE;
|
|
549
|
+
const commentMarker = parseCommentNode(node, parent, {
|
|
550
|
+
closingPrefix,
|
|
551
|
+
keywordPrefix,
|
|
552
|
+
metaCommentIdentifier
|
|
553
|
+
});
|
|
554
|
+
if (commentMarker?.type === "open" && rules[commentMarker.keyword] !== void 0) commentMarkers.push(commentMarker);
|
|
555
|
+
});
|
|
556
|
+
commentMarkers.sort((a, b) => rules[a.keyword].applicationOrder - rules[b.keyword].applicationOrder);
|
|
557
|
+
for (const comment of commentMarkers) {
|
|
558
|
+
const { closingPrefix, html, keyword, keywordPrefix, node, options, parent } = comment;
|
|
559
|
+
const rule = rules[keyword];
|
|
560
|
+
let newMarkdownString = "";
|
|
561
|
+
try {
|
|
562
|
+
newMarkdownString = await getRuleContent(rule, options, tree);
|
|
563
|
+
if (newMarkdownString.trim() === "") saveLog(file, "error", "expand", `Got empty content when expanding ${html}`, node);
|
|
564
|
+
} catch (error) {
|
|
565
|
+
if (error instanceof Error) {
|
|
566
|
+
const causeMessage = error.cause instanceof Error ? `: ${error.cause.message}` : "";
|
|
567
|
+
saveLog(file, "error", "expand", `Caught error expanding ${html}, Error message: "${error.message}${causeMessage}"`, node);
|
|
568
|
+
}
|
|
569
|
+
continue;
|
|
570
|
+
}
|
|
571
|
+
const newNodes = remark().use(remarkGfm).parse(newMarkdownString).children;
|
|
572
|
+
const closingNode = {
|
|
573
|
+
type: "html",
|
|
574
|
+
value: `<!-- ${closingPrefix}${keywordPrefix}${keyword} -->`
|
|
575
|
+
};
|
|
576
|
+
const openingCommentIndex = parent.children.indexOf(node);
|
|
577
|
+
parent.children.splice(openingCommentIndex + 1, 0, ...newNodes, closingNode);
|
|
578
|
+
saveLog(file, "info", "expand", `Expanded: ${html}`, node);
|
|
579
|
+
}
|
|
580
|
+
if (addMetaComment) {
|
|
581
|
+
const metaComment = {
|
|
582
|
+
type: "html",
|
|
583
|
+
value: `<!--${metaCommentIdentifier} ${typeof addMetaComment === "string" ? addMetaComment : "Warning: Content inside HTML comment blocks was generated by mdat and may be overwritten."} ${metaCommentIdentifier}-->`
|
|
584
|
+
};
|
|
585
|
+
tree.children.unshift(metaComment);
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
//#endregion
|
|
589
|
+
//#region src/lib/mdast-utils/mdast-util-mdat-split.ts
|
|
590
|
+
/**
|
|
591
|
+
* Mdast utility plugin to split any multi-comment nodes and their content into individual MDAST HTML
|
|
592
|
+
* nodes. They're wrapped in a paragraph so as not to introduce new breaks.
|
|
593
|
+
*/
|
|
594
|
+
function mdatSplit(tree, file) {
|
|
595
|
+
visit(tree, "html", (node, index, parent) => {
|
|
596
|
+
if (parent === void 0 || index === void 0) return CONTINUE;
|
|
597
|
+
const htmlNodes = splitHtmlIntoMdastNodes(node);
|
|
598
|
+
if (htmlNodes.length > 1) {
|
|
599
|
+
saveLog(file, "warn", "split", "Multiple comments in a single HTML node.", node);
|
|
600
|
+
parent.children.splice(index, 1, {
|
|
601
|
+
children: htmlNodes,
|
|
602
|
+
type: "paragraph"
|
|
603
|
+
});
|
|
604
|
+
}
|
|
605
|
+
});
|
|
606
|
+
}
|
|
607
|
+
function splitHtmlIntoMdastNodes(mdastNode) {
|
|
608
|
+
const htmlTree = fromHtml(mdastNode.value, { fragment: true });
|
|
609
|
+
const mdastNodes = [];
|
|
610
|
+
visit(htmlTree, (hastNode) => {
|
|
611
|
+
if (hastNode.type === "root") return CONTINUE;
|
|
612
|
+
if (hastNode.type === "text") {
|
|
613
|
+
mdastNodes.push({
|
|
614
|
+
position: addStartPoint(hastNode.position, mdastNode.position?.start),
|
|
615
|
+
type: "text",
|
|
616
|
+
value: getOriginalMarkup(mdastNode, hastNode)
|
|
617
|
+
});
|
|
618
|
+
return CONTINUE;
|
|
619
|
+
}
|
|
620
|
+
mdastNodes.push({
|
|
621
|
+
position: addStartPoint(hastNode.position, mdastNode.position?.start),
|
|
622
|
+
type: "html",
|
|
623
|
+
value: getOriginalMarkup(mdastNode, hastNode)
|
|
624
|
+
});
|
|
625
|
+
return SKIP;
|
|
626
|
+
});
|
|
627
|
+
return mdastNodes;
|
|
628
|
+
}
|
|
629
|
+
function addStartPoint(position, start) {
|
|
630
|
+
if (position === void 0 || start === void 0) return void 0;
|
|
631
|
+
const startLine = position.start.line - 1 + start.line;
|
|
632
|
+
const endLine = position.end.line - 1 + start.line;
|
|
633
|
+
return {
|
|
634
|
+
start: {
|
|
635
|
+
column: position.start.line === 1 ? position.start.column - 1 + start.column : position.start.column,
|
|
636
|
+
line: startLine,
|
|
637
|
+
offset: position.start.offset !== void 0 && start.offset !== void 0 ? position.start.offset + start.offset : void 0
|
|
638
|
+
},
|
|
639
|
+
end: {
|
|
640
|
+
column: position.end.line === 1 ? position.end.column - 1 + start.column : position.end.column,
|
|
641
|
+
line: endLine,
|
|
642
|
+
offset: position.end.offset !== void 0 && start.offset !== void 0 ? position.end.offset + start.offset : void 0
|
|
643
|
+
}
|
|
644
|
+
};
|
|
645
|
+
}
|
|
646
|
+
function getOriginalMarkup(mdastNode, hastNode) {
|
|
647
|
+
if (hastNode.position === void 0) throw new Error("Hast ElementContent node has no position!");
|
|
648
|
+
return mdastNode.value.slice(hastNode.position.start.offset, hastNode.position.end.offset);
|
|
649
|
+
}
|
|
650
|
+
//#endregion
|
|
651
|
+
//#region src/lib/mdast-utils/mdast-util-mdat.ts
|
|
652
|
+
async function mdat(tree, file, options) {
|
|
653
|
+
const { addMetaComment, closingPrefix, keywordPrefix, metaCommentIdentifier, rules } = options;
|
|
654
|
+
mdatSplit(tree, file);
|
|
655
|
+
mdatClean(tree, file, {
|
|
656
|
+
closingPrefix,
|
|
657
|
+
keywordPrefix,
|
|
658
|
+
metaCommentIdentifier
|
|
659
|
+
});
|
|
660
|
+
await mdatExpand(tree, file, {
|
|
661
|
+
addMetaComment,
|
|
662
|
+
closingPrefix,
|
|
663
|
+
keywordPrefix,
|
|
664
|
+
metaCommentIdentifier,
|
|
665
|
+
rules
|
|
666
|
+
});
|
|
667
|
+
await mdatCheck(tree, file, {
|
|
668
|
+
addMetaComment,
|
|
669
|
+
closingPrefix,
|
|
670
|
+
keywordPrefix,
|
|
671
|
+
metaCommentIdentifier,
|
|
672
|
+
paranoid: false,
|
|
673
|
+
rules
|
|
674
|
+
});
|
|
675
|
+
}
|
|
676
|
+
//#endregion
|
|
677
|
+
//#region src/lib/mdat/deep-merge-defined.ts
|
|
678
|
+
function stripUndefinedDeep(object) {
|
|
679
|
+
if (Array.isArray(object)) return object.map((v) => v && typeof v === "object" ? stripUndefinedDeep(v) : v).filter((v) => v !== void 0);
|
|
680
|
+
return Object.entries(object).map(([k, v]) => [k, v && typeof v === "object" ? stripUndefinedDeep(v) : v]).reduce((acc, [k, v]) => v === void 0 ? acc : {
|
|
681
|
+
...acc,
|
|
682
|
+
[k]: v
|
|
683
|
+
}, {});
|
|
684
|
+
}
|
|
685
|
+
function deepMergeDefined(...objects) {
|
|
686
|
+
return deepmerge(...objects.map((v, i) => i === 0 ? v : stripUndefinedDeep(v)));
|
|
687
|
+
}
|
|
688
|
+
//#endregion
|
|
689
|
+
//#region src/lib/remark-mdat.ts
|
|
690
|
+
const defaultOptions = {
|
|
691
|
+
addMetaComment: false,
|
|
692
|
+
closingPrefix: "/",
|
|
693
|
+
keywordPrefix: "",
|
|
694
|
+
metaCommentIdentifier: "+",
|
|
695
|
+
rules: { mdat: `Powered by the Markdown Autophagic Template system: [mdat](https://github.com/kitschpatrol/mdat).` }
|
|
696
|
+
};
|
|
697
|
+
const optionsSchema = z.object({
|
|
698
|
+
addMetaComment: z.union([z.boolean(), z.string()]).optional(),
|
|
699
|
+
closingPrefix: z.string().min(1).optional(),
|
|
700
|
+
keywordPrefix: z.string().optional(),
|
|
701
|
+
metaCommentIdentifier: z.string().optional(),
|
|
702
|
+
rules: rulesSchema.optional()
|
|
703
|
+
}).describe("MDAT Options");
|
|
704
|
+
/**
|
|
705
|
+
* A remark plugin that expands HTML comments in Markdown files.
|
|
706
|
+
*/
|
|
707
|
+
const remarkMdat = function(options) {
|
|
708
|
+
const resolvedOptions = deepMergeDefined(defaultOptions, options);
|
|
709
|
+
return async function(tree, file) {
|
|
710
|
+
await mdat(tree, file, resolvedOptions);
|
|
711
|
+
};
|
|
712
|
+
};
|
|
713
|
+
//#endregion
|
|
714
|
+
export { deepMergeDefined, remarkMdat as default, getMdatReports, getSoleRule, getSoleRuleKey, log, mdat, mdatCheck, mdatClean, mdatExpand, mdatSplit, optionsSchema, reporterMdat, rulesSchema };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "remark-mdat",
|
|
3
|
-
"version": "1.2.
|
|
3
|
+
"version": "1.2.5",
|
|
4
4
|
"description": "A remark plugin implementing the Markdown Autophagic Template (MDAT) system.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"mdat",
|
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
"email": "eric@ericmika.com",
|
|
27
27
|
"url": "https://ericmika.com"
|
|
28
28
|
},
|
|
29
|
+
"sideEffects": false,
|
|
29
30
|
"type": "module",
|
|
30
31
|
"exports": {
|
|
31
32
|
".": {
|
|
@@ -49,7 +50,7 @@
|
|
|
49
50
|
"picocolors": "^1.1.1",
|
|
50
51
|
"remark": "^15.0.1",
|
|
51
52
|
"remark-gfm": "^4.0.1",
|
|
52
|
-
"type-fest": "^5.
|
|
53
|
+
"type-fest": "^5.5.0",
|
|
53
54
|
"unified": "^11.0.5",
|
|
54
55
|
"unist-util-visit": "^5.1.0",
|
|
55
56
|
"vfile": "^6.0.3",
|
|
@@ -58,13 +59,13 @@
|
|
|
58
59
|
},
|
|
59
60
|
"devDependencies": {
|
|
60
61
|
"@arethetypeswrong/core": "^0.18.2",
|
|
61
|
-
"@kitschpatrol/shared-config": "^6.
|
|
62
|
-
"@types/node": "~20.19.
|
|
63
|
-
"bumpp": "^
|
|
64
|
-
"publint": "^0.3.
|
|
65
|
-
"tsdown": "^0.
|
|
62
|
+
"@kitschpatrol/shared-config": "^6.1.0",
|
|
63
|
+
"@types/node": "~20.19.37",
|
|
64
|
+
"bumpp": "^11.0.1",
|
|
65
|
+
"publint": "^0.3.18",
|
|
66
|
+
"tsdown": "^0.21.4",
|
|
66
67
|
"typescript": "~5.9.3",
|
|
67
|
-
"vitest": "^4.
|
|
68
|
+
"vitest": "^4.1.1"
|
|
68
69
|
},
|
|
69
70
|
"engines": {
|
|
70
71
|
"node": ">=20.0.0"
|
|
@@ -82,6 +83,6 @@
|
|
|
82
83
|
"fix": "ksc fix",
|
|
83
84
|
"lint": "ksc lint",
|
|
84
85
|
"release": "bumpp --commit 'Release: %s' && pnpm run build && NPM_AUTH_TOKEN=$(op read 'op://Personal/npm/token') && pnpm publish",
|
|
85
|
-
"test": "vitest"
|
|
86
|
+
"test": "vitest run"
|
|
86
87
|
}
|
|
87
88
|
}
|
package/readme.md
CHANGED
|
@@ -6,10 +6,18 @@
|
|
|
6
6
|
|
|
7
7
|
<!-- /title -->
|
|
8
8
|
|
|
9
|
-
<!-- badges
|
|
9
|
+
<!-- badges { custom: {
|
|
10
|
+
"CI": {
|
|
11
|
+
image: "https://github.com/kitschpatrol/remark-mdat/actions/workflows/ci.yml/badge.svg",
|
|
12
|
+
link: "https://github.com/kitschpatrol/remark-mdat/actions/workflows/ci.yml",
|
|
13
|
+
},
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
-->
|
|
10
17
|
|
|
11
18
|
[](https://npmjs.com/package/remark-mdat)
|
|
12
19
|
[](https://opensource.org/licenses/MIT)
|
|
20
|
+
[](https://github.com/kitschpatrol/remark-mdat/actions/workflows/ci.yml)
|
|
13
21
|
|
|
14
22
|
<!-- /badges -->
|
|
15
23
|
|