@mintlify/cli 4.0.1733 → 4.0.1735

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.
Files changed (38) hide show
  1. package/__test__/agent-claim.test.ts +17 -22
  2. package/__test__/agent-credentials.test.ts +91 -4
  3. package/__test__/deploy.test.ts +4 -6
  4. package/__test__/validateCommand.test.ts +141 -0
  5. package/__test__/validateEngine.test.ts +46 -0
  6. package/__test__/validateRules.test.ts +500 -0
  7. package/bin/agent-claim.js +3 -10
  8. package/bin/agent-credentials.js +75 -6
  9. package/bin/cli.js +22 -1
  10. package/bin/deploy.js +1 -8
  11. package/bin/resolveFiles.js +3 -1
  12. package/bin/tsconfig.build.tsbuildinfo +1 -1
  13. package/bin/validate/command.js +58 -0
  14. package/bin/validate/engine.js +76 -0
  15. package/bin/validate/files.js +148 -0
  16. package/bin/validate/mdx.js +201 -0
  17. package/bin/validate/output.js +28 -0
  18. package/bin/validate/rules/content/voidTags.js +35 -0
  19. package/bin/validate/rules/index.js +3 -0
  20. package/bin/validate/rules/parseError.js +7 -0
  21. package/bin/validate/site.js +55 -0
  22. package/bin/validate/types.js +1 -0
  23. package/package.json +8 -8
  24. package/src/agent-claim.ts +1 -10
  25. package/src/agent-credentials.ts +53 -10
  26. package/src/cli.tsx +24 -1
  27. package/src/deploy.ts +2 -9
  28. package/src/resolveFiles.ts +3 -1
  29. package/src/validate/command.tsx +71 -0
  30. package/src/validate/engine.ts +93 -0
  31. package/src/validate/files.ts +212 -0
  32. package/src/validate/mdx.ts +252 -0
  33. package/src/validate/output.tsx +56 -0
  34. package/src/validate/rules/content/voidTags.ts +35 -0
  35. package/src/validate/rules/index.ts +5 -0
  36. package/src/validate/rules/parseError.ts +9 -0
  37. package/src/validate/site.ts +52 -0
  38. package/src/validate/types.ts +88 -0
