@mintlify/cli 4.0.1763 → 4.0.1765

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 (40) hide show
  1. package/__test__/migrate.test.ts +186 -0
  2. package/__test__/validateRules.test.ts +36 -0
  3. package/bin/cli.js +35 -0
  4. package/bin/migrate.js +54 -0
  5. package/bin/tsconfig.build.tsbuildinfo +1 -1
  6. package/bin/validate/files.js +16 -4
  7. package/bin/validate/mdx.js +1 -1
  8. package/migrate/index.md +56 -0
  9. package/migrate/sources/confluence.md +120 -0
  10. package/migrate/sources/docsify.md +128 -0
  11. package/migrate/sources/document360.md +117 -0
  12. package/migrate/sources/docus.md +136 -0
  13. package/migrate/sources/docusaurus.md +143 -0
  14. package/migrate/sources/fern.md +130 -0
  15. package/migrate/sources/fumadocs.md +141 -0
  16. package/migrate/sources/gitbook.md +120 -0
  17. package/migrate/sources/github-wiki.md +90 -0
  18. package/migrate/sources/jekyll.md +104 -0
  19. package/migrate/sources/mdbook.md +98 -0
  20. package/migrate/sources/mkdocs.md +113 -0
  21. package/migrate/sources/nextra.md +144 -0
  22. package/migrate/sources/notion.md +99 -0
  23. package/migrate/sources/nuxt.md +113 -0
  24. package/migrate/sources/pdf.md +99 -0
  25. package/migrate/sources/readme.md +118 -0
  26. package/migrate/sources/redocly.md +114 -0
  27. package/migrate/sources/starlight.md +136 -0
  28. package/migrate/sources/vitepress.md +143 -0
  29. package/migrate/sources/vuepress.md +153 -0
  30. package/migrate/topics/api.md +76 -0
  31. package/migrate/topics/changelog.md +47 -0
  32. package/migrate/topics/conversion.md +194 -0
  33. package/migrate/topics/discovery.md +93 -0
  34. package/migrate/topics/navigation.md +82 -0
  35. package/migrate/topics/verify.md +58 -0
  36. package/package.json +10 -10
  37. package/src/cli.tsx +47 -0
  38. package/src/migrate.ts +76 -0
  39. package/src/validate/files.ts +23 -3
  40. package/src/validate/mdx.ts +1 -1
