politty 0.2.2 → 0.3.1

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.
@@ -42,6 +42,7 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
42
42
  const require_schema_extractor = require('../schema-extractor-B9D3Rf22.cjs');
43
43
  const require_subcommand_router = require('../subcommand-router-Vf-0w9P4.cjs');
44
44
  let zod = require("zod");
45
+ let node_util = require("node:util");
45
46
  let node_fs = require("node:fs");
46
47
  node_fs = __toESM(node_fs);
47
48
  let node_path = require("node:path");
@@ -749,6 +750,232 @@ function parseExampleCmd(cmd) {
749
750
  return args;
750
751
  }
751
752
 
753
+ //#endregion
754
+ //#region src/docs/render-args.ts
755
+ /**
756
+ * Extract ResolvedFieldMeta array from ArgsShape
757
+ *
758
+ * This converts a raw args shape (like `commonArgs`) into the
759
+ * ResolvedFieldMeta format used by politty's rendering functions.
760
+ */
761
+ function extractArgsFields(args) {
762
+ return require_schema_extractor.extractFields(zod.z.object(args)).fields;
763
+ }
764
+ /**
765
+ * Render args definition as a markdown options table
766
+ *
767
+ * This function takes raw args definitions (like `commonArgs`) and
768
+ * renders them as a markdown table suitable for documentation.
769
+ *
770
+ * @example
771
+ * import { renderArgsTable } from "politty/docs";
772
+ * import { commonArgs, workspaceArgs } from "./args";
773
+ *
774
+ * const table = renderArgsTable({
775
+ * ...commonArgs,
776
+ * ...workspaceArgs,
777
+ * });
778
+ * // | Option | Alias | Description | Default |
779
+ * // |--------|-------|-------------|---------|
780
+ * // | `--env-file <ENV_FILE>` | `-e` | Path to environment file | - |
781
+ * // ...
782
+ *
783
+ * @param args - Args shape (Record of string keys to Zod schemas with arg() metadata)
784
+ * @param options - Rendering options
785
+ * @returns Rendered markdown table string
786
+ */
787
+ function renderArgsTable(args, options) {
788
+ const optionFields = extractArgsFields(args).filter((f) => !f.positional);
789
+ if (optionFields.length === 0) return "";
790
+ if (options?.columns) return renderFilteredTable(optionFields, options.columns);
791
+ return renderOptionsTableFromArray(optionFields);
792
+ }
793
+ /**
794
+ * Escape markdown special characters in table cells
795
+ */
796
+ function escapeTableCell$1(str) {
797
+ return str.replace(/\|/g, "\\|").replace(/\n/g, " ");
798
+ }
799
+ /**
800
+ * Format default value for display
801
+ */
802
+ function formatDefaultValue(value) {
803
+ if (value === void 0) return "-";
804
+ return `\`${JSON.stringify(value)}\``;
805
+ }
806
+ /**
807
+ * Render table with filtered columns
808
+ */
809
+ function renderFilteredTable(options, columns) {
810
+ const lines = [];
811
+ const headerCells = [];
812
+ const separatorCells = [];
813
+ for (const col of columns) switch (col) {
814
+ case "option":
815
+ headerCells.push("Option");
816
+ separatorCells.push("------");
817
+ break;
818
+ case "alias":
819
+ headerCells.push("Alias");
820
+ separatorCells.push("-----");
821
+ break;
822
+ case "description":
823
+ headerCells.push("Description");
824
+ separatorCells.push("-----------");
825
+ break;
826
+ case "required":
827
+ headerCells.push("Required");
828
+ separatorCells.push("--------");
829
+ break;
830
+ case "default":
831
+ headerCells.push("Default");
832
+ separatorCells.push("-------");
833
+ break;
834
+ case "env":
835
+ headerCells.push("Env");
836
+ separatorCells.push("---");
837
+ break;
838
+ }
839
+ lines.push(`| ${headerCells.join(" | ")} |`);
840
+ lines.push(`| ${separatorCells.join(" | ")} |`);
841
+ for (const opt of options) {
842
+ const cells = [];
843
+ for (const col of columns) switch (col) {
844
+ case "option": {
845
+ const placeholder = opt.placeholder ?? opt.cliName.toUpperCase().replace(/-/g, "_");
846
+ const optionName = opt.type === "boolean" ? `\`--${opt.cliName}\`` : `\`--${opt.cliName} <${placeholder}>\``;
847
+ cells.push(optionName);
848
+ break;
849
+ }
850
+ case "alias":
851
+ cells.push(opt.alias ? `\`-${opt.alias}\`` : "-");
852
+ break;
853
+ case "description":
854
+ cells.push(escapeTableCell$1(opt.description ?? ""));
855
+ break;
856
+ case "required":
857
+ cells.push(opt.required ? "Yes" : "No");
858
+ break;
859
+ case "default":
860
+ cells.push(formatDefaultValue(opt.defaultValue));
861
+ break;
862
+ case "env": {
863
+ const envNames = opt.env ? Array.isArray(opt.env) ? opt.env.map((e) => `\`${e}\``).join(", ") : `\`${opt.env}\`` : "-";
864
+ cells.push(envNames);
865
+ break;
866
+ }
867
+ }
868
+ lines.push(`| ${cells.join(" | ")} |`);
869
+ }
870
+ return lines.join("\n");
871
+ }
872
+
873
+ //#endregion
874
+ //#region src/docs/render-index.ts
875
+ /**
876
+ * Escape markdown special characters in table cells
877
+ */
878
+ function escapeTableCell(str) {
879
+ return str.replace(/\|/g, "\\|").replace(/\n/g, " ");
880
+ }
881
+ /**
882
+ * Generate anchor from command path
883
+ */
884
+ function generateAnchor(commandPath) {
885
+ return commandPath.replace(/\s+/g, "-").toLowerCase();
886
+ }
887
+ /**
888
+ * Check if a command is a leaf (has no subcommands)
889
+ */
890
+ function isLeafCommand(info) {
891
+ return info.subCommands.length === 0;
892
+ }
893
+ /**
894
+ * Expand commands to include their subcommands
895
+ * If a command has subcommands, recursively find all commands under it
896
+ *
897
+ * @param commandPaths - Command paths to expand
898
+ * @param allCommands - Map of all available commands
899
+ * @param leafOnly - If true, only include leaf commands; if false, include all commands
900
+ */
901
+ function expandCommands(commandPaths, allCommands, leafOnly) {
902
+ const result = [];
903
+ for (const cmdPath of commandPaths) {
904
+ const info = allCommands.get(cmdPath);
905
+ if (!info) continue;
906
+ if (isLeafCommand(info)) result.push(cmdPath);
907
+ else for (const [path, pathInfo] of allCommands) if (cmdPath === "" ? path.length > 0 : path.startsWith(cmdPath + " ") || path === cmdPath) {
908
+ if (isLeafCommand(pathInfo) || !leafOnly) result.push(path);
909
+ }
910
+ }
911
+ return result;
912
+ }
913
+ /**
914
+ * Render a single category section
915
+ */
916
+ function renderCategory(category, allCommands, headingLevel, leafOnly) {
917
+ const h = "#".repeat(headingLevel);
918
+ const lines = [];
919
+ lines.push(`${h} [${category.title}](${category.docPath})`);
920
+ lines.push("");
921
+ lines.push(category.description);
922
+ lines.push("");
923
+ const commandPaths = expandCommands(category.commands, allCommands, leafOnly);
924
+ lines.push("| Command | Description |");
925
+ lines.push("|---------|-------------|");
926
+ for (const cmdPath of commandPaths) {
927
+ const info = allCommands.get(cmdPath);
928
+ if (!info) continue;
929
+ if (leafOnly && !isLeafCommand(info)) continue;
930
+ const displayName = cmdPath || info.name;
931
+ const anchor = generateAnchor(displayName);
932
+ const desc = escapeTableCell(info.description ?? "");
933
+ lines.push(`| [${displayName}](${category.docPath}#${anchor}) | ${desc} |`);
934
+ }
935
+ return lines.join("\n");
936
+ }
937
+ /**
938
+ * Render command index from categories
939
+ *
940
+ * Generates a category-based index of commands with links to documentation.
941
+ *
942
+ * @example
943
+ * const categories: CommandCategory[] = [
944
+ * {
945
+ * title: "Application Commands",
946
+ * description: "Commands for managing applications.",
947
+ * commands: ["init", "generate", "apply"],
948
+ * docPath: "./cli/application.md",
949
+ * },
950
+ * ];
951
+ *
952
+ * const index = await renderCommandIndex(mainCommand, categories);
953
+ * // ### [Application Commands](./cli/application.md)
954
+ * //
955
+ * // Commands for managing applications.
956
+ * //
957
+ * // | Command | Description |
958
+ * // |---------|-------------|
959
+ * // | [init](./cli/application.md#init) | Initialize a project |
960
+ * // ...
961
+ *
962
+ * @param command - Root command to extract command information from
963
+ * @param categories - Category definitions for grouping commands
964
+ * @param options - Rendering options
965
+ * @returns Rendered markdown string
966
+ */
967
+ async function renderCommandIndex(command, categories, options) {
968
+ const headingLevel = options?.headingLevel ?? 3;
969
+ const leafOnly = options?.leafOnly ?? true;
970
+ const allCommands = await collectAllCommands(command);
971
+ const sections = [];
972
+ for (const category of categories) {
973
+ const section = renderCategory(category, allCommands, headingLevel, leafOnly);
974
+ sections.push(section);
975
+ }
976
+ return sections.join("\n\n");
977
+ }
978
+
752
979
  //#endregion
