@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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mintlify/cli",
|
|
3
|
-
"version": "4.0.
|
|
3
|
+
"version": "4.0.1745",
|
|
4
4
|
"description": "The Mintlify CLI",
|
|
5
5
|
"engines": {
|
|
6
6
|
"node": ">=18.0.0"
|
|
@@ -45,12 +45,12 @@
|
|
|
45
45
|
},
|
|
46
46
|
"dependencies": {
|
|
47
47
|
"@inquirer/prompts": "7.9.0",
|
|
48
|
-
"@mintlify/common": "1.0.
|
|
49
|
-
"@mintlify/link-rot": "3.0.
|
|
48
|
+
"@mintlify/common": "1.0.1310",
|
|
49
|
+
"@mintlify/link-rot": "3.0.1541",
|
|
50
50
|
"@mintlify/models": "0.0.379",
|
|
51
|
-
"@mintlify/og": "0.0.
|
|
52
|
-
"@mintlify/prebuild": "1.0.
|
|
53
|
-
"@mintlify/previewing": "4.0.
|
|
51
|
+
"@mintlify/og": "0.0.109",
|
|
52
|
+
"@mintlify/prebuild": "1.0.1477",
|
|
53
|
+
"@mintlify/previewing": "4.0.1559",
|
|
54
54
|
"@mintlify/validation": "0.1.944",
|
|
55
55
|
"adm-zip": "0.6.0",
|
|
56
56
|
"chalk": "5.2.0",
|
|
@@ -60,6 +60,7 @@
|
|
|
60
60
|
"ink": "6.3.0",
|
|
61
61
|
"inquirer": "12.3.0",
|
|
62
62
|
"js-yaml": "4.3.1",
|
|
63
|
+
"katex": "0.16.47",
|
|
63
64
|
"mdast-util-mdx-jsx": "3.2.0",
|
|
64
65
|
"open": "8.4.2",
|
|
65
66
|
"openid-client": "6.8.2",
|
|
@@ -71,6 +72,7 @@
|
|
|
71
72
|
"remark-gfm": "4.0.1",
|
|
72
73
|
"remark-math": "6.0.0",
|
|
73
74
|
"remark-mdx": "3.1.1",
|
|
75
|
+
"remark-smartypants": "3.0.2",
|
|
74
76
|
"remark-stringify": "11.0.0",
|
|
75
77
|
"unified": "11.0.5",
|
|
76
78
|
"unist-util-visit": "5.0.0",
|
|
@@ -82,7 +84,7 @@
|
|
|
82
84
|
"keytar": "7.9.0"
|
|
83
85
|
},
|
|
84
86
|
"devDependencies": {
|
|
85
|
-
"@mintlify/editor": "0.0.
|
|
87
|
+
"@mintlify/editor": "0.0.583",
|
|
86
88
|
"@mintlify/ts-config": "2.0.2",
|
|
87
89
|
"@tsconfig/recommended": "1.0.2",
|
|
88
90
|
"@types/detect-port": "1.3.2",
|
|
@@ -101,5 +103,5 @@
|
|
|
101
103
|
"vitest": "2.1.9",
|
|
102
104
|
"vitest-mock-process": "1.0.4"
|
|
103
105
|
},
|
|
104
|
-
"gitHead": "
|
|
106
|
+
"gitHead": "7c0206add0761d1ed8d623e11e9d9a0b9c010663"
|
|
105
107
|
}
|
package/src/validate/engine.ts
CHANGED
|
@@ -131,20 +131,14 @@ const parseLevel = (file: string, content: string, baseDir: string) => {
|
|
|
131
131
|
|
|
132
132
|
/** 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. */
|
|
133
133
|
export const applyFixes = async (site: Site, diagnostics: Diagnostic[]): Promise<Change[]> => {
|
|
134
|
-
const
|
|
135
|
-
const read = async (file: string) => {
|
|
136
|
-
if (!contents.has(file)) {
|
|
137
|
-
contents.set(file, await fs.readFile(path.join(site.baseDir, file), 'utf-8'));
|
|
138
|
-
}
|
|
139
|
-
return contents.get(file) ?? '';
|
|
140
|
-
};
|
|
134
|
+
const read = (file: string) => site.read(file) ?? '';
|
|
141
135
|
const chosen = new Map<string, { fix: FileEdit; change: Change }[]>();
|
|
142
136
|
const overlaps = ({ file, start, end }: FileEdit) =>
|
|
143
137
|
(chosen.get(file) ?? []).some(({ fix }) => start < fix.end && fix.start < end);
|
|
144
138
|
|
|
145
139
|
for (const { rule, fix } of diagnostics) {
|
|
146
140
|
if (!fix || overlaps(fix)) continue;
|
|
147
|
-
const content =
|
|
141
|
+
const content = read(fix.file);
|
|
148
142
|
const change = {
|
|
149
143
|
rule,
|
|
150
144
|
file: fix.file,
|
|
@@ -167,7 +161,7 @@ export const applyFixes = async (site: Site, diagnostics: Diagnostic[]): Promise
|
|
|
167
161
|
|
|
168
162
|
const applied: Change[] = [];
|
|
169
163
|
for (const [file, picks] of chosen) {
|
|
170
|
-
const content =
|
|
164
|
+
const content = read(file);
|
|
171
165
|
const updated = applyEdits(content, selectEdits(picks.map(({ fix }) => fix)));
|
|
172
166
|
if (/\.mdx?$/.test(file) && level(file, updated) < level(file, content)) {
|
|
173
167
|
continue;
|
|
@@ -191,7 +185,7 @@ export const checkSite = async (
|
|
|
191
185
|
options: { selected?: ReadonlySet<string>; localSchema: boolean; fix: boolean }
|
|
192
186
|
) => {
|
|
193
187
|
const fixed: Change[] = [];
|
|
194
|
-
|
|
188
|
+
let site = await loadSite(baseDir, options);
|
|
195
189
|
const { selected } = options;
|
|
196
190
|
if (selected && !site.files.some(({ file }) => selected.has(file))) {
|
|
197
191
|
throw new UsageError(
|
|
@@ -207,10 +201,10 @@ export const checkSite = async (
|
|
|
207
201
|
const changes = await applyFixes(site, pending);
|
|
208
202
|
if (changes.length === 0) break;
|
|
209
203
|
fixed.push(...changes);
|
|
204
|
+
site = await loadSite(baseDir, options);
|
|
210
205
|
const changed = new Set(changes.map(({ file }) => file));
|
|
211
206
|
pending = sortDiagnostics(await checkFiles({ ...site, selected: changed }, fileRulesOf(rules)));
|
|
212
207
|
}
|
|
213
208
|
if (fixed.length === 0) return { site, diagnostics, fixed };
|
|
214
|
-
|
|
215
|
-
return { site: fixedSite, diagnostics: await runChecks(fixedSite, rules), fixed };
|
|
209
|
+
return { site, diagnostics: await runChecks(site, rules), fixed };
|
|
216
210
|
};
|
package/src/validate/files.ts
CHANGED
|
@@ -6,10 +6,15 @@ import {
|
|
|
6
6
|
listContentFiles,
|
|
7
7
|
} from '@mintlify/prebuild';
|
|
8
8
|
import type { Root } from 'mdast';
|
|
9
|
-
import fs from 'node:fs/promises';
|
|
10
|
-
import path from 'node:path';
|
|
11
9
|
|
|
12
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
applyEdits,
|
|
12
|
+
collectAnchorLinks,
|
|
13
|
+
parseContent,
|
|
14
|
+
relocate,
|
|
15
|
+
selectEdits,
|
|
16
|
+
unmapEdit,
|
|
17
|
+
} from './mdx.js';
|
|
13
18
|
import { PARSE_ERROR } from './rules/parseError.js';
|
|
14
19
|
import type {
|
|
15
20
|
Diagnostic,
|
|
@@ -22,22 +27,6 @@ import type {
|
|
|
22
27
|
SiteFile,
|
|
23
28
|
} from './types.js';
|
|
24
29
|
|
|
25
|
-
const CONCURRENCY = 16;
|
|
26
|
-
|
|
27
|
-
const mapLimit = async <T, R>(items: T[], task: (item: T) => Promise<R>): Promise<R[]> => {
|
|
28
|
-
const results = new Array<R>(items.length);
|
|
29
|
-
let next = 0;
|
|
30
|
-
await Promise.all(
|
|
31
|
-
Array.from({ length: Math.min(CONCURRENCY, items.length) }, async () => {
|
|
32
|
-
while (next < items.length) {
|
|
33
|
-
const index = next++;
|
|
34
|
-
results[index] = await task(items[index] as T);
|
|
35
|
-
}
|
|
36
|
-
})
|
|
37
|
-
);
|
|
38
|
-
return results;
|
|
39
|
-
};
|
|
40
|
-
|
|
41
30
|
const CHECKED_CATEGORIES = new Set<string | null>(['page', 'snippet', 'snippet-v2']);
|
|
42
31
|
|
|
43
32
|
export const readSiteFiles = async (baseDir: string): Promise<SiteFile[]> => {
|
|
@@ -80,6 +69,8 @@ export const toParseError = (error: unknown): ParseError => {
|
|
|
80
69
|
};
|
|
81
70
|
};
|
|
82
71
|
|
|
72
|
+
type Extras = Pick<FileContext, 'readFile' | 'anchorLinks'>;
|
|
73
|
+
|
|
83
74
|
const parseErrorDiagnostic = (file: string, error: ParseError): Diagnostic => ({
|
|
84
75
|
...error,
|
|
85
76
|
rule: PARSE_ERROR.id,
|
|
@@ -99,8 +90,13 @@ const tryParseContent = (content: string): Root | undefined => {
|
|
|
99
90
|
}
|
|
100
91
|
};
|
|
101
92
|
|
|
102
|
-
export const parseFile = (
|
|
103
|
-
|
|
93
|
+
export const parseFile = (
|
|
94
|
+
file: string,
|
|
95
|
+
content: string,
|
|
96
|
+
baseDir: string,
|
|
97
|
+
extras: Extras = {}
|
|
98
|
+
): Parsed => {
|
|
99
|
+
const context = { file, content, baseDir, frontmatter: readFrontmatter(content), ...extras };
|
|
104
100
|
const tree = tryParseContent(content);
|
|
105
101
|
if (tree) return { context: { ...context, tree }, approximate: false };
|
|
106
102
|
try {
|
|
@@ -152,6 +148,7 @@ const checkFile = (
|
|
|
152
148
|
{ file, kind }: SiteFile,
|
|
153
149
|
content: string,
|
|
154
150
|
baseDir: string,
|
|
151
|
+
extras: Extras,
|
|
155
152
|
variables?: Record<string, string>
|
|
156
153
|
): Diagnostic[] => {
|
|
157
154
|
const rules = kind === 'snippet' ? allRules.filter((rule) => !rule.pagesOnly) : allRules;
|
|
@@ -177,7 +174,7 @@ const checkFile = (
|
|
|
177
174
|
toDiagnostic(rule, file, finding, stages, SOURCE_STAGE)
|
|
178
175
|
);
|
|
179
176
|
|
|
180
|
-
const parsed = parseFile(file, afterRaw, baseDir);
|
|
177
|
+
const parsed = parseFile(file, afterRaw, baseDir, extras);
|
|
181
178
|
if ('error' in parsed) {
|
|
182
179
|
diagnostics.unshift(
|
|
183
180
|
parseErrorDiagnostic(file, {
|
|
@@ -196,17 +193,30 @@ const checkFile = (
|
|
|
196
193
|
return diagnostics;
|
|
197
194
|
};
|
|
198
195
|
|
|
196
|
+
const collectSiteAnchorLinks = async (site: Site): Promise<ReadonlySet<string>> => {
|
|
197
|
+
const pages = await Promise.all(
|
|
198
|
+
site.files.map(({ file }) => collectAnchorLinks(file, site.read(file) ?? ''))
|
|
199
|
+
);
|
|
200
|
+
return new Set(pages.flat());
|
|
201
|
+
};
|
|
202
|
+
|
|
199
203
|
export const checkFiles = async (site: Site, rules: FileRule[]): Promise<Diagnostic[]> => {
|
|
204
|
+
const anchorRules = new Set(rules.filter((rule) => rule.needsAnchorLinks).map(({ id }) => id));
|
|
200
205
|
const targets = site.files.filter(({ file }) => !site.selected || site.selected.has(file));
|
|
201
|
-
const
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
} catch (error) {
|
|
206
|
-
const message = error instanceof Error ? error.message : 'could not read this file';
|
|
207
|
-
return [parseErrorDiagnostic(entry.file, { message })];
|
|
206
|
+
const checkTarget = (entry: SiteFile, extras: Extras) => {
|
|
207
|
+
const content = site.read(entry.file);
|
|
208
|
+
if (content === undefined) {
|
|
209
|
+
return [parseErrorDiagnostic(entry.file, { message: 'could not read this file' })];
|
|
208
210
|
}
|
|
209
|
-
return checkFile(rules, entry, content, site.baseDir, site.variables);
|
|
211
|
+
return checkFile(rules, entry, content, site.baseDir, extras, site.variables);
|
|
212
|
+
};
|
|
213
|
+
const needsLinks = (diagnostics: Diagnostic[]) =>
|
|
214
|
+
diagnostics.some(({ rule }) => anchorRules.has(rule));
|
|
215
|
+
const checked = targets.map((entry) => checkTarget(entry, { readFile: site.read }));
|
|
216
|
+
if (!checked.some(needsLinks)) return checked.flat();
|
|
217
|
+
const extras = { readFile: site.read, anchorLinks: await collectSiteAnchorLinks(site) };
|
|
218
|
+
return targets.flatMap((entry, index) => {
|
|
219
|
+
const diagnostics = checked[index] ?? [];
|
|
220
|
+
return needsLinks(diagnostics) ? checkTarget(entry, extras) : diagnostics;
|
|
210
221
|
});
|
|
211
|
-
return results.flat();
|
|
212
222
|
};
|
package/src/validate/mdx.ts
CHANGED
|
@@ -4,7 +4,12 @@ import {
|
|
|
4
4
|
getCodeRanges,
|
|
5
5
|
HEADING_ID_RE,
|
|
6
6
|
JSXParser,
|
|
7
|
+
pagePathToSlug,
|
|
8
|
+
replaceSlashIndex,
|
|
9
|
+
safeCleanHeadingId,
|
|
10
|
+
toPosixPath,
|
|
7
11
|
} from '@mintlify/common';
|
|
12
|
+
import { decorateGraphNodeFromPageContent, Node, PathType } from '@mintlify/link-rot';
|
|
8
13
|
import type { Root } from 'mdast';
|
|
9
14
|
|
|
10
15
|
import type { Edit } from './types.js';
|
|
@@ -250,3 +255,36 @@ export const relocate = (
|
|
|
250
255
|
const mapped = toPosition(original.content, offset);
|
|
251
256
|
return position.column === undefined ? { line: mapped.line } : mapped;
|
|
252
257
|
};
|
|
258
|
+
|
|
259
|
+
const pagePath = (file: string) => replaceSlashIndex(pagePathToSlug(file)).replace(/^\.$/, '');
|
|
260
|
+
|
|
261
|
+
export const collectAnchorLinks = async (file: string, content: string): Promise<string[]> => {
|
|
262
|
+
const node = new Node(file);
|
|
263
|
+
try {
|
|
264
|
+
await decorateGraphNodeFromPageContent(node, content);
|
|
265
|
+
} catch {
|
|
266
|
+
return [];
|
|
267
|
+
}
|
|
268
|
+
return node.paths.flatMap((link) =>
|
|
269
|
+
link.pathType === PathType.INTERNAL && link.anchorLink
|
|
270
|
+
? [
|
|
271
|
+
`${pagePath(toPosixPath(link.toString()))}#${safeCleanHeadingId(link.anchorLink.slice(1))}`,
|
|
272
|
+
]
|
|
273
|
+
: []
|
|
274
|
+
);
|
|
275
|
+
};
|
|
276
|
+
|
|
277
|
+
export const linkedAnchor = (
|
|
278
|
+
links: ReadonlySet<string> | undefined,
|
|
279
|
+
file: string,
|
|
280
|
+
slug: string
|
|
281
|
+
): string | undefined => {
|
|
282
|
+
const prefix = `${pagePath(file)}#`;
|
|
283
|
+
for (const link of links ?? []) {
|
|
284
|
+
if (!link.startsWith(prefix)) continue;
|
|
285
|
+
const anchor = link.slice(prefix.length);
|
|
286
|
+
const suffix = anchor.startsWith(`${slug}-`) && /^\d/.test(anchor.slice(slug.length + 1));
|
|
287
|
+
if (anchor === slug || suffix) return anchor;
|
|
288
|
+
}
|
|
289
|
+
return undefined;
|
|
290
|
+
};
|
|
@@ -27,25 +27,39 @@ const POSITION = /^:(\d+)(?::(\d+))?/;
|
|
|
27
27
|
|
|
28
28
|
const QUOTED = /"([^"]+)"/;
|
|
29
29
|
|
|
30
|
-
const toFinding = (site: Site,
|
|
31
|
-
const clean = stripVTControlCharacters(message).trim();
|
|
32
|
-
const importer =
|
|
33
|
-
|
|
34
|
-
|
|
30
|
+
const toFinding = (site: Site, warning: Warning): Finding => {
|
|
31
|
+
const clean = stripVTControlCharacters(warning.message).trim();
|
|
32
|
+
const importer =
|
|
33
|
+
warning.file == null && IMPORT_TYPES.has(warning.type)
|
|
34
|
+
? IMPORTERS.map((pattern) => pattern.exec(clean)?.[1]).find(Boolean)
|
|
35
|
+
: undefined;
|
|
35
36
|
// Missing nav pages are always about the config, even when the page looks like a file
|
|
36
|
-
const match =
|
|
37
|
+
const match =
|
|
38
|
+
warning.file != null || importer || warning.type === 'missing-nav-page'
|
|
39
|
+
? null
|
|
40
|
+
: FILE_IN_MESSAGE.exec(clean);
|
|
37
41
|
const found = importer ?? match?.[0];
|
|
38
|
-
const file =
|
|
42
|
+
const file =
|
|
43
|
+
warning.file ?? (found ? normalizeRelativePath(toPosixPath(found)) : site.configFile);
|
|
39
44
|
const position = match ? POSITION.exec(clean.slice(match.index + match[0].length)) : null;
|
|
40
45
|
// Config warnings quote the entry they're about, such as a navigation page
|
|
41
|
-
const quoted =
|
|
46
|
+
const quoted =
|
|
47
|
+
warning.file == null && file === site.configFile ? QUOTED.exec(clean)?.[0] : undefined;
|
|
48
|
+
const located =
|
|
49
|
+
warning.ref != null && warning.line == null
|
|
50
|
+
? site.locate(file, JSON.stringify(warning.ref))
|
|
51
|
+
: quoted
|
|
52
|
+
? site.locate(file, quoted)
|
|
53
|
+
: undefined;
|
|
42
54
|
return {
|
|
43
55
|
file,
|
|
44
|
-
...(
|
|
45
|
-
line:
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
56
|
+
...(warning.line != null
|
|
57
|
+
? { line: warning.line, ...(warning.column != null && { column: warning.column }) }
|
|
58
|
+
: position && {
|
|
59
|
+
line: Number(position[1]),
|
|
60
|
+
...(position[2] && { column: Number(position[2]) }),
|
|
61
|
+
}),
|
|
62
|
+
...located,
|
|
49
63
|
message: clean,
|
|
50
64
|
};
|
|
51
65
|
};
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import {
|
|
2
|
+
AVOIDED_PAGE_MODES,
|
|
3
|
+
getTableOfContentsTitle,
|
|
4
|
+
safeCleanHeadingId,
|
|
5
|
+
slugify,
|
|
6
|
+
} from '@mintlify/common';
|
|
7
|
+
import type { Heading, Root } from 'mdast';
|
|
8
|
+
import remarkSmartypants from 'remark-smartypants';
|
|
9
|
+
import { unified } from 'unified';
|
|
10
|
+
|
|
11
|
+
import { hasCustomHeadingId, linkedAnchor } from '../../mdx.js';
|
|
12
|
+
import type { FileRule } from '../../types.js';
|
|
13
|
+
|
|
14
|
+
const normalize = (value: string) => value.trim().replace(/\s+/g, ' ');
|
|
15
|
+
|
|
16
|
+
const smartypants = unified().use(remarkSmartypants);
|
|
17
|
+
|
|
18
|
+
const firstHeading = (tree: Root) =>
|
|
19
|
+
tree.children.find((node): node is Heading => node.type === 'heading');
|
|
20
|
+
|
|
21
|
+
const renderedText = (heading: Heading) => {
|
|
22
|
+
const clone = structuredClone(heading);
|
|
23
|
+
const root: Root = { type: 'root', children: [clone] };
|
|
24
|
+
smartypants.runSync(root);
|
|
25
|
+
return getTableOfContentsTitle(clone);
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export const duplicateH1: FileRule = {
|
|
29
|
+
id: 'duplicate-h1',
|
|
30
|
+
group: 'content',
|
|
31
|
+
severity: 'warning',
|
|
32
|
+
scope: 'file',
|
|
33
|
+
description: 'remove a first heading that repeats the frontmatter title',
|
|
34
|
+
pagesOnly: true,
|
|
35
|
+
needsAnchorLinks: true,
|
|
36
|
+
check: ({ tree, frontmatter, content, file, anchorLinks }) => {
|
|
37
|
+
const { title, mode } = frontmatter;
|
|
38
|
+
if (typeof title !== 'string' || AVOIDED_PAGE_MODES.includes(String(mode))) return [];
|
|
39
|
+
const first = firstHeading(tree);
|
|
40
|
+
if (!first || first.depth !== 1) return [];
|
|
41
|
+
if (normalize(getTableOfContentsTitle(first)) !== normalize(title)) return [];
|
|
42
|
+
const start = first.position?.start.offset ?? 0;
|
|
43
|
+
const end = first.position?.end.offset ?? start;
|
|
44
|
+
const location = {
|
|
45
|
+
line: first.position?.start.line,
|
|
46
|
+
column: first.position?.start.column,
|
|
47
|
+
};
|
|
48
|
+
const lineEnd = content.indexOf('\n', start);
|
|
49
|
+
if (hasCustomHeadingId(content.slice(start, lineEnd === -1 ? undefined : lineEnd))) {
|
|
50
|
+
return [
|
|
51
|
+
{
|
|
52
|
+
...location,
|
|
53
|
+
message:
|
|
54
|
+
'first heading duplicates the frontmatter title; it has a custom {#id}, so remove it by hand and update links to it',
|
|
55
|
+
},
|
|
56
|
+
];
|
|
57
|
+
}
|
|
58
|
+
const anchor = [renderedText(first), getTableOfContentsTitle(first)]
|
|
59
|
+
.map((text) => linkedAnchor(anchorLinks, file, safeCleanHeadingId(slugify(text))))
|
|
60
|
+
.find((linked) => linked !== undefined);
|
|
61
|
+
if (anchor !== undefined) {
|
|
62
|
+
return [
|
|
63
|
+
{
|
|
64
|
+
...location,
|
|
65
|
+
message: `first heading duplicates the frontmatter title; links point to #${anchor}, so remove it by hand and update them`,
|
|
66
|
+
},
|
|
67
|
+
];
|
|
68
|
+
}
|
|
69
|
+
const trailing = /^\n*/.exec(content.slice(end))?.[0].length ?? 0;
|
|
70
|
+
return [
|
|
71
|
+
{
|
|
72
|
+
...location,
|
|
73
|
+
message: 'first heading duplicates the frontmatter title',
|
|
74
|
+
edit: { start, end: end + trailing, text: '' },
|
|
75
|
+
},
|
|
76
|
+
];
|
|
77
|
+
},
|
|
78
|
+
};
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { maskCode, maskFrontmatter, maskNonMarkup, toPosition } from '../../mdx.js';
|
|
2
|
+
import type { FileRule, FileFinding } from '../../types.js';
|
|
3
|
+
|
|
4
|
+
type Pattern = {
|
|
5
|
+
regex: RegExp;
|
|
6
|
+
scope?: 'prose' | 'links' | 'prose-and-esm' | 'everything';
|
|
7
|
+
message: (match: RegExpMatchArray) => string;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
const CALLOUTS: Record<string, string> = {
|
|
11
|
+
note: '<Note>',
|
|
12
|
+
info: '<Info>',
|
|
13
|
+
tip: '<Tip>',
|
|
14
|
+
hint: '<Tip>',
|
|
15
|
+
success: '<Check>',
|
|
16
|
+
important: '<Note>',
|
|
17
|
+
warning: '<Warning>',
|
|
18
|
+
caution: '<Warning>',
|
|
19
|
+
danger: '<Warning>',
|
|
20
|
+
error: '<Warning>',
|
|
21
|
+
details: '<Accordion>',
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const callout = (name: string | undefined) =>
|
|
25
|
+
CALLOUTS[name?.toLowerCase() ?? ''] ?? 'a callout (<Note>, <Info>, <Tip> or <Warning>)';
|
|
26
|
+
|
|
27
|
+
const LIQUID_TAGS: Record<string, string> = {
|
|
28
|
+
hint: 'a callout (<Note>, <Info>, <Tip> or <Warning>)',
|
|
29
|
+
tabs: '<Tabs>',
|
|
30
|
+
tab: '<Tab title="…">',
|
|
31
|
+
stepper: '<Steps>',
|
|
32
|
+
step: '<Step title="…">',
|
|
33
|
+
embed: 'a link or <Frame>',
|
|
34
|
+
'content-ref': '<Card>',
|
|
35
|
+
include: 'a snippet import',
|
|
36
|
+
code: 'a fenced code block',
|
|
37
|
+
file: 'a link to the file',
|
|
38
|
+
swagger: 'an OpenAPI page',
|
|
39
|
+
openapi: 'an OpenAPI page',
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const PATTERNS: Pattern[] = [
|
|
43
|
+
{
|
|
44
|
+
regex: /\{%-?\s*(?!end)([\w-]+)[^%\n]*?-?%\}/g,
|
|
45
|
+
message: ([tag, name]) => {
|
|
46
|
+
const style = name === 'hint' ? /style=["']?(\w+)/.exec(tag)?.[1] : undefined;
|
|
47
|
+
const target = style
|
|
48
|
+
? callout(style)
|
|
49
|
+
: (LIQUID_TAGS[name ?? ''] ?? 'Markdown or a Mintlify component');
|
|
50
|
+
return `${tag} is a GitBook or Liquid tag, which Mintlify does not support; use ${target}`;
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
regex: /\{\{[<%]\s*([\w/-]+)[^}\n]*[>%]\}\}/g,
|
|
55
|
+
message: ([tag]) =>
|
|
56
|
+
`${tag} is a Hugo shortcode, which Mintlify does not support; use Markdown or a Mintlify component`,
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
regex:
|
|
60
|
+
/\{\{-?\s*(?:(?:site|page|layout|include|jekyll|paginator)\.[^}\n]*|[^\s|{}]+\s*\|\s*\w+[^}\n]*)-?\}\}/g,
|
|
61
|
+
message: ([tag]) =>
|
|
62
|
+
`${tag} is a Liquid variable, which Mintlify does not support; write the value, or use a snippet or a variable`,
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
regex: /^ {0,3}:{3,}\s*([A-Za-z][\w-]*)/gm,
|
|
66
|
+
message: ([, name]) =>
|
|
67
|
+
`:::${name} is a Docusaurus or VitePress admonition, which Mintlify does not support; use ${callout(name)}`,
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
regex: /^ {0,3}::(?!:)([A-Za-z][\w-]*)/gm,
|
|
71
|
+
message: ([, name]) =>
|
|
72
|
+
`::${name} is an MDC (Nuxt Content, Docus) block, which Mintlify does not support; use the matching Mintlify component`,
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
regex: /^ {0,3}(!!!|\?\?\?\+?)[ \t]+([A-Za-z][\w-]*)/gm,
|
|
76
|
+
message: ([, marker, name]) =>
|
|
77
|
+
`${marker} ${name} is an MkDocs admonition, which Mintlify does not support; use ${
|
|
78
|
+
marker === '!!!' ? callout(name) : '<Accordion>'
|
|
79
|
+
}`,
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
regex: /^ {0,3}===\+?[ \t]+["']/gm,
|
|
83
|
+
message: () =>
|
|
84
|
+
`=== "…" is an MkDocs content tab, which Mintlify does not support; use <Tabs> with <Tab title="…">`,
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
regex: /^[ \t]*;?[ \t]*--8<--/gm,
|
|
88
|
+
scope: 'everything',
|
|
89
|
+
message: () =>
|
|
90
|
+
'--8<-- is an MkDocs snippet include, which Mintlify does not support; use a snippet import or paste the content',
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
regex: /\{:\s*[.#\w][^}\n]*\}/g,
|
|
94
|
+
message: ([list]) =>
|
|
95
|
+
`${list} is a Kramdown attribute list, which Mintlify does not support; remove it, use a component, or use {#id} for a heading anchor`,
|
|
96
|
+
},
|
|
97
|
+
{
|
|
98
|
+
regex: /(?<![[{\w])\[\[([^[\]\n]+)\]\](?![(:[])/g,
|
|
99
|
+
message: ([link]) =>
|
|
100
|
+
`${link} is a wiki link, which Mintlify does not support; use a Markdown link such as [text](/path)`,
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
regex: /(?:\]\(|href=["'])((?:doc|ref|blog|changelog|page):[^)\s"']+)/g,
|
|
104
|
+
scope: 'links',
|
|
105
|
+
message: ([, link]) =>
|
|
106
|
+
`${link} is a ReadMe link, which Mintlify does not support; link to the page path instead, such as /${
|
|
107
|
+
link?.split(':')[1] ?? ''
|
|
108
|
+
}`,
|
|
109
|
+
},
|
|
110
|
+
{
|
|
111
|
+
regex: /["'(]@site\/[^"')\s]*/g,
|
|
112
|
+
scope: 'prose-and-esm',
|
|
113
|
+
message: ([path]) =>
|
|
114
|
+
`${path.slice(1)} uses the Docusaurus @site alias, which Mintlify does not resolve; use a root-relative path such as /images/… or /snippets/…`,
|
|
115
|
+
},
|
|
116
|
+
];
|
|
117
|
+
|
|
118
|
+
export const foreignSyntax: FileRule = {
|
|
119
|
+
id: 'foreign-syntax',
|
|
120
|
+
group: 'content',
|
|
121
|
+
severity: 'warning',
|
|
122
|
+
scope: 'file',
|
|
123
|
+
description: "syntax from another docs framework that Mintlify doesn't render",
|
|
124
|
+
raw: true,
|
|
125
|
+
check: ({ content }) => {
|
|
126
|
+
const prose = maskCode(content);
|
|
127
|
+
const literal = maskNonMarkup(content);
|
|
128
|
+
const views = {
|
|
129
|
+
prose,
|
|
130
|
+
links: prose,
|
|
131
|
+
'prose-and-esm': maskCode(content, { keepEsm: true }),
|
|
132
|
+
everything: maskFrontmatter(content),
|
|
133
|
+
};
|
|
134
|
+
const issues: FileFinding[] = [];
|
|
135
|
+
for (const { regex, scope = 'prose', message } of PATTERNS) {
|
|
136
|
+
for (const match of views[scope].matchAll(regex)) {
|
|
137
|
+
const start = match.index + (match[0].length - match[0].trimStart().length);
|
|
138
|
+
if (scope === 'prose' && literal[start] !== prose[start]) continue;
|
|
139
|
+
issues.push({ ...toPosition(content, start), message: message(match) });
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return issues;
|
|
143
|
+
},
|
|
144
|
+
};
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { isCurrencyMath } from '@mintlify/common';
|
|
2
|
+
import katex from 'katex';
|
|
3
|
+
import { visit } from 'unist-util-visit';
|
|
4
|
+
|
|
5
|
+
import type { FileRule, FileFinding } from '../../types.js';
|
|
6
|
+
|
|
7
|
+
export const katexRule: FileRule = {
|
|
8
|
+
id: 'katex',
|
|
9
|
+
group: 'content',
|
|
10
|
+
severity: 'warning',
|
|
11
|
+
scope: 'file',
|
|
12
|
+
description: "math that KaTeX can't render",
|
|
13
|
+
check: ({ tree, content }) => {
|
|
14
|
+
const issues: FileFinding[] = [];
|
|
15
|
+
visit(tree, (node) => {
|
|
16
|
+
if (node.type !== 'math' && node.type !== 'inlineMath') return;
|
|
17
|
+
if (node.type === 'inlineMath' && isCurrencyMath(node, content)) return;
|
|
18
|
+
try {
|
|
19
|
+
katex.renderToString(node.value, {
|
|
20
|
+
throwOnError: true,
|
|
21
|
+
displayMode: node.type === 'math',
|
|
22
|
+
});
|
|
23
|
+
} catch (error) {
|
|
24
|
+
issues.push({
|
|
25
|
+
line: node.position?.start.line,
|
|
26
|
+
column: node.position?.start.column,
|
|
27
|
+
message: error instanceof Error ? error.message : 'invalid math',
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
return issues;
|
|
32
|
+
},
|
|
33
|
+
};
|