@@ -0,0 +1,186 @@
1
+ import { isBuiltInComponent } from '@mintlify/common';
2
+ import { addLog } from '@mintlify/previewing';
3
+ import { spawn, type ChildProcess } from 'node:child_process';
4
+ import { EventEmitter } from 'node:events';
5
+ import { readdirSync, readFileSync } from 'node:fs';
6
+ import path from 'node:path';
7
+ import { mockProcessExit } from 'vitest-mock-process';
8
+
9
+ import { windowsCompatibleSpawnOptions } from '../src/helpers.js';
10
+ import {
11
+ agentArgs,
12
+ agents,
13
+ listMigrationDocs,
14
+ migrateWithAgent,
15
+ migrationInstructions,
16
+ } from '../src/migrate.js';
17
+ import { runCommand } from './utils.js';
18
+
19
+ vi.mock('node:child_process', async (importOriginal) => ({
20
+ ...(await importOriginal<typeof import('node:child_process')>()),
21
+ spawn: vi.fn(),
22
+ }));
23
+ vi.mock('@mintlify/previewing', async (importOriginal) => ({
24
+ ...(await importOriginal<typeof import('@mintlify/previewing')>()),
25
+ addLog: vi.fn(),
26
+ }));
27
+
28
+ const spawnMock = vi.mocked(spawn);
29
+ const child = (emit: (child: EventEmitter) => void) => {
30
+ spawnMock.mockImplementationOnce(() => {
31
+ const proc = new EventEmitter();
32
+ setImmediate(() => emit(proc));
33
+ return proc as ChildProcess;
34
+ });
35
+ };
36
+ const enoent = (proc: EventEmitter) =>
37
+ proc.emit('error', Object.assign(new Error('spawn ENOENT'), { code: 'ENOENT' }));
38
+
39
+ const root = path.join(__dirname, '../migrate');
40
+ const names = (section: string) =>
41
+ readdirSync(path.join(root, section))
42
+ .filter((file) => file.endsWith('.md'))
43
+ .map((file) => file.slice(0, -3));
44
+ const read = (...segments: string[]) => readFileSync(path.join(root, ...segments), 'utf8');
45
+ const docs = [
46
+ 'index.md',
47
+ ...names('topics').map((name) => `topics/${name}.md`),
48
+ ...names('sources').map((name) => `sources/${name}.md`),
49
+ ];
50
+ const prose = (markdown: string) => markdown.replace(/^(`{3,}).*\n[\s\S]*?^\1\s*$/gm, '');
51
+
52
+ const template = [
53
+ '## Detect',
54
+ '## Get the source',
55
+ '### From the repo',
56
+ '### From an export',
57
+ '### From the live site',
58
+ '## Config → docs.json',
59
+ '## Navigation',
60
+ '## Content conversion',
61
+ '## Assets',
62
+ '## URLs and redirects',
63
+ '## Known gaps',
64
+ ];
65
+
66
+ const components = (text: string) => [...text.matchAll(/<([A-Z][\w.]+)/g)].map(([, name]) => name!);
67
+ const targets = (markdown: string) =>
68
+ prose(markdown)
69
+ .split('\n')
70
+ .flatMap((line) =>
71
+ line.startsWith('|')
72
+ ? [line.split('|').slice(1)]
73
+ : line.split(/[;.] /).map((clause) => clause.split('→'))
74
+ )
75
+ .flatMap(([source = '', ...rest]) =>
76
+ components(rest.join(' ')).filter((name) => !components(source).includes(name))
77
+ );
78
+
79
+ const exit = mockProcessExit();
80
+
81
+ describe('migrate', () => {
82
+ it('lists topics and sources from disk', () => {
83
+ expect(listMigrationDocs('topics')).toEqual(names('topics').sort());
84
+ expect(listMigrationDocs('sources')).toEqual(names('sources').sort());
85
+ expect(listMigrationDocs('sources')).toContain('nuxt');
86
+ });
87
+
88
+ it('prints the overview with every topic and source', () => {
89
+ const output = migrationInstructions({});
90
+ expect(output.startsWith(read('index.md').trimEnd())).toBe(true);
91
+ expect(output).toContain(`Topics: ${names('topics').sort().join(', ')}.`);
92
+ expect(output).toContain(`Sources: ${names('sources').sort().join(', ')}.`);
93
+ });
94
+
95
+ it('appends the source guide to the overview', () => {
96
+ const output = migrationInstructions({ from: 'nuxt' });
97
+ expect(output.startsWith(read('index.md').trimEnd())).toBe(true);
98
+ expect(output.endsWith(read('sources', 'nuxt.md'))).toBe(true);
99
+ });
100
+
101
+ it('prints only the topic', () => {
102
+ expect(migrationInstructions({ topic: 'verify' })).toBe(read('topics', 'verify.md'));
103
+ });
104
+
105
+ it.each(names('sources'))('source %s follows the template', (name) => {
106
+ const headings = prose(read('sources', `${name}.md`)).match(/^#{1,3} .*/gm) ?? [];
107
+ expect(headings[0]).toMatch(/^# \S/);
108
+ expect(headings.slice(1)).toEqual(template);
109
+ });
110
+
111
+ it.each(docs)('%s references only existing topics and sources', (doc) => {
112
+ for (const [, flag, name] of read(doc).matchAll(/--(topic|from) ([a-z][\w-]*)/g)) {
113
+ expect(names(flag === 'topic' ? 'topics' : 'sources'), `${doc}: --${flag} ${name}`).toContain(
114
+ name
115
+ );
116
+ }
117
+ });
118
+
119
+ it.each(docs)('%s maps only to Mintlify components', (doc) => {
120
+ expect(targets(read(doc)).filter((name) => !isBuiltInComponent(name))).toEqual([]);
121
+ });
122
+
123
+ it('rejects unknown values with the valid choices', async () => {
124
+ const stderr = vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
125
+ await runCommand('migrate', '--from', 'nope').catch(() => undefined);
126
+ const output = stderr.mock.calls.map(([chunk]) => String(chunk)).join('');
127
+ expect(output).toContain('nope');
128
+ expect(output).toContain('nuxt');
129
+ stderr.mockRestore();
130
+ });
131
+
132
+ it('offers every agent as an --agent choice', async () => {
133
+ const stderr = vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
134
+ await runCommand('migrate', '--agent', 'nope').catch(() => undefined);
135
+ const output = stderr.mock.calls.map(([chunk]) => String(chunk)).join('');
136
+ expect(output).toContain(
137
+ `Choices: ${Object.keys(agents)
138
+ .map((name) => `"${name}"`)
139
+ .join(', ')}`
140
+ );
141
+ stderr.mockRestore();
142
+ });
143
+
144
+ const base = 'Migrate a documentation site to Mintlify in the current directory. Run';
145
+ const plain = `${base} \`mint migrate\` and follow the instructions it prints.`;
146
+ const nuxt = `${base} \`mint migrate --from nuxt\` and follow the instructions it prints.`;
147
+
148
+ it.each([
149
+ ['claude', [plain], [nuxt]],
150
+ ['codex', [plain], [nuxt]],
151
+ ['cursor', [plain], [nuxt]],
152
+ ['gemini', ['-i', plain], ['-i', nuxt]],
153
+ ] as const)('%s builds its argv', (name, without, withFrom) => {
154
+ expect(agentArgs(name)).toEqual(without);
155
+ expect(agentArgs(name, 'nuxt')).toEqual(withFrom);
156
+ });
157
+
158
+ it('--claude runs claude', async () => {
159
+ child((proc) => proc.emit('close', 0));
160
+ await runCommand('migrate', '--claude', '--from', 'nuxt');
161
+ expect(spawnMock).toHaveBeenCalledWith(
162
+ 'claude',
163
+ [windowsCompatibleSpawnOptions.shell ? `"${nuxt}"` : nuxt],
164
+ expect.objectContaining({ stdio: 'inherit' })
165
+ );
166
+ expect(exit).toHaveBeenCalledWith(0);
167
+ });
168
+
169
+ it('rejects a missing binary with its install command', async () => {
170
+ child(enoent);
171
+ await expect(migrateWithAgent('gemini')).rejects.toThrow(
172
+ `gemini not found. Install it with: ${agents.gemini.install}`
173
+ );
174
+ });
175
+
176
+ it('prints the install command and exits nonzero for a missing binary', async () => {
177
+ child(enoent);
178
+ await runCommand('migrate', '--agent', 'cursor');
179
+ expect(vi.mocked(addLog)).toHaveBeenCalledWith(
180
+ expect.objectContaining({
181
+ props: { message: `cursor-agent not found. Install it with: ${agents.cursor.install}` },
182
+ })
183
+ );
184
+ expect(exit).toHaveBeenCalledWith(1);
185
+ });
186
+ });
@@ -838,6 +838,42 @@ describe('validate rules', () => {
838
838
  expect(hasErrors(result.issues)).toBe(true);
839
839
  });
