@mintlify/cli 4.0.1409 → 4.0.1411

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mintlify/cli",
3
- "version": "4.0.1409",
3
+ "version": "4.0.1411",
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.1095",
49
- "@mintlify/link-rot": "3.0.1295",
50
- "@mintlify/models": "0.0.349",
51
- "@mintlify/prebuild": "1.0.1248",
52
- "@mintlify/previewing": "4.0.1319",
53
- "@mintlify/validation": "0.1.831",
48
+ "@mintlify/common": "1.0.1096",
49
+ "@mintlify/link-rot": "3.0.1296",
50
+ "@mintlify/models": "0.0.350",
51
+ "@mintlify/prebuild": "1.0.1249",
52
+ "@mintlify/previewing": "4.0.1320",
53
+ "@mintlify/validation": "0.1.832",
54
54
  "adm-zip": "0.6.0",
55
55
  "chalk": "5.2.0",
56
56
  "color": "4.2.3",
@@ -82,7 +82,7 @@
82
82
  "keytar": "7.9.0"
83
83
  },
84
84
  "devDependencies": {
85
- "@mintlify/editor": "0.0.306",
85
+ "@mintlify/editor": "0.0.307",
86
86
  "@mintlify/ts-config": "2.0.2",
87
87
  "@tsconfig/recommended": "1.0.2",
88
88
  "@types/detect-port": "1.3.2",
@@ -101,5 +101,5 @@
101
101
  "vitest": "2.1.9",
102
102
  "vitest-mock-process": "1.0.4"
103
103
  },
104
- "gitHead": "58645c3d6d895f90fa2ac2e730ee97ed65382b8f"
104
+ "gitHead": "6b1d097e59a6d87a0ea4ecfd627190a766ab339b"
105
105
  }
package/src/cli.tsx CHANGED
@@ -24,8 +24,6 @@ import { comingSoon } from './comingSoon.js';
24
24
  import { setTelemetryEnabled } from './config.js';
25
25
  import { getConfigValue, setConfigValue, clearConfigValue } from './config.js';
26
26
  import { API_URL } from './constants.js';
27
- import { deslopHandler } from './deslop/index.js';
28
- import { resolveExplicitFiles } from './deslop/resolveFiles.js';
29
27
  import { formatHandler } from './format.js';