@@ -0,0 +1,252 @@
1
+ import {
2
+ coreRemark,
3
+ FRONTMATTER_RE,
4
+ getCodeRanges,
5
+ HEADING_ID_RE,
6
+ JSXParser,
7
+ } from '@mintlify/common';
8
+ import type { Root } from 'mdast';
9
+
10
+ import type { Edit } from './types.js';
11
+
12
+ const HEADING_ID = new RegExp(HEADING_ID_RE.source, 'gm');
13
+ const ESM_START = /^(?:import|export)(?=[\s{*])/;
14
+ const JSX_TAG = /<[A-Za-z][\w.:-]*(?:"[^"]*"|'[^']*'|\{[^{}]*\}|[^<>"'{}])*>/g;
15
+ const ATTR_VALUE = /"[^"]*"|'[^']*'|\{[^{}]*\}/g;
16
+
17
+ export const hasCustomHeadingId = (line: string) => /\{\s*#[^}]+?\s*\}\s*$/.test(line);
18
+
19
+ export const parseContent = (content: string): Root => {
20
+ const padded = content.replace(
21
+ HEADING_ID,
22
+ (match, indent: string, hashes: string, text: string) => {
23
+ const end = match.indexOf(text, indent.length + hashes.length) + text.length;
24
+ return match.slice(0, end).padEnd(match.length, ' ');
25
+ }
26
+ );
27
+ return coreRemark().parse(padded);
28
+ };
29
+
30
+ type Range = [number, number];
31
+
32
+ const blankRanges = (content: string, ranges: Range[]) => {
33
+ let result = '';
34
+ let cursor = 0;
35
+ for (const [from, to] of [...ranges].sort((a, b) => a[0] - b[0])) {
36
+ const start = Math.max(from, cursor);
37
+ if (to <= start) continue;
38
+ result += content.slice(cursor, start) + content.slice(start, to).replace(/[^\n]/g, ' ');
39
+ cursor = to;
40
+ }
41
+ return result + content.slice(cursor);
42
+ };
43
+
44
+ const maskWith = (content: string, maskers: ((content: string) => Range[])[]) =>
45
+ maskers.reduce((text, ranges) => blankRanges(text, ranges(text)), content);
46
+
47
+ const isCompleteEsm = (text: string) => {
48
+ try {
49
+ JSXParser.parse(text, { ecmaVersion: 'latest', sourceType: 'module' });
50
+ return true;
51
+ } catch (error) {
52
+ const pos = (error as { pos?: unknown }).pos;
53
+ return typeof pos !== 'number' || pos < text.trimEnd().length;
54
+ }
55
+ };
56
+
57
+ const frontmatterRanges = (content: string): Range[] => {
58
+ const match = FRONTMATTER_RE.exec(content);
59
+ return match ? [[match.index, match.index + match[0].length]] : [];
60
+ };
61
+
62
+ const esmRanges = (content: string, codeRanges: Range[]): Range[] => {
63
+ let esm: string[] | undefined;
64
+ let previousBlank = true;
65
+ const ranges: Range[] = [];
66
+ let offset = 0;
67
+ for (const line of content.split('\n')) {
68
+ const isBlank = line.trim() === '';
69
+ const wasBlank = previousBlank;
70
+ previousBlank = isBlank;
71
+ if (esm) {
72
+ if (isBlank && isCompleteEsm(esm.join('\n'))) esm = undefined;
73
+ else {
74
+ esm.push(line);
75
+ ranges.push([offset, offset + line.length]);
76
+ }
77
+ } else if (
78
+ wasBlank &&
79
+ ESM_START.test(line) &&
80
+ !codeRanges.some(([start, end]) => start <= offset && offset < end)
81
+ ) {
82
+ esm = [line];
83
+ ranges.push([offset, offset + line.length]);
84
+ }
85
+ offset += line.length + 1;
86
+ }
87
+ return ranges;
88
+ };
89
+
90
+ export const maskFrontmatter = (content: string): string =>
91
+ blankRanges(content, frontmatterRanges(content));
92
+
93
+ export const maskCode = (content: string, { keepEsm = false } = {}): string => {
94
+ const codeRanges = getCodeRanges(content);
95
+ return blankRanges(content, [
96
+ ...frontmatterRanges(content),
97
+ ...codeRanges,
98
+ ...(keepEsm ? [] : esmRanges(content, codeRanges)),
99
+ ]);
100
+ };
101
+
102
+ const OPENS = new Set(['{', '${']);
103
+
104
+ const maskExpression = (content: string, start: number): { ranges: Range[]; end: number } => {
105
+ const ranges: Range[] = [];
106
+ let depth = 0;
107
+ try {
108
+ const tokens = JSXParser.tokenizer(content.slice(start), {
109
+ ecmaVersion: 'latest',
110
+ onComment: (block, _text, from, to) =>
111
+ ranges.push([start + from + 2, start + to - (block ? 2 : 0)]),
112
+ });
113
+ for (const token of tokens) {
114
+ const label = token.type.label;
115
+ if (label === 'template') ranges.push([start + token.start, start + token.end]);
116
+ else if (label === 'string') ranges.push([start + token.start + 1, start + token.end - 1]);
117
+ else if (label === 'regexp') {
118
+ const raw = content.slice(start + token.start, start + token.end);
119
+ ranges.push([start + token.start + 1, start + token.start + raw.lastIndexOf('/')]);
120
+ } else if (OPENS.has(label)) depth++;
121
+ else if (label === '}' && depth-- === 0) return { ranges, end: start + token.end };
122
+ }
123
+ } catch (error) {
124
+ if (!(error instanceof SyntaxError && 'pos' in error && typeof error.pos === 'number')) {
125
+ return { ranges, end: content.length };
126
+ }
127
+ const pos = start + error.pos;
128
+ if (!error.message.startsWith('Unterminated string')) return { ranges, end: pos };
129
+ const lineEnd = content.indexOf('\n', pos);
130
+ ranges.push([pos + 1, lineEnd === -1 ? content.length : lineEnd]);
131
+ }
132
+ return { ranges, end: content.length };
133
+ };
134
+
135
+ const expressionRanges = (content: string): Range[] => {
136
+ const ranges: Range[] = [];
137
+ for (let index = 0; index < content.length; index++) {
138
+ const char = content[index];
139
+ if (char === '\\') index++;
140
+ else if (char === '{') {
141
+ const expression = maskExpression(content, index + 1);
142
+ ranges.push(...expression.ranges);
143
+ index = expression.end - 1;
144
+ }
145
+ }
146
+ return ranges;
147
+ };
148
+
149
+ const attributeRanges = (content: string): Range[] =>
150
+ [...content.matchAll(JSX_TAG)].flatMap(({ index, 0: tag }) =>
151
+ [...tag.matchAll(ATTR_VALUE)].map(
152
+ ({ index: at, 0: value }): Range => [index + at + 1, index + at + value.length - 1]
153
+ )
154
+ );
155
+
156
+ const escapedTagRanges = (content: string): Range[] =>
157
+ [...content.matchAll(/\\[\s\S]/g)].flatMap(({ index, 0: escape }): Range[] =>
158
+ escape === '\\<' ? [[index + 1, index + 2]] : []
159
+ );
160
+
161
+ export const maskNonMarkup = (content: string): string =>
162
+ maskWith(maskCode(content), [attributeRanges, escapedTagRanges, expressionRanges]);
163
+
164
+ export const selectEdits = <T extends Edit>(edits: T[]): T[] => {
165
+ const ordered = edits
166
+ .map((edit, index) => ({ edit, index }))
167
+ .sort((a, b) => b.edit.start - a.edit.start || b.edit.end - a.edit.end || b.index - a.index);
168
+ const selected: T[] = [];
169
+ let floor = Infinity;
170
+ for (const { edit } of ordered) {
171
+ if (edit.end > floor) continue;
172
+ selected.push(edit);
173
+ floor = edit.start;
174
+ }
175
+ return selected;
176
+ };
177
+
178
+ export const applyEdits = (content: string, edits: Edit[]): string =>
179
+ selectEdits(edits).reduce(
180
+ (result, edit) => result.slice(0, edit.start) + edit.text + result.slice(edit.end),
181
+ content
182
+ );
183
+
184
+ const toOffset = (content: string, line: number, column = 1) => {
185
+ let offset = 0;
186
+ for (let current = 1; current < line; current++) {
187
+ const next = content.indexOf('\n', offset);
188
+ if (next === -1) break;
189
+ offset = next + 1;
190
+ }
191
+ return offset + column - 1;
192
+ };
193
+
194
+ export const toPosition = (content: string, offset: number) => {
195
+ const before = content.slice(0, offset).split('\n');
196
+ return { line: before.length, column: (before.pop()?.length ?? 0) + 1 };
197
+ };
198
+
199
+ const byStart = (a: Edit, b: Edit) => a.start - b.start;
200
+
201
+ /** Maps an offset in edited content back to the content before the edits, or undefined inside an edit. */
202
+ function toOriginal(edits: Edit[], offset: number, inside: 'snap'): number;
203
+ function toOriginal(edits: Edit[], offset: number, inside: 'reject'): number | undefined;
204
+ function toOriginal(edits: Edit[], offset: number, inside: 'snap' | 'reject') {
205
+ let delta = 0;
206
+ for (const edit of edits) {
207
+ const start = edit.start + delta;
208
+ if (inside === 'snap' ? offset < start : offset <= start) break;
209
+ if (offset < start + edit.text.length) return inside === 'snap' ? edit.start : undefined;
210
+ delta += edit.text.length - (edit.end - edit.start);
211
+ }
212
+ return offset - delta;
213
+ }
214
+
215
+ /** Maps an edit made against a later stage back to the original content, or undefined when it touches an earlier fix. */
216
+ export const unmapEdit = (
217
+ stages: { content: string; edits: Edit[] }[],
218
+ index: number,
219
+ edit: Edit
220
+ ): Edit | undefined => {
221
+ let { start, end } = edit;
222
+ for (const { edits } of stages.slice(0, index).reverse()) {
223
+ const sorted = [...edits].sort(byStart);
224
+ const mappedStart = toOriginal(sorted, start, 'reject');
225
+ const mappedEnd = toOriginal(sorted, end, 'reject');
226
+ if (mappedStart === undefined || mappedEnd === undefined) return undefined;
227
+ if (sorted.some((earlier) => earlier.start < mappedEnd && earlier.end > mappedStart)) {
228
+ return undefined;
229
+ }
230
+ start = mappedStart;
231
+ end = mappedEnd;
232
+ }
233
+ return { start, end, text: edit.text };
234
+ };
235
+
236
+ export const relocate = (
237
+ stages: { content: string; edits: Edit[] }[],
238
+ index: number,
239
+ position: { line?: number; column?: number }
240
+ ) => {
241
+ const stage = stages[index];
242
+ const original = stages[0];
243
+ if (!stage || !original || position.line === undefined) return {};
244
+ const offset = stages
245
+ .slice(0, index)
246
+ .reduceRight(
247
+ (current, { edits }) => toOriginal([...edits].sort(byStart), current, 'snap'),
248
+ toOffset(stage.content, position.line, position.column)
249
+ );
250
+ const mapped = toPosition(original.content, offset);
251
+ return position.column === undefined ? { line: mapped.line } : mapped;
252
+ };
@@ -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,5 @@
1
+ import type { Rule } from '../types.js';
2
+ import { voidTags } from './content/voidTags.js';
3
+
4
+ /** Every check mint validate runs, in report order. */
5
+ export const RULES: Rule[] = [voidTags];
@@ -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;