840
840
 
841
+ it('suggests escaping a < that cannot start a tag', async () => {
842
+ write(dir, {
843
+ 'index.mdx': '---\ntitle: Home\n---\n\nLatency is <5ms.\n',
844
+ 'end.mdx': '---\ntitle: End\n---\n\nThe last one <\n',
845
+ });
846
+
847
+ const { issues } = await check(dir, {});
848
+
849
+ expect(issues.map(({ file, line, column, message }) => [file, line, column, message])).toEqual([
850
+ [
851
+ 'end.mdx',
852
+ 5,
853
+ 15,
854
+ "Unexpected end of file before name, expected a character that can start a name, such as a letter, `$`, or `_`; if this < isn't a tag, escape it as \\< (other checks were skipped for this file)",
855
+ ],
856
+ [
857
+ 'index.mdx',
858
+ 5,
859
+ 13,
860
+ "Unexpected character `5` (U+0035) before name, expected a character that can start a name, such as a letter, `$`, or `_`; if this < isn't a tag, escape it as \\< (other checks were skipped for this file)",
861
+ ],
862
+ ]);
863
+ });
864
+
865
+ it('does not suggest escaping < for other parse errors', async () => {
866
+ write(dir, {
867
+ 'index.mdx': '---\ntitle: Home\n---\n\nReturns a List<String>.\n',
868
+ 'math.mdx': '---\ntitle: Math\n---\n\nTotal {1 <}\n',
869
+ });
870
+
871
+ const { issues } = await check(dir, {});
872
+
873
+ expect(issues.map(({ rule }) => rule)).toEqual(['parse-error', 'parse-error']);
874
+ for (const { message } of issues) expect(message).not.toContain('escape it');
875
+ });
876
+
841
877
  it('still runs parse-error when --rules picks only other rules', async () => {
842
878
  write(dir, { 'index.mdx': '---\ntitle: Home\n---\n\n<Note>\nunclosed\n' });
843
879
 
package/bin/cli.js CHANGED
@@ -35,6 +35,7 @@ import { login } from './login.js';
35
35
  import { logout } from './logout.js';
36
36
  import { mdxLinter } from './mdxLinter.js';
37
37
  import { createTelemetryMiddleware } from './middlewares/telemetryMiddleware.js';
38
+ import { agents, listMigrationDocs, migrateWithAgent, migrationInstructions, } from './migrate.js';
38
39
  import { ogCommand } from './og.js';
39
40
  import { buildOverlayRegistryForCheck, checkOpenApiFile } from './openApiCheck.js';
40
41
  import { scoreHandler } from './score/index.js';
@@ -683,6 +684,40 @@ export const cli = ({ packageName = 'mint' }) => {
683
684
  .command('format', 'Format MDX files in the current directory', (yargs) => yargs
684
685
  .usage('usage: mint format')
685
686
  .example('mint format', 'format all MDX files in the current directory'), formatHandler)
687
+ .command('migrate', 'Print instructions for migrating a docs site to Mintlify', (yargs) => yargs
688
+ .option('from', {
689
+ type: 'string',
690
+ choices: listMigrationDocs('sources'),
691
+ description: 'Include instructions for a source platform',
692
+ })
693
+ .option('topic', {
694
+ type: 'string',
695
+ choices: listMigrationDocs('topics'),
696
+ description: 'Print a single topic',
697
+ })
698
+ .option('agent', {
699
+ type: 'string',
700
+ choices: Object.keys(agents),
701
+ description: 'Run the migration with a coding agent',
702
+ })
703
+ .option('claude', {
704
+ type: 'boolean',
705
+ description: 'Alias for --agent claude',
706
+ }), (argv) => __awaiter(void 0, void 0, void 0, function* () {
707
+ var _a;
708
+ const agent = (_a = argv.agent) !== null && _a !== void 0 ? _a : (argv.claude ? 'claude' : undefined);
709
+ if (agent) {
710
+ try {
711
+ return yield terminate(yield migrateWithAgent(agent, argv.from));
712
+ }
713
+ catch (error) {
714
+ addLog(_jsx(ErrorLog, { message: error instanceof Error ? error.message : String(error) }));
715
+ return yield terminate(1);
716
+ }
717
+ }
718
+ yield new Promise((resolve) => process.stdout.write(migrationInstructions(argv), () => resolve()));
719
+ yield terminate(0);
720
+ }))
686
721
  // Coming soon commands — visible in help, tracked via telemetry to gauge interest.
687
722
  .command('ai', '[Coming soon] AI-powered documentation (run mint ai to vote)', () => undefined, comingSoon('ai', packageName))
688
723
  .command('mcp', '[Coming soon] MCP server for documentation (run mint mcp to vote)', () => undefined, comingSoon('mcp', packageName))
package/bin/migrate.js ADDED
@@ -0,0 +1,54 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { readdirSync, readFileSync } from 'node:fs';
3
+ import path from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { windowsCompatibleSpawnOptions } from './helpers.js';
6
+ const root = fileURLToPath(new URL('../migrate', import.meta.url));
7
+ const read = (...segments) => readFileSync(path.join(root, ...segments), 'utf8');
8
+ export const listMigrationDocs = (section) => readdirSync(path.join(root, section))
9
+ .filter((file) => file.endsWith('.md'))
10
+ .map((file) => file.slice(0, -3))
11
+ .sort();
12
+ const overview = () => [
13
+ read('index.md').trimEnd(),
14
+ `Topics: ${listMigrationDocs('topics').join(', ')}. Run \`mint migrate --topic <name>\`.`,
15
+ `Sources: ${listMigrationDocs('sources').join(', ')}. Run \`mint migrate --from <name>\`.`,
16
+ ].join('\n\n');
17
+ export const migrationInstructions = ({ from, topic }) => {
18
+ if (topic)
19
+ return read('topics', `${topic}.md`);
20
+ if (from)
21
+ return `${overview()}\n\n${read('sources', `${from}.md`)}`;
22
+ return `${overview()}\n`;
23
+ };
24
+ const prompt = (from) => `Migrate a documentation site to Mintlify in the current directory. Run \`mint migrate${from ? ` --from ${from}` : ''}\` and follow the instructions it prints.`;
25
+ export const agents = {
26
+ claude: {
27
+ bin: 'claude',
28
+ args: (prompt) => [prompt],
29
+ install: 'npm install -g @anthropic-ai/claude-code',
30
+ },
31
+ codex: {
32
+ bin: 'codex',
33
+ args: (prompt) => [prompt],
34
+ install: 'npm install -g @openai/codex',
35
+ },
36
+ cursor: {
37
+ bin: 'cursor-agent',
38
+ args: (prompt) => [prompt],
39
+ install: 'curl https://cursor.com/install -fsS | bash',
40
+ },
41
+ gemini: {
42
+ bin: 'gemini',
43
+ args: (prompt) => ['-i', prompt],
44
+ install: 'npm install -g @google/gemini-cli',
45
+ },
46
+ };
47
+ export const agentArgs = (name, from) => agents[name].args(prompt(from));
48
+ export const migrateWithAgent = (name, from) => new Promise((resolve, reject) => {
49
+ const { bin, install } = agents[name];
50
+ const args = agentArgs(name, from).map((arg) => windowsCompatibleSpawnOptions.shell ? `"${arg}"` : arg);
51
+ const child = spawn(bin, args, Object.assign({ stdio: 'inherit' }, windowsCompatibleSpawnOptions));
52
+ child.on('error', (error) => reject(error.code === 'ENOENT' ? new Error(`${bin} not found. Install it with: ${install}`) : error));
53
+ child.on('close', (code) => resolve(code !== null && code !== void 0 ? code : 1));
54
+ });