@mintlify/cli 4.0.1433 → 4.0.1437

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.
@@ -1033,6 +1033,7 @@ function getImageWidthFromStyle(style) {
1033
1033
  }
1034
1034
 
1035
1035
  // ../editor/dist/converter/parse/handlers/media.js
1036
+ var isAttributeRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
1036
1037
  function parseImgNode(node, spreadAttrs = false) {
1037
1038
  const attributes = parseMdxAttributes(node.attributes);
1038
1039
  const className = attributes.className;
@@ -1042,7 +1043,7 @@ function parseImgNode(node, spreadAttrs = false) {
1042
1043
  }
1043
1044
  const style = attributes.style;
1044
1045
  let width;
1045
- if (style && typeof style === "object") {
1046
+ if (isAttributeRecord(style)) {
1046
1047
  width = getImageWidthFromStyle(style);
1047
1048
  }
1048
1049
  return {
@@ -1050,8 +1051,18 @@ function parseImgNode(node, spreadAttrs = false) {
1050
1051
  attrs: { imgAttributes: spreadAttrs ? Object.assign({}, attributes) : attributes, align, width }
1051
1052
  };
1052
1053
  }
1054
+ function isVideoSourceElement(child) {
1055
+ return (child.type === "mdxJsxTextElement" || child.type === "mdxJsxFlowElement") && child.name === "source";
1056
+ }
1053
1057
  function parseVideoNode(node) {
1054
- const sources = node.children.filter((child) => (child.type === "mdxJsxTextElement" || child.type === "mdxJsxFlowElement") && child.name === "source").map((child) => parseMdxAttributes(child.attributes));
1058
+ const editable = node.children.every((child) => isVideoSourceElement(child) || child.type === "text" && child.value.trim() === "");
1059
+ if (!editable) {
1060
+ return {
1061
+ type: node.type === "mdxJsxTextElement" ? BLOCK_TYPES.inlineUnknownTag : BLOCK_TYPES.unknownTag,
1062
+ attrs: { mdxJsxNode: node }
1063
+ };
1064
+ }
1065
+ const sources = node.children.filter(isVideoSourceElement).map((child) => parseMdxAttributes(child.attributes));
1055
1066
  return {
1056
1067
  type: BLOCK_TYPES.video,
1057
1068
  attrs: {
@@ -1061,9 +1072,15 @@ function parseVideoNode(node) {
1061
1072
  };
1062
1073
  }
1063
1074
  var DARK_ONLY_CLASSES = /* @__PURE__ */ new Set(["hidden", "dark:block"]);
1075
+ var LIGHT_ONLY_CLASSES = /* @__PURE__ */ new Set(["dark:hidden"]);
1076
+ function readImageAttributes(node) {
1077
+ var _a;
1078
+ const attributes = (_a = node.attrs) === null || _a === void 0 ? void 0 : _a.imgAttributes;
1079
+ return isAttributeRecord(attributes) ? attributes : null;
1080
+ }
1064
1081
  function getImageClassName(node) {
1065
- var _a, _b;
1066
- const className = (_b = (_a = node.attrs) === null || _a === void 0 ? void 0 : _a.imgAttributes) === null || _b === void 0 ? void 0 : _b.className;
1082
+ var _a;
1083
+ const className = (_a = readImageAttributes(node)) === null || _a === void 0 ? void 0 : _a.className;
1067
1084
  return typeof className === "string" ? className : void 0;
1068
1085
  }
1069
1086
  function isDarkOnlyImage(node) {
@@ -1083,18 +1100,35 @@ function isLightOnlyImage(node) {
1083
1100
  return false;
1084
1101
  return className.split(/\s+/).includes("dark:hidden");
1085
1102
  }
1086
- function stripThemeClasses(className) {
1087
- const cleaned = className.split(/\s+/).filter((c) => !DARK_ONLY_CLASSES.has(c) && c !== "dark:hidden").join(" ").trim();
1103
+ function stripThemeClasses(className, role) {
1104
+ if (!className)
1105
+ return void 0;
1106
+ const themeClasses = role === "dark" ? DARK_ONLY_CLASSES : LIGHT_ONLY_CLASSES;
1107
+ const cleaned = className.split(/\s+/).filter((c) => !themeClasses.has(c)).join(" ").trim();
1088
1108
  return cleaned || void 0;
1089
1109
  }
1110
+ function nonThemeAttributeEntries(attrs2) {
1111
+ return Object.entries(attrs2).filter(([key]) => key !== "src" && key !== "className").sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0);
1112
+ }
1113
+ function canMergeAsThemePair(lightImgAttrs, darkImgAttrs, lightClassName, darkClassName) {
1114
+ const lightBase = stripThemeClasses(lightClassName, "light");
1115
+ const darkBase = stripThemeClasses(darkClassName, "dark");
1116
+ const lightBaseWithoutBlock = (lightBase === null || lightBase === void 0 ? void 0 : lightBase.split(/\s+/).filter((c) => c !== "block").join(" ").trim()) || void 0;
1117
+ if (darkBase !== lightBase && darkBase !== lightBaseWithoutBlock) {
1118
+ return false;
1119
+ }
1120
+ const lightEntries = nonThemeAttributeEntries(lightImgAttrs);
1121
+ const darkEntries = nonThemeAttributeEntries(darkImgAttrs);
1122
+ return lightEntries.length === darkEntries.length && JSON.stringify(lightEntries) === JSON.stringify(darkEntries);
1123
+ }
1090
1124
  function mergeDarkLightImagePairs(nodes2) {
1091
- var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k;
1125
+ var _a, _b, _c, _d, _e, _f;
1092
1126
  const result = [];
1093
1127
  let i = 0;
1094
1128
  while (i < nodes2.length) {
1095
- if (i + 1 < nodes2.length) {
1096
- const current = nodes2[i];
1097
- const next = nodes2[i + 1];
1129
+ const current = nodes2[i];
1130
+ const next = nodes2[i + 1];
1131
+ if (current && next) {
1098
1132
  let darkNode;
1099
1133
  let lightNode;
1100
1134
  if (isDarkOnlyImage(current) && isLightOnlyImage(next)) {
@@ -1104,12 +1138,12 @@ function mergeDarkLightImagePairs(nodes2) {
1104
1138
  lightNode = current;
1105
1139
  darkNode = next;
1106
1140
  }
1107
- if (darkNode && lightNode) {
1108
- const darkImgAttrs = (_a = darkNode.attrs) === null || _a === void 0 ? void 0 : _a.imgAttributes;
1109
- const lightImgAttrs = (_b = lightNode.attrs) === null || _b === void 0 ? void 0 : _b.imgAttributes;
1110
- const darkSrc = darkImgAttrs.src;
1111
- const lightSrc = lightImgAttrs.src;
1112
- const baseClassName = stripThemeClasses((_d = (_c = getImageClassName(lightNode)) !== null && _c !== void 0 ? _c : getImageClassName(darkNode)) !== null && _d !== void 0 ? _d : "");
1141
+ const darkImgAttrs = darkNode ? readImageAttributes(darkNode) : null;
1142
+ const lightImgAttrs = lightNode ? readImageAttributes(lightNode) : null;
1143
+ const darkSrc = darkImgAttrs === null || darkImgAttrs === void 0 ? void 0 : darkImgAttrs.src;
1144
+ const lightSrc = lightImgAttrs === null || lightImgAttrs === void 0 ? void 0 : lightImgAttrs.src;
1145
+ if (darkNode && lightNode && darkImgAttrs && lightImgAttrs && typeof darkSrc === "string" && typeof lightSrc === "string" && canMergeAsThemePair(lightImgAttrs, darkImgAttrs, getImageClassName(lightNode), getImageClassName(darkNode))) {
1146
+ const baseClassName = stripThemeClasses(getImageClassName(lightNode), "light");
1113
1147
  const mergedImgAttrs = Object.assign({}, lightImgAttrs);
1114
1148
  delete mergedImgAttrs.src;
1115
1149
  if (baseClassName) {
@@ -1123,15 +1157,16 @@ function mergeDarkLightImagePairs(nodes2) {
1123
1157
  imgAttributes: mergedImgAttrs,
1124
1158
  lightSrc,
1125
1159
  darkSrc,
1126
- align: (_f = (_e = lightNode.attrs) === null || _e === void 0 ? void 0 : _e.align) !== null && _f !== void 0 ? _f : (_g = darkNode.attrs) === null || _g === void 0 ? void 0 : _g.align,
1127
- width: (_j = (_h = lightNode.attrs) === null || _h === void 0 ? void 0 : _h.width) !== null && _j !== void 0 ? _j : (_k = darkNode.attrs) === null || _k === void 0 ? void 0 : _k.width
1160
+ align: (_b = (_a = lightNode.attrs) === null || _a === void 0 ? void 0 : _a.align) !== null && _b !== void 0 ? _b : (_c = darkNode.attrs) === null || _c === void 0 ? void 0 : _c.align,
1161
+ width: (_e = (_d = lightNode.attrs) === null || _d === void 0 ? void 0 : _d.width) !== null && _e !== void 0 ? _e : (_f = darkNode.attrs) === null || _f === void 0 ? void 0 : _f.width
1128
1162
  }
1129
1163
  });
1130
1164
  i += 2;
1131
1165
  continue;
1132
1166
  }
1133
1167
  }
1134
- result.push(nodes2[i]);
1168
+ if (current)
1169
+ result.push(current);
1135
1170
  i++;
1136
1171
  }
1137
1172
  return result;
@@ -1158,7 +1193,7 @@ function convertToInlineImage(node) {
1158
1193
  let width = 100;
1159
1194
  if (attributes.width && typeof attributes.width === "number") {
1160
1195
  width = attributes.width;
1161
- } else if (attributes.style && typeof attributes.style === "object") {
1196
+ } else if (isAttributeRecord(attributes.style)) {
1162
1197
  const styleWidth = getImageWidthFromStyle(attributes.style);
1163
1198
  if (styleWidth)
1164
1199
  width = styleWidth;
@@ -2713,10 +2748,10 @@ function serializeJsxAttribute(attrs2, key, value) {
2713
2748
  const serialized = serializePropValue(value);
2714
2749
  return serialized ? `${key}=${serialized}` : null;
2715
2750
  }
2716
- var isAttributeRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
2751
+ var isAttributeRecord2 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
2717
2752
  function getNamedExpressionAttr(attrs2, key) {
2718
2753
  const raw = attrs2[NAMED_EXPRESSION_ATTRS_KEY];
2719
- if (!isAttributeRecord(raw))
2754
+ if (!isAttributeRecord2(raw))
2720
2755
  return void 0;
2721
2756
  const value = raw[key];
2722
2757
  return typeof value === "string" ? value : void 0;
@@ -2809,7 +2844,7 @@ function renderImage(state, node) {
2809
2844
  return;
2810
2845
  }
2811
2846
  const hasExtraAttrs = Object.keys(imgAttributes).some((attr) => attr !== "src" && attr !== "alt" && attr !== "title");
2812
- if (hasExtraAttrs || width || align) {
2847
+ if (hasExtraAttrs || width || align || !fitsMarkdownImageValues(Object.assign(Object.assign({}, imgAttributes), { src }))) {
2813
2848
  const finalAttrs = Object.assign(Object.assign({}, imgAttributes), { src });
2814
2849
  if (align) {
2815
2850
  finalAttrs["className"] = addAlignClass(finalAttrs["className"], align);
@@ -2869,11 +2904,27 @@ function writeMarkdownImage(state, imgAttributes, src) {
2869
2904
  state.write(`![${alt}](${escapedSrc})`);
2870
2905
  }
2871
2906
  }
2907
+ var MARKDOWN_UNSAFE_SRC = /[\s<>\u0000-\u001F]/;
2908
+ var MARKDOWN_UNSAFE_TEXT = /[\r\n]/;
2909
+ function fitsMarkdownImageValues(attrs2) {
2910
+ const src = attrs2["src"];
2911
+ if (typeof src !== "string" || !src || MARKDOWN_UNSAFE_SRC.test(src))
2912
+ return false;
2913
+ const alt = attrs2["alt"];
2914
+ if (typeof alt === "string" && MARKDOWN_UNSAFE_TEXT.test(alt))
2915
+ return false;
2916
+ const title = attrs2["title"];
2917
+ if (typeof title === "string" && MARKDOWN_UNSAFE_TEXT.test(title))
2918
+ return false;
2919
+ return true;
2920
+ }
2872
2921
  function fitsMarkdownImageSyntax(node, attrs2) {
2873
2922
  if (!node.marks.some((mark) => mark.type.name === "link"))
2874
2923
  return false;
2875
2924
  if (typeof attrs2["src"] !== "string" || !attrs2["src"])
2876
2925
  return false;
2926
+ if (!fitsMarkdownImageValues(attrs2))
2927
+ return false;
2877
2928
  return Object.entries(attrs2).every(([key, value]) => key === "src" || key === "alt" || key === "title" || value == null);
2878
2929
  }
2879
2930
  function renderInlineImage(state, node) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mintlify/cli",
3
- "version": "4.0.1433",
3
+ "version": "4.0.1437",
4
4
  "description": "The Mintlify CLI",
5
5
  "engines": {
6
6
  "node": ">=18.0.0"
@@ -45,12 +45,13 @@
45
45
  },
46
46
  "dependencies": {
47
47
  "@inquirer/prompts": "7.9.0",
48
- "@mintlify/common": "1.0.1110",
49
- "@mintlify/link-rot": "3.0.1312",
50
- "@mintlify/models": "0.0.350",
51
- "@mintlify/prebuild": "1.0.1264",
52
- "@mintlify/previewing": "4.0.1336",
53
- "@mintlify/validation": "0.1.841",
48
+ "@mintlify/agent-harness": "0.0.3",
49
+ "@mintlify/common": "1.0.1111",
50
+ "@mintlify/link-rot": "3.0.1313",
51
+ "@mintlify/models": "0.0.351",
52
+ "@mintlify/prebuild": "1.0.1265",
53
+ "@mintlify/previewing": "4.0.1337",
54
+ "@mintlify/validation": "0.1.842",
54
55
  "adm-zip": "0.6.0",
55
56
  "chalk": "5.2.0",
56
57
  "color": "4.2.3",
@@ -82,7 +83,7 @@
82
83
  "keytar": "7.9.0"
83
84
  },
84
85
  "devDependencies": {
85
- "@mintlify/editor": "0.0.326",
86
+ "@mintlify/editor": "0.0.328",
86
87
  "@mintlify/ts-config": "2.0.2",
87
88
  "@tsconfig/recommended": "1.0.2",
88
89
  "@types/detect-port": "1.3.2",
@@ -101,5 +102,5 @@
101
102
  "vitest": "2.1.9",
102
103
  "vitest-mock-process": "1.0.4"
103
104
  },
104
- "gitHead": "b35172bff15786ea8cfd4e8e00f868046e5e1ff8"
105
+ "gitHead": "246494b8852335337e48cd0518b54abb2fb3f774"
105
106
  }
package/src/cli.tsx CHANGED
@@ -44,6 +44,7 @@ import { login } from './login.js';
44
44
  import { logout } from './logout.js';
45
45
  import { mdxLinter } from './mdxLinter.js';
46
46
  import { createTelemetryMiddleware } from './middlewares/telemetryMiddleware.js';
47
+ import { mintTestHandler } from './mintTest.js';
47
48
  import { checkOpenApiFile, getOpenApiFilenamesFromDocsConfig } from './openApiCheck.js';
48
49
  import { resolveExplicitFiles } from './resolveFiles.js';
49
50
  import { scoreHandler } from './score/index.js';
@@ -85,15 +86,18 @@ const showWelcome = async (packageName: string): Promise<void> => {
85
86
  };
86
87
 
87
88
  export const cli = ({ packageName = 'mint' }: { packageName?: string }) => {
88
- if (shouldShowWelcome(hideBin(process.argv))) {
89
+ const commandArguments = hideBin(process.argv);
90
+ if (shouldShowWelcome(commandArguments)) {
89
91
  return showWelcome(packageName);
90
92
  }
91
93
 
92
94
  const telemetryMiddleware = createTelemetryMiddleware();
93
- render(<Logs />);
95
+ if (commandArguments.find((argument) => !argument.startsWith('-')) !== 'test') {
96
+ render(<Logs />);
97
+ }
94
98
 
95
99
  return (
96
- yargs(hideBin(process.argv))
100
+ yargs(commandArguments)
97
101
  .scriptName(packageName)
98
102
  .option('telemetry', {
99
103
  type: 'boolean',
@@ -239,6 +243,14 @@ export const cli = ({ packageName = 'mint' }: { packageName?: string }) => {
239
243
  });
240
244
  }
241
245
  )
246
+ .command(
247
+ 'test',
248
+ 'Test runnable code blocks with a local coding agent',
249
+ (yargs) => yargs.usage('usage: mint test'),
250
+ async () => {
251
+ await mintTestHandler();
252
+ }
253
+ )
242
254
  .command(
243
255
  'export',
244
256
  'Export a static site for air-gapped deployment',
@@ -762,12 +774,6 @@ export const cli = ({ packageName = 'mint' }: { packageName?: string }) => {
762
774
  () => undefined,
763
775
  comingSoon('ai', packageName)
764
776
  )
765
- .command(
766
- 'test',
767
- '[Coming soon] Test your documentation (run mint test to vote)',
768
- () => undefined,
769
- comingSoon('test', packageName)
770
- )
771
777
  .command(
772
778
  'mcp',
773
779
  '[Coming soon] MCP server for documentation (run mint mcp to vote)',
@@ -0,0 +1,102 @@
1
+ import {
2
+ DEFAULT_COMMAND_TIMEOUT_MS,
3
+ DEFAULT_CONCURRENCY,
4
+ clearRunSave,
5
+ discoverCodeBlocks,
6
+ loadRunSave,
7
+ runCodeTests,
8
+ type RunSave,
9
+ } from '@mintlify/agent-harness';
10
+ import path from 'node:path';
11
+
12
+ import { CMD_EXEC_PATH, findDocsRoot, isAI, terminate } from './helpers.js';
13
+ import { getAccessToken } from './keyring.js';
14
+ import { fileInNavPaths, loadDocsNavScope } from './mintTestNav.js';
15
+ import { countReport, formatDuration, runMintTestUi } from './mintTestUi.js';
16
+ import { getCliStatus } from './status.js';
17
+
18
+ export async function mintTestHandler(): Promise<void> {
19
+ try {
20
+ const accessToken = await getAccessToken().catch(() => null);
21
+ if (!accessToken) {
22
+ process.stderr.write('not logged in. Run `mint login` to authenticate.\n');
23
+ await terminate(1);
24
+ return;
25
+ }
26
+ const cliStatus = await getCliStatus(accessToken);
27
+ if (cliStatus?.org.name.toLowerCase() !== 'mintlify') {
28
+ process.stderr.write('mint test is not available for your organization.\n');
29
+ await terminate(1);
30
+ return;
31
+ }
32
+ const targetPath = await findDocsRoot(CMD_EXEC_PATH);
33
+ const interactive =
34
+ process.stdin.isTTY === true &&
35
+ process.stdout.isTTY === true &&
36
+ !isAI() &&
37
+ !process.env.CI &&
38
+ process.env.CLI_TEST_MODE !== 'true';
39
+
40
+ const { docsRoot, pages: discoveredPages } = await discoverCodeBlocks(targetPath);
41
+ const scope = await loadDocsNavScope(docsRoot);
42
+ const pages = scope
43
+ ? discoveredPages.filter((page) => fileInNavPaths(page.file, scope.navPaths))
44
+ : discoveredPages;
45
+ const autoSelectedFiles = (
46
+ scope ? pages.filter((page) => fileInNavPaths(page.file, scope.autoSelectedPaths)) : pages
47
+ ).map((page) => page.file);
48
+ const totalPages = scope ? scope.navPaths.size : pages.length;
49
+ const outputDirectory = path.join(docsRoot, 'tests', 'mint-test');
50
+
51
+ let save: RunSave | null = await loadRunSave(outputDirectory);
52
+ if (save) {
53
+ const known = new Set(pages.map((page) => page.file));
54
+ const selectedFiles = save.selectedFiles.filter((file) => known.has(file));
55
+ if (selectedFiles.length === 0) {
56
+ await clearRunSave(outputDirectory);
57
+ save = null;
58
+ } else {
59
+ save = { ...save, selectedFiles };
60
+ }
61
+ }
62
+
63
+ if (interactive) {
64
+ const exitCode = await runMintTestUi({
65
+ targetPath,
66
+ docsRoot,
67
+ pages,
68
+ autoSelectedFiles,
69
+ totalPages,
70
+ save,
71
+ });
72
+ await terminate(exitCode);
73
+ return;
74
+ }
75
+
76
+ if (save) process.stdout.write(`resuming run ${save.runId}\n`);
77
+ const report = await runCodeTests({
78
+ path: targetPath,
79
+ agents: save?.agents ?? ['claude'],
80
+ model: save?.model ?? 'claude-opus-4-8',
81
+ selectedFiles: save?.selectedFiles ?? autoSelectedFiles,
82
+ outputDirectory,
83
+ runId: save?.runId ?? null,
84
+ concurrency: DEFAULT_CONCURRENCY,
85
+ commandTimeoutMs: DEFAULT_COMMAND_TIMEOUT_MS,
86
+ signal: new AbortController().signal,
87
+ onTaskUpdate: () => {},
88
+ });
89
+ const counts = countReport(report.tasks);
90
+ process.stdout.write(
91
+ `mint test ${report.status}: ${counts.passed} passed, ${counts.failed} failed, ${counts.agentErrors} agent errors\n`
92
+ );
93
+ process.stdout.write(`finished in ${formatDuration(report.durationMs)}\n`);
94
+ process.stdout.write(`report: ${report.reportPath}\n`);
95
+ await terminate(report.status === 'passed' ? 0 : 1);
96
+ } catch (error) {
97
+ process.stderr.write(
98
+ `mint test failed: ${error instanceof Error ? error.message : 'unknown error'}\n`
99
+ );
100
+ await terminate(1);
101
+ }
102
+ }
@@ -0,0 +1,31 @@
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
+ }
@@ -0,0 +1,134 @@
1
+ import type { DocsPage } from '@mintlify/agent-harness';
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
+ }