@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.
- package/__test__/agent-claim.test.ts +17 -22
- package/__test__/agent-credentials.test.ts +91 -4
- package/__test__/deploy.test.ts +4 -6
- package/__test__/validateCommand.test.ts +141 -0
- package/__test__/validateEngine.test.ts +46 -0
- package/__test__/validateRules.test.ts +500 -0
- package/bin/agent-claim.js +3 -10
- package/bin/agent-credentials.js +75 -6
- package/bin/cli.js +22 -1
- package/bin/deploy.js +1 -8
- 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/agent-claim.ts +1 -10
- package/src/agent-credentials.ts +53 -10
- package/src/cli.tsx +24 -1
- package/src/deploy.ts +2 -9
- 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
package/src/agent-claim.ts
CHANGED
|
@@ -56,7 +56,6 @@ const signupSchema = z.object({ orgId: z.string().min(1), claimed: z.literal(tru
|
|
|
56
56
|
|
|
57
57
|
export async function claimDeployment(
|
|
58
58
|
options: { apiUrl: string; directory: string; code?: string; email?: string },
|
|
59
|
-
warn: (message: string) => void,
|
|
60
59
|
progress: (message: string) => void = () => {}
|
|
61
60
|
): Promise<string> {
|
|
62
61
|
const { apiUrl, directory } = options;
|
|
@@ -81,15 +80,7 @@ export async function claimDeployment(
|
|
|
81
80
|
if (new URL(discovery.issuer).origin !== new URL(credentials.issuer).origin) {
|
|
82
81
|
throw new Error('Saved agent credentials belong to another issuer.');
|
|
83
82
|
}
|
|
84
|
-
const save =
|
|
85
|
-
try {
|
|
86
|
-
await saveAgentCredentials(apiUrl, directory, value);
|
|
87
|
-
} catch {
|
|
88
|
-
warn(
|
|
89
|
-
'Could not save claimed credentials to the keyring. Continuing for this run; a retry may not recover the completed claim.'
|
|
90
|
-
);
|
|
91
|
-
}
|
|
92
|
-
};
|
|
83
|
+
const save = (value: AgentCredentials) => saveAgentCredentials(apiUrl, directory, value);
|
|
93
84
|
if (!credentials.claimVerified) {
|
|
94
85
|
progress('Completing the WorkOS claim...');
|
|
95
86
|
const completed = await agentResponse(
|
package/src/agent-credentials.ts
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
|
+
import fs from 'node:fs/promises';
|
|
2
3
|
import path from 'node:path';
|
|
3
4
|
import { z } from 'zod';
|
|
4
5
|
|
|
6
|
+
import { CONFIG_DIR } from './constants.js';
|
|
5
7
|
import { getKeytar } from './keyring.js';
|
|
6
8
|
|
|
7
9
|
const service = 'mintlify-agent';
|
|
10
|
+
const fallbackFile = () => path.join(CONFIG_DIR, 'agent-credentials.json');
|
|
8
11
|
|
|
9
12
|
export const agentCredentialsSchema = z.object({
|
|
10
13
|
registrationId: z.string().min(1),
|
|
@@ -28,14 +31,59 @@ function registrationAccount(apiUrl: string, registrationId: string): string {
|
|
|
28
31
|
return `${new URL(apiUrl).origin}:${registrationId}`;
|
|
29
32
|
}
|
|
30
33
|
|
|
34
|
+
async function readFallback(): Promise<Record<string, string>> {
|
|
35
|
+
try {
|
|
36
|
+
return z
|
|
37
|
+
.record(z.string(), z.string())
|
|
38
|
+
.parse(JSON.parse(await fs.readFile(fallbackFile(), 'utf-8')));
|
|
39
|
+
} catch {
|
|
40
|
+
return {};
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function writeFallback(entries: Record<string, string>): Promise<void> {
|
|
45
|
+
await fs.mkdir(CONFIG_DIR, { recursive: true });
|
|
46
|
+
const temp = `${fallbackFile()}.${process.pid}.tmp`;
|
|
47
|
+
await fs.writeFile(temp, JSON.stringify(entries, null, 2), { mode: 0o600 });
|
|
48
|
+
await fs.chmod(temp, 0o600);
|
|
49
|
+
await fs.rename(temp, fallbackFile());
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function getSecret(account: string): Promise<string | null> {
|
|
53
|
+
let keyringError: unknown;
|
|
54
|
+
try {
|
|
55
|
+
const value = await (await getKeytar()).getPassword(service, account);
|
|
56
|
+
if (value) return value;
|
|
57
|
+
} catch (error) {
|
|
58
|
+
keyringError = error;
|
|
59
|
+
}
|
|
60
|
+
const value = (await readFallback())[account];
|
|
61
|
+
if (value) return value;
|
|
62
|
+
if (keyringError) throw keyringError;
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function setSecret(account: string, value: string): Promise<void> {
|
|
67
|
+
try {
|
|
68
|
+
await (await getKeytar()).setPassword(service, account, value);
|
|
69
|
+
} catch {
|
|
70
|
+
await writeFallback({ ...(await readFallback()), [account]: value });
|
|
71
|
+
await getKeytar()
|
|
72
|
+
.then((keytar) => keytar.deletePassword(service, account))
|
|
73
|
+
.catch(() => {});
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
const { [account]: stale, ...rest } = await readFallback();
|
|
77
|
+
if (stale) await writeFallback(rest).catch(() => {});
|
|
78
|
+
}
|
|
79
|
+
|
|
31
80
|
export async function loadAgentCredentials(
|
|
32
81
|
apiUrl: string,
|
|
33
82
|
directory: string
|
|
34
83
|
): Promise<AgentCredentials | null> {
|
|
35
|
-
const
|
|
36
|
-
const registrationId = await keytar.getPassword(service, projectAccount(apiUrl, directory));
|
|
84
|
+
const registrationId = await getSecret(projectAccount(apiUrl, directory));
|
|
37
85
|
if (!registrationId) return null;
|
|
38
|
-
const value = await
|
|
86
|
+
const value = await getSecret(registrationAccount(apiUrl, registrationId));
|
|
39
87
|
if (!value)
|
|
40
88
|
throw new Error('Saved agent credentials are missing. Use --new to create another site.');
|
|
41
89
|
const credentials = agentCredentialsSchema.parse(JSON.parse(value));
|
|
@@ -51,11 +99,6 @@ export async function saveAgentCredentials(
|
|
|
51
99
|
credentials: AgentCredentials
|
|
52
100
|
): Promise<void> {
|
|
53
101
|
const value = agentCredentialsSchema.parse(credentials);
|
|
54
|
-
|
|
55
|
-
await
|
|
56
|
-
service,
|
|
57
|
-
registrationAccount(apiUrl, value.registrationId),
|
|
58
|
-
JSON.stringify(value)
|
|
59
|
-
);
|
|
60
|
-
await keytar.setPassword(service, projectAccount(apiUrl, directory), value.registrationId);
|
|
102
|
+
await setSecret(registrationAccount(apiUrl, value.registrationId), JSON.stringify(value));
|
|
103
|
+
await setSecret(projectAccount(apiUrl, directory), value.registrationId);
|
|
61
104
|
}
|
package/src/cli.tsx
CHANGED
|
@@ -56,6 +56,7 @@ import { sendPublishPrompt, signup } from './signup.js';
|
|
|
56
56
|
import { status, getCliSubdomains } from './status.js';
|
|
57
57
|
import { trackTelemetryPreferenceChange } from './telemetry/track.js';
|
|
58
58
|
import { update } from './update.js';
|
|
59
|
+
import { runLintStep, splitList } from './validate/command.js';
|
|
59
60
|
import { renderWelcome } from './welcome.js';
|
|
60
61
|
import { workflowsBuilder } from './workflows/index.js';
|
|
61
62
|
|
|
@@ -142,7 +143,6 @@ export const cli = ({ packageName = 'mint' }: { packageName?: string }) => {
|
|
|
142
143
|
try {
|
|
143
144
|
const message = await claimDeployment(
|
|
144
145
|
{ apiUrl: API_URL, directory: argv.directory, code: argv.code, email: argv.email },
|
|
145
|
-
(message) => process.stderr.write(`Warning: ${message}\n`),
|
|
146
146
|
(message) => process.stderr.write(`${message}\n`)
|
|
147
147
|
);
|
|
148
148
|
process.stdout.write(`${message}\n`);
|
|
@@ -379,9 +379,32 @@ export const cli = ({ packageName = 'mint' }: { packageName?: string }) => {
|
|
|
379
379
|
default: false,
|
|
380
380
|
description: 'Disable OpenAPI file generation',
|
|
381
381
|
})
|
|
382
|
+
.option('files', {
|
|
383
|
+
type: 'string',
|
|
384
|
+
array: true,
|
|
385
|
+
description:
|
|
386
|
+
'Limit page checks to these files or globs; site-wide checks and the build still cover the whole site',
|
|
387
|
+
})
|
|
388
|
+
.option('enable', {
|
|
389
|
+
type: 'string',
|
|
390
|
+
array: true,
|
|
391
|
+
coerce: splitList,
|
|
392
|
+
description:
|
|
393
|
+
'Turn on rules or groups (comma-separated), for example after --disable all',
|
|
394
|
+
})
|
|
395
|
+
.option('disable', {
|
|
396
|
+
type: 'string',
|
|
397
|
+
array: true,
|
|
398
|
+
coerce: splitList,
|
|
399
|
+
description:
|
|
400
|
+
'Turn off rules or groups (comma-separated), or all; parse errors are still reported',
|
|
401
|
+
})
|
|
382
402
|
.usage('usage: mintlify validate [options]')
|
|
383
403
|
.example('mintlify validate', 'validate the build'),
|
|
384
404
|
async (argv) => {
|
|
405
|
+
const lintPassed = await runLintStep(argv);
|
|
406
|
+
if (!lintPassed) return;
|
|
407
|
+
|
|
385
408
|
if (!argv['disable-openapi']) {
|
|
386
409
|
let openApiFilenames: string[] = [];
|
|
387
410
|
|
package/src/deploy.ts
CHANGED
|
@@ -62,15 +62,8 @@ export async function prepareAgentDeployment(
|
|
|
62
62
|
if (!subdomain) throw new Error('Choose a site name with --subdomain <name>.');
|
|
63
63
|
const discovery = await discoverAgentAuth(apiUrl);
|
|
64
64
|
|
|
65
|
-
const save =
|
|
66
|
-
|
|
67
|
-
await saveAgentCredentials(apiUrl, directory, credentials);
|
|
68
|
-
} catch {
|
|
69
|
-
warn(
|
|
70
|
-
'Could not save agent credentials to the keyring. Continuing for this run; later commands may not recover access.'
|
|
71
|
-
);
|
|
72
|
-
}
|
|
73
|
-
};
|
|
65
|
+
const save = (credentials: AgentCredentials) =>
|
|
66
|
+
saveAgentCredentials(apiUrl, directory, credentials);
|
|
74
67
|
|
|
75
68
|
for (let attempt = 0; attempt < 2; attempt++) {
|
|
76
69
|
const credentials = saved ?? (await registerAgent(discovery));
|
package/src/resolveFiles.ts
CHANGED
|
@@ -66,6 +66,8 @@ async function walk(baseDir: string, dir = ''): Promise<string[]> {
|
|
|
66
66
|
return files;
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
+
export class NoMatchingFilesError extends Error {}
|
|
70
|
+
|
|
69
71
|
export async function resolveExplicitFiles(baseDir: string, args: string[]): Promise<string[]> {
|
|
70
72
|
let allFiles: string[] | null = null;
|
|
71
73
|
const matched: string[] = [];
|
|
@@ -90,7 +92,7 @@ export async function resolveExplicitFiles(baseDir: string, args: string[]): Pro
|
|
|
90
92
|
}
|
|
91
93
|
|
|
92
94
|
if (unmatched.length > 0) {
|
|
93
|
-
throw new
|
|
95
|
+
throw new NoMatchingFilesError(
|
|
94
96
|
`No matching files for: ${unmatched.join(', ')} (globs match .md and .mdx files)`
|
|
95
97
|
);
|
|
96
98
|
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { addLog, clearLogs, ErrorLog, SpinnerLog } from '@mintlify/previewing';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
import { CMD_EXEC_PATH, terminate } from '../helpers.js';
|
|
5
|
+
import { NoMatchingFilesError, resolveExplicitFiles } from '../resolveFiles.js';
|
|
6
|
+
import { checkSite, selectRules, UsageError } from './engine.js';
|
|
7
|
+
import { logDiagnostics } from './output.js';
|
|
8
|
+
|
|
9
|
+
export type ValidateArgs = {
|
|
10
|
+
'local-schema': boolean;
|
|
11
|
+
'client-version'?: string;
|
|
12
|
+
groups?: (string | number)[];
|
|
13
|
+
files?: (string | number)[];
|
|
14
|
+
enable?: string[];
|
|
15
|
+
disable?: string[];
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export const EXIT = { passed: 0, failed: 1, crashed: 2 } as const;
|
|
19
|
+
|
|
20
|
+
export const splitList = (values: string[]) =>
|
|
21
|
+
values.flatMap((value) =>
|
|
22
|
+
value
|
|
23
|
+
.split(',')
|
|
24
|
+
.map((item) => item.trim())
|
|
25
|
+
.filter(Boolean)
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
const errorMessage = (error: unknown) => (error instanceof Error ? error.message : String(error));
|
|
29
|
+
|
|
30
|
+
const resolveSelection = async (baseDir: string, values: (string | number)[] | undefined) => {
|
|
31
|
+
const args = (values ?? []).map(String).filter(Boolean);
|
|
32
|
+
if (args.length === 0) return undefined;
|
|
33
|
+
const files = await resolveExplicitFiles(CMD_EXEC_PATH, args);
|
|
34
|
+
return new Set(
|
|
35
|
+
files.map((file) =>
|
|
36
|
+
path.relative(baseDir, path.resolve(CMD_EXEC_PATH, file)).split(path.sep).join('/')
|
|
37
|
+
)
|
|
38
|
+
);
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export const runLintStep = async (
|
|
42
|
+
argv: ValidateArgs,
|
|
43
|
+
baseDir = CMD_EXEC_PATH
|
|
44
|
+
): Promise<boolean> => {
|
|
45
|
+
try {
|
|
46
|
+
const rules = selectRules({
|
|
47
|
+
enable: argv.enable,
|
|
48
|
+
disable: argv.disable ?? [],
|
|
49
|
+
});
|
|
50
|
+
const selected = await resolveSelection(baseDir, argv.files);
|
|
51
|
+
addLog(<SpinnerLog message="validating..." />);
|
|
52
|
+
|
|
53
|
+
const { diagnostics } = await checkSite(baseDir, rules, {
|
|
54
|
+
selected,
|
|
55
|
+
localSchema: argv['local-schema'],
|
|
56
|
+
});
|
|
57
|
+
clearLogs();
|
|
58
|
+
const failed = logDiagnostics(diagnostics);
|
|
59
|
+
if (failed) {
|
|
60
|
+
await terminate(EXIT.failed);
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
return true;
|
|
64
|
+
} catch (error) {
|
|
65
|
+
const usage = error instanceof UsageError || error instanceof NoMatchingFilesError;
|
|
66
|
+
clearLogs();
|
|
67
|
+
addLog(<ErrorLog message={errorMessage(error)} />);
|
|
68
|
+
await terminate(usage ? EXIT.failed : EXIT.crashed);
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
};
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { checkFiles } from './files.js';
|
|
2
|
+
import { RULES } from './rules/index.js';
|
|
3
|
+
import { PARSE_ERROR } from './rules/parseError.js';
|
|
4
|
+
import { loadSite } from './site.js';
|
|
5
|
+
import {
|
|
6
|
+
GROUPS,
|
|
7
|
+
type Diagnostic,
|
|
8
|
+
type DiagnosticSource,
|
|
9
|
+
type FileRule,
|
|
10
|
+
type Finding,
|
|
11
|
+
type Rule,
|
|
12
|
+
type Site,
|
|
13
|
+
} from './types.js';
|
|
14
|
+
|
|
15
|
+
export class UsageError extends Error {}
|
|
16
|
+
|
|
17
|
+
const ruleList = () => RULES.map(({ id }) => id).join(', ');
|
|
18
|
+
|
|
19
|
+
const matchToken = (token: string): Rule[] => {
|
|
20
|
+
if (token === 'all') return RULES;
|
|
21
|
+
const rule = RULES.find(({ id }) => id === token);
|
|
22
|
+
if (rule) return [rule];
|
|
23
|
+
if ((GROUPS as string[]).includes(token)) {
|
|
24
|
+
return RULES.filter(({ group }) => group === token);
|
|
25
|
+
}
|
|
26
|
+
throw new UsageError(
|
|
27
|
+
`unknown rule or group "${token}". Groups: all, ${GROUPS.join(', ')}. Rules: ${ruleList()}`
|
|
28
|
+
);
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/** Every rule, minus --disable, plus --enable. parse-error is the engine's own, not selectable. */
|
|
32
|
+
export const selectRules = ({
|
|
33
|
+
enable = [],
|
|
34
|
+
disable = [],
|
|
35
|
+
}: {
|
|
36
|
+
enable?: string[];
|
|
37
|
+
disable?: string[];
|
|
38
|
+
}): Rule[] => {
|
|
39
|
+
if ([...disable, ...enable].includes(PARSE_ERROR.id)) {
|
|
40
|
+
throw new UsageError(`${PARSE_ERROR.id} always runs and can't be disabled`);
|
|
41
|
+
}
|
|
42
|
+
const selected = new Set(RULES);
|
|
43
|
+
for (const token of disable) for (const rule of matchToken(token)) selected.delete(rule);
|
|
44
|
+
for (const token of enable) for (const rule of matchToken(token)) selected.add(rule);
|
|
45
|
+
return RULES.filter((rule) => selected.has(rule));
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const toDiagnostic = (source: DiagnosticSource, finding: Finding): Diagnostic => ({
|
|
49
|
+
...finding,
|
|
50
|
+
rule: source.id,
|
|
51
|
+
group: source.group,
|
|
52
|
+
severity: finding.severity ?? source.severity,
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
const RULE_ORDER = new Map(RULES.map(({ id }, index) => [id, index]));
|
|
56
|
+
|
|
57
|
+
const sortDiagnostics = (diagnostics: Diagnostic[]) =>
|
|
58
|
+
diagnostics.sort(
|
|
59
|
+
(a, b) =>
|
|
60
|
+
(a.file < b.file ? -1 : a.file > b.file ? 1 : 0) ||
|
|
61
|
+
(a.line ?? 0) - (b.line ?? 0) ||
|
|
62
|
+
(a.column ?? 0) - (b.column ?? 0) ||
|
|
63
|
+
(RULE_ORDER.get(a.rule) ?? 0) - (RULE_ORDER.get(b.rule) ?? 0)
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
/** Runs every selected rule that doesn't need the build. */
|
|
67
|
+
export const runChecks = async (site: Site, rules: Rule[]) => {
|
|
68
|
+
const fileRules = rules.filter((rule): rule is FileRule => rule.scope === 'file');
|
|
69
|
+
const diagnostics = await checkFiles(site, fileRules);
|
|
70
|
+
|
|
71
|
+
for (const rule of rules) {
|
|
72
|
+
if (rule.scope !== 'site' || rule.needsBuild) continue;
|
|
73
|
+
for (const finding of await rule.check(site)) diagnostics.push(toDiagnostic(rule, finding));
|
|
74
|
+
}
|
|
75
|
+
return sortDiagnostics(diagnostics);
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
/** Loads the site and runs the selected rules. */
|
|
79
|
+
export const checkSite = async (
|
|
80
|
+
baseDir: string,
|
|
81
|
+
rules: Rule[],
|
|
82
|
+
options: { selected?: ReadonlySet<string>; localSchema: boolean }
|
|
83
|
+
) => {
|
|
84
|
+
const site = await loadSite(baseDir, options);
|
|
85
|
+
const { selected } = options;
|
|
86
|
+
if (selected && !site.files.some(({ file }) => selected.has(file))) {
|
|
87
|
+
throw new UsageError(
|
|
88
|
+
`none of the given files are pages or snippets of this site: ${[...selected].join(', ')}`
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
const diagnostics = await runChecks(site, rules);
|
|
92
|
+
return { site, diagnostics };
|
|
93
|
+
};
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import { getAST, getFileCategory, parseFrontmatter, replaceVariables } from '@mintlify/common';
|
|
2
|
+
import {
|
|
3
|
+
categorizeMdxFiles,
|
|
4
|
+
getErrorPosition,
|
|
5
|
+
getMintIgnore,
|
|
6
|
+
listContentFiles,
|
|
7
|
+
} from '@mintlify/prebuild';
|
|
8
|
+
import type { Root } from 'mdast';
|
|
9
|
+
import fs from 'node:fs/promises';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
|
|
12
|
+
import { applyEdits, parseContent, relocate, selectEdits, unmapEdit } from './mdx.js';
|
|
13
|
+
import { PARSE_ERROR } from './rules/parseError.js';
|
|
14
|
+
import type {
|
|
15
|
+
Diagnostic,
|
|
16
|
+
Edit,
|
|
17
|
+
FileContext,
|
|
18
|
+
FileFinding,
|
|
19
|
+
FileKind,
|
|
20
|
+
FileRule,
|
|
21
|
+
Site,
|
|
22
|
+
SiteFile,
|
|
23
|
+
} from './types.js';
|
|
24
|
+
|
|
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
|
+
const CHECKED_CATEGORIES = new Set<string | null>(['page', 'snippet', 'snippet-v2']);
|
|
42
|
+
|
|
43
|
+
export const readSiteFiles = async (baseDir: string): Promise<SiteFile[]> => {
|
|
44
|
+
const { mdxCandidates: candidates } = await listContentFiles(
|
|
45
|
+
baseDir,
|
|
46
|
+
await getMintIgnore(baseDir)
|
|
47
|
+
);
|
|
48
|
+
const { snippets, snippetsV2 } = await categorizeMdxFiles(baseDir, candidates);
|
|
49
|
+
const snippetFiles = new Set([...snippets, ...snippetsV2]);
|
|
50
|
+
return candidates
|
|
51
|
+
.filter((name) => /\.mdx?$/.test(name) && CHECKED_CATEGORIES.has(getFileCategory(name)))
|
|
52
|
+
.map((name) => ({
|
|
53
|
+
file: name.replace(/^\//, ''),
|
|
54
|
+
kind: (snippetFiles.has(name) ? 'snippet' : 'page') as FileKind,
|
|
55
|
+
}));
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const readFrontmatter = (content: string): Record<string, unknown> => {
|
|
59
|
+
try {
|
|
60
|
+
const { attributes } = parseFrontmatter<unknown>(content);
|
|
61
|
+
return attributes && typeof attributes === 'object' ? { ...attributes } : {};
|
|
62
|
+
} catch {
|
|
63
|
+
return {};
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
type ParseError = { line?: number; column?: number; message: string };
|
|
68
|
+
|
|
69
|
+
const REASON_POSITION = /\s*\(\d+:\d+(?:-\d+:\d+)?\)$/;
|
|
70
|
+
|
|
71
|
+
export const toParseError = (error: unknown): ParseError => {
|
|
72
|
+
const reason = (error as { reason?: unknown } | null)?.reason;
|
|
73
|
+
const fallback = error instanceof Error ? error.message : 'could not parse this file';
|
|
74
|
+
const text = typeof reason === 'string' ? reason : fallback;
|
|
75
|
+
const position = error && typeof error === 'object' ? getErrorPosition(error) : null;
|
|
76
|
+
return {
|
|
77
|
+
...(position?.line != null && { line: position.line }),
|
|
78
|
+
...(position?.column != null && { column: position.column }),
|
|
79
|
+
message: text.replace(REASON_POSITION, ''),
|
|
80
|
+
};
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const parseErrorDiagnostic = (file: string, error: ParseError): Diagnostic => ({
|
|
84
|
+
...error,
|
|
85
|
+
rule: PARSE_ERROR.id,
|
|
86
|
+
group: PARSE_ERROR.group,
|
|
87
|
+
severity: PARSE_ERROR.severity,
|
|
88
|
+
file,
|
|
89
|
+
message: `${error.message} (other checks were skipped for this file)`,
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
type Parsed = { context: FileContext; approximate: boolean } | { error: ParseError };
|
|
93
|
+
|
|
94
|
+
const tryParseContent = (content: string): Root | undefined => {
|
|
95
|
+
try {
|
|
96
|
+
return parseContent(content);
|
|
97
|
+
} catch {
|
|
98
|
+
return undefined;
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
const parseFile = (file: string, content: string, baseDir: string): Parsed => {
|
|
103
|
+
const context = { file, content, baseDir, frontmatter: readFrontmatter(content) };
|
|
104
|
+
const tree = tryParseContent(content);
|
|
105
|
+
if (tree) return { context: { ...context, tree }, approximate: false };
|
|
106
|
+
try {
|
|
107
|
+
return { context: { ...context, tree: getAST(content, file) }, approximate: true };
|
|
108
|
+
} catch (error) {
|
|
109
|
+
return { error: toParseError(error) };
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
const EMPTY_TREE = { type: 'root' as const, children: [] };
|
|
114
|
+
|
|
115
|
+
const SOURCE_STAGE = 1;
|
|
116
|
+
const AFTER_RAW_STAGE = 2;
|
|
117
|
+
|
|
118
|
+
type Stage = { content: string; edits: Edit[] };
|
|
119
|
+
|
|
120
|
+
const VARIABLE = /\{\{\s*([\w.\-]+)\s*\}\}/g;
|
|
121
|
+
|
|
122
|
+
const variableEdits = (content: string, variables: Record<string, string> = {}): Edit[] =>
|
|
123
|
+
[...content.matchAll(VARIABLE)]
|
|
124
|
+
.filter((match) => match[1] !== undefined && variables[match[1]] !== undefined)
|
|
125
|
+
.map((match) => ({
|
|
126
|
+
start: match.index,
|
|
127
|
+
end: match.index + match[0].length,
|
|
128
|
+
text: replaceVariables(match[0], variables),
|
|
129
|
+
}));
|
|
130
|
+
|
|
131
|
+
const toDiagnostic = (
|
|
132
|
+
rule: FileRule,
|
|
133
|
+
file: string,
|
|
134
|
+
{ edit, ...finding }: FileFinding,
|
|
135
|
+
stages: Stage[],
|
|
136
|
+
stage: number
|
|
137
|
+
): Diagnostic => {
|
|
138
|
+
const original = edit && unmapEdit(stages, stage, edit);
|
|
139
|
+
return {
|
|
140
|
+
...finding,
|
|
141
|
+
...relocate(stages, stage, finding),
|
|
142
|
+
rule: rule.id,
|
|
143
|
+
group: rule.group,
|
|
144
|
+
severity: rule.severity,
|
|
145
|
+
file,
|
|
146
|
+
...(original && { fix: { file, ...original } }),
|
|
147
|
+
};
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
const checkFile = (
|
|
151
|
+
allRules: FileRule[],
|
|
152
|
+
{ file, kind }: SiteFile,
|
|
153
|
+
content: string,
|
|
154
|
+
baseDir: string,
|
|
155
|
+
variables?: Record<string, string>
|
|
156
|
+
): Diagnostic[] => {
|
|
157
|
+
const rules = kind === 'snippet' ? allRules.filter((rule) => !rule.pagesOnly) : allRules;
|
|
158
|
+
const raw = rules.filter((rule) => rule.raw);
|
|
159
|
+
const treeRules = rules.filter((rule) => !rule.raw);
|
|
160
|
+
const varEdits = kind === 'snippet' ? variableEdits(content, variables) : [];
|
|
161
|
+
const source = applyEdits(content, varEdits);
|
|
162
|
+
const rawContext = { file, content: source, baseDir, frontmatter: {}, tree: EMPTY_TREE };
|
|
163
|
+
|
|
164
|
+
const rawFound = raw.flatMap((rule) =>
|
|
165
|
+
rule.check(rawContext).map((finding) => ({ rule, finding }))
|
|
166
|
+
);
|
|
167
|
+
const rawEdits = selectEdits(
|
|
168
|
+
rawFound.flatMap(({ finding }) => (finding.edit ? [finding.edit] : []))
|
|
169
|
+
);
|
|
170
|
+
const afterRaw = applyEdits(source, rawEdits);
|
|
171
|
+
const stages: Stage[] = [
|
|
172
|
+
{ content, edits: varEdits },
|
|
173
|
+
{ content: source, edits: rawEdits },
|
|
174
|
+
{ content: afterRaw, edits: [] },
|
|
175
|
+
];
|
|
176
|
+
const diagnostics = rawFound.map(({ rule, finding }) =>
|
|
177
|
+
toDiagnostic(rule, file, finding, stages, SOURCE_STAGE)
|
|
178
|
+
);
|
|
179
|
+
|
|
180
|
+
const parsed = parseFile(file, afterRaw, baseDir);
|
|
181
|
+
if ('error' in parsed) {
|
|
182
|
+
diagnostics.unshift(
|
|
183
|
+
parseErrorDiagnostic(file, {
|
|
184
|
+
...parsed.error,
|
|
185
|
+
...relocate(stages, AFTER_RAW_STAGE, parsed.error),
|
|
186
|
+
})
|
|
187
|
+
);
|
|
188
|
+
return diagnostics;
|
|
189
|
+
}
|
|
190
|
+
for (const rule of treeRules) {
|
|
191
|
+
for (const finding of rule.check(parsed.context)) {
|
|
192
|
+
const found = parsed.approximate ? { ...finding, edit: undefined } : finding;
|
|
193
|
+
diagnostics.push(toDiagnostic(rule, file, found, stages, AFTER_RAW_STAGE));
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
return diagnostics;
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
export const checkFiles = async (site: Site, rules: FileRule[]): Promise<Diagnostic[]> => {
|
|
200
|
+
const targets = site.files.filter(({ file }) => !site.selected || site.selected.has(file));
|
|
201
|
+
const results = await mapLimit(targets, async (entry) => {
|
|
202
|
+
let content: string;
|
|
203
|
+
try {
|
|
204
|
+
content = await fs.readFile(path.join(site.baseDir, entry.file), 'utf-8');
|
|
205
|
+
} catch (error) {
|
|
206
|
+
const message = error instanceof Error ? error.message : 'could not read this file';
|
|
207
|
+
return [parseErrorDiagnostic(entry.file, { message })];
|
|
208
|
+
}
|
|
209
|
+
return checkFile(rules, entry, content, site.baseDir, site.variables);
|
|
210
|
+
});
|
|
211
|
+
return results.flat();
|
|
212
|
+
};
|