@mintlify/cli 4.0.1315 → 4.0.1317
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__/deslop/client.test.ts +167 -0
- package/__test__/deslop/resolveFiles.test.ts +183 -0
- package/__test__/deslop/whitespace.test.ts +94 -0
- package/bin/cli.js +38 -0
- package/bin/deslop/client.js +74 -0
- package/bin/deslop/index.js +242 -0
- package/bin/deslop/resolveFiles.js +186 -0
- package/bin/deslop/types.js +1 -0
- package/bin/deslop/whitespace.js +144 -0
- package/bin/tsconfig.build.tsbuildinfo +1 -1
- package/package.json +6 -6
- package/src/cli.tsx +50 -0
- package/src/deslop/client.ts +77 -0
- package/src/deslop/index.tsx +286 -0
- package/src/deslop/resolveFiles.ts +176 -0
- package/src/deslop/types.ts +39 -0
- package/src/deslop/whitespace.ts +155 -0
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { checkPage, checkPages } from '../../src/deslop/client.js';
|
|
2
|
+
import type { DeslopCheckedResponse } from '../../src/deslop/types.js';
|
|
3
|
+
|
|
4
|
+
vi.mock('../../src/keyring.js', () => ({
|
|
5
|
+
getAccessToken: vi.fn().mockResolvedValue(null),
|
|
6
|
+
getRefreshToken: vi.fn().mockResolvedValue(null),
|
|
7
|
+
storeCredentials: vi.fn().mockResolvedValue(undefined),
|
|
8
|
+
}));
|
|
9
|
+
|
|
10
|
+
const mockFetch = vi.fn();
|
|
11
|
+
global.fetch = mockFetch;
|
|
12
|
+
|
|
13
|
+
beforeEach(() => {
|
|
14
|
+
vi.stubEnv('MINTLIFY_SESSION_TOKEN', 'test-token');
|
|
15
|
+
vi.stubEnv('MINTLIFY_API_URL', 'http://test-server:5000');
|
|
16
|
+
mockFetch.mockReset();
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
afterEach(() => {
|
|
20
|
+
vi.unstubAllEnvs();
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
const checked: DeslopCheckedResponse = {
|
|
24
|
+
path: 'docs/foo.mdx',
|
|
25
|
+
skipped: null,
|
|
26
|
+
predictionShort: 'AI',
|
|
27
|
+
fractionAi: 0.8,
|
|
28
|
+
fractionAiAssisted: 0.1,
|
|
29
|
+
fractionHuman: 0.1,
|
|
30
|
+
windows: [],
|
|
31
|
+
creditsCharged: 1,
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
function mockOk(data: unknown) {
|
|
35
|
+
mockFetch.mockResolvedValueOnce({
|
|
36
|
+
ok: true,
|
|
37
|
+
status: 200,
|
|
38
|
+
json: () => Promise.resolve(data),
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function mockStatus(status: number, body: string) {
|
|
43
|
+
mockFetch.mockResolvedValueOnce({
|
|
44
|
+
ok: false,
|
|
45
|
+
status,
|
|
46
|
+
statusText: 'Error',
|
|
47
|
+
text: () => Promise.resolve(body),
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function calledUrl(): URL {
|
|
52
|
+
const arg = mockFetch.mock.calls[0]![0];
|
|
53
|
+
return new URL(typeof arg === 'string' ? arg : String(arg));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function calledInit(): RequestInit {
|
|
57
|
+
return mockFetch.mock.calls[0]![1] as RequestInit;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
describe('checkPage auth', () => {
|
|
61
|
+
it('throws when no session token is set', async () => {
|
|
62
|
+
vi.stubEnv('MINTLIFY_SESSION_TOKEN', '');
|
|
63
|
+
await expect(checkPage('docs', 'a.mdx', '# hi')).rejects.toThrow('Not authenticated');
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('sends bearer token header', async () => {
|
|
67
|
+
mockOk(checked);
|
|
68
|
+
await checkPage('docs', 'a.mdx', '# hi');
|
|
69
|
+
expect(mockFetch).toHaveBeenCalledWith(
|
|
70
|
+
expect.any(String),
|
|
71
|
+
expect.objectContaining({
|
|
72
|
+
headers: expect.objectContaining({ Authorization: 'Bearer test-token' }),
|
|
73
|
+
})
|
|
74
|
+
);
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
describe('checkPage request', () => {
|
|
79
|
+
it('POSTs path and content to the deslop route with the subdomain', async () => {
|
|
80
|
+
mockOk(checked);
|
|
81
|
+
const result = await checkPage('docs', 'docs/foo.mdx', '# Hello\n');
|
|
82
|
+
|
|
83
|
+
const url = calledUrl();
|
|
84
|
+
expect(url.pathname).toBe('/api/cli/deslop');
|
|
85
|
+
expect(url.searchParams.get('subdomain')).toBe('docs');
|
|
86
|
+
const init = calledInit();
|
|
87
|
+
expect(init.method).toBe('POST');
|
|
88
|
+
expect(init.body).toBe(JSON.stringify({ path: 'docs/foo.mdx', content: '# Hello\n' }));
|
|
89
|
+
expect(init.headers).toMatchObject({ 'Content-Type': 'application/json' });
|
|
90
|
+
expect(result).toMatchObject({ path: 'docs/foo.mdx', predictionShort: 'AI' });
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('maps 402 to an out-of-credits message', async () => {
|
|
94
|
+
mockStatus(402, '{"error":"insufficient_credits"}');
|
|
95
|
+
await expect(checkPage('docs', 'a.mdx', 'x')).rejects.toThrow(
|
|
96
|
+
'Out of AI credits — upgrade or top up in the Mintlify dashboard.'
|
|
97
|
+
);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('maps 503 to an unavailable message', async () => {
|
|
101
|
+
mockStatus(503, '{"error":"deslop_unavailable"}');
|
|
102
|
+
await expect(checkPage('docs', 'a.mdx', 'x')).rejects.toThrow(
|
|
103
|
+
'Deslop is temporarily unavailable, try again shortly.'
|
|
104
|
+
);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('throws a generic API error for other failures', async () => {
|
|
108
|
+
mockStatus(500, 'boom');
|
|
109
|
+
await expect(checkPage('docs', 'a.mdx', 'x')).rejects.toThrow('API error (500): boom');
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
describe('checkPages', () => {
|
|
114
|
+
it('records a per-file error without aborting the batch', async () => {
|
|
115
|
+
mockFetch.mockImplementation((_url: string, init: RequestInit) => {
|
|
116
|
+
const body = JSON.parse(String(init.body)) as { path: string };
|
|
117
|
+
if (body.path === 'docs/bad.mdx') {
|
|
118
|
+
return Promise.reject(new Error('socket hang up'));
|
|
119
|
+
}
|
|
120
|
+
return Promise.resolve({
|
|
121
|
+
ok: true,
|
|
122
|
+
status: 200,
|
|
123
|
+
json: () => Promise.resolve({ ...checked, path: body.path }),
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
const pages = [
|
|
128
|
+
{ path: 'docs/a.mdx', content: 'a' },
|
|
129
|
+
{ path: 'docs/bad.mdx', content: 'b' },
|
|
130
|
+
{ path: 'docs/c.mdx', content: 'c' },
|
|
131
|
+
];
|
|
132
|
+
const results = await checkPages('docs', pages);
|
|
133
|
+
|
|
134
|
+
expect(results).toHaveLength(3);
|
|
135
|
+
expect(results[0]).toMatchObject({ file: 'docs/a.mdx', response: { path: 'docs/a.mdx' } });
|
|
136
|
+
expect(results[1]).toEqual({ file: 'docs/bad.mdx', error: 'socket hang up' });
|
|
137
|
+
expect(results[2]).toMatchObject({ file: 'docs/c.mdx', response: { path: 'docs/c.mdx' } });
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('preserves input order for more pages than the concurrency limit', async () => {
|
|
141
|
+
mockFetch.mockImplementation((_url: string, init: RequestInit) => {
|
|
142
|
+
const body = JSON.parse(String(init.body)) as { path: string };
|
|
143
|
+
const delay = body.path === 'p0.mdx' ? 30 : 1;
|
|
144
|
+
return new Promise((resolve) =>
|
|
145
|
+
setTimeout(
|
|
146
|
+
() =>
|
|
147
|
+
resolve({
|
|
148
|
+
ok: true,
|
|
149
|
+
status: 200,
|
|
150
|
+
json: () => Promise.resolve({ ...checked, path: body.path }),
|
|
151
|
+
}),
|
|
152
|
+
delay
|
|
153
|
+
)
|
|
154
|
+
);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
const pages = Array.from({ length: 9 }, (_, i) => ({ path: `p${i}.mdx`, content: 'x' }));
|
|
158
|
+
const results = await checkPages('docs', pages);
|
|
159
|
+
|
|
160
|
+
expect(results).toHaveLength(9);
|
|
161
|
+
results.forEach((result, i) => {
|
|
162
|
+
expect(result.file).toBe(`p${i}.mdx`);
|
|
163
|
+
expect(result).toMatchObject({ response: { path: `p${i}.mdx` } });
|
|
164
|
+
});
|
|
165
|
+
expect(mockFetch).toHaveBeenCalledTimes(9);
|
|
166
|
+
});
|
|
167
|
+
});
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { categorizeFilePaths } from '@mintlify/prebuild';
|
|
2
|
+
import { execFile } from 'node:child_process';
|
|
3
|
+
import fs from 'node:fs/promises';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
|
|
7
|
+
import { resolveChangedFiles, resolveExplicitFiles } from '../../src/deslop/resolveFiles.js';
|
|
8
|
+
|
|
9
|
+
vi.mock('node:child_process', () => ({ execFile: vi.fn() }));
|
|
10
|
+
|
|
11
|
+
vi.mock('@mintlify/prebuild', () => ({
|
|
12
|
+
getMintIgnore: vi.fn().mockResolvedValue([]),
|
|
13
|
+
categorizeFilePaths: vi.fn().mockResolvedValue({ contentFilenames: [] }),
|
|
14
|
+
}));
|
|
15
|
+
|
|
16
|
+
const mockExecFile = vi.mocked(execFile);
|
|
17
|
+
const mockCategorize = vi.mocked(categorizeFilePaths);
|
|
18
|
+
|
|
19
|
+
type GitCallback = (error: Error | null, stdout: string, stderr: string) => void;
|
|
20
|
+
|
|
21
|
+
function mockGit(outputs: Record<string, string | Error>) {
|
|
22
|
+
mockExecFile.mockImplementation(((
|
|
23
|
+
_file: string,
|
|
24
|
+
args: string[],
|
|
25
|
+
_options: unknown,
|
|
26
|
+
callback: GitCallback
|
|
27
|
+
) => {
|
|
28
|
+
const key = args.join(' ');
|
|
29
|
+
const value = outputs[key];
|
|
30
|
+
if (value === undefined) {
|
|
31
|
+
callback(new Error(`unexpected git command: ${key}`), '', '');
|
|
32
|
+
} else if (value instanceof Error) {
|
|
33
|
+
callback(value, '', '');
|
|
34
|
+
} else {
|
|
35
|
+
callback(null, value, '');
|
|
36
|
+
}
|
|
37
|
+
}) as never);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
let baseDir: string;
|
|
41
|
+
|
|
42
|
+
async function writeFixture(rel: string) {
|
|
43
|
+
const filePath = path.join(baseDir, rel);
|
|
44
|
+
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
|
45
|
+
await fs.writeFile(filePath, '# fixture\n');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
beforeEach(async () => {
|
|
49
|
+
baseDir = await fs.mkdtemp(path.join(os.tmpdir(), 'deslop-test-'));
|
|
50
|
+
mockExecFile.mockReset();
|
|
51
|
+
mockCategorize.mockClear();
|
|
52
|
+
mockCategorize.mockResolvedValue({ contentFilenames: [] } as never);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
afterEach(async () => {
|
|
56
|
+
await fs.rm(baseDir, { recursive: true, force: true });
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
describe('resolveExplicitFiles', () => {
|
|
60
|
+
it('resolves literal paths', async () => {
|
|
61
|
+
await writeFixture('docs/a.mdx');
|
|
62
|
+
await expect(resolveExplicitFiles(baseDir, ['docs/a.mdx'])).resolves.toEqual(['docs/a.mdx']);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('normalizes leading ./', async () => {
|
|
66
|
+
await writeFixture('docs/a.md');
|
|
67
|
+
await expect(resolveExplicitFiles(baseDir, ['./docs/a.md'])).resolves.toEqual(['docs/a.md']);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('errors listing paths that matched nothing', async () => {
|
|
71
|
+
await writeFixture('docs/a.mdx');
|
|
72
|
+
await expect(
|
|
73
|
+
resolveExplicitFiles(baseDir, ['docs/a.mdx', 'docs/missing.mdx', 'nope/*.mdx'])
|
|
74
|
+
).rejects.toThrow('No matching files for: docs/missing.mdx, nope/*.mdx');
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('accepts existing literal paths regardless of extension', async () => {
|
|
78
|
+
await writeFixture('README.txt');
|
|
79
|
+
await expect(resolveExplicitFiles(baseDir, ['README.txt'])).resolves.toEqual(['README.txt']);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('rejects a directory passed as a literal path', async () => {
|
|
83
|
+
await fs.mkdir(path.join(baseDir, 'docs'), { recursive: true });
|
|
84
|
+
await expect(resolveExplicitFiles(baseDir, ['docs'])).rejects.toThrow('No matching files');
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('expands globs recursively and keeps only docs files', async () => {
|
|
88
|
+
await writeFixture('docs/a.mdx');
|
|
89
|
+
await writeFixture('docs/nested/deep/b.mdx');
|
|
90
|
+
await writeFixture('docs/nested/notes.txt');
|
|
91
|
+
await writeFixture('top.md');
|
|
92
|
+
|
|
93
|
+
await expect(resolveExplicitFiles(baseDir, ['docs/**/*.mdx'])).resolves.toEqual([
|
|
94
|
+
'docs/a.mdx',
|
|
95
|
+
'docs/nested/deep/b.mdx',
|
|
96
|
+
]);
|
|
97
|
+
await expect(resolveExplicitFiles(baseDir, ['*.md'])).resolves.toEqual(['top.md']);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('dedupes overlapping literal and glob matches', async () => {
|
|
101
|
+
await writeFixture('docs/a.mdx');
|
|
102
|
+
await expect(resolveExplicitFiles(baseDir, ['docs/a.mdx', 'docs/*.mdx'])).resolves.toEqual([
|
|
103
|
+
'docs/a.mdx',
|
|
104
|
+
]);
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
describe('resolveChangedFiles', () => {
|
|
109
|
+
it('throws a friendly error outside a git repo', async () => {
|
|
110
|
+
mockGit({ 'rev-parse --is-inside-work-tree': new Error('not a git repository') });
|
|
111
|
+
await expect(resolveChangedFiles(baseDir)).rejects.toThrow(
|
|
112
|
+
'Not a git repository, so changed pages cannot be detected. Pass explicit paths: `mint deslop <files..>`.'
|
|
113
|
+
);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('unions diff and untracked files, keeping existing docs pages in the content set', async () => {
|
|
117
|
+
await writeFixture('docs.json');
|
|
118
|
+
await writeFixture('docs/a.mdx');
|
|
119
|
+
await writeFixture('docs/b.md');
|
|
120
|
+
mockGit({
|
|
121
|
+
'rev-parse --is-inside-work-tree': 'true\n',
|
|
122
|
+
'-c core.quotePath=false diff --name-only --relative HEAD':
|
|
123
|
+
'docs/a.mdx\nsrc/code.ts\ndocs/gone.mdx\n',
|
|
124
|
+
'-c core.quotePath=false ls-files --others --exclude-standard': 'docs/b.md\nREADME.txt\n',
|
|
125
|
+
});
|
|
126
|
+
mockCategorize.mockResolvedValue({
|
|
127
|
+
contentFilenames: ['/docs/a.mdx', 'docs/b.md', '/docs/c.mdx'],
|
|
128
|
+
} as never);
|
|
129
|
+
|
|
130
|
+
await expect(resolveChangedFiles(baseDir)).resolves.toEqual(['docs/a.mdx', 'docs/b.md']);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('skips the content-set filter outside a docs repo', async () => {
|
|
134
|
+
await writeFixture('README.md');
|
|
135
|
+
mockGit({
|
|
136
|
+
'rev-parse --is-inside-work-tree': 'true\n',
|
|
137
|
+
'-c core.quotePath=false diff --name-only --relative HEAD': 'README.md\nsrc/code.ts\n',
|
|
138
|
+
'-c core.quotePath=false ls-files --others --exclude-standard': '',
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
await expect(resolveChangedFiles(baseDir)).resolves.toEqual(['README.md']);
|
|
142
|
+
expect(mockCategorize).not.toHaveBeenCalled();
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('excludes changed pages missing from the content set (mintignored)', async () => {
|
|
146
|
+
await writeFixture('docs.json');
|
|
147
|
+
await writeFixture('docs/a.mdx');
|
|
148
|
+
await writeFixture('internal/secret.mdx');
|
|
149
|
+
mockGit({
|
|
150
|
+
'rev-parse --is-inside-work-tree': 'true\n',
|
|
151
|
+
'-c core.quotePath=false diff --name-only --relative HEAD':
|
|
152
|
+
'docs/a.mdx\ninternal/secret.mdx\n',
|
|
153
|
+
'-c core.quotePath=false ls-files --others --exclude-standard': '',
|
|
154
|
+
});
|
|
155
|
+
mockCategorize.mockResolvedValue({ contentFilenames: ['docs/a.mdx'] } as never);
|
|
156
|
+
|
|
157
|
+
await expect(resolveChangedFiles(baseDir)).resolves.toEqual(['docs/a.mdx']);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it('fails closed when the content set cannot be resolved in a docs repo', async () => {
|
|
161
|
+
await writeFixture('docs.json');
|
|
162
|
+
await writeFixture('docs/a.mdx');
|
|
163
|
+
mockGit({
|
|
164
|
+
'rev-parse --is-inside-work-tree': 'true\n',
|
|
165
|
+
'-c core.quotePath=false diff --name-only --relative HEAD': 'docs/a.mdx\n',
|
|
166
|
+
'-c core.quotePath=false ls-files --others --exclude-standard': '',
|
|
167
|
+
});
|
|
168
|
+
mockCategorize.mockRejectedValue(new Error('bad docs.json'));
|
|
169
|
+
|
|
170
|
+
await expect(resolveChangedFiles(baseDir)).rejects.toThrow('Could not read docs.json');
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('returns empty without scanning content when nothing changed', async () => {
|
|
174
|
+
mockGit({
|
|
175
|
+
'rev-parse --is-inside-work-tree': 'true\n',
|
|
176
|
+
'-c core.quotePath=false diff --name-only --relative HEAD': 'src/code.ts\n',
|
|
177
|
+
'-c core.quotePath=false ls-files --others --exclude-standard': '',
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
await expect(resolveChangedFiles(baseDir)).resolves.toEqual([]);
|
|
181
|
+
expect(mockCategorize).not.toHaveBeenCalled();
|
|
182
|
+
});
|
|
183
|
+
});
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { normalizeWhitespace, summarizeWhitespace } from '../../src/deslop/whitespace.js';
|
|
4
|
+
|
|
5
|
+
describe('normalizeWhitespace', () => {
|
|
6
|
+
it('strips trailing whitespace and collapses blank-line runs', () => {
|
|
7
|
+
const { fixed, counts } = normalizeWhitespace('Line one. \n\n\n\n\nLine two.\n');
|
|
8
|
+
expect(fixed).toBe('Line one.\n\nLine two.\n');
|
|
9
|
+
expect(counts.trailing).toBe(1);
|
|
10
|
+
expect(counts.blankRuns).toBe(1);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it('replaces unicode spaces and removes zero-width characters in prose', () => {
|
|
14
|
+
const { fixed, counts } = normalizeWhitespace('Non breaking and zerowidth here.\n');
|
|
15
|
+
expect(fixed).toBe('Non breaking and zerowidth here.\n');
|
|
16
|
+
expect(counts.unicodeSpaces).toBe(1);
|
|
17
|
+
expect(counts.zeroWidth).toBe(1);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('collapses internal double spaces but preserves leading indentation', () => {
|
|
21
|
+
const { fixed } = normalizeWhitespace(' - item with gaps\n');
|
|
22
|
+
expect(fixed).toBe(' - item with gaps\n');
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('leaves fenced code blocks byte-for-byte', () => {
|
|
26
|
+
const input = 'Prose here.\n\n```js\nconst x = 1; \n\n\n\ny;\n```\n\nMore prose.\n';
|
|
27
|
+
const { fixed } = normalizeWhitespace(input);
|
|
28
|
+
expect(fixed).toContain('const x = 1; \n\n\n\ny;');
|
|
29
|
+
expect(fixed).toContain('Prose here.');
|
|
30
|
+
expect(fixed).toContain('More prose.');
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('preserves whitespace inside 4+-backtick fences (extended fences)', () => {
|
|
34
|
+
const input =
|
|
35
|
+
'Prose here.\n\n````md\n```js\nconst x = 1; \n```\n\n\n\ny;\n````\n\nMore prose.\n';
|
|
36
|
+
const { fixed } = normalizeWhitespace(input);
|
|
37
|
+
expect(fixed).toContain('const x = 1; \n```\n\n\n\ny;');
|
|
38
|
+
expect(fixed).toContain('More prose.');
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('leaves YAML frontmatter untouched', () => {
|
|
42
|
+
const input = '---\ntitle: Spaced Title\n---\n\nBody text.\n';
|
|
43
|
+
const { fixed } = normalizeWhitespace(input);
|
|
44
|
+
expect(fixed).toContain('title: Spaced Title');
|
|
45
|
+
expect(fixed).toContain('Body text.');
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('reports no change for already-clean content', () => {
|
|
49
|
+
const clean = 'A tidy line.\n\nAnother tidy line.\n';
|
|
50
|
+
const { changed } = normalizeWhitespace(clean);
|
|
51
|
+
expect(changed).toBe(false);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('preserves inline code spans (no collapse/trim inside backticks)', () => {
|
|
55
|
+
const { fixed } = normalizeWhitespace('Run `npm install x` then relax now.\n');
|
|
56
|
+
expect(fixed).toBe('Run `npm install x` then relax now.\n');
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('strips trailing whitespace on CRLF lines and keeps the CR', () => {
|
|
60
|
+
const { fixed, counts } = normalizeWhitespace('First line. \r\nSecond.\r\n');
|
|
61
|
+
expect(fixed).toBe('First line.\r\nSecond.\r\n');
|
|
62
|
+
expect(counts.trailing).toBe(1);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('counts collapsed internal spaces so a space-only fix still registers', () => {
|
|
66
|
+
const { changed, counts } = normalizeWhitespace('one two three\n');
|
|
67
|
+
expect(changed).toBe(true);
|
|
68
|
+
expect(counts.spaces).toBe(1);
|
|
69
|
+
expect(summarizeWhitespace(counts)).toContain('collapsed spaces');
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('summarizes counts or returns null when clean', () => {
|
|
73
|
+
expect(
|
|
74
|
+
summarizeWhitespace({
|
|
75
|
+
trailing: 2,
|
|
76
|
+
blankRuns: 0,
|
|
77
|
+
unicodeSpaces: 0,
|
|
78
|
+
zeroWidth: 1,
|
|
79
|
+
spaces: 0,
|
|
80
|
+
finalNewline: false,
|
|
81
|
+
})
|
|
82
|
+
).toBe('2 trailing, 1 zero-width chars');
|
|
83
|
+
expect(
|
|
84
|
+
summarizeWhitespace({
|
|
85
|
+
trailing: 0,
|
|
86
|
+
blankRuns: 0,
|
|
87
|
+
unicodeSpaces: 0,
|
|
88
|
+
zeroWidth: 0,
|
|
89
|
+
spaces: 0,
|
|
90
|
+
finalNewline: false,
|
|
91
|
+
})
|
|
92
|
+
).toBeNull();
|
|
93
|
+
});
|
|
94
|
+
});
|
package/bin/cli.js
CHANGED
|
@@ -20,12 +20,14 @@ import { comingSoon } from './comingSoon.js';
|
|
|
20
20
|
import { setTelemetryEnabled } from './config.js';
|
|
21
21
|
import { getConfigValue, setConfigValue, clearConfigValue } from './config.js';
|
|
22
22
|
import { API_URL } from './constants.js';
|
|
23
|
+
import { deslopHandler } from './deslop/index.js';
|
|
23
24
|
import { CMD_EXEC_PATH, checkPort, checkNodeVersion, autoUpgradeIfNeeded, getVersions, isAI, suppressConsoleWarnings, terminate, } from './helpers.js';
|
|
24
25
|
import { init } from './init.js';
|
|
25
26
|
import { getAccessToken } from './keyring.js';
|
|
26
27
|
import { login } from './login.js';
|
|
27
28
|
import { logout } from './logout.js';
|
|
28
29
|
import { mdxLinter } from './mdxLinter.js';
|
|
30
|
+
import { subdomainMiddleware } from './middlewares/subdomainMiddleware.js';
|
|
29
31
|
import { createTelemetryMiddleware } from './middlewares/telemetryMiddleware.js';
|
|
30
32
|
import { checkOpenApiFile, getOpenApiFilenamesFromDocsConfig } from './openApiCheck.js';
|
|
31
33
|
import { scoreHandler } from './score/index.js';
|
|
@@ -490,6 +492,42 @@ export const cli = ({ packageName = 'mint' }) => {
|
|
|
490
492
|
})
|
|
491
493
|
.example('mint score', 'Run agent readiness checks on your default subdomain')
|
|
492
494
|
.example('mint score docs.example.com', 'Run agent readiness checks on a URL'), scoreHandler)
|
|
495
|
+
.command('deslop [files..]', 'Detect AI-sounding prose and get humanization suggestions', (yargs) => yargs
|
|
496
|
+
.middleware(subdomainMiddleware)
|
|
497
|
+
.positional('files', {
|
|
498
|
+
type: 'string',
|
|
499
|
+
array: true,
|
|
500
|
+
description: 'Files or globs to check (defaults to git-changed pages)',
|
|
501
|
+
})
|
|
502
|
+
.option('format', {
|
|
503
|
+
type: 'string',
|
|
504
|
+
choices: ['table', 'plain', 'json'],
|
|
505
|
+
description: 'Output format',
|
|
506
|
+
})
|
|
507
|
+
.option('subdomain', {
|
|
508
|
+
type: 'string',
|
|
509
|
+
description: 'Documentation subdomain (default: mint config set subdomain)',
|
|
510
|
+
})
|
|
511
|
+
.option('threshold', {
|
|
512
|
+
type: 'number',
|
|
513
|
+
default: 0.5,
|
|
514
|
+
description: 'Fail a page when its AI-written fraction meets this value (0-1)',
|
|
515
|
+
})
|
|
516
|
+
.option('fix-whitespace', {
|
|
517
|
+
type: 'boolean',
|
|
518
|
+
default: false,
|
|
519
|
+
description: 'Normalize prose whitespace in the checked files (skips code and frontmatter)',
|
|
520
|
+
})
|
|
521
|
+
.check((argv) => {
|
|
522
|
+
if (typeof argv.threshold === 'number' &&
|
|
523
|
+
(argv.threshold < 0 || argv.threshold > 1 || Number.isNaN(argv.threshold))) {
|
|
524
|
+
throw new Error('--threshold must be a number between 0 and 1');
|
|
525
|
+
}
|
|
526
|
+
return true;
|
|
527
|
+
})
|
|
528
|
+
.example('mint deslop', 'Check git-changed pages for AI-sounding prose')
|
|
529
|
+
.example('mint deslop docs/guide.mdx', 'Check a specific page')
|
|
530
|
+
.example('mint deslop "docs/**/*.mdx" --format json', 'Check pages by glob with agent-friendly JSON output'), deslopHandler)
|
|
493
531
|
// Coming soon commands — visible in help, tracked via telemetry to gauge interest.
|
|
494
532
|
.command('ai', '[Coming soon] AI-powered documentation (run mint ai to vote)', () => undefined, comingSoon('ai', packageName))
|
|
495
533
|
.command('test', '[Coming soon] Test your documentation (run mint test to vote)', () => undefined, comingSoon('test', packageName))
|
|
@@ -0,0 +1,74 @@
|
|
|
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 { authenticatedFetch } from '../authenticatedFetch.js';
|
|
11
|
+
import { API_URL } from '../constants.js';
|
|
12
|
+
const DEFAULT_CONCURRENCY = 4;
|
|
13
|
+
// Errors that apply to the whole batch (out of credits, service down) rather
|
|
14
|
+
// than a single page — hitting one means the rest will fail identically.
|
|
15
|
+
class DeslopAbortError extends Error {
|
|
16
|
+
}
|
|
17
|
+
export function checkPage(subdomain, pagePath, content) {
|
|
18
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
19
|
+
const endpoint = new URL(`${API_URL}/api/cli/deslop`);
|
|
20
|
+
endpoint.searchParams.set('subdomain', subdomain);
|
|
21
|
+
const res = yield authenticatedFetch(endpoint.toString(), {
|
|
22
|
+
method: 'POST',
|
|
23
|
+
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
|
|
24
|
+
body: JSON.stringify({ path: pagePath, content }),
|
|
25
|
+
});
|
|
26
|
+
if (res.status === 402) {
|
|
27
|
+
throw new DeslopAbortError('Out of AI credits — upgrade or top up in the Mintlify dashboard.');
|
|
28
|
+
}
|
|
29
|
+
if (res.status === 503) {
|
|
30
|
+
throw new DeslopAbortError('Deslop is temporarily unavailable, try again shortly.');
|
|
31
|
+
}
|
|
32
|
+
if (!res.ok) {
|
|
33
|
+
const body = yield res.text().catch(() => '');
|
|
34
|
+
throw new Error(`API error (${res.status}): ${body || res.statusText}`);
|
|
35
|
+
}
|
|
36
|
+
return res.json();
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
export function checkPages(subdomain_1, pages_1) {
|
|
40
|
+
return __awaiter(this, arguments, void 0, function* (subdomain, pages, concurrency = DEFAULT_CONCURRENCY) {
|
|
41
|
+
const results = new Array(pages.length);
|
|
42
|
+
let next = 0;
|
|
43
|
+
let aborted = false;
|
|
44
|
+
const worker = () => __awaiter(this, void 0, void 0, function* () {
|
|
45
|
+
while (next < pages.length && !aborted) {
|
|
46
|
+
const index = next++;
|
|
47
|
+
const page = pages[index];
|
|
48
|
+
try {
|
|
49
|
+
const response = yield checkPage(subdomain, page.path, page.content);
|
|
50
|
+
results[index] = { file: page.path, response };
|
|
51
|
+
}
|
|
52
|
+
catch (err) {
|
|
53
|
+
results[index] = {
|
|
54
|
+
file: page.path,
|
|
55
|
+
error: err instanceof Error ? err.message : 'unknown error',
|
|
56
|
+
};
|
|
57
|
+
if (err instanceof DeslopAbortError)
|
|
58
|
+
aborted = true;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
yield Promise.all(Array.from({ length: Math.min(concurrency, pages.length) }, worker));
|
|
63
|
+
// Pages left unprocessed because the batch was aborted after a global failure.
|
|
64
|
+
for (let index = 0; index < pages.length; index++) {
|
|
65
|
+
if (!results[index]) {
|
|
66
|
+
results[index] = {
|
|
67
|
+
file: pages[index].path,
|
|
68
|
+
error: 'Not checked — stopped after a credits or availability error.',
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return results;
|
|
73
|
+
});
|
|
74
|
+
}
|