753
980
  //#region src/docs/types.ts
754
981
  /**
@@ -772,6 +999,40 @@ function commandStartMarker(commandPath) {
772
999
  function commandEndMarker(commandPath) {
773
1000
  return `<!-- ${COMMAND_MARKER_PREFIX}:${commandPath}:end -->`;
774
1001
  }
1002
+ /**
1003
+ * Marker prefix for global options sections in generated documentation
1004
+ * Format: <!-- politty:global-options:start --> ... <!-- politty:global-options:end -->
1005
+ */
1006
+ const GLOBAL_OPTIONS_MARKER_PREFIX = "politty:global-options";
1007
+ /**
1008
+ * Generate start marker for a global options section
1009
+ */
1010
+ function globalOptionsStartMarker() {
1011
+ return `<!-- ${GLOBAL_OPTIONS_MARKER_PREFIX}:start -->`;
1012
+ }
1013
+ /**
1014
+ * Generate end marker for a global options section
1015
+ */
1016
+ function globalOptionsEndMarker() {
1017
+ return `<!-- ${GLOBAL_OPTIONS_MARKER_PREFIX}:end -->`;
1018
+ }
1019
+ /**
1020
+ * Marker prefix for index sections in generated documentation
1021
+ * Format: <!-- politty:index:start --> ... <!-- politty:index:end -->
1022
+ */
1023
+ const INDEX_MARKER_PREFIX = "politty:index";
1024
+ /**
1025
+ * Generate start marker for an index section
1026
+ */
1027
+ function indexStartMarker() {
1028
+ return `<!-- ${INDEX_MARKER_PREFIX}:start -->`;
1029
+ }
1030
+ /**
1031
+ * Generate end marker for an index section
1032
+ */
1033
+ function indexEndMarker() {
1034
+ return `<!-- ${INDEX_MARKER_PREFIX}:end -->`;
1035
+ }
775
1036
 
776
1037
  //#endregion
777
1038
  //#region src/docs/golden-test.ts
