@mintlify/cli 4.0.1732 → 4.0.1734
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__/validateCommand.test.ts +141 -0
- package/__test__/validateEngine.test.ts +46 -0
- package/__test__/validateRules.test.ts +500 -0
- package/bin/cli.js +21 -0
- package/bin/resolveFiles.js +3 -1
- package/bin/tsconfig.build.tsbuildinfo +1 -1
- package/bin/validate/command.js +58 -0
- package/bin/validate/engine.js +76 -0
- package/bin/validate/files.js +148 -0
- package/bin/validate/mdx.js +201 -0
- package/bin/validate/output.js +28 -0
- package/bin/validate/rules/content/voidTags.js +35 -0
- package/bin/validate/rules/index.js +3 -0
- package/bin/validate/rules/parseError.js +7 -0
- package/bin/validate/site.js +55 -0
- package/bin/validate/types.js +1 -0
- package/package.json +8 -8
- package/src/cli.tsx +24 -0
- package/src/resolveFiles.ts +3 -1
- package/src/validate/command.tsx +71 -0
- package/src/validate/engine.ts +93 -0
- package/src/validate/files.ts +212 -0
- package/src/validate/mdx.ts +252 -0
- package/src/validate/output.tsx +56 -0
- package/src/validate/rules/content/voidTags.ts +35 -0
- package/src/validate/rules/index.ts +5 -0
- package/src/validate/rules/parseError.ts +9 -0
- package/src/validate/site.ts +52 -0
- package/src/validate/types.ts +88 -0
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { addLog, ErrorLog } from '@mintlify/previewing';
|
|
2
|
+
import { Box, Text } from 'ink';
|
|
3
|
+
|
|
4
|
+
import type { Diagnostic } from './types.js';
|
|
5
|
+
|
|
6
|
+
const location = ({ line, column }: Pick<Diagnostic, 'line' | 'column'>) =>
|
|
7
|
+
line ? `${line}${column ? `:${column}` : ''}` : '';
|
|
8
|
+
|
|
9
|
+
const DiagnosticLine = ({ diagnostic }: { diagnostic: Diagnostic }) => {
|
|
10
|
+
const at = location(diagnostic);
|
|
11
|
+
return (
|
|
12
|
+
<Text>
|
|
13
|
+
{' '}
|
|
14
|
+
{diagnostic.severity === 'error' ? (
|
|
15
|
+
<Text color="red">✗</Text>
|
|
16
|
+
) : (
|
|
17
|
+
<Text color="yellow">!</Text>
|
|
18
|
+
)}{' '}
|
|
19
|
+
{at && <Text dimColor>{at} </Text>}
|
|
20
|
+
<Text dimColor>{diagnostic.rule}</Text> {diagnostic.message}
|
|
21
|
+
{diagnostic.fix && <Text dimColor> (fixable)</Text>}
|
|
22
|
+
</Text>
|
|
23
|
+
);
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const plural = (count: number, word: string) => `${count} ${word}${count === 1 ? '' : 's'}`;
|
|
27
|
+
|
|
28
|
+
export const logDiagnostics = (all: Diagnostic[]) => {
|
|
29
|
+
const byFile = new Map<string, Diagnostic[]>();
|
|
30
|
+
for (const diagnostic of all) {
|
|
31
|
+
byFile.get(diagnostic.file)?.push(diagnostic) ?? byFile.set(diagnostic.file, [diagnostic]);
|
|
32
|
+
}
|
|
33
|
+
for (const [file, diagnostics] of byFile) {
|
|
34
|
+
addLog(
|
|
35
|
+
<Box flexDirection="column">
|
|
36
|
+
<Text bold>{file}</Text>
|
|
37
|
+
{diagnostics.map((diagnostic, index) => (
|
|
38
|
+
<DiagnosticLine key={index} diagnostic={diagnostic} />
|
|
39
|
+
))}
|
|
40
|
+
</Box>
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const errors = all.filter(({ severity }) => severity === 'error').length;
|
|
45
|
+
const warnings = all.length - errors;
|
|
46
|
+
if (errors > 0) {
|
|
47
|
+
addLog(
|
|
48
|
+
<ErrorLog
|
|
49
|
+
message={`${plural(errors, 'error')}${warnings > 0 ? `, ${plural(warnings, 'warning')}` : ''}`}
|
|
50
|
+
/>
|
|
51
|
+
);
|
|
52
|
+
} else if (warnings > 0) {
|
|
53
|
+
addLog(<Text dimColor>{plural(warnings, 'warning')}</Text>);
|
|
54
|
+
}
|
|
55
|
+
return errors > 0;
|
|
56
|
+
};
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { maskNonMarkup } from '../../mdx.js';
|
|
2
|
+
import type { FileRule, FileFinding } from '../../types.js';
|
|
3
|
+
|
|
4
|
+
const VOID_TAG = /<(br|hr|img)(?=[\s/>])((?:"[^"]*"|'[^']*'|\{[^{}]*\}|[^<>"'{}])*?)(\/?)>/g;
|
|
5
|
+
|
|
6
|
+
export const voidTags: FileRule = {
|
|
7
|
+
id: 'void-tags',
|
|
8
|
+
group: 'content',
|
|
9
|
+
severity: 'error',
|
|
10
|
+
scope: 'file',
|
|
11
|
+
description: "self-close void HTML tags (<br>, <hr>, <img>); MDX can't parse them unclosed",
|
|
12
|
+
raw: true,
|
|
13
|
+
check: ({ content }) => {
|
|
14
|
+
const masked = maskNonMarkup(content);
|
|
15
|
+
const issues: FileFinding[] = [];
|
|
16
|
+
for (const match of masked.matchAll(VOID_TAG)) {
|
|
17
|
+
if (match[3]) continue;
|
|
18
|
+
const start = match.index;
|
|
19
|
+
const end = start + match[0].length;
|
|
20
|
+
if (new RegExp(`^\\s*</${match[1]}\\s*>`).test(masked.slice(end))) continue;
|
|
21
|
+
const before = content.slice(0, start).split('\n');
|
|
22
|
+
issues.push({
|
|
23
|
+
line: before.length,
|
|
24
|
+
column: (before.pop()?.length ?? 0) + 1,
|
|
25
|
+
message: `<${match[1]}> must be self-closed`,
|
|
26
|
+
edit: {
|
|
27
|
+
start,
|
|
28
|
+
end,
|
|
29
|
+
text: `${content.slice(start, end - 1).trimEnd()} />`,
|
|
30
|
+
},
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
return issues;
|
|
34
|
+
},
|
|
35
|
+
};
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { DiagnosticSource } from '../types.js';
|
|
2
|
+
|
|
3
|
+
/** The engine's built-in diagnostic for files neither parser can parse; not a selectable rule. */
|
|
4
|
+
export const PARSE_ERROR: DiagnosticSource = {
|
|
5
|
+
id: 'parse-error',
|
|
6
|
+
group: 'content',
|
|
7
|
+
severity: 'error',
|
|
8
|
+
description: "files MDX can't parse; other checks skip them",
|
|
9
|
+
};
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
import { readSiteFiles } from './files.js';
|
|
5
|
+
import { toPosition } from './mdx.js';
|
|
6
|
+
import type { Site } from './types.js';
|
|
7
|
+
|
|
8
|
+
const readVariables = (config: string): Record<string, string> | undefined => {
|
|
9
|
+
try {
|
|
10
|
+
const { variables } = JSON.parse(config) as { variables?: unknown };
|
|
11
|
+
return variables && typeof variables === 'object'
|
|
12
|
+
? (variables as Record<string, string>)
|
|
13
|
+
: undefined;
|
|
14
|
+
} catch {
|
|
15
|
+
return undefined;
|
|
16
|
+
}
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
/** Lists the site's pages and snippets; locate reads files from disk on demand. */
|
|
20
|
+
export const loadSite = async (
|
|
21
|
+
baseDir: string,
|
|
22
|
+
{ selected, localSchema }: { selected?: ReadonlySet<string>; localSchema: boolean }
|
|
23
|
+
): Promise<Site> => {
|
|
24
|
+
const files = await readSiteFiles(baseDir);
|
|
25
|
+
const byPath = new Map<string, string>();
|
|
26
|
+
const configFile = existsSync(path.join(baseDir, 'docs.json')) ? 'docs.json' : 'mint.json';
|
|
27
|
+
|
|
28
|
+
const read = (file: string) => {
|
|
29
|
+
if (!byPath.has(file)) {
|
|
30
|
+
try {
|
|
31
|
+
byPath.set(file, readFileSync(path.join(baseDir, file), 'utf-8'));
|
|
32
|
+
} catch {
|
|
33
|
+
byPath.set(file, '');
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return byPath.get(file) ?? '';
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
return {
|
|
40
|
+
baseDir,
|
|
41
|
+
files,
|
|
42
|
+
selected,
|
|
43
|
+
configFile,
|
|
44
|
+
variables: readVariables(read(configFile)),
|
|
45
|
+
localSchema,
|
|
46
|
+
locate: (file, text) => {
|
|
47
|
+
const content = read(file);
|
|
48
|
+
const offset = text ? content.indexOf(text) : -1;
|
|
49
|
+
return offset === -1 ? {} : toPosition(content, offset);
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
};
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import type { BuildValidationResult } from '@mintlify/previewing';
|
|
2
|
+
import type { Root } from 'mdast';
|
|
3
|
+
|
|
4
|
+
export type Group = 'content' | 'nav' | 'config' | 'openapi';
|
|
5
|
+
|
|
6
|
+
export const GROUPS: Group[] = ['content', 'nav', 'config', 'openapi'];
|
|
7
|
+
|
|
8
|
+
export type Severity = 'error' | 'warning';
|
|
9
|
+
|
|
10
|
+
export type Edit = { start: number; end: number; text: string };
|
|
11
|
+
|
|
12
|
+
export type FileEdit = Edit & { file: string };
|
|
13
|
+
|
|
14
|
+
export type Diagnostic = {
|
|
15
|
+
rule: string;
|
|
16
|
+
group: Group;
|
|
17
|
+
severity: Severity;
|
|
18
|
+
file: string;
|
|
19
|
+
line?: number;
|
|
20
|
+
column?: number;
|
|
21
|
+
message: string;
|
|
22
|
+
fix?: FileEdit;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/** What a rule reports; the engine adds its id and group, and its severity unless overridden. */
|
|
26
|
+
export type Finding = Omit<Diagnostic, 'rule' | 'group' | 'severity'> & { severity?: Severity };
|
|
27
|
+
|
|
28
|
+
type RuleMeta = {
|
|
29
|
+
id: string;
|
|
30
|
+
group: Group;
|
|
31
|
+
severity: Severity;
|
|
32
|
+
needsBuild?: boolean;
|
|
33
|
+
description: string;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export type DiagnosticSource = Pick<RuleMeta, 'id' | 'group' | 'severity' | 'description'>;
|
|
37
|
+
|
|
38
|
+
export type FileKind = 'page' | 'snippet';
|
|
39
|
+
|
|
40
|
+
export type FileContext = {
|
|
41
|
+
file: string;
|
|
42
|
+
content: string;
|
|
43
|
+
baseDir: string;
|
|
44
|
+
frontmatter: Record<string, unknown>;
|
|
45
|
+
tree: Root;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/** A finding in one file, with an optional edit against the content the rule saw. */
|
|
49
|
+
export type FileFinding = {
|
|
50
|
+
line?: number;
|
|
51
|
+
column?: number;
|
|
52
|
+
message: string;
|
|
53
|
+
edit?: Edit;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
/** Checks one page or snippet at a time. Raw rules read text; the rest also get the MDX tree. */
|
|
57
|
+
export type FileRule = RuleMeta & {
|
|
58
|
+
scope: 'file';
|
|
59
|
+
raw?: boolean;
|
|
60
|
+
pagesOnly?: boolean;
|
|
61
|
+
needsAnchorLinks?: boolean;
|
|
62
|
+
check: (context: FileContext) => FileFinding[];
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
export type SiteFile = { file: string; kind: FileKind };
|
|
66
|
+
|
|
67
|
+
export type BuildResult = BuildValidationResult;
|
|
68
|
+
|
|
69
|
+
export type Site = {
|
|
70
|
+
baseDir: string;
|
|
71
|
+
files: SiteFile[];
|
|
72
|
+
/** Set with --files: rules report on these files only. */
|
|
73
|
+
selected?: ReadonlySet<string>;
|
|
74
|
+
configFile: string;
|
|
75
|
+
variables?: Record<string, string>;
|
|
76
|
+
build?: BuildResult;
|
|
77
|
+
/** The line and column of the first occurrence of text in a file. */
|
|
78
|
+
locate: (file: string, text: string) => { line?: number; column?: number };
|
|
79
|
+
localSchema: boolean;
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
/** Checks the site as a whole. */
|
|
83
|
+
export type SiteRule = RuleMeta & {
|
|
84
|
+
scope: 'site';
|
|
85
|
+
check: (site: Site) => Finding[] | Promise<Finding[]>;
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
export type Rule = FileRule | SiteRule;
|