@mintlify/cli 4.0.1655 → 4.0.1656

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 (54) hide show
  1. package/bin/cli.js +1 -7
  2. package/bin/tsconfig.build.tsbuildinfo +1 -1
  3. package/package.json +2 -6
  4. package/src/cli.tsx +1 -12
  5. package/__test__/mintTestFilePreview.test.ts +0 -35
  6. package/__test__/mintTestOutput.test.ts +0 -256
  7. package/__test__/runHistory.test.ts +0 -240
  8. package/bin/agent-harness/agentPreflight.js +0 -24
  9. package/bin/agent-harness/buildTaskPrompt.js +0 -86
  10. package/bin/agent-harness/countReport.js +0 -10
  11. package/bin/agent-harness/discoverCodeBlocks.js +0 -101
  12. package/bin/agent-harness/executeCodeBlocks.js +0 -263
  13. package/bin/agent-harness/filterSelectedPages.js +0 -10
  14. package/bin/agent-harness/generateTestCode.js +0 -337
  15. package/bin/agent-harness/index.js +0 -95
  16. package/bin/agent-harness/loadAgentSdk.js +0 -46
  17. package/bin/agent-harness/manifest.js +0 -113
  18. package/bin/agent-harness/mapWithConcurrency.js +0 -26
  19. package/bin/agent-harness/runHistory.js +0 -256
  20. package/bin/agent-harness/runSave.js +0 -47
  21. package/bin/agent-harness/setupFolders.js +0 -73
  22. package/bin/agent-harness/tasks/checkTestability.js +0 -228
  23. package/bin/agent-harness/types.js +0 -66
  24. package/bin/mintTest.js +0 -122
  25. package/bin/mintTestFilePreview.js +0 -39
  26. package/bin/mintTestNav.js +0 -35
  27. package/bin/mintTestTelemetry.js +0 -63
  28. package/bin/mintTestText.js +0 -31
  29. package/bin/mintTestTree.js +0 -94
  30. package/bin/mintTestUi.js +0 -818
  31. package/src/agent-harness/MINT_TEST_SYSTEM_DESIGN.md +0 -64
  32. package/src/agent-harness/agentPreflight.ts +0 -13
  33. package/src/agent-harness/buildTaskPrompt.ts +0 -93
  34. package/src/agent-harness/countReport.ts +0 -17
  35. package/src/agent-harness/discoverCodeBlocks.ts +0 -90
  36. package/src/agent-harness/executeCodeBlocks.ts +0 -351
  37. package/src/agent-harness/filterSelectedPages.ts +0 -14
  38. package/src/agent-harness/generateTestCode.ts +0 -396
  39. package/src/agent-harness/index.ts +0 -127
  40. package/src/agent-harness/loadAgentSdk.ts +0 -35
  41. package/src/agent-harness/manifest.ts +0 -116
  42. package/src/agent-harness/mapWithConcurrency.ts +0 -21
  43. package/src/agent-harness/runHistory.ts +0 -255
  44. package/src/agent-harness/runSave.ts +0 -38
  45. package/src/agent-harness/setupFolders.ts +0 -87
  46. package/src/agent-harness/tasks/checkTestability.ts +0 -256
  47. package/src/agent-harness/types.ts +0 -239
  48. package/src/mintTest.tsx +0 -138
  49. package/src/mintTestFilePreview.ts +0 -32
  50. package/src/mintTestNav.ts +0 -31
  51. package/src/mintTestTelemetry.ts +0 -101
  52. package/src/mintTestText.ts +0 -35
  53. package/src/mintTestTree.ts +0 -134
  54. package/src/mintTestUi.tsx +0 -1378
