-
-
Notifications
You must be signed in to change notification settings - Fork 6
/
index.ts
362 lines (315 loc) · 13 KB
/
index.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
/* ——————————————————————————————————————————————————————————————————————————— *
* © Julian Cataldo — https://www.juliancataldo.com. *
* See LICENSE in the project root. *
/* —————————————————————————————————————————————————————————————————————————— */
/* eslint-disable max-lines */
import path from 'node:path';
import { existsSync } from 'node:fs';
import { findUp } from 'find-up';
// NOTE: minimatch@9 is breaking import.
import { minimatch } from 'minimatch';
/* ·········································································· */
import yaml, { type Document, isNode, LineCounter } from 'yaml';
import Ajv from 'ajv';
import type { Options as AjvOptions, ErrorObject as AjvErrorObject } from 'ajv';
import addFormats from 'ajv-formats';
import $RefParser from '@apidevtools/json-schema-ref-parser';
import type { JSONSchema7 } from 'json-schema';
/* ·········································································· */
import { lintRule } from 'unified-lint-rule';
import type { VFile } from 'unified-lint-rule/lib';
import type { Root, YAML } from 'mdast';
import type { VFileMessage } from 'vfile-message';
/* —————————————————————————————————————————————————————————————————————————— */
const url = 'https://github.com/JulianCataldo/remark-lint-frontmatter-schema';
const nativeJsErrorName = 'Markdown YAML frontmatter error (JSON Schema)';
export interface Settings {
/**
* Global workspace file associations mapping (for linter extension).
*
* **Example**: `'schemas/thing.schema.yaml': ['content/things/*.md']`
*/
schemas?: Record<string, string[]>;
/**
* Direct schema embedding (for using inside an `unified` transform pipeline).
*
* Format: JSON Schema - draft-2019-09
*
* **Documentation**: https://ajv.js.org/json-schema.html#draft-07
*/
embed?: JSONSchema7;
/**
* **Documentation**: https://ajv.js.org/options.html
*/
ajvOptions?: AjvOptions;
}
// IDEA: Might be interesting to populate with corresponding error reference
type JSONSchemaReference = 'https://ajv.js.org/json-schema.html';
export interface FrontmatterSchemaMessage extends VFileMessage {
schema: AjvErrorObject & { url: JSONSchemaReference };
}
interface FrontmatterObject {
$schema: string | undefined;
/* This is the typical Frontmatter object, as treated by common consumers */
[key: string]: unknown;
}
/* ·········································································· */
/* The vFile cwd isn't the same as the one from IDE extension.
Extension will cascade upward from the current processed file and
take the remarkrc file as its cwd. It's multi-level workspace
friendly. We have to mimick this behavior here, as remark lint rules doesn't
seems to offer an API to hook up on this? */
async function getRemarkCwd(startDir: string) {
const remarkConfigNames = [
'.remarkrc',
'.remarkrc.json',
'.remarkrc.yaml',
'.remarkrc.yml',
'.remarkrc.mjs',
'.remarkrc.js',
'.remarkrc.cjs',
];
const remarkConfPath = await findUp(remarkConfigNames, {
cwd: path.dirname(startDir),
});
if (remarkConfPath) {
return path.dirname(remarkConfPath);
}
return process.cwd();
}
/* ·········································································· */
function pushErrors(
errors: AjvErrorObject[],
yamlDoc: Document,
vFile: VFile,
/** File path from local `$schema` key or from global settings */
schemaRelPath: string,
/** Used to map character range to line / column tuple */
lineCounter: LineCounter,
) {
errors.forEach((error) => {
let reason = '';
if (error.message) {
/* Capitalize error message */
const errMessage =
`${error.message.charAt(0).toUpperCase()}` +
`${error.message.substring(1)}`;
let expected = '';
if (Array.isArray(error.params.allowedValues)) {
expected = `: \`${error.params.allowedValues.join('`, `')}\``;
} else if (typeof error.params.allowedValue === 'string') {
expected = `: \`${error.params.allowedValue}\``;
}
const sPath = schemaRelPath ? ` • ${schemaRelPath}` : '';
reason = `${errMessage}${expected}${sPath} • ${error.schemaPath}`;
}
/* Explode AJV error instance path and get corresponding YAML AST node */
const ajvPath = error.instancePath.substring(1).split('/');
const node = yamlDoc.getIn(ajvPath, true);
const message = vFile.message(reason);
// FIXME: Doesn't seems to be used in custom pipeline?
// Always returning `false`
message.fatal = true;
/* `name` comes from native JS `Error` object */
message.name = nativeJsErrorName;
/* Map YAML characters range to column / line positions,
-OR- squiggling the opening frontmatter fence for **root** path errors */
if (isNode(node)) {
/* Incriminated token */
message.actual = node.toString();
/* Map AJV Range to VFile Position, via YAML lib. parser */
if (node.range) {
const OPENING_FENCE_LINE_COUNT = 1; /* Takes the `---` into account */
const start = lineCounter.linePos(node.range[0]);
const end = lineCounter.linePos(node.range[1]);
message.position = {
start: {
line: start.line + OPENING_FENCE_LINE_COUNT,
column: start.col,
},
end: {
line: end.line + OPENING_FENCE_LINE_COUNT,
column: end.col,
},
};
// NOTE: Seems redundant, but otherwise, it is always set to 1:1 */
message.line = message.position.start.line;
message.column = message.position.start.column;
}
}
/* Assemble pretty per-error insights for end-user */
let note = `Keyword: ${error.keyword}`;
if (Array.isArray(error.params.allowedValues)) {
note += `\nAllowed values: ${error.params.allowedValues.join(', ')}`;
/* Auto-fix replacement suggestions for `enum` */
message.expected = error.params.allowedValues;
} else if (typeof error.params.allowedValue === 'string') {
note += `\nAllowed value: ${error.params.allowedValue}`;
/* Auto-fix replacement suggestion for `const` */
message.expected = [error.params.allowedValue];
}
if (typeof error.params.missingProperty === 'string') {
note += `\nMissing property: ${error.params.missingProperty}`;
}
if (typeof error.params.type === 'string') {
note += `\nType: ${error.params.type}`;
}
/* `schemaRelPath` path prefix will show up only when using
file association, not when using pipeline embedded schema */
note += `\nSchema path: ${schemaRelPath} · ${error.schemaPath}`;
message.note = note;
/* `message` comes from native JS `Error` object */
message.message = reason;
/* Adding custom data from AJV */
/* It’s OK to store custom data directly on the VFileMessage:
https://github.com/vfile/vfile-message#well-known-fields */
// NOTE: Might be better to type `message` before, instead of asserting here
(message as FrontmatterSchemaMessage).schema = {
url: 'https://ajv.js.org/json-schema.html',
...error,
};
});
}
/* ·········································································· */
async function validateFrontmatter(
sourceYaml: YAML,
vFile: VFile,
settings: Settings,
) {
const hasPropSchema = typeof settings.embed === 'object';
const lineCounter = new LineCounter();
let yamlDoc;
let yamlJS;
let hasLocalAssoc = false;
let schemaPathFromCwd: string | undefined;
const remarkCwd = await getRemarkCwd(vFile.path);
/* Parse the YAML literal and get the YAML Abstract Syntax Tree,
previously extracted by `remark-frontmatter` */
try {
yamlDoc = yaml.parseDocument(sourceYaml.value, { lineCounter });
yamlJS = yamlDoc.toJS() as FrontmatterObject | null;
/* Local `$schema` association takes precedence over global / prop. */
if (yamlJS?.$schema && typeof yamlJS.$schema === 'string') {
hasLocalAssoc = true;
/* Fallback if it's an embedded schema (no `path`) */
const vFilePath = vFile.path || '';
/* From current processed file directory (e.g. `./foo…` or `../foo…`) */
const dirFromCwd = path.isAbsolute(vFilePath)
? path.relative(process.cwd(), path.dirname(vFilePath))
: path.dirname(vFilePath);
const standardPath = path.join(dirFromCwd, yamlJS.$schema);
if (existsSync(standardPath)) {
schemaPathFromCwd = standardPath;
} else {
/* Non standard behavior, like TS / Vite, not JSON Schema resolution.
Resolving `/my/path` or `my/path` from current remark project root */
schemaPathFromCwd = path.join(remarkCwd, yamlJS.$schema);
}
}
} catch (error) {
if (error instanceof Error) {
const banner = `YAML frontmatter parsing: ${schemaPathFromCwd ?? ''}`;
vFile.message(`${banner} — ${error.name}: ${error.message}`);
}
}
/* ········································································ */
/* Global schemas associations, only if no local schema is set */
if (yamlDoc && yamlJS && !hasLocalAssoc) {
Object.entries(settings.schemas ?? {}).forEach(
([globSchemaPath, globSchemaAssocs]) => {
/* Check if current markdown file is associated with this schema */
globSchemaAssocs.forEach((mdFilePath) => {
if (typeof mdFilePath === 'string') {
const mdPathCleaned = path.normalize(mdFilePath);
/* With `remark`, `vFile.path` is already relative to project root,
while `eslint-plugin-mdx` gives an absolute path */
const vFilePathRel = path.relative(remarkCwd, vFile.path);
if (minimatch(vFilePathRel, mdPathCleaned)) {
schemaPathFromCwd = path.join(remarkCwd, globSchemaPath);
}
}
});
},
);
}
/* ········································································ */
let schema: JSONSchema7 | undefined;
if (hasPropSchema) {
schema = settings.embed;
} else if (schemaPathFromCwd) {
/* Load schema + references */
schema = await $RefParser
// NOTE: Ext. `$refs` are embedded, not local defs.
// Could be useful to embed ext. refs. in definitions,
// so we could keep the ref. name for debugging?
.bundle(schemaPathFromCwd)
.catch((error) => {
if (error instanceof Error) {
const banner = `YAML schema file load/parse: ${
schemaPathFromCwd ?? ''
}`;
vFile.message(`${banner} — ${error.name}: ${error.message}`);
}
return undefined;
})
/* Asserting then using a JSONSchema4 for AJV (JSONSchema7) is OK */
.then((refSchema) =>
refSchema ? (refSchema as JSONSchema7) : undefined,
);
/* Schema is now extracted,
remove in-file `$schema` key, so it will not interfere later */
if (hasLocalAssoc && yamlJS && typeof yamlJS.$schema === 'string') {
delete yamlJS.$schema;
}
}
/* ········································································ */
/* We got an extracted schema to work with */
if (schema && yamlDoc) {
/* Setup AJV (Another JSON-Schema Validator) */
const ajv = new Ajv({
/* Defaults */
allErrors: true /* So it doesn't stop at the first found error */,
strict: false /* Prevents warnings for valid, but relaxed schemas */,
/* User settings / overrides */
...settings.ajvOptions,
});
addFormats(ajv);
/* JSON Schema compilation + validation with AJV */
try {
const validate = ajv.compile(schema);
validate(yamlJS);
/* Push JSON Schema validation failures messages */
if (validate.errors?.length) {
pushErrors(
validate.errors,
yamlDoc,
vFile,
schemaPathFromCwd ?? '',
lineCounter,
);
}
} catch (error) {
if (error instanceof Error) {
const banner = `JSON schema malformed: ${schemaPathFromCwd ?? ''}`;
vFile.message(`${banner} — ${error.name}: ${error.message}`);
}
}
}
}
/* ·········································································· */
const remarkFrontmatterSchema = lintRule(
{
url,
origin: 'remark-lint:frontmatter-schema',
},
async (ast: Root, vFile: VFile, settings: Settings = {}) => {
if (ast.children.length) {
/* Handle only if the processed Markdown file has a frontmatter section */
const frontmatter = ast.children.find((child) => child.type === 'yaml');
if (frontmatter?.type === 'yaml') {
await validateFrontmatter(frontmatter, vFile, settings);
}
}
},
);
export default remarkFrontmatterSchema;