@mintlify/cli 4.0.1743 → 4.0.1745
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/__test__/foreignSyntax.test.ts +73 -0
- package/__test__/validateCommand.test.ts +64 -20
- package/__test__/validateEngine.test.ts +17 -4
- package/__test__/validateRules.test.ts +659 -8
- package/bin/tsconfig.build.tsbuildinfo +1 -1
- package/bin/validate/engine.js +6 -13
- package/bin/validate/files.js +26 -30
- package/bin/validate/mdx.js +38 -1
- package/bin/validate/rules/build.js +16 -8
- package/bin/validate/rules/content/duplicateH1.js +57 -0
- package/bin/validate/rules/content/foreignSyntax.js +120 -0
- package/bin/validate/rules/content/katex.js +34 -0
- package/bin/validate/rules/content/unknownComponent.js +121 -0
- package/bin/validate/rules/index.js +13 -1
- package/bin/validate/site.js +11 -9
- package/package.json +10 -8
- package/src/validate/engine.ts +6 -12
- package/src/validate/files.ts +41 -31
- package/src/validate/mdx.ts +38 -0
- package/src/validate/rules/build.ts +27 -13
- package/src/validate/rules/content/duplicateH1.ts +78 -0
- package/src/validate/rules/content/foreignSyntax.ts +144 -0
- package/src/validate/rules/content/katex.ts +33 -0
- package/src/validate/rules/content/unknownComponent.ts +130 -0
- package/src/validate/rules/index.ts +13 -1
- package/src/validate/site.ts +9 -8
- package/src/validate/types.ts +3 -0
package/bin/validate/engine.js
CHANGED
|
@@ -97,20 +97,13 @@ const parseLevel = (file, content, baseDir) => {
|
|
|
97
97
|
/** Applies every fix that doesn't overlap an earlier fix in the same file, writing each Markdown file only when its fixed content parses at least as well as before. */
|
|
98
98
|
export const applyFixes = (site, diagnostics) => __awaiter(void 0, void 0, void 0, function* () {
|
|
99
99
|
var _a;
|
|
100
|
-
const
|
|
101
|
-
const read = (file) => __awaiter(void 0, void 0, void 0, function* () {
|
|
102
|
-
var _a;
|
|
103
|
-
if (!contents.has(file)) {
|
|
104
|
-
contents.set(file, yield fs.readFile(path.join(site.baseDir, file), 'utf-8'));
|
|
105
|
-
}
|
|
106
|
-
return (_a = contents.get(file)) !== null && _a !== void 0 ? _a : '';
|
|
107
|
-
});
|
|
100
|
+
const read = (file) => { var _a; return (_a = site.read(file)) !== null && _a !== void 0 ? _a : ''; };
|
|
108
101
|
const chosen = new Map();
|
|
109
102
|
const overlaps = ({ file, start, end }) => { var _a; return ((_a = chosen.get(file)) !== null && _a !== void 0 ? _a : []).some(({ fix }) => start < fix.end && fix.start < end); };
|
|
110
103
|
for (const { rule, fix } of diagnostics) {
|
|
111
104
|
if (!fix || overlaps(fix))
|
|
112
105
|
continue;
|
|
113
|
-
const content =
|
|
106
|
+
const content = read(fix.file);
|
|
114
107
|
const change = Object.assign(Object.assign({ rule, file: fix.file }, toPosition(content, fix.start)), { before: content.slice(fix.start, fix.end), after: fix.text });
|
|
115
108
|
chosen.set(fix.file, [...((_a = chosen.get(fix.file)) !== null && _a !== void 0 ? _a : []), { fix, change }]);
|
|
116
109
|
}
|
|
@@ -118,7 +111,7 @@ export const applyFixes = (site, diagnostics) => __awaiter(void 0, void 0, void
|
|
|
118
111
|
const level = (file, content) => parseLevel(file, snippets.has(file) ? replaceVariables(content, site.variables) : content, site.baseDir);
|
|
119
112
|
const applied = [];
|
|
120
113
|
for (const [file, picks] of chosen) {
|
|
121
|
-
const content =
|
|
114
|
+
const content = read(file);
|
|
122
115
|
const updated = applyEdits(content, selectEdits(picks.map(({ fix }) => fix)));
|
|
123
116
|
if (/\.mdx?$/.test(file) && level(file, updated) < level(file, content)) {
|
|
124
117
|
continue;
|
|
@@ -136,7 +129,7 @@ const MAX_FIX_PASSES = 5;
|
|
|
136
129
|
*/
|
|
137
130
|
export const checkSite = (baseDir, rules, options) => __awaiter(void 0, void 0, void 0, function* () {
|
|
138
131
|
const fixed = [];
|
|
139
|
-
|
|
132
|
+
let site = yield loadSite(baseDir, options);
|
|
140
133
|
const { selected } = options;
|
|
141
134
|
if (selected && !site.files.some(({ file }) => selected.has(file))) {
|
|
142
135
|
throw new UsageError(`none of the given files are pages or snippets of this site: ${[...selected].join(', ')}`);
|
|
@@ -151,11 +144,11 @@ export const checkSite = (baseDir, rules, options) => __awaiter(void 0, void 0,
|
|
|
151
144
|
if (changes.length === 0)
|
|
152
145
|
break;
|
|
153
146
|
fixed.push(...changes);
|
|
147
|
+
site = yield loadSite(baseDir, options);
|
|
154
148
|
const changed = new Set(changes.map(({ file }) => file));
|
|
155
149
|
pending = sortDiagnostics(yield checkFiles(Object.assign(Object.assign({}, site), { selected: changed }), fileRulesOf(rules)));
|
|
156
150
|
}
|
|
157
151
|
if (fixed.length === 0)
|
|
158
152
|
return { site, diagnostics, fixed };
|
|
159
|
-
|
|
160
|
-
return { site: fixedSite, diagnostics: yield runChecks(fixedSite, rules), fixed };
|
|
153
|
+
return { site, diagnostics: yield runChecks(site, rules), fixed };
|
|
161
154
|
});
|
package/bin/validate/files.js
CHANGED
|
@@ -20,22 +20,8 @@ var __rest = (this && this.__rest) || function (s, e) {
|
|
|
20
20
|
};
|
|
21
21
|
import { getAST, getFileCategory, parseFrontmatter, replaceVariables } from '@mintlify/common';
|
|
22
22
|
import { categorizeMdxFiles, getErrorPosition, getMintIgnore, listContentFiles, } from '@mintlify/prebuild';
|
|
23
|
-
import
|
|
24
|
-
import path from 'node:path';
|
|
25
|
-
import { applyEdits, parseContent, relocate, selectEdits, unmapEdit } from './mdx.js';
|
|
23
|
+
import { applyEdits, collectAnchorLinks, parseContent, relocate, selectEdits, unmapEdit, } from './mdx.js';
|
|
26
24
|
import { PARSE_ERROR } from './rules/parseError.js';
|
|
27
|
-
const CONCURRENCY = 16;
|
|
28
|
-
const mapLimit = (items, task) => __awaiter(void 0, void 0, void 0, function* () {
|
|
29
|
-
const results = new Array(items.length);
|
|
30
|
-
let next = 0;
|
|
31
|
-
yield Promise.all(Array.from({ length: Math.min(CONCURRENCY, items.length) }, () => __awaiter(void 0, void 0, void 0, function* () {
|
|
32
|
-
while (next < items.length) {
|
|
33
|
-
const index = next++;
|
|
34
|
-
results[index] = yield task(items[index]);
|
|
35
|
-
}
|
|
36
|
-
})));
|
|
37
|
-
return results;
|
|
38
|
-
});
|
|
39
25
|
const CHECKED_CATEGORIES = new Set(['page', 'snippet', 'snippet-v2']);
|
|
40
26
|
export const readSiteFiles = (baseDir) => __awaiter(void 0, void 0, void 0, function* () {
|
|
41
27
|
const { mdxCandidates: candidates } = yield listContentFiles(baseDir, yield getMintIgnore(baseDir));
|
|
@@ -74,8 +60,8 @@ const tryParseContent = (content) => {
|
|
|
74
60
|
return undefined;
|
|
75
61
|
}
|
|
76
62
|
};
|
|
77
|
-
export const parseFile = (file, content, baseDir) => {
|
|
78
|
-
const context = { file, content, baseDir, frontmatter: readFrontmatter(content) };
|
|
63
|
+
export const parseFile = (file, content, baseDir, extras = {}) => {
|
|
64
|
+
const context = Object.assign({ file, content, baseDir, frontmatter: readFrontmatter(content) }, extras);
|
|
79
65
|
const tree = tryParseContent(content);
|
|
80
66
|
if (tree)
|
|
81
67
|
return { context: Object.assign(Object.assign({}, context), { tree }), approximate: false };
|
|
@@ -102,7 +88,7 @@ const toDiagnostic = (rule, file, _a, stages, stage) => {
|
|
|
102
88
|
const original = edit && unmapEdit(stages, stage, edit);
|
|
103
89
|
return Object.assign(Object.assign(Object.assign(Object.assign({}, finding), relocate(stages, stage, finding)), { rule: rule.id, group: rule.group, severity: rule.severity, file }), (original && { fix: Object.assign({ file }, original) }));
|
|
104
90
|
};
|
|
105
|
-
const checkFile = (allRules, { file, kind }, content, baseDir, variables) => {
|
|
91
|
+
const checkFile = (allRules, { file, kind }, content, baseDir, extras, variables) => {
|
|
106
92
|
const rules = kind === 'snippet' ? allRules.filter((rule) => !rule.pagesOnly) : allRules;
|
|
107
93
|
const raw = rules.filter((rule) => rule.raw);
|
|
108
94
|
const treeRules = rules.filter((rule) => !rule.raw);
|
|
@@ -118,7 +104,7 @@ const checkFile = (allRules, { file, kind }, content, baseDir, variables) => {
|
|
|
118
104
|
{ content: afterRaw, edits: [] },
|
|
119
105
|
];
|
|
120
106
|
const diagnostics = rawFound.map(({ rule, finding }) => toDiagnostic(rule, file, finding, stages, SOURCE_STAGE));
|
|
121
|
-
const parsed = parseFile(file, afterRaw, baseDir);
|
|
107
|
+
const parsed = parseFile(file, afterRaw, baseDir, extras);
|
|
122
108
|
if ('error' in parsed) {
|
|
123
109
|
diagnostics.unshift(parseErrorDiagnostic(file, Object.assign(Object.assign({}, parsed.error), relocate(stages, AFTER_RAW_STAGE, parsed.error))));
|
|
124
110
|
return diagnostics;
|
|
@@ -131,18 +117,28 @@ const checkFile = (allRules, { file, kind }, content, baseDir, variables) => {
|
|
|
131
117
|
}
|
|
132
118
|
return diagnostics;
|
|
133
119
|
};
|
|
120
|
+
const collectSiteAnchorLinks = (site) => __awaiter(void 0, void 0, void 0, function* () {
|
|
121
|
+
const pages = yield Promise.all(site.files.map(({ file }) => { var _a; return collectAnchorLinks(file, (_a = site.read(file)) !== null && _a !== void 0 ? _a : ''); }));
|
|
122
|
+
return new Set(pages.flat());
|
|
123
|
+
});
|
|
134
124
|
export const checkFiles = (site, rules) => __awaiter(void 0, void 0, void 0, function* () {
|
|
125
|
+
const anchorRules = new Set(rules.filter((rule) => rule.needsAnchorLinks).map(({ id }) => id));
|
|
135
126
|
const targets = site.files.filter(({ file }) => !site.selected || site.selected.has(file));
|
|
136
|
-
const
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
127
|
+
const checkTarget = (entry, extras) => {
|
|
128
|
+
const content = site.read(entry.file);
|
|
129
|
+
if (content === undefined) {
|
|
130
|
+
return [parseErrorDiagnostic(entry.file, { message: 'could not read this file' })];
|
|
140
131
|
}
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
132
|
+
return checkFile(rules, entry, content, site.baseDir, extras, site.variables);
|
|
133
|
+
};
|
|
134
|
+
const needsLinks = (diagnostics) => diagnostics.some(({ rule }) => anchorRules.has(rule));
|
|
135
|
+
const checked = targets.map((entry) => checkTarget(entry, { readFile: site.read }));
|
|
136
|
+
if (!checked.some(needsLinks))
|
|
137
|
+
return checked.flat();
|
|
138
|
+
const extras = { readFile: site.read, anchorLinks: yield collectSiteAnchorLinks(site) };
|
|
139
|
+
return targets.flatMap((entry, index) => {
|
|
140
|
+
var _a;
|
|
141
|
+
const diagnostics = (_a = checked[index]) !== null && _a !== void 0 ? _a : [];
|
|
142
|
+
return needsLinks(diagnostics) ? checkTarget(entry, extras) : diagnostics;
|
|
143
|
+
});
|
|
148
144
|
});
|
package/bin/validate/mdx.js
CHANGED
|
@@ -1,4 +1,14 @@
|
|
|
1
|
-
|
|
1
|
+
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
|
|
2
|
+
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
|
|
3
|
+
return new (P || (P = Promise))(function (resolve, reject) {
|
|
4
|
+
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
|
|
5
|
+
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
|
|
6
|
+
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
|
|
7
|
+
step((generator = generator.apply(thisArg, _arguments || [])).next());
|
|
8
|
+
});
|
|
9
|
+
};
|
|
10
|
+
import { coreRemark, FRONTMATTER_RE, getCodeRanges, HEADING_ID_RE, JSXParser, pagePathToSlug, replaceSlashIndex, safeCleanHeadingId, toPosixPath, } from '@mintlify/common';
|
|
11
|
+
import { decorateGraphNodeFromPageContent, Node, PathType } from '@mintlify/link-rot';
|
|
2
12
|
const HEADING_ID = new RegExp(HEADING_ID_RE.source, 'gm');
|
|
3
13
|
const ESM_START = /^(?:import|export)(?=[\s{*])/;
|
|
4
14
|
const JSX_TAG = /<[A-Za-z][\w.:-]*(?:"[^"]*"|'[^']*'|\{[^{}]*\}|[^<>"'{}])*>/g;
|
|
@@ -199,3 +209,30 @@ export const relocate = (stages, index, position) => {
|
|
|
199
209
|
const mapped = toPosition(original.content, offset);
|
|
200
210
|
return position.column === undefined ? { line: mapped.line } : mapped;
|
|
201
211
|
};
|
|
212
|
+
const pagePath = (file) => replaceSlashIndex(pagePathToSlug(file)).replace(/^\.$/, '');
|
|
213
|
+
export const collectAnchorLinks = (file, content) => __awaiter(void 0, void 0, void 0, function* () {
|
|
214
|
+
const node = new Node(file);
|
|
215
|
+
try {
|
|
216
|
+
yield decorateGraphNodeFromPageContent(node, content);
|
|
217
|
+
}
|
|
218
|
+
catch (_a) {
|
|
219
|
+
return [];
|
|
220
|
+
}
|
|
221
|
+
return node.paths.flatMap((link) => link.pathType === PathType.INTERNAL && link.anchorLink
|
|
222
|
+
? [
|
|
223
|
+
`${pagePath(toPosixPath(link.toString()))}#${safeCleanHeadingId(link.anchorLink.slice(1))}`,
|
|
224
|
+
]
|
|
225
|
+
: []);
|
|
226
|
+
});
|
|
227
|
+
export const linkedAnchor = (links, file, slug) => {
|
|
228
|
+
const prefix = `${pagePath(file)}#`;
|
|
229
|
+
for (const link of links !== null && links !== void 0 ? links : []) {
|
|
230
|
+
if (!link.startsWith(prefix))
|
|
231
|
+
continue;
|
|
232
|
+
const anchor = link.slice(prefix.length);
|
|
233
|
+
const suffix = anchor.startsWith(`${slug}-`) && /^\d/.test(anchor.slice(slug.length + 1));
|
|
234
|
+
if (anchor === slug || suffix)
|
|
235
|
+
return anchor;
|
|
236
|
+
}
|
|
237
|
+
return undefined;
|
|
238
|
+
};
|
|
@@ -17,20 +17,28 @@ const IMPORT_TYPES = new Set([
|
|
|
17
17
|
]);
|
|
18
18
|
const POSITION = /^:(\d+)(?::(\d+))?/;
|
|
19
19
|
const QUOTED = /"([^"]+)"/;
|
|
20
|
-
const toFinding = (site,
|
|
21
|
-
var _a;
|
|
22
|
-
const clean = stripVTControlCharacters(message).trim();
|
|
23
|
-
const importer = IMPORT_TYPES.has(type)
|
|
20
|
+
const toFinding = (site, warning) => {
|
|
21
|
+
var _a, _b;
|
|
22
|
+
const clean = stripVTControlCharacters(warning.message).trim();
|
|
23
|
+
const importer = warning.file == null && IMPORT_TYPES.has(warning.type)
|
|
24
24
|
? IMPORTERS.map((pattern) => { var _a; return (_a = pattern.exec(clean)) === null || _a === void 0 ? void 0 : _a[1]; }).find(Boolean)
|
|
25
25
|
: undefined;
|
|
26
26
|
// Missing nav pages are always about the config, even when the page looks like a file
|
|
27
|
-
const match = importer || type === 'missing-nav-page'
|
|
27
|
+
const match = warning.file != null || importer || warning.type === 'missing-nav-page'
|
|
28
|
+
? null
|
|
29
|
+
: FILE_IN_MESSAGE.exec(clean);
|
|
28
30
|
const found = importer !== null && importer !== void 0 ? importer : match === null || match === void 0 ? void 0 : match[0];
|
|
29
|
-
const file = found ? normalizeRelativePath(toPosixPath(found)) : site.configFile;
|
|
31
|
+
const file = (_a = warning.file) !== null && _a !== void 0 ? _a : (found ? normalizeRelativePath(toPosixPath(found)) : site.configFile);
|
|
30
32
|
const position = match ? POSITION.exec(clean.slice(match.index + match[0].length)) : null;
|
|
31
33
|
// Config warnings quote the entry they're about, such as a navigation page
|
|
32
|
-
const quoted = file === site.configFile ? (
|
|
33
|
-
|
|
34
|
+
const quoted = warning.file == null && file === site.configFile ? (_b = QUOTED.exec(clean)) === null || _b === void 0 ? void 0 : _b[0] : undefined;
|
|
35
|
+
const located = warning.ref != null && warning.line == null
|
|
36
|
+
? site.locate(file, JSON.stringify(warning.ref))
|
|
37
|
+
: quoted
|
|
38
|
+
? site.locate(file, quoted)
|
|
39
|
+
: undefined;
|
|
40
|
+
return Object.assign(Object.assign(Object.assign({ file }, (warning.line != null
|
|
41
|
+
? Object.assign({ line: warning.line }, (warning.column != null && { column: warning.column })) : position && Object.assign({ line: Number(position[1]) }, (position[2] && { column: Number(position[2]) })))), located), { message: clean });
|
|
34
42
|
};
|
|
35
43
|
/** Warning types some rule turns into diagnostics, so a strict-build failure is explained. */
|
|
36
44
|
const COVERED_TYPES = new Set(['parse-error']);
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { AVOIDED_PAGE_MODES, getTableOfContentsTitle, safeCleanHeadingId, slugify, } from '@mintlify/common';
|
|
2
|
+
import remarkSmartypants from 'remark-smartypants';
|
|
3
|
+
import { unified } from 'unified';
|
|
4
|
+
import { hasCustomHeadingId, linkedAnchor } from '../../mdx.js';
|
|
5
|
+
const normalize = (value) => value.trim().replace(/\s+/g, ' ');
|
|
6
|
+
const smartypants = unified().use(remarkSmartypants);
|
|
7
|
+
const firstHeading = (tree) => tree.children.find((node) => node.type === 'heading');
|
|
8
|
+
const renderedText = (heading) => {
|
|
9
|
+
const clone = structuredClone(heading);
|
|
10
|
+
const root = { type: 'root', children: [clone] };
|
|
11
|
+
smartypants.runSync(root);
|
|
12
|
+
return getTableOfContentsTitle(clone);
|
|
13
|
+
};
|
|
14
|
+
export const duplicateH1 = {
|
|
15
|
+
id: 'duplicate-h1',
|
|
16
|
+
group: 'content',
|
|
17
|
+
severity: 'warning',
|
|
18
|
+
scope: 'file',
|
|
19
|
+
description: 'remove a first heading that repeats the frontmatter title',
|
|
20
|
+
pagesOnly: true,
|
|
21
|
+
needsAnchorLinks: true,
|
|
22
|
+
check: ({ tree, frontmatter, content, file, anchorLinks }) => {
|
|
23
|
+
var _a, _b, _c, _d, _e, _f, _g, _h;
|
|
24
|
+
const { title, mode } = frontmatter;
|
|
25
|
+
if (typeof title !== 'string' || AVOIDED_PAGE_MODES.includes(String(mode)))
|
|
26
|
+
return [];
|
|
27
|
+
const first = firstHeading(tree);
|
|
28
|
+
if (!first || first.depth !== 1)
|
|
29
|
+
return [];
|
|
30
|
+
if (normalize(getTableOfContentsTitle(first)) !== normalize(title))
|
|
31
|
+
return [];
|
|
32
|
+
const start = (_b = (_a = first.position) === null || _a === void 0 ? void 0 : _a.start.offset) !== null && _b !== void 0 ? _b : 0;
|
|
33
|
+
const end = (_d = (_c = first.position) === null || _c === void 0 ? void 0 : _c.end.offset) !== null && _d !== void 0 ? _d : start;
|
|
34
|
+
const location = {
|
|
35
|
+
line: (_e = first.position) === null || _e === void 0 ? void 0 : _e.start.line,
|
|
36
|
+
column: (_f = first.position) === null || _f === void 0 ? void 0 : _f.start.column,
|
|
37
|
+
};
|
|
38
|
+
const lineEnd = content.indexOf('\n', start);
|
|
39
|
+
if (hasCustomHeadingId(content.slice(start, lineEnd === -1 ? undefined : lineEnd))) {
|
|
40
|
+
return [
|
|
41
|
+
Object.assign(Object.assign({}, location), { message: 'first heading duplicates the frontmatter title; it has a custom {#id}, so remove it by hand and update links to it' }),
|
|
42
|
+
];
|
|
43
|
+
}
|
|
44
|
+
const anchor = [renderedText(first), getTableOfContentsTitle(first)]
|
|
45
|
+
.map((text) => linkedAnchor(anchorLinks, file, safeCleanHeadingId(slugify(text))))
|
|
46
|
+
.find((linked) => linked !== undefined);
|
|
47
|
+
if (anchor !== undefined) {
|
|
48
|
+
return [
|
|
49
|
+
Object.assign(Object.assign({}, location), { message: `first heading duplicates the frontmatter title; links point to #${anchor}, so remove it by hand and update them` }),
|
|
50
|
+
];
|
|
51
|
+
}
|
|
52
|
+
const trailing = (_h = (_g = /^\n*/.exec(content.slice(end))) === null || _g === void 0 ? void 0 : _g[0].length) !== null && _h !== void 0 ? _h : 0;
|
|
53
|
+
return [
|
|
54
|
+
Object.assign(Object.assign({}, location), { message: 'first heading duplicates the frontmatter title', edit: { start, end: end + trailing, text: '' } }),
|
|
55
|
+
];
|
|
56
|
+
},
|
|
57
|
+
};
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { maskCode, maskFrontmatter, maskNonMarkup, toPosition } from '../../mdx.js';
|
|
2
|
+
const CALLOUTS = {
|
|
3
|
+
note: '<Note>',
|
|
4
|
+
info: '<Info>',
|
|
5
|
+
tip: '<Tip>',
|
|
6
|
+
hint: '<Tip>',
|
|
7
|
+
success: '<Check>',
|
|
8
|
+
important: '<Note>',
|
|
9
|
+
warning: '<Warning>',
|
|
10
|
+
caution: '<Warning>',
|
|
11
|
+
danger: '<Warning>',
|
|
12
|
+
error: '<Warning>',
|
|
13
|
+
details: '<Accordion>',
|
|
14
|
+
};
|
|
15
|
+
const callout = (name) => { var _a, _b; return (_b = CALLOUTS[(_a = name === null || name === void 0 ? void 0 : name.toLowerCase()) !== null && _a !== void 0 ? _a : '']) !== null && _b !== void 0 ? _b : 'a callout (<Note>, <Info>, <Tip> or <Warning>)'; };
|
|
16
|
+
const LIQUID_TAGS = {
|
|
17
|
+
hint: 'a callout (<Note>, <Info>, <Tip> or <Warning>)',
|
|
18
|
+
tabs: '<Tabs>',
|
|
19
|
+
tab: '<Tab title="…">',
|
|
20
|
+
stepper: '<Steps>',
|
|
21
|
+
step: '<Step title="…">',
|
|
22
|
+
embed: 'a link or <Frame>',
|
|
23
|
+
'content-ref': '<Card>',
|
|
24
|
+
include: 'a snippet import',
|
|
25
|
+
code: 'a fenced code block',
|
|
26
|
+
file: 'a link to the file',
|
|
27
|
+
swagger: 'an OpenAPI page',
|
|
28
|
+
openapi: 'an OpenAPI page',
|
|
29
|
+
};
|
|
30
|
+
const PATTERNS = [
|
|
31
|
+
{
|
|
32
|
+
regex: /\{%-?\s*(?!end)([\w-]+)[^%\n]*?-?%\}/g,
|
|
33
|
+
message: ([tag, name]) => {
|
|
34
|
+
var _a, _b;
|
|
35
|
+
const style = name === 'hint' ? (_a = /style=["']?(\w+)/.exec(tag)) === null || _a === void 0 ? void 0 : _a[1] : undefined;
|
|
36
|
+
const target = style
|
|
37
|
+
? callout(style)
|
|
38
|
+
: ((_b = LIQUID_TAGS[name !== null && name !== void 0 ? name : '']) !== null && _b !== void 0 ? _b : 'Markdown or a Mintlify component');
|
|
39
|
+
return `${tag} is a GitBook or Liquid tag, which Mintlify does not support; use ${target}`;
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
regex: /\{\{[<%]\s*([\w/-]+)[^}\n]*[>%]\}\}/g,
|
|
44
|
+
message: ([tag]) => `${tag} is a Hugo shortcode, which Mintlify does not support; use Markdown or a Mintlify component`,
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
regex: /\{\{-?\s*(?:(?:site|page|layout|include|jekyll|paginator)\.[^}\n]*|[^\s|{}]+\s*\|\s*\w+[^}\n]*)-?\}\}/g,
|
|
48
|
+
message: ([tag]) => `${tag} is a Liquid variable, which Mintlify does not support; write the value, or use a snippet or a variable`,
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
regex: /^ {0,3}:{3,}\s*([A-Za-z][\w-]*)/gm,
|
|
52
|
+
message: ([, name]) => `:::${name} is a Docusaurus or VitePress admonition, which Mintlify does not support; use ${callout(name)}`,
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
regex: /^ {0,3}::(?!:)([A-Za-z][\w-]*)/gm,
|
|
56
|
+
message: ([, name]) => `::${name} is an MDC (Nuxt Content, Docus) block, which Mintlify does not support; use the matching Mintlify component`,
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
regex: /^ {0,3}(!!!|\?\?\?\+?)[ \t]+([A-Za-z][\w-]*)/gm,
|
|
60
|
+
message: ([, marker, name]) => `${marker} ${name} is an MkDocs admonition, which Mintlify does not support; use ${marker === '!!!' ? callout(name) : '<Accordion>'}`,
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
regex: /^ {0,3}===\+?[ \t]+["']/gm,
|
|
64
|
+
message: () => `=== "…" is an MkDocs content tab, which Mintlify does not support; use <Tabs> with <Tab title="…">`,
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
regex: /^[ \t]*;?[ \t]*--8<--/gm,
|
|
68
|
+
scope: 'everything',
|
|
69
|
+
message: () => '--8<-- is an MkDocs snippet include, which Mintlify does not support; use a snippet import or paste the content',
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
regex: /\{:\s*[.#\w][^}\n]*\}/g,
|
|
73
|
+
message: ([list]) => `${list} is a Kramdown attribute list, which Mintlify does not support; remove it, use a component, or use {#id} for a heading anchor`,
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
regex: /(?<![[{\w])\[\[([^[\]\n]+)\]\](?![(:[])/g,
|
|
77
|
+
message: ([link]) => `${link} is a wiki link, which Mintlify does not support; use a Markdown link such as [text](/path)`,
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
regex: /(?:\]\(|href=["'])((?:doc|ref|blog|changelog|page):[^)\s"']+)/g,
|
|
81
|
+
scope: 'links',
|
|
82
|
+
message: ([, link]) => {
|
|
83
|
+
var _a;
|
|
84
|
+
return `${link} is a ReadMe link, which Mintlify does not support; link to the page path instead, such as /${(_a = link === null || link === void 0 ? void 0 : link.split(':')[1]) !== null && _a !== void 0 ? _a : ''}`;
|
|
85
|
+
},
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
regex: /["'(]@site\/[^"')\s]*/g,
|
|
89
|
+
scope: 'prose-and-esm',
|
|
90
|
+
message: ([path]) => `${path.slice(1)} uses the Docusaurus @site alias, which Mintlify does not resolve; use a root-relative path such as /images/… or /snippets/…`,
|
|
91
|
+
},
|
|
92
|
+
];
|
|
93
|
+
export const foreignSyntax = {
|
|
94
|
+
id: 'foreign-syntax',
|
|
95
|
+
group: 'content',
|
|
96
|
+
severity: 'warning',
|
|
97
|
+
scope: 'file',
|
|
98
|
+
description: "syntax from another docs framework that Mintlify doesn't render",
|
|
99
|
+
raw: true,
|
|
100
|
+
check: ({ content }) => {
|
|
101
|
+
const prose = maskCode(content);
|
|
102
|
+
const literal = maskNonMarkup(content);
|
|
103
|
+
const views = {
|
|
104
|
+
prose,
|
|
105
|
+
links: prose,
|
|
106
|
+
'prose-and-esm': maskCode(content, { keepEsm: true }),
|
|
107
|
+
everything: maskFrontmatter(content),
|
|
108
|
+
};
|
|
109
|
+
const issues = [];
|
|
110
|
+
for (const { regex, scope = 'prose', message } of PATTERNS) {
|
|
111
|
+
for (const match of views[scope].matchAll(regex)) {
|
|
112
|
+
const start = match.index + (match[0].length - match[0].trimStart().length);
|
|
113
|
+
if (scope === 'prose' && literal[start] !== prose[start])
|
|
114
|
+
continue;
|
|
115
|
+
issues.push(Object.assign(Object.assign({}, toPosition(content, start)), { message: message(match) }));
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return issues;
|
|
119
|
+
},
|
|
120
|
+
};
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { isCurrencyMath } from '@mintlify/common';
|
|
2
|
+
import katex from 'katex';
|
|
3
|
+
import { visit } from 'unist-util-visit';
|
|
4
|
+
export const katexRule = {
|
|
5
|
+
id: 'katex',
|
|
6
|
+
group: 'content',
|
|
7
|
+
severity: 'warning',
|
|
8
|
+
scope: 'file',
|
|
9
|
+
description: "math that KaTeX can't render",
|
|
10
|
+
check: ({ tree, content }) => {
|
|
11
|
+
const issues = [];
|
|
12
|
+
visit(tree, (node) => {
|
|
13
|
+
var _a, _b;
|
|
14
|
+
if (node.type !== 'math' && node.type !== 'inlineMath')
|
|
15
|
+
return;
|
|
16
|
+
if (node.type === 'inlineMath' && isCurrencyMath(node, content))
|
|
17
|
+
return;
|
|
18
|
+
try {
|
|
19
|
+
katex.renderToString(node.value, {
|
|
20
|
+
throwOnError: true,
|
|
21
|
+
displayMode: node.type === 'math',
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
catch (error) {
|
|
25
|
+
issues.push({
|
|
26
|
+
line: (_a = node.position) === null || _a === void 0 ? void 0 : _a.start.line,
|
|
27
|
+
column: (_b = node.position) === null || _b === void 0 ? void 0 : _b.start.column,
|
|
28
|
+
message: error instanceof Error ? error.message : 'invalid math',
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
return issues;
|
|
33
|
+
},
|
|
34
|
+
};
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { collectPatternNames, collectExportedNames, getAST, isBuiltInComponent, JSX_EXTENSIONS, visitReferences, } from '@mintlify/common';
|
|
2
|
+
import { visit } from 'unist-util-visit';
|
|
3
|
+
const getDeclared = (context) => {
|
|
4
|
+
const declared = new Set();
|
|
5
|
+
visit(context.tree, 'mdxjsEsm', (node) => {
|
|
6
|
+
var _a, _b, _c;
|
|
7
|
+
for (const statement of (_c = (_b = (_a = node.data) === null || _a === void 0 ? void 0 : _a.estree) === null || _b === void 0 ? void 0 : _b.body) !== null && _c !== void 0 ? _c : []) {
|
|
8
|
+
if (statement.type === 'ImportDeclaration') {
|
|
9
|
+
for (const specifier of statement.specifiers) {
|
|
10
|
+
collectPatternNames(specifier.local, declared);
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
else if (statement.type === 'ExportNamedDeclaration') {
|
|
14
|
+
const declaration = statement.declaration;
|
|
15
|
+
if ((declaration === null || declaration === void 0 ? void 0 : declaration.type) === 'VariableDeclaration') {
|
|
16
|
+
for (const declarator of declaration.declarations) {
|
|
17
|
+
collectPatternNames(declarator.id, declared);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
else if (declaration) {
|
|
21
|
+
collectPatternNames(declaration.id, declared);
|
|
22
|
+
}
|
|
23
|
+
for (const specifier of statement.specifiers) {
|
|
24
|
+
collectPatternNames(specifier.local, declared);
|
|
25
|
+
collectPatternNames(specifier.exported, declared);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
else if (statement.type === 'ExportDefaultDeclaration') {
|
|
29
|
+
const declaration = statement.declaration;
|
|
30
|
+
if (declaration.type === 'FunctionDeclaration' || declaration.type === 'ClassDeclaration') {
|
|
31
|
+
collectPatternNames(declaration.id, declared);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
return declared;
|
|
37
|
+
};
|
|
38
|
+
const getImportPosition = (context) => {
|
|
39
|
+
var _a, _b;
|
|
40
|
+
const frontmatter = context.tree.children.find((child) => ['yaml', 'toml'].includes(child.type));
|
|
41
|
+
return (_b = (_a = frontmatter === null || frontmatter === void 0 ? void 0 : frontmatter.position) === null || _a === void 0 ? void 0 : _a.end.offset) !== null && _b !== void 0 ? _b : 0;
|
|
42
|
+
};
|
|
43
|
+
const getSpecifier = (snippet, file, name) => {
|
|
44
|
+
let exported;
|
|
45
|
+
try {
|
|
46
|
+
exported = collectExportedNames(getAST(snippet, file));
|
|
47
|
+
}
|
|
48
|
+
catch (_a) {
|
|
49
|
+
return undefined;
|
|
50
|
+
}
|
|
51
|
+
if (exported.has(name))
|
|
52
|
+
return `{ ${name} }`;
|
|
53
|
+
return exported.has('default') ? name : undefined;
|
|
54
|
+
};
|
|
55
|
+
export const unknownComponent = {
|
|
56
|
+
id: 'unknown-component',
|
|
57
|
+
group: 'content',
|
|
58
|
+
severity: 'warning',
|
|
59
|
+
scope: 'file',
|
|
60
|
+
description: 'JSX components that are not built in, imported or exported',
|
|
61
|
+
pagesOnly: true,
|
|
62
|
+
check: (context) => {
|
|
63
|
+
var _a, _b;
|
|
64
|
+
const declared = getDeclared(context);
|
|
65
|
+
const reported = new Set();
|
|
66
|
+
const issues = [];
|
|
67
|
+
const insertAt = getImportPosition(context);
|
|
68
|
+
const prefix = insertAt === 0 ? '' : '\n\n';
|
|
69
|
+
const following = (_b = (_a = /^\n*/.exec(context.content.slice(insertAt))) === null || _a === void 0 ? void 0 : _a[0].length) !== null && _b !== void 0 ? _b : 0;
|
|
70
|
+
const suffix = '\n'.repeat(Math.max(0, 2 - following));
|
|
71
|
+
const check = (tag, position) => {
|
|
72
|
+
var _a;
|
|
73
|
+
const name = tag === null || tag === void 0 ? void 0 : tag.split('.')[0];
|
|
74
|
+
if (!tag || !name || !/^[A-Z]/.test(name))
|
|
75
|
+
return;
|
|
76
|
+
if (declared.has(name) || reported.has(tag) || isBuiltInComponent(tag))
|
|
77
|
+
return;
|
|
78
|
+
reported.add(tag);
|
|
79
|
+
const file = JSX_EXTENSIONS.map((extension) => `snippets/${name}${extension}`).find((candidate) => { var _a; return ((_a = context.readFile) === null || _a === void 0 ? void 0 : _a.call(context, candidate)) !== undefined; });
|
|
80
|
+
const source = file && ((_a = context.readFile) === null || _a === void 0 ? void 0 : _a.call(context, file));
|
|
81
|
+
const specifier = file && source !== undefined && tag === name ? getSpecifier(source, file, name) : undefined;
|
|
82
|
+
issues.push({
|
|
83
|
+
line: position === null || position === void 0 ? void 0 : position.line,
|
|
84
|
+
column: position === null || position === void 0 ? void 0 : position.column,
|
|
85
|
+
message: `<${tag}> is not built in, imported or exported on this page`,
|
|
86
|
+
edit: specifier
|
|
87
|
+
? {
|
|
88
|
+
start: insertAt,
|
|
89
|
+
end: insertAt,
|
|
90
|
+
text: `${prefix}import ${specifier} from "/${file}";${suffix}`,
|
|
91
|
+
}
|
|
92
|
+
: undefined,
|
|
93
|
+
});
|
|
94
|
+
};
|
|
95
|
+
const checkJsxInExpression = (estree, fallback) => visitReferences(estree, new Set(), {
|
|
96
|
+
reference: (identifier) => {
|
|
97
|
+
var _a;
|
|
98
|
+
if (identifier.type !== 'JSXIdentifier')
|
|
99
|
+
return;
|
|
100
|
+
const loc = identifier.loc;
|
|
101
|
+
check(String(identifier.name), (_a = loc === null || loc === void 0 ? void 0 : loc.start) !== null && _a !== void 0 ? _a : fallback);
|
|
102
|
+
},
|
|
103
|
+
});
|
|
104
|
+
visit(context.tree, (node) => {
|
|
105
|
+
var _a, _b, _c, _d, _e;
|
|
106
|
+
if (node.type === 'mdxJsxFlowElement' || node.type === 'mdxJsxTextElement') {
|
|
107
|
+
check(node.name, (_a = node.position) === null || _a === void 0 ? void 0 : _a.start);
|
|
108
|
+
for (const attribute of node.attributes) {
|
|
109
|
+
const value = attribute.type === 'mdxJsxAttribute' ? attribute.value : attribute;
|
|
110
|
+
if (value && typeof value === 'object') {
|
|
111
|
+
checkJsxInExpression((_b = value.data) === null || _b === void 0 ? void 0 : _b.estree, (_c = node.position) === null || _c === void 0 ? void 0 : _c.start);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
else if (node.type === 'mdxFlowExpression' || node.type === 'mdxTextExpression') {
|
|
116
|
+
checkJsxInExpression((_d = node.data) === null || _d === void 0 ? void 0 : _d.estree, (_e = node.position) === null || _e === void 0 ? void 0 : _e.start);
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
return issues;
|
|
120
|
+
},
|
|
121
|
+
};
|
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
import { BUILD_RULES } from './build.js';
|
|
2
|
+
import { duplicateH1 } from './content/duplicateH1.js';
|
|
3
|
+
import { foreignSyntax } from './content/foreignSyntax.js';
|
|
4
|
+
import { katexRule } from './content/katex.js';
|
|
5
|
+
import { unknownComponent } from './content/unknownComponent.js';
|
|
2
6
|
import { voidTags } from './content/voidTags.js';
|
|
3
7
|
import { openApiSpec } from './openapi.js';
|
|
4
8
|
/** Every check mint validate runs, in report order. */
|
|
5
|
-
export const RULES = [
|
|
9
|
+
export const RULES = [
|
|
10
|
+
voidTags,
|
|
11
|
+
duplicateH1,
|
|
12
|
+
unknownComponent,
|
|
13
|
+
katexRule,
|
|
14
|
+
foreignSyntax,
|
|
15
|
+
openApiSpec,
|
|
16
|
+
...BUILD_RULES,
|
|
17
|
+
];
|
package/bin/validate/site.js
CHANGED
|
@@ -7,7 +7,7 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, ge
|
|
|
7
7
|
step((generator = generator.apply(thisArg, _arguments || [])).next());
|
|
8
8
|
});
|
|
9
9
|
};
|
|
10
|
-
import
|
|
10
|
+
import fs from 'node:fs';
|
|
11
11
|
import path from 'node:path';
|
|
12
12
|
import { readSiteFiles } from './files.js';
|
|
13
13
|
import { toPosition } from './mdx.js';
|
|
@@ -24,30 +24,32 @@ const readVariables = (config) => {
|
|
|
24
24
|
};
|
|
25
25
|
/** Lists the site's pages and snippets; locate reads files from disk on demand. */
|
|
26
26
|
export const loadSite = (baseDir_1, _a) => __awaiter(void 0, [baseDir_1, _a], void 0, function* (baseDir, { selected, localSchema }) {
|
|
27
|
+
var _b;
|
|
27
28
|
const files = yield readSiteFiles(baseDir);
|
|
28
29
|
const byPath = new Map();
|
|
29
|
-
const configFile = existsSync(path.join(baseDir, 'docs.json')) ? 'docs.json' : 'mint.json';
|
|
30
|
+
const configFile = fs.existsSync(path.join(baseDir, 'docs.json')) ? 'docs.json' : 'mint.json';
|
|
30
31
|
const read = (file) => {
|
|
31
|
-
var _a;
|
|
32
32
|
if (!byPath.has(file)) {
|
|
33
33
|
try {
|
|
34
|
-
byPath.set(file, readFileSync(path.join(baseDir, file), 'utf-8'));
|
|
34
|
+
byPath.set(file, fs.readFileSync(path.join(baseDir, file), 'utf-8'));
|
|
35
35
|
}
|
|
36
|
-
catch (
|
|
37
|
-
byPath.set(file,
|
|
36
|
+
catch (_a) {
|
|
37
|
+
byPath.set(file, undefined);
|
|
38
38
|
}
|
|
39
39
|
}
|
|
40
|
-
return
|
|
40
|
+
return byPath.get(file);
|
|
41
41
|
};
|
|
42
42
|
return {
|
|
43
43
|
baseDir,
|
|
44
44
|
files,
|
|
45
45
|
selected,
|
|
46
46
|
configFile,
|
|
47
|
-
variables: readVariables(read(configFile)),
|
|
47
|
+
variables: readVariables((_b = read(configFile)) !== null && _b !== void 0 ? _b : ''),
|
|
48
48
|
localSchema,
|
|
49
|
+
read,
|
|
49
50
|
locate: (file, text) => {
|
|
50
|
-
|
|
51
|
+
var _a;
|
|
52
|
+
const content = (_a = read(file)) !== null && _a !== void 0 ? _a : '';
|
|
51
53
|
const offset = text ? content.indexOf(text) : -1;
|
|
52
54
|
return offset === -1 ? {} : toPosition(content, offset);
|
|
53
55
|
},
|