@@ -781,7 +1042,9 @@ function commandEndMarker(commandPath) {
781
1042
  */
782
1043
  async function applyFormatter(content, formatter) {
783
1044
  if (!formatter) return content;
784
- return await formatter(content);
1045
+ const formatted = await formatter(content);
1046
+ if (!content.endsWith("\n") && formatted.endsWith("\n")) return formatted.slice(0, -1);
1047
+ return formatted;
785
1048
  }
786
1049
  /**
787
1050
  * Check if update mode is enabled via environment variable
@@ -795,6 +1058,7 @@ function isUpdateMode() {
795
1058
  */
796
1059
  function normalizeFileConfig(config) {
797
1060
  if (Array.isArray(config)) return { commands: config };
1061
+ if (!("commands" in config) || !Array.isArray(config.commands)) throw new Error("Invalid file config: object form must include a \"commands\" array. Use [] to skip generation intentionally.");
798
1062
  return config;
799
1063
  }
800
1064
  /**
@@ -881,6 +1145,31 @@ function filterIgnoredCommands(commandPaths, ignores) {
881
1145
  });
882
1146
  }
883
1147
  /**
1148
+ * Resolve wildcards to direct matches without subcommand expansion.
1149
+ * Returns the "top-level" commands for use in CommandCategory.commands,
1150
+ * where expandCommands in render-index handles subcommand expansion.
1151
+ */
1152
+ function resolveTopLevelCommands(specifiedCommands, allCommands) {
1153
+ const result = [];
1154
+ for (const cmdPath of specifiedCommands) if (containsWildcard(cmdPath)) result.push(...expandWildcardPattern(cmdPath, allCommands));
1155
+ else if (allCommands.has(cmdPath)) result.push(cmdPath);
1156
+ return result;
1157
+ }
1158
+ /**
1159
+ * Resolve file command configuration to concrete command paths.
1160
+ * This applies wildcard/subcommand expansion and ignore filtering.
1161
+ */
1162
+ function resolveConfiguredCommandPaths(fileConfigRaw, allCommands, ignores) {
1163
+ const fileConfig = normalizeFileConfig(fileConfigRaw);
1164
+ const specifiedCommands = fileConfig.commands;
1165
+ return {
1166
+ fileConfig,
1167
+ specifiedCommands,
1168
+ commandPaths: filterIgnoredCommands(expandCommandPaths(specifiedCommands, allCommands), ignores),
1169
+ topLevelCommands: filterIgnoredCommands(resolveTopLevelCommands(specifiedCommands, allCommands), ignores)
1170
+ };
1171
+ }
1172
+ /**
884
1173
  * Validate that there are no conflicts between files and ignores (with wildcard support)
885
1174
  */
886
1175
  function validateNoConflicts(filesCommands, ignores, allCommands) {
@@ -928,13 +1217,13 @@ function sortDepthFirst(commandPaths, specifiedOrder) {
928
1217
  for (const path of commandPaths) if (!visited.has(path)) result.push(path);
929
1218
  return result;
930
1219
  }
931
- /**
932
- * Generate file header from FileConfig
933
- */
934
1220
  function generateFileHeader(fileConfig) {
935
1221
  if (!fileConfig.title && !fileConfig.description) return null;
936
1222
  const parts = [];
937
- if (fileConfig.title) parts.push(`# ${fileConfig.title}`);
1223
+ if (fileConfig.title) {
1224
+ const heading = "#".repeat(fileConfig.headingLevel ?? 1);
1225
+ parts.push(`${heading} ${fileConfig.title}`);
1226
+ }
938
1227
  if (fileConfig.description) {
939
1228
  parts.push("");
940
1229
  parts.push(fileConfig.description);
@@ -943,6 +1232,51 @@ function generateFileHeader(fileConfig) {
943
1232
  return parts.join("\n");
944
1233
  }
945
1234
  /**
1235
+ * Extract a leading file header (title and optional description paragraph)
1236
+ */
1237
+ function extractFileHeader(content) {
1238
+ if (!/^#{1,6} /.test(content)) return null;
1239
+ const titleEnd = content.indexOf("\n");
1240
+ if (titleEnd === -1) return content;
1241
+ let cursor = titleEnd + 1;
1242
+ if (content[cursor] === "\n") cursor += 1;
1243
+ while (cursor < content.length) {
1244
+ const lineEnd = content.indexOf("\n", cursor);
1245
+ const line = lineEnd === -1 ? content.slice(cursor) : content.slice(cursor, lineEnd);
1246
+ if (line.length === 0 || /^#{1,6}\s/.test(line) || line.startsWith("<!-- politty:")) break;
1247
+ cursor = lineEnd === -1 ? content.length : lineEnd + 1;
1248
+ }
1249
+ return content.slice(0, cursor);
1250
+ }
1251
+ /**
1252
+ * Validate and optionally update configured file header
1253
+ */
1254
+ function processFileHeader(existingContent, fileConfig, updateMode) {
1255
+ const generatedHeader = generateFileHeader(fileConfig);
1256
+ if (!generatedHeader) return {
1257
+ content: existingContent,
1258
+ hasError: false,
1259
+ wasUpdated: false
1260
+ };
1261
+ if (existingContent.startsWith(generatedHeader)) return {
1262
+ content: existingContent,
1263
+ hasError: false,
1264
+ wasUpdated: false
1265
+ };
1266
+ const existingHeader = extractFileHeader(existingContent) ?? "";
1267
+ if (!updateMode) return {
1268
+ content: existingContent,
1269
+ diff: formatDiff(existingHeader, generatedHeader),
1270
+ hasError: true,
1271
+ wasUpdated: false
1272
+ };
1273
+ return {
1274
+ content: `${generatedHeader}${(existingHeader ? existingContent.slice(existingHeader.length) : existingContent).replace(/^\n+/, "")}`,
1275
+ hasError: false,
1276
+ wasUpdated: true
1277
+ };
1278
+ }
1279
+ /**
946
1280
  * Extract a command section from content using markers
947
1281
  * Returns the content between start and end markers (including markers)
948
1282
  */
@@ -956,6 +1290,18 @@ function extractCommandSection(content, commandPath) {
956
1290
  return content.slice(startIndex, endIndex + endMarker.length);
957
1291
  }
958
1292
  /**
1293
+ * Collect command paths from command start markers in content.
1294
+ */
1295
+ function collectCommandMarkerPaths(content) {
1296
+ const markerPattern = /<!--\s*politty:command:(.*?):start\s*-->/g;
1297
+ const paths = [];
1298
+ for (const match of content.matchAll(markerPattern)) paths.push(match[1] ?? "");
1299
+ return paths;
1300
+ }
1301
+ function formatCommandPath(commandPath) {
1302
+ return commandPath === "" ? "<root>" : commandPath;
1303
+ }
1304
+ /**
959
1305
  * Replace a command section in content using markers
960
1306
  * Returns the updated content with the new section
961
1307
  */
@@ -987,25 +1333,291 @@ function insertCommandSection(content, commandPath, newSection, specifiedOrder)
987
1333
  return content.slice(0, insertPos) + newSection + "\n" + content.slice(nextIndex);
988
1334
  }
989
1335
  }
990
- for (let i = targetIndex - 1; i >= 0; i--) {
991
- const prevCmd = specifiedOrder[i];
992
- if (prevCmd === void 0) continue;
993
- const prevEndMarker = commandEndMarker(prevCmd);
994
- const prevEndIndex = content.indexOf(prevEndMarker);
995
- if (prevEndIndex !== -1) {
996
- const insertPos = prevEndIndex + prevEndMarker.length;
997
- return content.slice(0, insertPos) + "\n" + newSection + content.slice(insertPos);
1336
+ for (let i = targetIndex - 1; i >= 0; i--) {
1337
+ const prevCmd = specifiedOrder[i];
1338
+ if (prevCmd === void 0) continue;
1339
+ const prevEndMarker = commandEndMarker(prevCmd);
1340
+ const prevEndIndex = content.indexOf(prevEndMarker);
1341
+ if (prevEndIndex !== -1) {
1342
+ const insertPos = prevEndIndex + prevEndMarker.length;
1343
+ return content.slice(0, insertPos) + "\n" + newSection + content.slice(insertPos);
1344
+ }
1345
+ }
1346
+ return content.trimEnd() + "\n" + newSection + "\n";
1347
+ }
1348
+ /**
1349
+ * Extract a marker section from content
1350
+ * Returns the content between start and end markers (including markers)
1351
+ */
1352
+ function extractMarkerSection(content, startMarker, endMarker) {
1353
+ const startIndex = content.indexOf(startMarker);
1354
+ if (startIndex === -1) return null;
1355
+ const endIndex = content.indexOf(endMarker, startIndex);
1356
+ if (endIndex === -1) return null;
1357
+ return content.slice(startIndex, endIndex + endMarker.length);
1358
+ }
1359
+ /**
1360
+ * Replace a marker section in content
1361
+ * Returns the updated content with the new section
1362
+ */
1363
+ function replaceMarkerSection(content, startMarker, endMarker, newSection) {
1364
+ const startIndex = content.indexOf(startMarker);
1365
+ if (startIndex === -1) return null;
1366
+ const endIndex = content.indexOf(endMarker, startIndex);
1367
+ if (endIndex === -1) return null;
1368
+ return content.slice(0, startIndex) + newSection + content.slice(endIndex + endMarker.length);
1369
+ }
1370
+ /**
1371
+ * Check if config is the { args, options? } shape (not shorthand ArgsShape)
1372
+ *
1373
+ * Distinguishes between:
1374
+ * - { args: ArgsShape, options?: ArgsTableOptions } → returns true
1375
+ * - ArgsShape (e.g., { verbose: ZodType, args: ZodType }) → returns false
1376
+ *
1377
+ * The key insight is that in the { args, options? } shape, config.args is an ArgsShape
1378
+ * (Record of ZodTypes), while in shorthand, config itself is the ArgsShape and config.args
1379
+ * would be a single ZodType if user has an option named "args".
1380
+ */
1381
+ function isGlobalOptionsConfigWithOptions(config) {
1382
+ if (typeof config !== "object" || config === null || !("args" in config)) return false;
1383
+ return !(config.args instanceof zod.z.ZodType);
1384
+ }
1385
+ /**
1386
+ * Collect option fields that are actually rendered by global options markers.
1387
+ * Positional args are not rendered in args tables, so they must not be excluded.
1388
+ */
1389
+ function collectRenderableGlobalOptionFields(argsShape) {
1390
+ return require_schema_extractor.extractFields(zod.z.object(argsShape)).fields.filter((field) => !field.positional);
1391
+ }
1392
+ /**
1393
+ * Compare option definitions for global-options compatibility.
1394
+ */
1395
+ function areGlobalOptionsEquivalent(a, b) {
1396
+ const { schema: _aSchema, ...aRest } = a;
1397
+ const { schema: _bSchema, ...bRest } = b;
1398
+ return (0, node_util.isDeepStrictEqual)(aRest, bRest);
1399
+ }
1400
+ /**
1401
+ * Normalize rootDoc.globalOptions to { args, options? } form.
1402
+ */
1403
+ function normalizeGlobalOptions(config) {
1404
+ if (!config) return void 0;
1405
+ return isGlobalOptionsConfigWithOptions(config) ? config : { args: config };
1406
+ }
1407
+ /**
1408
+ * Collect global option definitions from rootDoc.
1409
+ * Global options are intentionally applied to all generated command sections.
1410
+ */
1411
+ function collectGlobalOptionDefinitions(rootDoc) {
1412
+ const globalOptions = /* @__PURE__ */ new Map();
1413
+ if (!rootDoc?.globalOptions) return globalOptions;
1414
+ const normalized = normalizeGlobalOptions(rootDoc.globalOptions);
1415
+ if (!normalized) return globalOptions;
1416
+ for (const field of collectRenderableGlobalOptionFields(normalized.args)) globalOptions.set(field.name, field);
1417
+ return globalOptions;
1418
+ }
1419
+ /**
1420
+ * Derive CommandCategory[] from files mapping.
1421
+ * Category title/description come from the first command in each file entry.
1422
+ */
1423
+ function deriveIndexFromFiles(files, rootDocPath, allCommands, ignores) {
1424
+ const categories = [];
1425
+ for (const [filePath, fileConfigRaw] of Object.entries(files)) {
1426
+ const { commandPaths, topLevelCommands } = resolveConfiguredCommandPaths(fileConfigRaw, allCommands, ignores);
1427
+ if (commandPaths.length === 0) continue;
1428
+ const docPath = "./" + node_path.relative(node_path.dirname(rootDocPath), filePath).replace(/\\/g, "/");
1429
+ const firstCmdPath = commandPaths[0];
1430
+ const cmdInfo = firstCmdPath !== void 0 ? allCommands.get(firstCmdPath) : void 0;
1431
+ categories.push({
1432
+ title: cmdInfo?.name ?? node_path.basename(filePath, node_path.extname(filePath)),
1433
+ description: cmdInfo?.description ?? "",
1434
+ commands: topLevelCommands,
1435
+ docPath
1436
+ });
1437
+ }
1438
+ return categories;
1439
+ }
1440
+ /**
1441
+ * Collect command paths that are actually documented in configured files.
1442
+ */
1443
+ function collectDocumentedCommandPaths(files, allCommands, ignores) {
1444
+ const documentedCommandPaths = /* @__PURE__ */ new Set();
1445
+ for (const fileConfigRaw of Object.values(files)) {
1446
+ const { commandPaths } = resolveConfiguredCommandPaths(fileConfigRaw, allCommands, ignores);
1447
+ for (const commandPath of commandPaths) documentedCommandPaths.add(commandPath);
1448
+ }
1449
+ return documentedCommandPaths;
1450
+ }
1451
+ /**
1452
+ * Collect command paths that are targeted in configured files.
1453
+ */
1454
+ function collectTargetDocumentedCommandPaths(targetCommands, files, allCommands, ignores) {
1455
+ const documentedTargetCommandPaths = /* @__PURE__ */ new Set();
1456
+ for (const filePath of Object.keys(files)) {
1457
+ const targetCommandsInFile = findTargetCommandsInFile(targetCommands, filePath, files, allCommands, ignores);
1458
+ for (const commandPath of targetCommandsInFile) documentedTargetCommandPaths.add(commandPath);
1459
+ }
1460
+ return documentedTargetCommandPaths;
1461
+ }
1462
+ /**
1463
+ * Validate that excluded command options match globalOptions definitions.
1464
+ */
1465
+ function validateGlobalOptionCompatibility(documentedCommandPaths, allCommands, globalOptions) {
1466
+ if (globalOptions.size === 0) return;
1467
+ const conflicts = [];
1468
+ for (const commandPath of documentedCommandPaths) {
1469
+ const info = allCommands.get(commandPath);
1470
+ if (!info) continue;
1471
+ for (const option of info.options) {
1472
+ const globalOption = globalOptions.get(option.name);
1473
+ if (!globalOption) continue;
1474
+ if (!areGlobalOptionsEquivalent(globalOption, option)) conflicts.push(`Command "${formatCommandPath(commandPath)}" option "--${option.cliName}" does not match globalOptions definition for "${option.name}".`);
1475
+ }
1476
+ }
1477
+ if (conflicts.length > 0) throw new Error(`Invalid globalOptions configuration:\n - ${conflicts.join("\n - ")}`);
1478
+ }
1479
+ /**
1480
+ * Generate global options section content with markers
1481
+ */
1482
+ function generateGlobalOptionsSection(config) {
1483
+ const startMarker = globalOptionsStartMarker();
1484
+ const endMarker = globalOptionsEndMarker();
1485
+ return [
1486
+ startMarker,
1487
+ renderArgsTable(config.args, config.options),
1488
+ endMarker
1489
+ ].join("\n");
1490
+ }
1491
+ /**
1492
+ * Generate index section content with markers
1493
+ */
1494
+ async function generateIndexSection(categories, command, options) {
1495
+ const startMarker = indexStartMarker();
1496
+ const endMarker = indexEndMarker();
1497
+ return [
1498
+ startMarker,
1499
+ await renderCommandIndex(command, categories, options),
1500
+ endMarker
1501
+ ].join("\n");
1502
+ }
1503
+ /**
1504
+ * Normalize a doc file path for equivalence checks.
1505
+ */
1506
+ function normalizeDocPathForComparison(filePath) {
1507
+ return node_path.resolve(filePath);
1508
+ }
1509
+ /**
1510
+ * Process global options marker in file content
1511
+ * Returns result with updated content and any diffs
1512
+ */
1513
+ async function processGlobalOptionsMarker(existingContent, globalOptionsConfig, updateMode, formatter) {
1514
+ let content = existingContent;
1515
+ const diffs = [];
1516
+ let hasError = false;
1517
+ let wasUpdated = false;
1518
+ const startMarker = globalOptionsStartMarker();
1519
+ const endMarker = globalOptionsEndMarker();
1520
+ const generatedSection = await applyFormatter(generateGlobalOptionsSection(globalOptionsConfig), formatter);
1521
+ const existingSection = extractMarkerSection(content, startMarker, endMarker);
1522
+ if (!existingSection) {
1523
+ hasError = true;
1524
+ diffs.push(`Global options marker not found in file. Expected markers:\n${startMarker}\n...\n${endMarker}`);
1525
+ return {
1526
+ content,
1527
+ diffs,
1528
+ hasError,
1529
+ wasUpdated
1530
+ };
1531
+ }
1532
+ if (existingSection !== generatedSection) if (updateMode) {
1533
+ const updated = replaceMarkerSection(content, startMarker, endMarker, generatedSection);
1534
+ if (updated) {
1535
+ content = updated;
1536
+ wasUpdated = true;
1537
+ } else {
1538
+ hasError = true;
1539
+ diffs.push("Failed to replace global options section");
1540
+ }
1541
+ } else {
1542
+ hasError = true;
1543
+ diffs.push(formatDiff(existingSection, generatedSection));
1544
+ }
1545
+ return {
1546
+ content,
1547
+ diffs,
1548
+ hasError,
1549
+ wasUpdated
1550
+ };
1551
+ }
1552
+ /**
1553
+ * Process index marker in file content
1554
+ * Returns result with updated content and any diffs.
1555
+ * If the marker is not present in the file, the section is silently skipped.
1556
+ */
1557
+ async function processIndexMarker(existingContent, categories, command, updateMode, formatter, indexOptions) {
1558
+ let content = existingContent;
1559
+ const diffs = [];
1560
+ let hasError = false;
1561
+ let wasUpdated = false;
1562
+ const startMarker = indexStartMarker();
1563
+ const endMarker = indexEndMarker();
1564
+ const hasStartMarker = content.includes(startMarker);
1565
+ const hasEndMarker = content.includes(endMarker);
1566
+ if (!hasStartMarker && !hasEndMarker) return {
1567
+ content,
1568
+ diffs,
1569
+ hasError,
1570
+ wasUpdated
1571
+ };
1572
+ if (!hasStartMarker || !hasEndMarker) {
1573
+ hasError = true;
1574
+ diffs.push("Index marker section is malformed: both start and end markers are required.");
1575
+ return {
1576
+ content,
1577
+ diffs,
1578
+ hasError,
1579
+ wasUpdated
1580
+ };
1581
+ }
1582
+ const existingSection = extractMarkerSection(content, startMarker, endMarker);
1583
+ if (!existingSection) {
1584
+ hasError = true;
1585
+ diffs.push("Index marker section is malformed: start marker must appear before end marker.");
1586
+ return {
1587
+ content,
1588
+ diffs,
1589
+ hasError,
1590
+ wasUpdated
1591
+ };
1592
+ }
1593
+ const generatedSection = await applyFormatter(await generateIndexSection(categories, command, indexOptions), formatter);
1594
+ if (existingSection !== generatedSection) if (updateMode) {
1595
+ const updated = replaceMarkerSection(content, startMarker, endMarker, generatedSection);
1596
+ if (updated) {
1597
+ content = updated;
1598
+ wasUpdated = true;
1599
+ } else {
1600
+ hasError = true;
1601
+ diffs.push("Failed to replace index section");
998
1602
  }
1603
+ } else {
1604
+ hasError = true;
1605
+ diffs.push(formatDiff(existingSection, generatedSection));
999
1606
  }
1000
- return content.trimEnd() + "\n" + newSection + "\n";
1607
+ return {
1608
+ content,
1609
+ diffs,
1610
+ hasError,
1611
+ wasUpdated
1612
+ };
1001
1613
  }
1002
1614
  /**
1003
1615
  * Find which file contains a specific command
1004
1616
  */
1005
1617
  function findFileForCommand(commandPath, files, allCommands, ignores) {
1006
1618
  for (const [filePath, fileConfigRaw] of Object.entries(files)) {
1007
- const specifiedCommands = normalizeFileConfig(fileConfigRaw).commands;
1008
- if (filterIgnoredCommands(expandCommandPaths(specifiedCommands, allCommands), ignores).includes(commandPath)) return filePath;
1619
+ const { commandPaths } = resolveConfiguredCommandPaths(fileConfigRaw, allCommands, ignores);
1620
+ if (commandPaths.includes(commandPath)) return filePath;
1009
1621
  }
1010
1622
  return null;
1011
1623
  }
@@ -1016,8 +1628,7 @@ function findFileForCommand(commandPath, files, allCommands, ignores) {
1016
1628
  function findTargetCommandsInFile(targetCommands, filePath, files, allCommands, ignores) {
1017
1629
  const fileConfigRaw = files[filePath];
1018
1630
  if (!fileConfigRaw) return [];
1019
- const specifiedCommands = normalizeFileConfig(fileConfigRaw).commands;
1020
- const commandPaths = filterIgnoredCommands(expandCommandPaths(specifiedCommands, allCommands), ignores);
1631
+ const { specifiedCommands, commandPaths } = resolveConfiguredCommandPaths(fileConfigRaw, allCommands, ignores);
1021
1632
  const expandedTargets = /* @__PURE__ */ new Set();
1022
1633
  for (const targetCmd of targetCommands) {
1023
1634
  if (!commandPaths.includes(targetCmd)) continue;
@@ -1056,7 +1667,7 @@ function generateFileMarkdown(commandPaths, allCommands, render, filePath, fileM
1056
1667
  const section = generateCommandSection(cmdPath, allCommands, render, filePath, fileMap);
1057
1668
  if (section) sections.push(section);
1058
1669
  }
1059
- return sections.join("\n");
1670
+ return `${sections.join("\n")}\n`;
1060
1671
  }
1061
1672
  /**
1062
1673
  * Build a map of command path to file path
@@ -1064,8 +1675,7 @@ function generateFileMarkdown(commandPaths, allCommands, render, filePath, fileM
1064
1675
  function buildFileMap(files, allCommands, ignores) {
1065
1676
  const fileMap = {};
1066
1677
  for (const [filePath, fileConfigRaw] of Object.entries(files)) {
1067
- const specifiedCommands = normalizeFileConfig(fileConfigRaw).commands;
1068
- const commandPaths = filterIgnoredCommands(expandCommandPaths(specifiedCommands, allCommands), ignores);
1678
+ const { commandPaths } = resolveConfiguredCommandPaths(fileConfigRaw, allCommands, ignores);
1069
1679
  for (const cmdPath of commandPaths) fileMap[cmdPath] = filePath;
1070
1680
  }
1071
1681
  return fileMap;
@@ -1086,10 +1696,21 @@ async function executeConfiguredExamples(allCommands, examplesConfig, rootComman
1086
1696
  * Generate documentation from command definition
1087
1697
  */
1088
1698
  async function generateDoc(config) {
1089
- const { command, files, ignores = [], format = {}, formatter, examples: examplesConfig, targetCommands } = config;
1699
+ const { command, rootDoc, files, ignores = [], format = {}, formatter, examples: examplesConfig, targetCommands } = config;
1090
1700
  const updateMode = isUpdateMode();
1701
+ if (rootDoc) {
1702
+ const normalizedRootDocPath = normalizeDocPathForComparison(rootDoc.path);
1703
+ if (Object.keys(files).some((filePath) => normalizeDocPathForComparison(filePath) === normalizedRootDocPath)) throw new Error(`rootDoc.path "${rootDoc.path}" must not also appear as a key in files.`);
1704
+ }
1091
1705
  const allCommands = await collectAllCommands(command);
1092
1706
  if (examplesConfig) await executeConfiguredExamples(allCommands, examplesConfig, command);
1707
+ const hasTargetCommands = targetCommands !== void 0 && targetCommands.length > 0;
1708
+ if (hasTargetCommands) {
1709
+ for (const targetCommand of targetCommands) if (!findFileForCommand(targetCommand, files, allCommands, ignores)) throw new Error(`Target command "${targetCommand}" not found in any file configuration`);
1710
+ }
1711
+ const globalOptionDefinitions = collectGlobalOptionDefinitions(rootDoc);
1712
+ validateGlobalOptionCompatibility(hasTargetCommands ? collectTargetDocumentedCommandPaths(targetCommands, files, allCommands, ignores) : collectDocumentedCommandPaths(files, allCommands, ignores), allCommands, globalOptionDefinitions);
1713
+ if (globalOptionDefinitions.size > 0) for (const info of allCommands.values()) info.options = info.options.filter((opt) => !globalOptionDefinitions.has(opt.name));
1093
1714
  const allFilesCommands = [];
1094
1715
  for (const fileConfigRaw of Object.values(files)) {
1095
1716
  const fileConfig = normalizeFileConfig(fileConfigRaw);
@@ -1100,15 +1721,14 @@ async function generateDoc(config) {
1100
1721
  const fileMap = buildFileMap(files, allCommands, ignores);
1101
1722
  const results = [];
1102
1723
  let hasError = false;
1103
- if (targetCommands && targetCommands.length > 0) {
1104
- for (const targetCommand of targetCommands) if (!findFileForCommand(targetCommand, files, allCommands, ignores)) throw new Error(`Target command "${targetCommand}" not found in any file configuration`);
1105
- }
1106
1724
  for (const [filePath, fileConfigRaw] of Object.entries(files)) {
1107
- const fileConfig = normalizeFileConfig(fileConfigRaw);
1108
- const specifiedCommands = fileConfig.commands;
1725
+ const { fileConfig, specifiedCommands, commandPaths } = resolveConfiguredCommandPaths(fileConfigRaw, allCommands, ignores);
1109
1726
  if (specifiedCommands.length === 0) continue;
1110
- const commandPaths = filterIgnoredCommands(expandCommandPaths(specifiedCommands, allCommands), ignores);
1111
1727
  if (commandPaths.length === 0) continue;
1728
+ const fileTargetCommands = hasTargetCommands ? findTargetCommandsInFile(targetCommands, filePath, files, allCommands, ignores) : [];
1729
+ if (hasTargetCommands && fileTargetCommands.length === 0) continue;
1730
+ let fileStatus = "match";
1731
+ const diffs = [];
1112
1732
  const minDepth = Math.min(...commandPaths.map((p) => allCommands.get(p)?.depth ?? 1));
1113
1733
  const adjustedHeadingLevel = Math.max(1, (format?.headingLevel ?? 1) - (minDepth - 1));
1114
1734
  const fileRenderer = createCommandRenderer({
@@ -1116,12 +1736,8 @@ async function generateDoc(config) {
1116
1736
  headingLevel: adjustedHeadingLevel
1117
1737
  });
1118
1738
  const render = fileConfig.render ?? fileRenderer;
1119
- if (targetCommands !== void 0 && targetCommands.length > 0) {
1120
- const fileTargetCommands = findTargetCommandsInFile(targetCommands, filePath, files, allCommands, ignores);
1121
- if (fileTargetCommands.length === 0) continue;
1739
+ if (hasTargetCommands) {
1122
1740
  let existingContent = readFile(filePath);
1123
- let fileStatus = "match";
1124
- const diffs = [];
1125
1741
  for (const targetCommand of fileTargetCommands) {
1126
1742
  const rawSection = generateCommandSection(targetCommand, allCommands, render, filePath, fileMap);
1127
1743
  if (!rawSection) throw new Error(`Target command "${targetCommand}" not found in commands`);
@@ -1167,33 +1783,75 @@ async function generateDoc(config) {
1167
1783
  diffs.push(formatDiff(existingSection, generatedSectionOnly));
1168
1784
  }
1169
1785
  }
1170
- results.push({
1171
- path: filePath,
1172
- status: fileStatus,
1173
- diff: diffs.length > 0 ? diffs.join("\n\n") : void 0
1174
- });
1175
1786
  } else {
1176
1787
  const generatedMarkdown = await applyFormatter(generateFileMarkdown(commandPaths, allCommands, render, filePath, fileMap, specifiedCommands, fileConfig), formatter);
1177
1788
  const comparison = compareWithExisting(generatedMarkdown, filePath);
1178
- if (comparison.match) results.push({
1179
- path: filePath,
1180
- status: "match"
1181
- });
1182
- else if (updateMode) {
1789
+ if (comparison.match) {} else if (updateMode) {
1183
1790
  writeFile(filePath, generatedMarkdown);
1184
- results.push({
1185
- path: filePath,
1186
- status: comparison.fileExists ? "updated" : "created"
1187
- });
1791
+ fileStatus = comparison.fileExists ? "updated" : "created";
1188
1792
  } else {
1189
1793
  hasError = true;
1190
- results.push({
1191
- path: filePath,
1192
- status: "diff",
1193
- diff: comparison.diff
1194
- });
1794
+ fileStatus = "diff";
1795
+ if (comparison.diff) diffs.push(comparison.diff);
1796
+ }
1797
+ }
1798
+ if (diffs.length > 0) fileStatus = "diff";
1799
+ results.push({
1800
+ path: filePath,
1801
+ status: fileStatus,
1802
+ diff: diffs.length > 0 ? diffs.join("\n\n") : void 0
1803
+ });
1804
+ }
1805
+ if (rootDoc) {
1806
+ const rootDocFilePath = rootDoc.path;
1807
+ let rootDocStatus = "match";
1808
+ const rootDocDiffs = [];
1809
+ const existingContent = readFile(rootDocFilePath);
1810
+ if (existingContent === null) {
1811
+ hasError = true;
1812
+ rootDocStatus = "diff";
1813
+ rootDocDiffs.push("File does not exist. Cannot validate rootDoc markers.");
1814
+ } else {
1815
+ let content = existingContent;
1816
+ let markerUpdated = false;
1817
+ const rootDocFileConfig = { title: command.name };
1818
+ if (rootDoc.headingLevel !== void 0) rootDocFileConfig.headingLevel = rootDoc.headingLevel;
1819
+ if (command.description !== void 0) rootDocFileConfig.description = command.description;
1820
+ const headerResult = processFileHeader(content, rootDocFileConfig, updateMode);
1821
+ content = headerResult.content;
1822
+ if (headerResult.diff) rootDocDiffs.push(headerResult.diff);
1823
+ if (headerResult.hasError) hasError = true;
1824
+ if (headerResult.wasUpdated) markerUpdated = true;
1825
+ const unexpectedCommandMarkers = Array.from(new Set(collectCommandMarkerPaths(content)));
1826
+ if (unexpectedCommandMarkers.length > 0) {
1827
+ hasError = true;
1828
+ rootDocDiffs.push(`Found unexpected command marker sections in rootDoc: ${unexpectedCommandMarkers.map((commandPath) => `"${formatCommandPath(commandPath)}"`).join(", ")}.`);
1829
+ }
1830
+ const normalizedGlobalOptions = normalizeGlobalOptions(rootDoc.globalOptions);
1831
+ if (normalizedGlobalOptions) {
1832
+ const globalOptionsResult = await processGlobalOptionsMarker(content, normalizedGlobalOptions, updateMode, formatter);
1833
+ content = globalOptionsResult.content;
1834
+ rootDocDiffs.push(...globalOptionsResult.diffs);
1835
+ if (globalOptionsResult.hasError) hasError = true;
1836
+ if (globalOptionsResult.wasUpdated) markerUpdated = true;
1837
+ }
1838
+ const derivedCategories = deriveIndexFromFiles(files, rootDocFilePath, allCommands, ignores);
1839
+ const indexResult = await processIndexMarker(content, derivedCategories, command, updateMode, formatter, rootDoc.index);
1840
+ content = indexResult.content;
1841
+ rootDocDiffs.push(...indexResult.diffs);
1842
+ if (indexResult.hasError) hasError = true;
1843
+ if (indexResult.wasUpdated) markerUpdated = true;
1844
+ if (updateMode && markerUpdated) {
1845
+ writeFile(rootDocFilePath, content);
1846
+ if (rootDocStatus === "match") rootDocStatus = "updated";
1195
1847
  }
1196
1848
  }
1849
+ if (rootDocDiffs.length > 0) rootDocStatus = "diff";
1850
+ results.push({
1851
+ path: rootDocFilePath,
1852
+ status: rootDocStatus,
1853
+ diff: rootDocDiffs.length > 0 ? rootDocDiffs.join("\n\n") : void 0
1854
+ });
1197
1855
  }
1198
1856
  return {
1199
1857
  success: !hasError,
@@ -1229,234 +1887,10 @@ function initDocFile(config, fileSystem) {
1229
1887
  else for (const filePath of Object.keys(config.files)) deleteFile(filePath, fileSystem);
1230
1888
  }
1231
1889
 
1232
- //#endregion
1233
- //#region src/docs/render-args.ts
1234
- /**
1235
- * Extract ResolvedFieldMeta array from ArgsShape
1236
- *
1237
- * This converts a raw args shape (like `commonArgs`) into the
1238
- * ResolvedFieldMeta format used by politty's rendering functions.
1239
- */
1240
- function extractArgsFields(args) {
1241
- return require_schema_extractor.extractFields(zod.z.object(args)).fields;
1242
- }
1243
- /**
1244
- * Render args definition as a markdown options table
1245
- *
1246
- * This function takes raw args definitions (like `commonArgs`) and
1247
- * renders them as a markdown table suitable for documentation.
1248
- *
1249
- * @example
1250
- * import { renderArgsTable } from "politty/docs";
1251
- * import { commonArgs, workspaceArgs } from "./args";
1252
- *
1253
- * const table = renderArgsTable({
1254
- * ...commonArgs,
1255
- * ...workspaceArgs,
1256
- * });
1257
- * // | Option | Alias | Description | Default |
1258
- * // |--------|-------|-------------|---------|
1259
- * // | `--env-file <ENV_FILE>` | `-e` | Path to environment file | - |
1260
- * // ...
1261
- *
1262
- * @param args - Args shape (Record of string keys to Zod schemas with arg() metadata)
1263
- * @param options - Rendering options
1264
- * @returns Rendered markdown table string
1265
- */
1266
- function renderArgsTable(args, options) {
1267
- const optionFields = extractArgsFields(args).filter((f) => !f.positional);
1268
- if (optionFields.length === 0) return "";
1269
- if (options?.columns) return renderFilteredTable(optionFields, options.columns);
1270
- return renderOptionsTableFromArray(optionFields);
1271
- }
1272
- /**
1273
- * Escape markdown special characters in table cells
1274
- */
1275
- function escapeTableCell$1(str) {
1276
- return str.replace(/\|/g, "\\|").replace(/\n/g, " ");
1277
- }
1278
- /**
1279
- * Format default value for display
1280
- */
1281
- function formatDefaultValue(value) {
1282
- if (value === void 0) return "-";
1283
- return `\`${JSON.stringify(value)}\``;
1284
- }
1285
- /**
1286
- * Render table with filtered columns
1287
- */
1288
- function renderFilteredTable(options, columns) {
1289
- const lines = [];
1290
- const headerCells = [];
1291
- const separatorCells = [];
1292
- for (const col of columns) switch (col) {
1293
- case "option":
1294
- headerCells.push("Option");
1295
- separatorCells.push("------");
1296
- break;
1297
- case "alias":
1298
- headerCells.push("Alias");
1299
- separatorCells.push("-----");
1300
- break;
1301
- case "description":
1302
- headerCells.push("Description");
1303
- separatorCells.push("-----------");
1304
- break;
1305
- case "required":
1306
- headerCells.push("Required");
1307
- separatorCells.push("--------");
1308
- break;
1309
- case "default":
1310
- headerCells.push("Default");
1311
- separatorCells.push("-------");
1312
- break;
1313
- case "env":
1314
- headerCells.push("Env");
1315
- separatorCells.push("---");
1316
- break;
1317
- }
1318
- lines.push(`| ${headerCells.join(" | ")} |`);
1319
- lines.push(`| ${separatorCells.join(" | ")} |`);
1320
- for (const opt of options) {
1321
- const cells = [];
1322
- for (const col of columns) switch (col) {
1323
- case "option": {
1324
- const placeholder = opt.placeholder ?? opt.cliName.toUpperCase().replace(/-/g, "_");
1325
- const optionName = opt.type === "boolean" ? `\`--${opt.cliName}\`` : `\`--${opt.cliName} <${placeholder}>\``;
1326
- cells.push(optionName);
1327
- break;
1328
- }
1329
- case "alias":
1330
- cells.push(opt.alias ? `\`-${opt.alias}\`` : "-");
1331
- break;
1332
- case "description":
1333
- cells.push(escapeTableCell$1(opt.description ?? ""));
1334
- break;
1335
- case "required":
1336
- cells.push(opt.required ? "Yes" : "No");
1337
- break;
1338
- case "default":
1339
- cells.push(formatDefaultValue(opt.defaultValue));
1340
- break;
1341
- case "env": {
1342
- const envNames = opt.env ? Array.isArray(opt.env) ? opt.env.map((e) => `\`${e}\``).join(", ") : `\`${opt.env}\`` : "-";
1343
- cells.push(envNames);
1344
- break;
1345
- }
1346
- }
1347
- lines.push(`| ${cells.join(" | ")} |`);
1348
- }
1349
- return lines.join("\n");
1350
- }
1351
-
1352
- //#endregion
1353
- //#region src/docs/render-index.ts
1354
- /**
1355
- * Escape markdown special characters in table cells
1356
- */
1357
- function escapeTableCell(str) {
1358
- return str.replace(/\|/g, "\\|").replace(/\n/g, " ");
1359
- }
1360
- /**
1361
- * Generate anchor from command path
1362
- */
1363
- function generateAnchor(commandPath) {
1364
- return commandPath.replace(/\s+/g, "-").toLowerCase();
1365
- }
1366
- /**
1367
- * Check if a command is a leaf (has no subcommands)
1368
- */
1369
- function isLeafCommand(info) {
1370
- return info.subCommands.length === 0;
1371
- }
1372
- /**
1373
- * Expand commands to include their subcommands
1374
- * If a command has subcommands, recursively find all commands under it
1375
- *
1376
- * @param commandPaths - Command paths to expand
1377
- * @param allCommands - Map of all available commands
1378
- * @param leafOnly - If true, only include leaf commands; if false, include all commands
1379
- */
1380
- function expandCommands(commandPaths, allCommands, leafOnly) {
1381
- const result = [];
1382
- for (const cmdPath of commandPaths) {
1383
- const info = allCommands.get(cmdPath);
1384
- if (!info) continue;
1385
- if (isLeafCommand(info)) result.push(cmdPath);
1386
- else for (const [path, pathInfo] of allCommands) if (cmdPath === "" ? path.length > 0 : path.startsWith(cmdPath + " ") || path === cmdPath) {
1387
- if (isLeafCommand(pathInfo) || !leafOnly) result.push(path);
1388
- }
1389
- }
1390
- return result;
1391
- }
1392
- /**
1393
- * Render a single category section
1394
- */
1395
- function renderCategory(category, allCommands, headingLevel, leafOnly) {
1396
- const h = "#".repeat(headingLevel);
1397
- const lines = [];
1398
- lines.push(`${h} [${category.title}](${category.docPath})`);
1399
- lines.push("");
1400
- lines.push(category.description);
1401
- lines.push("");
1402
- const commandPaths = expandCommands(category.commands, allCommands, leafOnly);
1403
- lines.push("| Command | Description |");
1404
- lines.push("|---------|-------------|");
1405
- for (const cmdPath of commandPaths) {
1406
- const info = allCommands.get(cmdPath);
1407
- if (!info) continue;
1408
- if (leafOnly && !isLeafCommand(info)) continue;
1409
- const displayName = cmdPath || info.name;
1410
- const anchor = generateAnchor(displayName);
1411
- const desc = escapeTableCell(info.description ?? "");
1412
- lines.push(`| [${displayName}](${category.docPath}#${anchor}) | ${desc} |`);
1413
- }
1414
- return lines.join("\n");
1415
- }
1416
- /**
1417
- * Render command index from categories
1418
- *
1419
- * Generates a category-based index of commands with links to documentation.
1420
- *
1421
- * @example
1422
- * const categories: CommandCategory[] = [
1423
- * {
1424
- * title: "Application Commands",
1425
- * description: "Commands for managing applications.",
1426
- * commands: ["init", "generate", "apply"],
1427
- * docPath: "./cli/application.md",
1428
- * },
1429
- * ];
1430
- *
1431
- * const index = await renderCommandIndex(mainCommand, categories);
1432
- * // ### [Application Commands](./cli/application.md)
1433
- * //
1434
- * // Commands for managing applications.
1435
- * //
1436
- * // | Command | Description |
1437
- * // |---------|-------------|
1438
- * // | [init](./cli/application.md#init) | Initialize a project |
1439
- * // ...
1440
- *
1441
- * @param command - Root command to extract command information from
1442
- * @param categories - Category definitions for grouping commands
1443
- * @param options - Rendering options
1444
- * @returns Rendered markdown string
1445
- */
1446
- async function renderCommandIndex(command, categories, options) {
1447
- const headingLevel = options?.headingLevel ?? 3;
1448
- const leafOnly = options?.leafOnly ?? true;
1449
- const allCommands = await collectAllCommands(command);
1450
- const sections = [];
1451
- for (const category of categories) {
1452
- const section = renderCategory(category, allCommands, headingLevel, leafOnly);
1453
- sections.push(section);
1454
- }
1455
- return sections.join("\n\n");
1456
- }
1457
-
1458
1890
  //#endregion
1459
1891
  exports.COMMAND_MARKER_PREFIX = COMMAND_MARKER_PREFIX;
1892
+ exports.GLOBAL_OPTIONS_MARKER_PREFIX = GLOBAL_OPTIONS_MARKER_PREFIX;
1893
+ exports.INDEX_MARKER_PREFIX = INDEX_MARKER_PREFIX;
1460
1894
  exports.UPDATE_GOLDEN_ENV = UPDATE_GOLDEN_ENV;
1461
1895
  exports.__exportAll = __exportAll;
1462
1896
  exports.__toESM = __toESM;
@@ -1471,6 +1905,10 @@ exports.defaultRenderers = defaultRenderers;
1471
1905
  exports.executeExamples = executeExamples;
1472
1906
  exports.formatDiff = formatDiff;
1473
1907
  exports.generateDoc = generateDoc;
1908
+ exports.globalOptionsEndMarker = globalOptionsEndMarker;
1909
+ exports.globalOptionsStartMarker = globalOptionsStartMarker;
1910
+ exports.indexEndMarker = indexEndMarker;
1911
+ exports.indexStartMarker = indexStartMarker;
1474
1912
  exports.initDocFile = initDocFile;
1475
1913
  exports.renderArgsTable = renderArgsTable;
1476
1914
  exports.renderArgumentsList = renderArgumentsList;