30
28
  import {
31
29
  CMD_EXEC_PATH,
@@ -44,9 +42,9 @@ import { getAccessToken } from './keyring.js';
44
42
  import { login } from './login.js';
45
43
  import { logout } from './logout.js';
46
44
  import { mdxLinter } from './mdxLinter.js';
47
- import { subdomainMiddleware } from './middlewares/subdomainMiddleware.js';
48
45
  import { createTelemetryMiddleware } from './middlewares/telemetryMiddleware.js';
49
46
  import { checkOpenApiFile, getOpenApiFilenamesFromDocsConfig } from './openApiCheck.js';
47
+ import { resolveExplicitFiles } from './resolveFiles.js';
50
48
  import { scoreHandler } from './score/index.js';
51
49
  import { signup } from './signup.js';
52
50
  import { status, getCliSubdomains } from './status.js';
@@ -746,54 +744,6 @@ export const cli = ({ packageName = 'mint' }: { packageName?: string }) => {
746
744
  .example('mint score docs.example.com', 'Run agent readiness checks on a URL'),
747
745
  scoreHandler
748
746
  )
749
- .command(
750
- 'deslop [files..]',
751
- 'Detect AI-sounding prose and get humanization suggestions',
752
- (yargs: Argv) =>
753
- yargs
754
- .middleware(subdomainMiddleware)
755
- .positional('files', {
756
- type: 'string',
757
- array: true,
758
- description: 'Files or globs to check (defaults to git-changed pages)',
759
- })
760
- .option('format', {
761
- type: 'string',
762
- choices: ['table', 'plain', 'json'] as const,
763
- description: 'Output format',
764
- })
765
- .option('subdomain', {
766
- type: 'string',
767
- description: 'Documentation subdomain (default: mint config set subdomain)',
768
- })
769
- .option('threshold', {
770
- type: 'number',
771
- default: 0.5,
772
- description: 'Fail a page when its AI-written fraction meets this value (0-1)',
773
- })
774
- .option('fix-whitespace', {
775
- type: 'boolean',
776
- default: false,
777
- description:
778
- 'Normalize prose whitespace in the checked files (skips code and frontmatter)',
779
- })
780
- .check((argv) => {
781
- if (
782
- typeof argv.threshold === 'number' &&
783
- (argv.threshold < 0 || argv.threshold > 1 || Number.isNaN(argv.threshold))
784
- ) {
785
- throw new Error('--threshold must be a number between 0 and 1');
786
- }
787
- return true;
788
- })
789
- .example('mint deslop', 'Check git-changed pages for AI-sounding prose')
790
- .example('mint deslop docs/guide.mdx', 'Check a specific page')
791
- .example(
792
- 'mint deslop "docs/**/*.mdx" --format json',
793
- 'Check pages by glob with agent-friendly JSON output'
794
- ),
795
- deslopHandler
796
- )
797
747
  .command(
798
748
  'format',
799
749
  'Format MDX files in the current directory',
@@ -1,5 +1,3 @@
1
- import { categorizeFilePaths, getMintIgnore } from '@mintlify/prebuild';
2
- import { execFile } from 'node:child_process';
3
1
  import fs from 'node:fs/promises';
4
2
  import path from 'node:path';
5
3
 
@@ -45,15 +43,6 @@ function globToRegExp(pattern: string): RegExp {
45
43
  return new RegExp(`^${source}$`);
46
44
  }
47
45
 
48
- async function exists(filePath: string): Promise<boolean> {
49
- try {
50
- await fs.access(filePath);
51
- return true;
52
- } catch {
53
- return false;
54
- }
55
- }
56
-
57
46
  async function isFile(filePath: string): Promise<boolean> {
58
47
  try {
59
48
  return (await fs.stat(filePath)).isFile();
@@ -108,69 +97,3 @@ export async function resolveExplicitFiles(baseDir: string, args: string[]): Pro
108
97
 
109
98
  return [...new Set(matched)];
110
99
  }
111
-
112
- function runGit(args: string[], cwd: string): Promise<string> {
113
- return new Promise((resolve, reject) => {
114
- execFile('git', args, { cwd }, (error, stdout) => {
115
- if (error) reject(error);
116
- else resolve(stdout);
117
- });
118
- });
119
- }
120
-
121
- function parseGitLines(stdout: string): string[] {
122
- return stdout
123
- .split('\n')
124
- .map((line) => line.trim())
125
- .filter(Boolean);
126
- }
127
-
128
- export async function resolveChangedFiles(baseDir: string): Promise<string[]> {
129
- try {
130
- await runGit(['rev-parse', '--is-inside-work-tree'], baseDir);
131
- } catch {
132
- throw new Error(
133
- 'Not a git repository, so changed pages cannot be detected. Pass explicit paths: `mint deslop <files..>`.'
134
- );
135
- }
136
-
137
- let diff: string;
138
- let untracked: string;
139
- try {
140
- [diff, untracked] = await Promise.all([
141
- runGit(['-c', 'core.quotePath=false', 'diff', '--name-only', '--relative', 'HEAD'], baseDir),
142
- runGit(['-c', 'core.quotePath=false', 'ls-files', '--others', '--exclude-standard'], baseDir),
143
- ]);
144
- } catch {
145
- throw new Error(
146
- 'Could not detect changed pages (does the repository have any commits?). Pass explicit paths: `mint deslop <files..>`.'
147
- );
148
- }
149
-
150
- const candidates = [...new Set([...parseGitLines(diff), ...parseGitLines(untracked)])]
151
- .map(normalizeArg)
152
- .filter((file) => DOCS_FILE.test(file));
153
-
154
- const existing: string[] = [];
155
- for (const file of candidates) {
156
- if (await exists(path.join(baseDir, file))) existing.push(file);
157
- }
158
- if (existing.length === 0) return [];
159
-
160
- if (!(await exists(path.join(baseDir, 'docs.json')))) {
161
- return existing;
162
- }
163
-
164
- try {
165
- const mintIgnore = await getMintIgnore(baseDir);
166
- const { contentFilenames } = await categorizeFilePaths(baseDir, mintIgnore);
167
- const contentSet = new Set(contentFilenames.map((file) => toPosix(file).replace(/^\//, '')));
168
- return existing.filter((file) => contentSet.has(file));
169
- } catch {
170
- // docs.json is present but its navigation could not be resolved. Fail closed
171
- // rather than sending possibly-ignored files to the credit-consuming API.
172
- throw new Error(
173
- 'Could not read docs.json to determine which pages to check. Pass explicit paths: `mint deslop <files..>`.'
174
- );
175
- }
176
- }
@@ -1,167 +0,0 @@
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
- });
@@ -1,183 +0,0 @@
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
- });
@@ -1,94 +0,0 @@
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 zero​width 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
- });