@@ -1,239 +0,0 @@
1
- import { z } from 'zod';
2
-
3
- export type HarnessAgent = 'claude' | 'codex';
4
-
5
- export interface DocsPage {
6
- file: string;
7
- blockCount: number;
8
- }
9
-
10
- export type CommandStatus = 'passed' | 'failed' | 'cancelled';
11
-
12
- export interface CommandResult {
13
- id: string;
14
- phase: 'setup' | 'test';
15
- status: CommandStatus;
16
- executable: string;
17
- args: string[];
18
- cwd: string;
19
- durationMs: number;
20
- exitCode: number | null;
21
- stdout: string;
22
- stderr: string;
23
- error?: string;
24
- }
25
-
26
- export type TaskStatus = 'passed' | 'failed' | 'agent_error' | 'cancelled';
27
-
28
- export interface TaskResult {
29
- id: string;
30
- agent: HarnessAgent;
31
- file: string;
32
- directory: string;
33
- generatedFiles: string[];
34
- testable: boolean;
35
- attempts: number;
36
- tokens: number;
37
- status: TaskStatus;
38
- error?: string;
39
- commands: CommandResult[];
40
- }
41
-
42
- export interface CodeTestReport {
43
- version: 1;
44
- runId: string;
45
- status: 'passed' | 'completed' | 'failed' | 'cancelled';
46
- docsRoot: string;
47
- outputDirectory: string;
48
- reportPath: string;
49
- agents: HarnessAgent[];
50
- model: string;
51
- selectedFiles: string[];
52
- startedAt: string;
53
- completedAt: string;
54
- durationMs: number;
55
- tasks: TaskResult[];
56
- /** Set when the run finished but could not be saved to `.mintlify/test` history. */
57
- historyError?: string;
58
- }
59
-
60
- export interface TestRunSummary {
61
- runId: string;
62
- status: CodeTestReport['status'];
63
- agents: HarnessAgent[];
64
- model: string;
65
- selectedFiles: string[];
66
- startedAt: string;
67
- completedAt: string;
68
- durationMs: number;
69
- reportPath: string;
70
- outputDirectory: string;
71
- counts: {
72
- passed: number;
73
- failed: number;
74
- cancelled: number;
75
- agentErrors: number;
76
- };
77
- }
78
-
79
- export interface TestRunsIndex {
80
- version: 1;
81
- runs: TestRunSummary[];
82
- }
83
-
84
- export type TaskPhase =
85
- | 'checking'
86
- | 'checked'
87
- | 'not_testable'
88
- | 'generating'
89
- | 'generated'
90
- | 'agent_error'
91
- | 'running'
92
- | 'passed'
93
- | 'failed'
94
- | 'cancelled';
95
-
96
- export interface TaskUpdate {
97
- id: string;
98
- agent: HarnessAgent;
99
- file: string;
100
- directory: string;
101
- generatedFiles: string[];
102
- phase: TaskPhase;
103
- tokens: number;
104
- /** Why the task ended in `agent_error` or `failed`; the first line is shown in the UI. */
105
- error?: string;
106
- }
107
-
108
- export interface TestGenerationResult {
109
- status: 'succeeded' | 'failed';
110
- agent: HarnessAgent;
111
- model: string;
112
- attemptCount: number;
113
- tokenCount: number;
114
- durationMs: number;
115
- generatedFileCount: number;
116
- }
117
-
118
- export interface RunSave {
119
- version: 1;
120
- runId: string;
121
- agents: HarnessAgent[];
122
- model: string;
123
- selectedFiles: string[];
124
- createdAt: string;
125
- }
126
-
127
- export interface RunCodeTestsOptions {
128
- path: string;
129
- agents: HarnessAgent[];
130
- model: string;
131
- selectedFiles: string[];
132
- outputDirectory: string;
133
- runId: string | null;
134
- concurrency: number;
135
- commandTimeoutMs: number;
136
- signal: AbortSignal;
137
- onTaskUpdate: (update: TaskUpdate) => void;
138
- onGenerationResult?: (result: TestGenerationResult) => void;
139
- }
140
-
141
- const identifier = z.string().trim().min(1).max(120);
142
-
143
- const commandSchema = z
144
- .object({
145
- id: identifier.optional(),
146
- executable: z.string().trim().min(1).max(512),
147
- args: z.array(z.string().max(10_000)).max(100),
148
- cwd: z.string().max(1_024).optional(),
149
- timeoutMs: z
150
- .number()
151
- .int()
152
- .min(1_000)
153
- .max(30 * 60 * 1_000)
154
- .optional(),
155
- })
156
- .strict();
157
-
158
- const manifestFileSchema = z
159
- .object({
160
- version: z.literal(1),
161
- generatedFiles: z.array(z.string().min(1).max(1_024)).max(1_000),
162
- setupCommands: z.array(commandSchema).max(50),
163
- testCommands: z.array(commandSchema).max(200),
164
- })
165
- .strict();
166
-
167
- type ManifestFile = z.infer<typeof manifestFileSchema>;
168
- type ManifestFileCommand = ManifestFile['testCommands'][number];
169
-
170
- export type ManifestCommand = Omit<ManifestFileCommand, 'id'> & { id: string };
171
-
172
- export interface GeneratedTestManifest {
173
- version: 1;
174
- generatedFiles: string[];
175
- setupCommands: ManifestCommand[];
176
- testCommands: ManifestCommand[];
177
- }
178
-
179
- /**
180
- * Agents often copy the manifest example verbatim and omit command ids. The id only needs to be
181
- * unique within the manifest, so assign `setup-N` / `test-N` instead of rejecting the whole page.
182
- */
183
- export function assignCommandIds(manifest: ManifestFile): GeneratedTestManifest {
184
- const taken = new Set(
185
- [...manifest.setupCommands, ...manifest.testCommands]
186
- .map((command) => command.id)
187
- .filter((id): id is string => id !== undefined)
188
- );
189
- const withIds = (commands: ManifestFileCommand[], phase: 'setup' | 'test'): ManifestCommand[] => {
190
- let next = 1;
191
- return commands.map((command) => {
192
- if (command.id !== undefined) return { ...command, id: command.id };
193
- let id = `${phase}-${next}`;
194
- while (taken.has(id)) {
195
- next += 1;
196
- id = `${phase}-${next}`;
197
- }
198
- taken.add(id);
199
- next += 1;
200
- return { ...command, id };
201
- });
202
- };
203
- return {
204
- version: manifest.version,
205
- generatedFiles: manifest.generatedFiles,
206
- setupCommands: withIds(manifest.setupCommands, 'setup'),
207
- testCommands: withIds(manifest.testCommands, 'test'),
208
- };
209
- }
210
-
211
- export const manifestSchema = manifestFileSchema.transform(assignCommandIds);
212
-
213
- export const testCheckSchema = z
214
- .object({
215
- version: z.literal(1),
216
- testable: z.boolean(),
217
- reason: z
218
- .string()
219
- .max(2_000)
220
- .optional()
221
- .transform((value) => (value?.trim() ? value.trim() : undefined)),
222
- })
223
- .strict();
224
-
225
- export type TestCheck = z.infer<typeof testCheckSchema>;
226
-
227
- export interface GeneratedTask {
228
- id: string;
229
- agent: HarnessAgent;
230
- docsRoot: string;
231
- file: string;
232
- directory: string;
233
- attempts: number;
234
- tokens: number;
235
- testable?: boolean;
236
- manifest?: GeneratedTestManifest;
237
- generatedFiles?: string[];
238
- error?: string;
239
- }
package/src/mintTest.tsx DELETED
@@ -1,138 +0,0 @@
1
- import path from 'node:path';
2
-
3
- import {
4
- DEFAULT_COMMAND_TIMEOUT_MS,
5
- DEFAULT_CONCURRENCY,
6
- clearRunSave,
7
- countReport,
8
- discoverCodeBlocks,
9
- loadLatestTestRun,
10
- loadRunSave,
11
- runCodeTests,
12
- type RunSave,
13
- } from './agent-harness/index.js';
14
- import { CMD_EXEC_PATH, findDocsRoot, isAI, terminate } from './helpers.js';
15
- import { getAccessToken } from './keyring.js';
16
- import { fileInNavPaths, loadDocsNavScope } from './mintTestNav.js';
17
- import {
18
- trackMintTestGenerationResult,
19
- trackMintTestRunCompleted,
20
- trackMintTestRunStarted,
21
- } from './mintTestTelemetry.js';
22
- import { firstLine, taskProblemLabel } from './mintTestText.js';
23
- import { formatDuration, runMintTestUi } from './mintTestUi.js';
24
-
25
- export async function mintTestHandler(): Promise<void> {
26
- try {
27
- const accessToken = await getAccessToken().catch(() => null);
28
- if (!accessToken) {
29
- process.stderr.write('not logged in. Run `mint login` to authenticate.\n');
30
- await terminate(1);
31
- return;
32
- }
33
- const targetPath = await findDocsRoot(CMD_EXEC_PATH);
34
- const interactive =
35
- process.stdin.isTTY === true &&
36
- process.stdout.isTTY === true &&
37
- !isAI() &&
38
- !process.env.CI &&
39
- process.env.CLI_TEST_MODE !== 'true';
40
-
41
- const { docsRoot, pages: discoveredPages } = await discoverCodeBlocks(targetPath);
42
- const scope = await loadDocsNavScope(docsRoot);
43
- const pages = scope
44
- ? discoveredPages.filter((page) => fileInNavPaths(page.file, scope.navPaths))
45
- : discoveredPages;
46
- const autoSelectedFiles = (
47
- scope ? pages.filter((page) => fileInNavPaths(page.file, scope.autoSelectedPaths)) : pages
48
- ).map((page) => page.file);
49
- const totalPages = scope ? scope.navPaths.size : pages.length;
50
- const outputDirectory = path.join(docsRoot, 'tests', 'mint-test');
51
-
52
- let save: RunSave | null = await loadRunSave(outputDirectory);
53
- if (save) {
54
- const known = new Set(pages.map((page) => page.file));
55
- const selectedFiles = save.selectedFiles.filter((file) => known.has(file));
56
- if (selectedFiles.length === 0) {
57
- await clearRunSave(outputDirectory);
58
- save = null;
59
- } else {
60
- save = { ...save, selectedFiles };
61
- }
62
- }
63
- const lastReport = await loadLatestTestRun(docsRoot);
64
-
65
- if (interactive) {
66
- const exitCode = await runMintTestUi({
67
- targetPath,
68
- docsRoot,
69
- pages,
70
- autoSelectedFiles,
71
- totalPages,
72
- save,
73
- lastReport,
74
- });
75
- await terminate(exitCode);
76
- return;
77
- }
78
-
79
- const agents = save?.agents ?? ['claude'];
80
- const model = save?.model ?? 'claude-opus-4-8';
81
- const selectedFiles = save?.selectedFiles ?? autoSelectedFiles;
82
- const resumed = save !== null;
83
- if (save) process.stdout.write(`resuming run ${save.runId}\n`);
84
- void trackMintTestRunStarted({
85
- mode: 'non_interactive',
86
- resumed,
87
- agents,
88
- model,
89
- selectedPageCount: new Set(selectedFiles).size,
90
- totalPageCount: totalPages,
91
- });
92
- const report = await runCodeTests({
93
- path: targetPath,
94
- agents,
95
- model,
96
- selectedFiles,
97
- outputDirectory,
98
- runId: save?.runId ?? null,
99
- concurrency: DEFAULT_CONCURRENCY,
100
- commandTimeoutMs: DEFAULT_COMMAND_TIMEOUT_MS,
101
- signal: new AbortController().signal,
102
- onTaskUpdate: () => {},
103
- onGenerationResult: (result) => {
104
- void trackMintTestGenerationResult({
105
- ...result,
106
- mode: 'non_interactive',
107
- resumed,
108
- });
109
- },
110
- });
111
- await trackMintTestRunCompleted(report, {
112
- mode: 'non_interactive',
113
- resumed,
114
- totalPageCount: totalPages,
115
- });
116
- const counts = countReport(report.tasks);
117
- process.stdout.write(
118
- `mint test ${report.status}: ${counts.passed} passed, ${counts.failed} failed, ${counts.agentErrors} agent errors\n`
119
- );
120
- process.stdout.write(`finished in ${formatDuration(report.durationMs)}\n`);
121
- process.stdout.write(`report: ${report.reportPath}\n`);
122
- for (const task of report.tasks) {
123
- if (task.status !== 'failed' && task.status !== 'agent_error') continue;
124
- const reason = firstLine(task.error);
125
- process.stdout.write(
126
- ` ✗ ${task.file}: ${taskProblemLabel(task.status)}${reason ? ` (${reason})` : ''}\n`
127
- );
128
- process.stdout.write(` output: ${task.directory}\n`);
129
- }
130
- if (report.historyError) process.stderr.write(`warning: ${report.historyError}\n`);
131
- await terminate(report.status === 'passed' ? 0 : 1);
132
- } catch (error) {
133
- process.stderr.write(
134
- `mint test failed: ${error instanceof Error ? error.message : 'unknown error'}\n`
135
- );
136
- await terminate(1);
137
- }
138
- }
@@ -1,32 +0,0 @@
1
- import fs from 'node:fs/promises';
2
-
3
- export const PREVIEW_BYTE_LIMIT = 64 * 1024;
4
-
5
- export type FilePreview = { binary: true } | { binary: false; text: string; truncated: boolean };
6
-
7
- export async function readFilePreview(filePath: string): Promise<FilePreview> {
8
- const buffer = new Uint8Array(PREVIEW_BYTE_LIMIT + 1);
9
- const file = await fs.open(filePath, 'r');
10
- let bytesRead = 0;
11
-
12
- try {
13
- while (bytesRead < buffer.length) {
14
- const result = await file.read(buffer, bytesRead, buffer.length - bytesRead, bytesRead);
15
- if (result.bytesRead === 0) break;
16
- bytesRead += result.bytesRead;
17
- }
18
- } finally {
19
- await file.close();
20
- }
21
-
22
- const contents = buffer.subarray(0, bytesRead);
23
- if (contents.includes(0)) return { binary: true };
24
-
25
- return {
26
- binary: false,
27
- text: new TextDecoder()
28
- .decode(contents.subarray(0, PREVIEW_BYTE_LIMIT))
29
- .replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, '�'),
30
- truncated: bytesRead > PREVIEW_BYTE_LIMIT,
31
- };
32
- }
@@ -1,31 +0,0 @@
1
- import { getAllPathsInDocsNav } from '@mintlify/common';
2
- import { getConfigObj } from '@mintlify/prebuild';
3
- import type { DocsConfig, NavigationConfig } from '@mintlify/validation';
4
-
5
- export interface DocsNavScope {
6
- navPaths: Set<string>;
7
- autoSelectedPaths: Set<string>;
8
- }
9
-
10
- export async function loadDocsNavScope(docsRoot: string): Promise<DocsNavScope | null> {
11
- const config = (await getConfigObj(docsRoot, 'docs').catch(() => null)) as DocsConfig | null;
12
- const navigation = config?.navigation;
13
- if (!navigation) return null;
14
- const navPaths = new Set(getAllPathsInDocsNav(navigation));
15
- if (navPaths.size === 0) return null;
16
- const firstLanguage =
17
- 'languages' in navigation ? (navigation.languages[0] as NavigationConfig | undefined) : null;
18
- const autoSelectedPaths = firstLanguage ? new Set(getAllPathsInDocsNav(firstLanguage)) : navPaths;
19
- return { navPaths, autoSelectedPaths };
20
- }
21
-
22
- export function navCandidatesForFile(file: string): string[] {
23
- const base = file.replace(/\.mdx?$/i, '');
24
- const candidates = [base];
25
- if (base.endsWith('/index')) candidates.push(base.slice(0, -'/index'.length));
26
- return candidates;
27
- }
28
-
29
- export function fileInNavPaths(file: string, paths: Set<string>): boolean {
30
- return navCandidatesForFile(file).some((candidate) => paths.has(candidate));
31
- }
@@ -1,101 +0,0 @@
1
- import {
2
- countReport,
3
- type CodeTestReport,
4
- type HarnessAgent,
5
- type TestGenerationResult,
6
- } from './agent-harness/index.js';
7
- import { trackEvent } from './telemetry/track.js';
8
-
9
- export type MintTestMode = 'interactive' | 'non_interactive';
10
-
11
- export interface MintTestRunStartedOptions {
12
- mode: MintTestMode;
13
- resumed: boolean;
14
- agents: HarnessAgent[];
15
- model: string;
16
- selectedPageCount: number;
17
- totalPageCount: number;
18
- }
19
-
20
- export interface MintTestRunCompletedOptions {
21
- mode: MintTestMode;
22
- resumed: boolean;
23
- totalPageCount: number;
24
- }
25
-
26
- export interface MintTestGenerationResultOptions extends TestGenerationResult {
27
- mode: MintTestMode;
28
- resumed: boolean;
29
- }
30
-
31
- export async function trackMintTestRunStarted({
32
- mode,
33
- resumed,
34
- agents,
35
- model,
36
- selectedPageCount,
37
- totalPageCount,
38
- }: MintTestRunStartedOptions): Promise<void> {
39
- await trackEvent('cli.test.started', {
40
- mode,
41
- resumed,
42
- agents,
43
- model,
44
- selected_page_count: selectedPageCount,
45
- total_page_count: totalPageCount,
46
- });
47
- }
48
-
49
- export async function trackMintTestRunCompleted(
50
- report: CodeTestReport,
51
- { mode, resumed, totalPageCount }: MintTestRunCompletedOptions
52
- ): Promise<void> {
53
- const counts = countReport(report.tasks);
54
- const skippedCheckCount = report.tasks.filter(
55
- (task) => !task.testable && task.status === 'passed'
56
- ).length;
57
- const tokenCount = report.tasks.reduce((total, task) => total + task.tokens, 0);
58
- const runnableCheckCount = report.tasks.filter((task) => task.testable).length;
59
-
60
- await trackEvent('cli.test.completed', {
61
- mode,
62
- resumed,
63
- status: report.status,
64
- agents: report.agents,
65
- model: report.model,
66
- selected_page_count: new Set(report.selectedFiles).size,
67
- total_page_count: totalPageCount,
68
- total_check_count: report.tasks.length,
69
- runnable_check_count: runnableCheckCount,
70
- passed_check_count: counts.passed - skippedCheckCount,
71
- failed_check_count: counts.failed,
72
- skipped_check_count: skippedCheckCount,
73
- agent_error_check_count: counts.agentErrors,
74
- cancelled_check_count: counts.cancelled,
75
- token_count: tokenCount,
76
- duration_ms: report.durationMs,
77
- });
78
- }
79
-
80
- export async function trackMintTestGenerationResult({
81
- status,
82
- mode,
83
- resumed,
84
- agent,
85
- model,
86
- attemptCount,
87
- tokenCount,
88
- durationMs,
89
- generatedFileCount,
90
- }: MintTestGenerationResultOptions): Promise<void> {
91
- await trackEvent(`cli.test.generation.${status}`, {
92
- mode,
93
- resumed,
94
- agent,
95
- model,
96
- attempt_count: attemptCount,
97
- token_count: tokenCount,
98
- duration_ms: durationMs,
99
- generated_file_count: generatedFileCount,
100
- });
101
- }
@@ -1,35 +0,0 @@
1
- export interface FooterSegment {
2
- key: string;
3
- action: string;
4
- }
5
-
6
- /**
7
- * Split a footer hint such as `↑/↓/j/k move · Enter exit` into key/action pairs so the key can
8
- * be rendered bold and the action plain.
9
- */
10
- export function footerSegments(text: string): FooterSegment[] {
11
- return text
12
- .split(' · ')
13
- .map((segment) => segment.trim())
14
- .filter((segment) => segment.length > 0)
15
- .map((segment) => {
16
- const split = segment.indexOf(' ');
17
- if (split === -1) return { key: segment, action: '' };
18
- return { key: segment.slice(0, split), action: segment.slice(split + 1).trim() };
19
- });
20
- }
21
-
22
- /** First non-empty line of a multi-line message, trimmed to fit a terminal row. */
23
- export function firstLine(text: string | undefined, maxLength = 160): string {
24
- if (!text) return '';
25
- const line = text
26
- .split(/\r?\n/)
27
- .map((candidate) => candidate.trim())
28
- .find((candidate) => candidate.length > 0);
29
- if (!line) return '';
30
- return line.length > maxLength ? `${line.slice(0, maxLength - 1)}…` : line;
31
- }
32
-
33
- export function taskProblemLabel(status: 'failed' | 'agent_error'): string {
34
- return status === 'agent_error' ? 'test could not be generated' : 'tests failed';
35
- }
@@ -1,134 +0,0 @@
1
- import type { DocsPage } from './agent-harness/index.js';
2
-
3
- export type ScopeSelectionState = 'all' | 'some' | 'none';
4
-
5
- export interface ScopeTreeRow {
6
- id: string;
7
- kind: 'directory' | 'file';
8
- label: string;
9
- depth: number;
10
- file?: string;
11
- blockCount: number;
12
- selectableFiles: string[];
13
- selection: ScopeSelectionState;
14
- expanded?: boolean;
15
- }
16
-
17
- interface TreeNode {
18
- id: string;
19
- kind: 'directory' | 'file';
20
- label: string;
21
- file?: string;
22
- blockCount: number;
23
- children: TreeNode[];
24
- }
25
-
26
- function buildTree(pages: DocsPage[]): TreeNode {
27
- const root: TreeNode = { id: 'dir:', kind: 'directory', label: '.', blockCount: 0, children: [] };
28
- const directories = new Map<string, TreeNode>([['', root]]);
29
-
30
- for (const page of pages) {
31
- const parts = page.file.split('/');
32
- let parent = root;
33
- let directoryPath = '';
34
- for (const part of parts.slice(0, -1)) {
35
- directoryPath = directoryPath ? `${directoryPath}/${part}` : part;
36
- let directory = directories.get(directoryPath);
37
- if (!directory) {
38
- directory = {
39
- id: `dir:${directoryPath}`,
40
- kind: 'directory',
41
- label: part,
42
- blockCount: 0,
43
- children: [],
44
- };
45
- directories.set(directoryPath, directory);
46
- parent.children.push(directory);
47
- }
48
- parent = directory;
49
- }
50
-
51
- parent.children.push({
52
- id: `file:${page.file}`,
53
- kind: 'file',
54
- label: parts[parts.length - 1] ?? page.file,
55
- file: page.file,
56
- blockCount: page.blockCount,
57
- children: [],
58
- });
59
- }
60
-
61
- const sumBlocks = (node: TreeNode): number => {
62
- if (node.kind === 'file') return node.blockCount;
63
- node.blockCount = node.children.reduce((total, child) => total + sumBlocks(child), 0);
64
- return node.blockCount;
65
- };
66
- sumBlocks(root);
67
-
68
- const sortChildren = (node: TreeNode): void => {
69
- node.children.sort((left, right) => {
70
- if (left.kind !== right.kind) return left.kind === 'directory' ? -1 : 1;
71
- return left.label.localeCompare(right.label);
72
- });
73
- for (const child of node.children) sortChildren(child);
74
- };
75
- sortChildren(root);
76
- return root;
77
- }
78
-
79
- function descendantFiles(node: TreeNode): string[] {
80
- if (node.kind === 'file') return node.file ? [node.file] : [];
81
- return node.children.flatMap(descendantFiles);
82
- }
83
-
84
- function selectionState(files: string[], selectedFiles: ReadonlySet<string>): ScopeSelectionState {
85
- if (files.length === 0) return 'none';
86
- const selectedCount = files.filter((file) => selectedFiles.has(file)).length;
87
- if (selectedCount === 0) return 'none';
88
- return selectedCount === files.length ? 'all' : 'some';
89
- }
90
-
91
- export function visibleScopeRows(
92
- pages: DocsPage[],
93
- expandedDirectories: ReadonlySet<string>,
94
- selectedFiles: ReadonlySet<string>
95
- ): ScopeTreeRow[] {
96
- const root = buildTree(pages);
97
- const rows: ScopeTreeRow[] = [];
98
-
99
- const visit = (node: TreeNode, depth: number) => {
100
- const files = descendantFiles(node);
101
- const expanded = node.kind === 'directory' ? expandedDirectories.has(node.id) : undefined;
102
- rows.push({
103
- id: node.id,
104
- kind: node.kind,
105
- label: node.label,
106
- depth,
107
- ...(node.file ? { file: node.file } : {}),
108
- blockCount: node.blockCount,
109
- selectableFiles: files,
110
- selection: selectionState(files, selectedFiles),
111
- ...(expanded !== undefined ? { expanded } : {}),
112
- });
113
- if (node.kind === 'directory' && expanded) {
114
- for (const child of node.children) visit(child, depth + 1);
115
- }
116
- };
117
-
118
- for (const child of root.children) visit(child, 0);
119
- return rows;
120
- }
121
-
122
- export function toggleScopeSelection(
123
- selectedFiles: ReadonlySet<string>,
124
- row: ScopeTreeRow
125
- ): Set<string> {
126
- const next = new Set(selectedFiles);
127
- if (row.selectableFiles.length === 0) return next;
128
- const shouldSelect = row.selectableFiles.some((file) => !next.has(file));
129
- for (const file of row.selectableFiles) {
130
- if (shouldSelect) next.add(file);
131
- else next.delete(file);
132
- }
133
- return next;
134
- }