politty 0.7.0 → 0.8.0

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.
@@ -487,8 +487,10 @@ function renderSubcommandsTableFromArray(subcommands, info, generateAnchors = tr
487
487
  let cmdCell;
488
488
  if (generateAnchors) {
489
489
  const anchor = generateAnchor$1(sub.fullPath);
490
- const subFile = fileMap?.[subCommandPath];
490
+ const hasSubFile = fileMap !== void 0 && Object.prototype.hasOwnProperty.call(fileMap, subCommandPath);
491
+ const subFile = hasSubFile ? fileMap[subCommandPath] : void 0;
491
492
  if (currentFile && subFile && currentFile !== subFile) cmdCell = `[\`${fullName}\`](${getRelativePath(currentFile, subFile)}#${anchor})`;
493
+ else if (fileMap && !hasSubFile) cmdCell = `\`${fullName}\``;
492
494
  else cmdCell = `[\`${fullName}\`](#${anchor})`;
493
495
  } else cmdCell = `\`${fullName}\``;
494
496
  if (hasAliases) lines.push(`| ${cmdCell} | ${aliasCell} | ${desc} |`);
@@ -551,14 +553,15 @@ function getGlobalOptionsLink(info) {
551
553
  return `See [Global Options](${info.rootDocPath && info.filePath && info.filePath !== info.rootDocPath ? `${getRelativePath(info.filePath, info.rootDocPath)}#global-options` : "#global-options"}) for options available to all commands.`;
552
554
  }
553
555
  function createCommandRenderer(options = {}) {
554
- const { headingLevel = 1, optionStyle = "table", generateAnchors = true, includeSubcommandDetails = true, renderDescription: customRenderDescription, renderUsage: customRenderUsage, renderArguments: customRenderArguments, renderOptions: customRenderOptions, renderSubcommands: customRenderSubcommands, renderNotes: customRenderNotes, renderFooter: customRenderFooter, renderExamples: customRenderExamples } = options;
556
+ const { headingLevel = 1, optionStyle = "table", generateAnchors = true, includeSubcommandDetails = true, markerless = false, renderDescription: customRenderDescription, renderUsage: customRenderUsage, renderArguments: customRenderArguments, renderOptions: customRenderOptions, renderSubcommands: customRenderSubcommands, renderNotes: customRenderNotes, renderFooter: customRenderFooter, renderExamples: customRenderExamples } = options;
557
+ const wrap = markerless ? (_type, _scope, content) => content : wrapWithMarker;
555
558
  return (info) => {
556
559
  const sections = [];
557
560
  const scope = info.commandPath;
558
561
  const effectiveLevel = Math.min(headingLevel + (info.depth - 1), 6);
559
562
  const h = "#".repeat(effectiveLevel);
560
563
  const title = info.commandPath || info.name;
561
- sections.push(wrapWithMarker("heading", scope, `${h} ${title}`));
564
+ sections.push(wrap("heading", scope, `${h} ${title}`));
562
565
  {
563
566
  const parts = [];
564
567
  if (info.description) parts.push(info.description);
@@ -570,7 +573,7 @@ function createCommandRenderer(options = {}) {
570
573
  info
571
574
  };
572
575
  const content = customRenderDescription ? customRenderDescription(context) : context.content;
573
- if (content) sections.push(wrapWithMarker("description", scope, content));
576
+ if (content) sections.push(wrap("description", scope, content));
574
577
  }
575
578
  }
576
579
  {
@@ -580,7 +583,7 @@ function createCommandRenderer(options = {}) {
580
583
  info
581
584
  };
582
585
  const content = customRenderUsage ? customRenderUsage(context) : context.content;
583
- if (content) sections.push(wrapWithMarker("usage", scope, content));
586
+ if (content) sections.push(wrap("usage", scope, content));
584
587
  }
585
588
  if (info.positionalArgs.length > 0) {
586
589
  const renderArgs = (args, opts) => {
@@ -596,7 +599,7 @@ function createCommandRenderer(options = {}) {
596
599
  info
597
600
  };
598
601
  const content = customRenderArguments ? customRenderArguments(context) : renderArgs(context.args);
599
- if (content) sections.push(wrapWithMarker("arguments", scope, content));
602
+ if (content) sections.push(wrap("arguments", scope, content));
600
603
  }
601
604
  if (info.options.length > 0) {
602
605
  const renderOpts = (opts, renderOpts) => {
@@ -616,11 +619,11 @@ function createCommandRenderer(options = {}) {
616
619
  info
617
620
  };
618
621
  const content = customRenderOptions ? customRenderOptions(context) : renderOpts(context.options);
619
- if (content) sections.push(wrapWithMarker("options", scope, content));
622
+ if (content) sections.push(wrap("options", scope, content));
620
623
  }
621
624
  {
622
625
  const globalLink = getGlobalOptionsLink(info);
623
- if (globalLink) sections.push(wrapWithMarker("global-options-link", scope, globalLink));
626
+ if (globalLink) sections.push(wrap("global-options-link", scope, globalLink));
624
627
  }
625
628
  if (info.subCommands.length > 0) {
626
629
  const effectiveAnchors = generateAnchors && includeSubcommandDetails;
@@ -637,7 +640,7 @@ function createCommandRenderer(options = {}) {
637
640
  info
638
641
  };
639
642
  const content = customRenderSubcommands ? customRenderSubcommands(context) : renderSubs(context.subcommands);
640
- if (content) sections.push(wrapWithMarker("subcommands", scope, content));
643
+ if (content) sections.push(wrap("subcommands", scope, content));
641
644
  }
642
645
  if (info.examples && info.examples.length > 0) {
643
646
  const renderEx = (examples, results, opts) => {
@@ -656,7 +659,7 @@ function createCommandRenderer(options = {}) {
656
659
  info
657
660
  };
658
661
  const content = customRenderExamples ? customRenderExamples(context) : renderEx(context.examples, context.results);
659
- if (content) sections.push(wrapWithMarker("examples", scope, content));
662
+ if (content) sections.push(wrap("examples", scope, content));
660
663
  }
661
664
  if (info.notes) {
662
665
  const context = {
@@ -665,7 +668,7 @@ function createCommandRenderer(options = {}) {
665
668
  info
666
669
  };
667
670
  const content = customRenderNotes ? customRenderNotes(context) : context.content;
668
- if (content) sections.push(wrapWithMarker("notes", scope, content));
671
+ if (content) sections.push(wrap("notes", scope, content));
669
672
  }
670
673
  {
671
674
  const context = {
@@ -1092,6 +1095,11 @@ function generateAnchor(commandPath) {
1092
1095
  function isLeafCommand(info) {
1093
1096
  return info.subCommands.length === 0;
1094
1097
  }
1098
+ function isSubcommandOf$1(childPath, parentPath) {
1099
+ if (childPath === parentPath) return true;
1100
+ if (parentPath === "") return childPath !== "";
1101
+ return childPath.startsWith(parentPath + " ");
1102
+ }
1095
1103
  /**
1096
1104
  * Expand commands to include their subcommands
1097
1105
  * If a command has subcommands, recursively find all commands under it
@@ -1122,13 +1130,23 @@ function renderCategory(category, allCommands, headingLevel, leafOnly) {
1122
1130
  lines.push("");
1123
1131
  lines.push(category.description);
1124
1132
  lines.push("");
1125
- const commandPaths = expandCommands(category.commands, allCommands, leafOnly);
1133
+ const commandPaths = category.noExpand ? category.commands : expandCommands(category.commands, allCommands, leafOnly);
1134
+ let visibleCommandPaths = commandPaths;
1135
+ const fallbackCommandPaths = /* @__PURE__ */ new Set();
1136
+ if (category.allowedCommands) {
1137
+ const allowed = new Set(category.allowedCommands);
1138
+ visibleCommandPaths = commandPaths.filter((cmdPath) => allowed.has(cmdPath));
1139
+ for (const configuredPath of category.commands) if (allowed.has(configuredPath) && !visibleCommandPaths.some((cmdPath) => isSubcommandOf$1(cmdPath, configuredPath))) {
1140
+ visibleCommandPaths.push(configuredPath);
1141
+ fallbackCommandPaths.add(configuredPath);
1142
+ }
1143
+ }
1126
1144
  lines.push("| Command | Description |");
1127
1145
  lines.push("|---------|-------------|");
1128
- for (const cmdPath of commandPaths) {
1146
+ for (const cmdPath of visibleCommandPaths) {
1129
1147
  const info = allCommands.get(cmdPath);
1130
1148
  if (!info) continue;
1131
- if (leafOnly && !isLeafCommand(info)) continue;
1149
+ if (!category.noExpand && leafOnly && !fallbackCommandPaths.has(cmdPath) && !isLeafCommand(info)) continue;
1132
1150
  const displayName = cmdPath || info.name;
1133
1151
  const anchor = generateAnchor(displayName);
1134
1152
  const desc = escapeTableCell(info.description ?? "");
@@ -1194,6 +1212,181 @@ function isTruthyEnv(envKey) {
1194
1212
  const value = process.env[envKey];
1195
1213
  return value === "true" || value === "1";
1196
1214
  }
1215
+ function extractYamlFrontMatter(content) {
1216
+ const lines = content.split(/\r?\n/);
1217
+ if (lines[0] !== "---") return null;
1218
+ const frontMatterLines = [];
1219
+ for (let i = 1; i < lines.length; i++) {
1220
+ const line = lines[i];
1221
+ if (line === "---" || line === "...") return frontMatterLines.join("\n");
1222
+ frontMatterLines.push(line ?? "");
1223
+ }
1224
+ return null;
1225
+ }
1226
+ function stripPolittyFrontMatterForOutput(content) {
1227
+ const lineEnding = detectLineEnding(content);
1228
+ const lines = content.split(/\r?\n/);
1229
+ if (lines[0] !== "---") return content;
1230
+ let endIndex = -1;
1231
+ for (let i = 1; i < lines.length; i++) {
1232
+ const line = lines[i];
1233
+ if (line === "---" || line === "...") {
1234
+ endIndex = i;
1235
+ break;
1236
+ }
1237
+ }
1238
+ if (endIndex === -1) return content;
1239
+ const frontMatterLines = lines.slice(1, endIndex);
1240
+ const keptFrontMatterLines = [];
1241
+ for (let i = 0; i < frontMatterLines.length; i++) {
1242
+ const line = frontMatterLines[i] ?? "";
1243
+ if (!/^politty\s*:\s*(.*)$/.test(line)) {
1244
+ keptFrontMatterLines.push(line);
1245
+ continue;
1246
+ }
1247
+ while (i + 1 < frontMatterLines.length) {
1248
+ const nextLine = frontMatterLines[i + 1] ?? "";
1249
+ if (nextLine.trim() !== "" && !nextLine.startsWith(" ") && !nextLine.startsWith(" ")) break;
1250
+ i++;
1251
+ }
1252
+ }
1253
+ const bodyLines = lines.slice(endIndex + 1);
1254
+ if (!keptFrontMatterLines.some((line) => line.trim() !== "")) return bodyLines.join(lineEnding).replace(new RegExp(`^${lineEnding}`), "");
1255
+ return [
1256
+ "---",
1257
+ ...keptFrontMatterLines,
1258
+ lines[endIndex] ?? "---",
1259
+ ...bodyLines
1260
+ ].join(lineEnding);
1261
+ }
1262
+ function stripYamlScalarQuotes(value) {
1263
+ const trimmed = value.trim();
1264
+ if (trimmed.length >= 2 && (trimmed.startsWith("\"") && trimmed.endsWith("\"") || trimmed.startsWith("'") && trimmed.endsWith("'"))) return trimmed.slice(1, -1);
1265
+ return trimmed;
1266
+ }
1267
+ function normalizeTemplatePlaceholderKey(value) {
1268
+ let normalized = stripYamlScalarQuotes(value);
1269
+ if (normalized === "") return null;
1270
+ const fullPlaceholder = normalized.match(/^\{\{politty:([^{}]*)\}\}$/);
1271
+ if (fullPlaceholder) normalized = fullPlaceholder[1] ?? "";
1272
+ else if (normalized.startsWith("politty:")) normalized = normalized.slice(8);
1273
+ return normalized === "" ? null : normalized;
1274
+ }
1275
+ function templatePlaceholderKey(placeholder) {
1276
+ return placeholder.slice(2, -2).slice(8);
1277
+ }
1278
+ function splitFrontMatterListValue(value) {
1279
+ const trimmed = value.trim();
1280
+ if (!trimmed.startsWith("[") || !trimmed.endsWith("]")) return [trimmed];
1281
+ return trimmed.slice(1, -1).split(",").map((item) => item.trim()).filter((item) => item.length > 0);
1282
+ }
1283
+ function addTemplatePlaceholderExclusion(exclusions, value) {
1284
+ const normalized = normalizeTemplatePlaceholderKey(value);
1285
+ if (normalized !== null) exclusions.add(normalized);
1286
+ }
1287
+ function collectExcludedTemplatePlaceholders(templateContent) {
1288
+ const exclusions = /* @__PURE__ */ new Set();
1289
+ const frontMatter = extractYamlFrontMatter(templateContent);
1290
+ if (frontMatter === null) return exclusions;
1291
+ let inPolittyBlock = false;
1292
+ let inExcludeList = false;
1293
+ let excludeIndent = 0;
1294
+ for (const line of frontMatter.split(/\r?\n/)) {
1295
+ const trimmed = line.trim();
1296
+ if (trimmed === "" || trimmed.startsWith("#")) continue;
1297
+ const topLevelPolitty = line.match(/^politty\s*:\s*(.*)$/);
1298
+ if (topLevelPolitty) {
1299
+ inPolittyBlock = (topLevelPolitty[1]?.trim() ?? "") === "";
1300
+ inExcludeList = false;
1301
+ continue;
1302
+ }
1303
+ if (!line.startsWith(" ") && !line.startsWith(" ")) {
1304
+ inPolittyBlock = false;
1305
+ inExcludeList = false;
1306
+ continue;
1307
+ }
1308
+ if (!inPolittyBlock) continue;
1309
+ const excludeEntry = line.match(/^(\s+)(?:exclude|excludes)\s*:\s*(.*)$/);
1310
+ if (excludeEntry) {
1311
+ const value = excludeEntry[2]?.trim() ?? "";
1312
+ if (value === "") {
1313
+ inExcludeList = true;
1314
+ excludeIndent = excludeEntry[1]?.length ?? 0;
1315
+ } else {
1316
+ inExcludeList = false;
1317
+ for (const item of splitFrontMatterListValue(value)) addTemplatePlaceholderExclusion(exclusions, item);
1318
+ }
1319
+ continue;
1320
+ }
1321
+ if (!inExcludeList) continue;
1322
+ const listItem = line.match(/^(\s*)-\s*(.+)$/);
1323
+ if (!listItem || (listItem[1]?.length ?? 0) <= excludeIndent) {
1324
+ inExcludeList = false;
1325
+ continue;
1326
+ }
1327
+ addTemplatePlaceholderExclusion(exclusions, listItem[2] ?? "");
1328
+ }
1329
+ return exclusions;
1330
+ }
1331
+ function collectTemplateIndexMetadata(templateContent) {
1332
+ const frontMatter = extractYamlFrontMatter(templateContent);
1333
+ if (frontMatter === null) return {};
1334
+ let inPolittyBlock = false;
1335
+ let inIndexBlock = false;
1336
+ let indexIndent = 0;
1337
+ const metadata = {};
1338
+ for (const line of frontMatter.split(/\r?\n/)) {
1339
+ const trimmed = line.trim();
1340
+ if (trimmed === "" || trimmed.startsWith("#")) continue;
1341
+ const topLevelPolitty = line.match(/^politty\s*:\s*(.*)$/);
1342
+ if (topLevelPolitty) {
1343
+ inPolittyBlock = (topLevelPolitty[1]?.trim() ?? "") === "";
1344
+ inIndexBlock = false;
1345
+ continue;
1346
+ }
1347
+ if (!line.startsWith(" ") && !line.startsWith(" ")) {
1348
+ inPolittyBlock = false;
1349
+ inIndexBlock = false;
1350
+ continue;
1351
+ }
1352
+ if (!inPolittyBlock) continue;
1353
+ const indexEntry = line.match(/^(\s+)index\s*:\s*(.*)$/);
1354
+ if (indexEntry) {
1355
+ inIndexBlock = (indexEntry[2]?.trim() ?? "") === "";
1356
+ indexIndent = indexEntry[1]?.length ?? 0;
1357
+ continue;
1358
+ }
1359
+ if (!inIndexBlock) continue;
1360
+ if ((line.match(/^(\s*)/)?.[1]?.length ?? 0) <= indexIndent) {
1361
+ inIndexBlock = false;
1362
+ continue;
1363
+ }
1364
+ const property = line.match(/^\s+(title|description)\s*:\s*(.+)$/);
1365
+ if (!property) continue;
1366
+ const key = property[1];
1367
+ const value = stripYamlScalarQuotes(property[2] ?? "");
1368
+ if (key === "title") metadata.title = value;
1369
+ else if (key === "description") metadata.description = value;
1370
+ }
1371
+ return metadata;
1372
+ }
1373
+ function createTemplateExclusions(rawKeys) {
1374
+ return {
1375
+ rawKeys,
1376
+ commandScopes: /* @__PURE__ */ new Set(),
1377
+ commandSections: /* @__PURE__ */ new Map(),
1378
+ globalOptions: false,
1379
+ index: false
1380
+ };
1381
+ }
1382
+ function setFileMapEntry(fileMap, commandPath, filePath) {
1383
+ Object.defineProperty(fileMap, commandPath, {
1384
+ value: filePath,
1385
+ enumerable: true,
1386
+ configurable: true,
1387
+ writable: true
1388
+ });
1389
+ }
1197
1390
  /**
1198
1391
  * Normalize file mapping entry to FileConfig
1199
1392
  */
@@ -1548,6 +1741,227 @@ function removeCommandSections(content, commandPath) {
1548
1741
  return content;
1549
1742
  }
1550
1743
  /**
1744
+ * Strip politty marker lines from content, then collapse the blank-line gaps the removed markers
1745
+ * leave behind (outside fenced code blocks only, so intentional blank lines inside generated
1746
+ * example/code blocks are preserved) and trim leading/trailing blank lines.
1747
+ */
1748
+ function stripPolittyMarkers(content) {
1749
+ let result = collapseBlankLinesOutsideCodeFences(content.split("\n").filter((line) => !/^<!-- politty:.*-->$/.test(line.trim())).join("\n"));
1750
+ result = result.replace(/^\n+/, "").replace(/\n+$/, "");
1751
+ return result;
1752
+ }
1753
+ /**
1754
+ * Collapse runs of 3+ newlines to 2, but only outside fenced code blocks so that intentional
1755
+ * blank lines inside handwritten code samples are preserved. Fences are lines whose trimmed
1756
+ * content starts with ``` or ~~~.
1757
+ */
1758
+ function collapseBlankLinesOutsideCodeFences(content) {
1759
+ const lines = content.split("\n");
1760
+ const out = [];
1761
+ let inFence = false;
1762
+ let blankRun = 0;
1763
+ for (const line of lines) {
1764
+ const trimmed = line.trim();
1765
+ if (trimmed.startsWith("```") || trimmed.startsWith("~~~")) {
1766
+ inFence = !inFence;
1767
+ blankRun = 0;
1768
+ out.push(line);
1769
+ continue;
1770
+ }
1771
+ if (!inFence && line.trim() === "") {
1772
+ blankRun++;
1773
+ if (blankRun >= 2) continue;
1774
+ } else if (!inFence) blankRun = 0;
1775
+ out.push(line);
1776
+ }
1777
+ return out.join("\n");
1778
+ }
1779
+ function detectLineEnding(content) {
1780
+ return content.includes("\r\n") ? "\r\n" : "\n";
1781
+ }
1782
+ function countLineBreaks(value) {
1783
+ return (value.match(/\n/g) ?? []).length;
1784
+ }
1785
+ /**
1786
+ * Type guard for SectionType values parsed from template placeholders.
1787
+ */
1788
+ function isSectionType(value) {
1789
+ return SECTION_TYPES.some((type) => type === value);
1790
+ }
1791
+ /**
1792
+ * Clamp a numeric heading level to the valid HeadingLevel range (1–6).
1793
+ * Uses a switch to return a literal union member, avoiding `as` assertions.
1794
+ */
1795
+ function clampHeadingLevel(level) {
1796
+ switch (Math.min(6, Math.max(1, Math.trunc(level)))) {
1797
+ case 1: return 1;
1798
+ case 2: return 2;
1799
+ case 3: return 3;
1800
+ case 4: return 4;
1801
+ case 5: return 5;
1802
+ default: return 6;
1803
+ }
1804
+ }
1805
+ function resolveTemplateCommandScope(tokens, allCommands) {
1806
+ if (tokens.length === 0) return allCommands === void 0 || allCommands.has("") ? "" : null;
1807
+ const exactScope = tokens.join(":");
1808
+ if (allCommands?.has(exactScope)) return exactScope;
1809
+ const colonSeparatedScope = tokens.join(" ");
1810
+ if (allCommands?.has(colonSeparatedScope)) return colonSeparatedScope;
1811
+ return allCommands === void 0 ? colonSeparatedScope : null;
1812
+ }
1813
+ function templateScopeFallback(tokens) {
1814
+ return tokens.join(" ");
1815
+ }
1816
+ /**
1817
+ * Parse a single {{politty:...}} placeholder string into a discriminated structure.
1818
+ * The `placeholder` argument should be the full `{{politty:...}}` text.
1819
+ *
1820
+ * Uses String.match / String.replace internally (not .exec) to avoid lastIndex
1821
+ * state issues from the shared TEMPLATE_PLACEHOLDER_REGEX constant.
1822
+ */
1823
+ function parsePlaceholder(placeholder, allCommands) {
1824
+ const tokens = placeholder.slice(2, -2).split(":");
1825
+ const directive = tokens[1];
1826
+ if (directive === "command") {
1827
+ const rest = tokens.slice(2);
1828
+ if (rest.length === 1 && rest[0] === "") return {
1829
+ kind: "invalid",
1830
+ reason: `Trailing colon in "${placeholder}"; use {{politty:command}} for the root command.`
1831
+ };
1832
+ const fullScope = resolveTemplateCommandScope(rest, allCommands);
1833
+ if (fullScope !== null) return {
1834
+ kind: "command",
1835
+ scope: fullScope,
1836
+ type: void 0
1837
+ };
1838
+ if (rest.length >= 2) {
1839
+ const last = rest[rest.length - 1];
1840
+ const scopeTokens = rest.slice(0, -1);
1841
+ const sectionScope = resolveTemplateCommandScope(scopeTokens, allCommands);
1842
+ if (last !== void 0 && isSectionType(last)) return {
1843
+ kind: "command",
1844
+ scope: sectionScope ?? templateScopeFallback(scopeTokens),
1845
+ type: last
1846
+ };
1847
+ if (last !== void 0 && sectionScope !== null) return {
1848
+ kind: "invalid",
1849
+ reason: `Unknown section type "${last}" for command scope "${formatCommandPath(sectionScope)}". Valid section types: ${SECTION_TYPES.join(", ")}`
1850
+ };
1851
+ }
1852
+ return {
1853
+ kind: "command",
1854
+ scope: templateScopeFallback(rest),
1855
+ type: void 0
1856
+ };
1857
+ }
1858
+ if (directive === "global-options") {
1859
+ if (tokens.length !== 2) return {
1860
+ kind: "invalid",
1861
+ reason: `Malformed placeholder "${placeholder}". Expected {{politty:global-options}}.`
1862
+ };
1863
+ return { kind: "global-options" };
1864
+ }
1865
+ if (directive === "index") {
1866
+ if (tokens.length !== 2) return {
1867
+ kind: "invalid",
1868
+ reason: `Malformed placeholder "${placeholder}". Expected {{politty:index}}.`
1869
+ };
1870
+ return { kind: "index" };
1871
+ }
1872
+ return {
1873
+ kind: "invalid",
1874
+ reason: `Unknown politty directive "${directive ?? ""}" in "${placeholder}". Valid directives: command, global-options, index`
1875
+ };
1876
+ }
1877
+ function buildTemplateExclusions(rawKeys, allCommands) {
1878
+ const exclusions = createTemplateExclusions(rawKeys);
1879
+ for (const key of rawKeys) {
1880
+ const parsed = parsePlaceholder(`{{politty:${key}}}`, allCommands);
1881
+ if (parsed.kind === "command") if (parsed.type === void 0) exclusions.commandScopes.add(parsed.scope);
1882
+ else {
1883
+ let sections = exclusions.commandSections.get(parsed.scope);
1884
+ if (!sections) {
1885
+ sections = /* @__PURE__ */ new Set();
1886
+ exclusions.commandSections.set(parsed.scope, sections);
1887
+ }
1888
+ sections.add(parsed.type);
1889
+ }
1890
+ else if (parsed.kind === "global-options") exclusions.globalOptions = true;
1891
+ else if (parsed.kind === "index") exclusions.index = true;
1892
+ }
1893
+ return exclusions;
1894
+ }
1895
+ function isCommandScopeExcluded(commandPath, excludedCommandScopes) {
1896
+ for (const excludedScope of excludedCommandScopes) if (isSubcommandOf(commandPath, excludedScope)) return true;
1897
+ return false;
1898
+ }
1899
+ function isCommandSectionExcluded(commandPath, sectionType, exclusions) {
1900
+ if (isCommandScopeExcluded(commandPath, exclusions.commandScopes)) return true;
1901
+ return exclusions.commandSections.get(commandPath)?.has(sectionType) ?? false;
1902
+ }
1903
+ function getTemplateCommandTreePaths(commandPath, allCommands, ignores, exclusions) {
1904
+ return sortDepthFirst(filterIgnoredCommands(expandCommandPaths([commandPath], allCommands), ignores).filter((path) => !isCommandScopeExcluded(path, exclusions.commandScopes)), [commandPath]);
1905
+ }
1906
+ function shouldSkipTemplatePlaceholder(placeholder, parsed, exclusions) {
1907
+ if (exclusions.rawKeys.has(templatePlaceholderKey(placeholder))) return true;
1908
+ if (parsed.kind === "command") {
1909
+ if (isCommandScopeExcluded(parsed.scope, exclusions.commandScopes)) return true;
1910
+ return parsed.type !== void 0 && (exclusions.commandSections.get(parsed.scope)?.has(parsed.type) ?? false);
1911
+ }
1912
+ if (parsed.kind === "global-options") return exclusions.globalOptions;
1913
+ if (parsed.kind === "index") return exclusions.index;
1914
+ return false;
1915
+ }
1916
+ function isRawCommandPlaceholderUnderExcludedScope(key, exclusions) {
1917
+ if (!key.startsWith("command:")) return false;
1918
+ const tokens = key.slice(8).split(":");
1919
+ for (const excludedScope of exclusions.commandScopes) {
1920
+ if (excludedScope === "") return true;
1921
+ const spaceTokens = excludedScope.split(" ");
1922
+ if (tokens.slice(0, spaceTokens.length).join(" ") === excludedScope) return true;
1923
+ const colonTokens = excludedScope.split(":");
1924
+ if (tokens.slice(0, colonTokens.length).join(":") === excludedScope) return true;
1925
+ }
1926
+ return false;
1927
+ }
1928
+ /**
1929
+ * Regex matching {{politty:...}} placeholders.
1930
+ * NOTE: only use with String.match / String.replace, never with .exec in a loop,
1931
+ * because the /g flag makes the regex stateful via lastIndex.
1932
+ */
1933
+ const TEMPLATE_PLACEHOLDER_REGEX = /\{\{politty:[^{}]*\}\}/g;
1934
+ function validateTemplatePlaceholderSyntax(templateContent, templatePath) {
1935
+ const validPlaceholderStarts = /* @__PURE__ */ new Set();
1936
+ for (const match of templateContent.matchAll(TEMPLATE_PLACEHOLDER_REGEX)) {
1937
+ const start = match.index;
1938
+ const end = start + match[0].length;
1939
+ if (templateContent[start - 1] === "{" || templateContent[end] === "}") {
1940
+ const snippet = templateContent.slice(Math.max(0, start - 1), Math.min(templateContent.length, end + 1)).split("\n")[0];
1941
+ throw new Error(`Malformed politty placeholder in template "${templatePath}": "${snippet}". Expected {{politty:...}}.`);
1942
+ }
1943
+ validPlaceholderStarts.add(start);
1944
+ }
1945
+ let searchIndex = 0;
1946
+ while (true) {
1947
+ const placeholderStart = templateContent.indexOf("{{politty:", searchIndex);
1948
+ if (placeholderStart === -1) return;
1949
+ if (!validPlaceholderStarts.has(placeholderStart)) {
1950
+ const snippet = templateContent.slice(placeholderStart, placeholderStart + 80).split("\n")[0];
1951
+ throw new Error(`Malformed politty placeholder in template "${templatePath}": "${snippet}". Expected {{politty:...}}.`);
1952
+ }
1953
+ searchIndex = placeholderStart + 10;
1954
+ }
1955
+ }
1956
+ function getUnknownSectionTypeError(scope, allCommands) {
1957
+ const separatorIndex = scope.lastIndexOf(":");
1958
+ if (separatorIndex === -1) return null;
1959
+ const commandScope = scope.slice(0, separatorIndex);
1960
+ const sectionType = scope.slice(separatorIndex + 1);
1961
+ if (sectionType === "" || !allCommands.has(commandScope)) return null;
1962
+ return `Unknown section type "${sectionType}" for command scope "${formatCommandPath(commandScope)}". Valid section types: ${SECTION_TYPES.join(", ")}`;
1963
+ }
1964
+ /**
1551
1965
  * Extract a marker section from content
1552
1966
  * Returns the content between start and end markers (including markers)
1553
1967
  */
@@ -1607,6 +2021,17 @@ function normalizeGlobalOptions(config) {
1607
2021
  return isGlobalOptionsConfigWithOptions(config) ? config : { args: config };
1608
2022
  }
1609
2023
  /**
2024
+ * Derive an ArgsShape from a globalArgs Zod schema, retaining only non-positional option fields.
2025
+ * Returns undefined when globalArgs is undefined or contains no option fields.
2026
+ * Used to build globalOptionDefinitions from globalArgs when rootDoc is not available.
2027
+ */
2028
+ function deriveGlobalArgsShape(globalArgs) {
2029
+ if (!globalArgs) return void 0;
2030
+ const optionFields = require_schema_extractor.extractFields(globalArgs).fields.filter((f) => !f.positional);
2031
+ if (optionFields.length === 0) return void 0;
2032
+ return Object.fromEntries(optionFields.map((f) => [f.name, f.schema]));
2033
+ }
2034
+ /**
1610
2035
  * Collect global option definitions from rootDoc.
1611
2036
  * Global options are intentionally applied to all generated command sections.
1612
2037
  */
@@ -1635,12 +2060,38 @@ function deriveIndexFromFiles(files, rootDocPath, allCommands, ignores) {
1635
2060
  title: fileConfig?.title ?? cmdInfo?.name ?? node_path.basename(filePath, node_path.extname(filePath)),
1636
2061
  description: fileConfig?.description ?? cmdInfo?.description ?? "",
1637
2062
  commands: topLevelCommands,
2063
+ allowedCommands: commandPaths,
1638
2064
  docPath
1639
2065
  });
1640
2066
  }
1641
2067
  return categories;
1642
2068
  }
1643
2069
  /**
2070
+ * Build index categories for the {{politty:index}} placeholder from other template outputs.
2071
+ * Each category lists exactly the heading-producing scopes of that output (noExpand), so the
2072
+ * index never links to commands that template mode did not render.
2073
+ */
2074
+ function deriveIndexFromTemplateOutputs(templateMeta, currentOutputPath, indexFilePath, allCommands) {
2075
+ const normalizedCurrent = normalizeDocPathForComparison(currentOutputPath);
2076
+ const categories = [];
2077
+ for (const [outputPath, meta] of templateMeta.entries()) {
2078
+ if (normalizeDocPathForComparison(outputPath) === normalizedCurrent) continue;
2079
+ const scopes = meta.headingScopes;
2080
+ if (scopes.length === 0) continue;
2081
+ const docPath = "./" + node_path.relative(node_path.dirname(indexFilePath), outputPath).replace(/\\/g, "/");
2082
+ const firstScope = scopes[0];
2083
+ const cmdInfo = firstScope !== void 0 ? allCommands.get(firstScope) : void 0;
2084
+ categories.push({
2085
+ title: meta.indexTitle ?? cmdInfo?.name ?? node_path.basename(outputPath, node_path.extname(outputPath)),
2086
+ description: meta.indexDescription ?? cmdInfo?.description ?? "",
2087
+ commands: scopes,
2088
+ docPath,
2089
+ noExpand: true
2090
+ });
2091
+ }
2092
+ return categories;
2093
+ }
2094
+ /**
1644
2095
  * Collect command paths that are actually documented in configured files.
1645
2096
  */
1646
2097
  function collectDocumentedCommandPaths(files, allCommands, ignores) {
@@ -1662,6 +2113,15 @@ function collectTargetDocumentedCommandPaths(targetCommands, files, allCommands,
1662
2113
  }
1663
2114
  return documentedTargetCommandPaths;
1664
2115
  }
2116
+ function commandPathMatchesTarget(commandPath, targetCommands) {
2117
+ return targetCommands.some((targetCommand) => isSubcommandOf(commandPath, targetCommand));
2118
+ }
2119
+ function templateMetaReferencesCommandTarget(meta, targetCommands) {
2120
+ return meta.referencedScopes.some((scope) => commandPathMatchesTarget(scope, targetCommands));
2121
+ }
2122
+ function templateMetaShouldProcessForTarget(meta, targetCommands) {
2123
+ return meta.emitsIndex || meta.emitsGlobalOptions || templateMetaReferencesCommandTarget(meta, targetCommands);
2124
+ }
1665
2125
  /**
1666
2126
  * Validate that excluded command options match globalOptions definitions.
1667
2127
  */
@@ -1680,16 +2140,19 @@ function validateGlobalOptionCompatibility(documentedCommandPaths, allCommands,
1680
2140
  if (conflicts.length > 0) throw new Error(`Invalid globalOptions configuration:\n - ${conflicts.join("\n - ")}`);
1681
2141
  }
1682
2142
  /**
2143
+ * Build global options content (anchor + args table) without markers
2144
+ */
2145
+ function buildGlobalOptionsContent(config) {
2146
+ return ["<a id=\"global-options\"></a>", renderArgsTable(config.args, config.options)].join("\n");
2147
+ }
2148
+ /**
1683
2149
  * Generate global options section content with markers
1684
2150
  */
1685
2151
  function generateGlobalOptionsSection(config) {
1686
- const startMarker = globalOptionsStartMarker();
1687
- const endMarker = globalOptionsEndMarker();
1688
2152
  return [
1689
- startMarker,
1690
- "<a id=\"global-options\"></a>",
1691
- renderArgsTable(config.args, config.options),
1692
- endMarker
2153
+ globalOptionsStartMarker(),
2154
+ buildGlobalOptionsContent(config),
2155
+ globalOptionsEndMarker()
1693
2156
  ].join("\n");
1694
2157
  }
1695
2158
  /**
@@ -1909,29 +2372,74 @@ function findTargetCommandsInFile(targetCommands, filePath, files, allCommands,
1909
2372
  /**
1910
2373
  * Generate a single command section (already contains section markers from renderer)
1911
2374
  */
1912
- function generateCommandSection(cmdPath, allCommands, render, filePath, fileMap, rootDocPath, hasGlobalOptions) {
2375
+ function generateCommandSection(cmdPath, allCommands, render, filePath, fileMap, rootDocPath, hasGlobalOptions, ignores = [], excludeOptionNames, templateExclusions) {
1913
2376
  const info = allCommands.get(cmdPath);
1914
2377
  if (!info) return null;
2378
+ if (templateExclusions && isCommandScopeExcluded(info.commandPath, templateExclusions.commandScopes)) return null;
1915
2379
  const enriched = {
1916
2380
  ...info,
1917
2381
  filePath,
1918
2382
  fileMap,
1919
2383
  rootDocPath
1920
2384
  };
2385
+ if (ignores.length > 0 || templateExclusions && templateExclusions.commandScopes.size > 0) enriched.subCommands = info.subCommands.filter((sub) => {
2386
+ const subCommandPath = sub.fullPath.join(" ");
2387
+ if (ignores.some((pattern) => matchesIgnorePattern(subCommandPath, pattern))) return false;
2388
+ return !(templateExclusions && isCommandScopeExcluded(subCommandPath, templateExclusions.commandScopes));
2389
+ });
1921
2390
  if (hasGlobalOptions !== void 0) enriched.hasGlobalOptions = hasGlobalOptions;
1922
- return render(enriched);
2391
+ if (excludeOptionNames && excludeOptionNames.size > 0) {
2392
+ enriched.options = info.options.filter((opt) => !excludeOptionNames.has(opt.name));
2393
+ if (info.extracted) enriched.extracted = filterExtractedFields(info.extracted, excludeOptionNames);
2394
+ }
2395
+ let rendered = render(enriched);
2396
+ if (templateExclusions) for (const [scope, sectionTypes] of templateExclusions.commandSections) {
2397
+ if (scope !== info.commandPath) continue;
2398
+ for (const sectionType of sectionTypes) {
2399
+ const section = extractSectionMarker(rendered, sectionType, scope);
2400
+ if (section !== null) rendered = rendered.replace(section, "");
2401
+ }
2402
+ rendered = collapseBlankLinesOutsideCodeFences(rendered);
2403
+ }
2404
+ return rendered;
2405
+ }
2406
+ function generateCommandTreeMarkdown(cmdPath, allCommands, render, ignores, filePath, fileMap, rootDocPath, hasGlobalOptions, excludeOptionNames, templateExclusions) {
2407
+ const commandPaths = getTemplateCommandTreePaths(cmdPath, allCommands, ignores, templateExclusions);
2408
+ const sections = [];
2409
+ for (const commandPath of commandPaths) {
2410
+ const section = generateCommandSection(commandPath, allCommands, render, filePath, fileMap, rootDocPath, hasGlobalOptions, ignores, excludeOptionNames, templateExclusions);
2411
+ if (section !== null) sections.push(section);
2412
+ }
2413
+ return sections.length === 0 ? null : sections.join("\n");
2414
+ }
2415
+ /**
2416
+ * Return a copy of ExtractedFields with the named options removed from every field collection
2417
+ * (top-level fields, union options, and discriminated-union variants). Used to exclude global
2418
+ * options from grouped option tables rendered directly from `extracted`.
2419
+ */
2420
+ function filterExtractedFields(extracted, excludeOptionNames) {
2421
+ const result = {
2422
+ ...extracted,
2423
+ fields: extracted.fields.filter((f) => !excludeOptionNames.has(f.name))
2424
+ };
2425
+ if (extracted.unionOptions) result.unionOptions = extracted.unionOptions.map((opt) => filterExtractedFields(opt, excludeOptionNames));
2426
+ if (extracted.variants) result.variants = extracted.variants.map((variant) => ({
2427
+ ...variant,
2428
+ fields: variant.fields.filter((f) => !excludeOptionNames.has(f.name))
2429
+ }));
2430
+ return result;
1923
2431
  }
1924
2432
  /**
1925
2433
  * Generate markdown for a file containing multiple commands
1926
2434
  * Each command section is wrapped with markers for partial validation
1927
2435
  */
1928
- function generateFileMarkdown(commandPaths, allCommands, render, filePath, fileMap, specifiedOrder, fileConfig, rootDocPath, hasGlobalOptions) {
2436
+ function generateFileMarkdown(commandPaths, allCommands, render, filePath, fileMap, specifiedOrder, fileConfig, rootDocPath, hasGlobalOptions, ignores = []) {
1929
2437
  const sections = [];
1930
2438
  const header = fileConfig ? generateFileHeader(fileConfig) : null;
1931
2439
  if (header) sections.push(header);
1932
2440
  const sortedPaths = sortDepthFirst(commandPaths, specifiedOrder ?? []);
1933
2441
  for (const cmdPath of sortedPaths) {
1934
- const section = generateCommandSection(cmdPath, allCommands, render, filePath, fileMap, rootDocPath, hasGlobalOptions);
2442
+ const section = generateCommandSection(cmdPath, allCommands, render, filePath, fileMap, rootDocPath, hasGlobalOptions, ignores);
1935
2443
  if (section) sections.push(section);
1936
2444
  }
1937
2445
  return `${sections.join("\n")}\n`;
@@ -1943,7 +2451,7 @@ function buildFileMap(files, allCommands, ignores) {
1943
2451
  const fileMap = {};
1944
2452
  for (const [filePath, fileConfigRaw] of Object.entries(files)) {
1945
2453
  const { commandPaths } = resolveConfiguredCommandPaths(fileConfigRaw, allCommands, ignores);
1946
- for (const cmdPath of commandPaths) fileMap[cmdPath] = filePath;
2454
+ for (const cmdPath of commandPaths) setFileMapEntry(fileMap, cmdPath, filePath);
1947
2455
  }
1948
2456
  return fileMap;
1949
2457
  }
@@ -1996,7 +2504,7 @@ function pathToFiles(pathConfig, allCommands) {
1996
2504
  * Generate documentation from command definition
1997
2505
  */
1998
2506
  async function generateDoc(config) {
1999
- const { command, ignores = [], format = {}, formatter, examples: examplesConfig, targetCommands, globalArgs } = config;
2507
+ const { command, ignores = [], format = {}, formatter, examples: examplesConfig, targetCommands, globalArgs, customizable = false } = config;
2000
2508
  const allCommands = await collectAllCommands(command);
2001
2509
  let files;
2002
2510
  let usingPathConfig = false;
@@ -2008,7 +2516,8 @@ async function generateDoc(config) {
2008
2516
  resolvedRootDocPath = converted.rootDocPath;
2009
2517
  usingPathConfig = true;
2010
2518
  } else if (config.files !== void 0) files = config.files;
2011
- else throw new Error("Either \"path\" or \"files\" must be specified.");
2519
+ else if (config.templates !== void 0) files = {};
2520
+ else throw new Error("Either \"path\", \"files\", or \"templates\" must be specified.");
2012
2521
  let rootDoc = config.rootDoc;
2013
2522
  if (!rootDoc && usingPathConfig && (globalArgs || config.rootInfo)) rootDoc = { path: resolvedRootDocPath };
2014
2523
  if (globalArgs && rootDoc && !rootDoc.globalOptions) {
@@ -2030,12 +2539,14 @@ async function generateDoc(config) {
2030
2539
  }
2031
2540
  if (examplesConfig) await executeConfiguredExamples(allCommands, examplesConfig, command);
2032
2541
  const hasTargetCommands = targetCommands !== void 0 && targetCommands.length > 0;
2033
- if (hasTargetCommands) {
2034
- for (const targetCommand of targetCommands) if (!findFileForCommand(targetCommand, files, allCommands, ignores)) throw new Error(`Target command "${targetCommand}" not found in any file configuration`);
2035
- }
2036
2542
  const globalOptionDefinitions = collectGlobalOptionDefinitions(rootDoc);
2037
- validateGlobalOptionCompatibility(hasTargetCommands ? collectTargetDocumentedCommandPaths(targetCommands, files, allCommands, ignores) : collectDocumentedCommandPaths(files, allCommands, ignores), allCommands, globalOptionDefinitions);
2038
- if (globalOptionDefinitions.size > 0) for (const info of allCommands.values()) info.options = info.options.filter((opt) => !globalOptionDefinitions.has(opt.name));
2543
+ const templateGlobalOptionFields = /* @__PURE__ */ new Map();
2544
+ if (config.templates) if (globalOptionDefinitions.size > 0) for (const [name, field] of globalOptionDefinitions) templateGlobalOptionFields.set(name, field);
2545
+ else {
2546
+ const shape = deriveGlobalArgsShape(globalArgs);
2547
+ if (shape) for (const field of collectRenderableGlobalOptionFields(shape)) templateGlobalOptionFields.set(field.name, field);
2548
+ }
2549
+ const documentedCommandPaths = hasTargetCommands ? collectTargetDocumentedCommandPaths(targetCommands, files, allCommands, ignores) : collectDocumentedCommandPaths(files, allCommands, ignores);
2039
2550
  const allFilesCommands = [];
2040
2551
  for (const fileConfigRaw of Object.values(files)) {
2041
2552
  const fileConfig = normalizeFileConfig(fileConfigRaw);
@@ -2044,6 +2555,159 @@ async function generateDoc(config) {
2044
2555
  validateIgnoresExist(ignores, allCommands);
2045
2556
  validateNoConflicts(allFilesCommands, ignores, allCommands);
2046
2557
  const fileMap = buildFileMap(files, allCommands, ignores);
2558
+ const templateContents = /* @__PURE__ */ new Map();
2559
+ const templateExclusions = /* @__PURE__ */ new Map();
2560
+ if (config.templates) for (const [outputPath, templatePath] of Object.entries(config.templates)) {
2561
+ const templateContent = readFile(templatePath);
2562
+ templateContents.set(outputPath, templateContent);
2563
+ if (templateContent !== null) templateExclusions.set(outputPath, buildTemplateExclusions(collectExcludedTemplatePlaceholders(templateContent), allCommands));
2564
+ }
2565
+ const templateEntries = Object.entries(config.templates ?? {});
2566
+ const templateMeta = /* @__PURE__ */ new Map();
2567
+ const templateValidationErrors = /* @__PURE__ */ new Map();
2568
+ if (templateEntries.length > 0) {
2569
+ const normalizedRootDocPath = rootDoc ? normalizeDocPathForComparison(rootDoc.path) : null;
2570
+ const normalizedFileKeys = new Set(Object.keys(files).map(normalizeDocPathForComparison));
2571
+ const normalizedTemplateOutputs = /* @__PURE__ */ new Set();
2572
+ const allNormalizedTemplateOutputs = new Set(templateEntries.map(([outputPath]) => normalizeDocPathForComparison(outputPath)));
2573
+ for (const [outputPath, templatePath] of templateEntries) {
2574
+ const normalizedOutput = normalizeDocPathForComparison(outputPath);
2575
+ const normalizedSource = normalizeDocPathForComparison(templatePath);
2576
+ if (normalizedFileKeys.has(normalizedOutput)) throw new Error(`Template output path "${outputPath}" conflicts with an existing files key.`);
2577
+ if (normalizedRootDocPath && normalizedOutput === normalizedRootDocPath) throw new Error(`Template output path "${outputPath}" conflicts with rootDoc.path "${rootDoc.path}".`);
2578
+ if (normalizedTemplateOutputs.has(normalizedOutput)) throw new Error(`Duplicate template output path: "${outputPath}".`);
2579
+ normalizedTemplateOutputs.add(normalizedOutput);
2580
+ if (normalizedSource === normalizedOutput) throw new Error(`Template output path "${outputPath}" must not be the same as its source template path.`);
2581
+ if (normalizedFileKeys.has(normalizedSource)) throw new Error(`Template source path "${templatePath}" conflicts with a files output key.`);
2582
+ if (normalizedRootDocPath && normalizedSource === normalizedRootDocPath) throw new Error(`Template source path "${templatePath}" conflicts with rootDoc.path "${rootDoc.path}".`);
2583
+ if (allNormalizedTemplateOutputs.has(normalizedSource)) throw new Error(`Template source path "${templatePath}" conflicts with a template output path.`);
2584
+ }
2585
+ const availableCommandPaths = Array.from(allCommands.keys()).join(", ");
2586
+ for (const [outputPath, templatePath] of templateEntries) {
2587
+ const templateContent = templateContents.get(outputPath) ?? null;
2588
+ const validationErrors = [];
2589
+ if (templateContent === null) {
2590
+ templateMeta.set(outputPath, {
2591
+ referencedScopes: [],
2592
+ headingScopes: [],
2593
+ commandTreeRoots: [],
2594
+ emitsGlobalOptions: false,
2595
+ emitsIndex: false
2596
+ });
2597
+ templateValidationErrors.set(outputPath, validationErrors);
2598
+ continue;
2599
+ }
2600
+ try {
2601
+ validateTemplatePlaceholderSyntax(templateContent, templatePath);
2602
+ } catch (error) {
2603
+ validationErrors.push(error instanceof Error ? error.message : String(error));
2604
+ }
2605
+ const placeholders = Array.from(new Set(templateContent.match(TEMPLATE_PLACEHOLDER_REGEX) ?? []));
2606
+ const scopes = /* @__PURE__ */ new Set();
2607
+ const headingScopes = /* @__PURE__ */ new Set();
2608
+ const commandTreeRoots = /* @__PURE__ */ new Set();
2609
+ let emitsGlobalOptions = false;
2610
+ let emitsIndex = false;
2611
+ const exclusions = templateExclusions.get(outputPath) ?? createTemplateExclusions(/* @__PURE__ */ new Set());
2612
+ const indexMetadata = collectTemplateIndexMetadata(templateContent);
2613
+ for (const placeholder of placeholders) {
2614
+ const placeholderKey = templatePlaceholderKey(placeholder);
2615
+ if (exclusions.rawKeys.has(placeholderKey) || isRawCommandPlaceholderUnderExcludedScope(placeholderKey, exclusions)) continue;
2616
+ const parsed = parsePlaceholder(placeholder, allCommands);
2617
+ if (shouldSkipTemplatePlaceholder(placeholder, parsed, exclusions)) continue;
2618
+ if (parsed.kind === "invalid") {
2619
+ validationErrors.push(`${parsed.reason} (in template "${templatePath}")`);
2620
+ continue;
2621
+ }
2622
+ if (parsed.kind === "command") {
2623
+ const { scope, type } = parsed;
2624
+ if (!allCommands.has(scope)) {
2625
+ const sectionTypeError = getUnknownSectionTypeError(scope, allCommands);
2626
+ if (sectionTypeError) {
2627
+ validationErrors.push(`${sectionTypeError} (in template "${templatePath}")`);
2628
+ continue;
2629
+ }
2630
+ validationErrors.push(`Unknown command scope "${scope}" in template "${templatePath}". Available: ${availableCommandPaths}`);
2631
+ continue;
2632
+ }
2633
+ if (ignores.some((pattern) => matchesIgnorePattern(scope, pattern))) {
2634
+ validationErrors.push(`Command scope "${scope}" in template "${templatePath}" conflicts with ignores configuration.`);
2635
+ continue;
2636
+ }
2637
+ if (type === void 0) {
2638
+ const commandTreePaths = getTemplateCommandTreePaths(scope, allCommands, ignores, exclusions);
2639
+ if (!isCommandSectionExcluded(scope, "heading", exclusions)) commandTreeRoots.add(scope);
2640
+ for (const commandTreePath of commandTreePaths) {
2641
+ scopes.add(commandTreePath);
2642
+ if (!isCommandSectionExcluded(commandTreePath, "heading", exclusions)) headingScopes.add(commandTreePath);
2643
+ }
2644
+ } else {
2645
+ scopes.add(scope);
2646
+ if (type === "heading" && !isCommandSectionExcluded(scope, "heading", exclusions)) {
2647
+ headingScopes.add(scope);
2648
+ commandTreeRoots.add(scope);
2649
+ }
2650
+ }
2651
+ } else if (parsed.kind === "global-options") emitsGlobalOptions = true;
2652
+ else if (parsed.kind === "index") emitsIndex = true;
2653
+ }
2654
+ if (emitsGlobalOptions) {
2655
+ if (!(!!rootDoc?.globalOptions || deriveGlobalArgsShape(globalArgs) !== void 0)) validationErrors.push(`Template "${templatePath}" uses {{politty:global-options}} but no global options are configured (neither rootDoc.globalOptions nor globalArgs with non-positional options).`);
2656
+ }
2657
+ templateMeta.set(outputPath, {
2658
+ referencedScopes: Array.from(scopes),
2659
+ headingScopes: Array.from(headingScopes),
2660
+ commandTreeRoots: Array.from(commandTreeRoots),
2661
+ emitsGlobalOptions,
2662
+ emitsIndex,
2663
+ ...indexMetadata.title !== void 0 ? { indexTitle: indexMetadata.title } : {},
2664
+ ...indexMetadata.description !== void 0 ? { indexDescription: indexMetadata.description } : {}
2665
+ });
2666
+ templateValidationErrors.set(outputPath, validationErrors);
2667
+ }
2668
+ for (const meta of templateMeta.values()) {
2669
+ if (hasTargetCommands && !templateMetaShouldProcessForTarget(meta, targetCommands)) continue;
2670
+ for (const scope of meta.referencedScopes) documentedCommandPaths.add(scope);
2671
+ }
2672
+ }
2673
+ if (hasTargetCommands) for (const targetCommand of targetCommands) {
2674
+ const targetFilePath = findFileForCommand(targetCommand, files, allCommands, ignores);
2675
+ const targetTemplatePath = Array.from(templateMeta.values()).some((meta) => templateMetaReferencesCommandTarget(meta, [targetCommand]));
2676
+ if (!targetFilePath && !targetTemplatePath) throw new Error(`Target command "${targetCommand}" not found in any file or template configuration`);
2677
+ }
2678
+ const activeTemplateMeta = hasTargetCommands && config.templates ? new Map(Array.from(templateMeta.entries()).filter(([, meta]) => templateMetaShouldProcessForTarget(meta, targetCommands))) : templateMeta;
2679
+ for (const [outputPath, validationErrors] of templateValidationErrors.entries()) if (validationErrors.length > 0 && activeTemplateMeta.has(outputPath)) throw new Error(validationErrors.join("\n"));
2680
+ const templateGlobalOptionsProviderPaths = Array.from(templateMeta.entries()).filter(([, meta]) => meta.emitsGlobalOptions).map(([outputPath]) => outputPath);
2681
+ const templateGlobalOptionsProviderPath = templateGlobalOptionsProviderPaths.length === 1 ? templateGlobalOptionsProviderPaths[0] : void 0;
2682
+ validateGlobalOptionCompatibility(documentedCommandPaths, allCommands, globalOptionDefinitions);
2683
+ if (globalOptionDefinitions.size === 0 && templateGlobalOptionFields.size > 0) {
2684
+ const emittingTemplateScopes = /* @__PURE__ */ new Set();
2685
+ for (const meta of activeTemplateMeta.values()) {
2686
+ if (!meta.emitsGlobalOptions && templateGlobalOptionsProviderPath === void 0) continue;
2687
+ for (const scope of meta.referencedScopes) emittingTemplateScopes.add(scope);
2688
+ }
2689
+ validateGlobalOptionCompatibility(emittingTemplateScopes, allCommands, templateGlobalOptionFields);
2690
+ }
2691
+ if (globalOptionDefinitions.size > 0) for (const info of allCommands.values()) {
2692
+ info.options = info.options.filter((opt) => !globalOptionDefinitions.has(opt.name));
2693
+ if (info.extracted) info.extracted = filterExtractedFields(info.extracted, new Set(globalOptionDefinitions.keys()));
2694
+ }
2695
+ const templateFileMap = {};
2696
+ for (const [scope, outputPath] of Object.entries(fileMap)) setFileMapEntry(templateFileMap, scope, outputPath);
2697
+ const scopeRootLength = (root) => root === "" ? 0 : root.split(" ").length;
2698
+ const templateOwners = /* @__PURE__ */ new Map();
2699
+ for (const [templateOutputPath, meta] of templateMeta.entries()) for (const scope of meta.headingScopes) {
2700
+ if (Object.prototype.hasOwnProperty.call(fileMap, scope)) continue;
2701
+ let bestRootLen = -1;
2702
+ for (const root of meta.commandTreeRoots) if (isSubcommandOf(scope, root)) bestRootLen = Math.max(bestRootLen, scopeRootLength(root));
2703
+ if (bestRootLen < 0) continue;
2704
+ const existing = templateOwners.get(scope);
2705
+ if (!existing || bestRootLen > existing.rootLen) templateOwners.set(scope, {
2706
+ outputPath: templateOutputPath,
2707
+ rootLen: bestRootLen
2708
+ });
2709
+ }
2710
+ for (const [scope, { outputPath }] of templateOwners) setFileMapEntry(templateFileMap, scope, outputPath);
2047
2711
  const results = [];
2048
2712
  let hasError = false;
2049
2713
  for (const [filePath, fileConfigRaw] of Object.entries(files)) {
@@ -2056,18 +2720,20 @@ async function generateDoc(config) {
2056
2720
  const diffs = [];
2057
2721
  const minDepth = Math.min(...commandPaths.map((p) => allCommands.get(p)?.depth ?? 1));
2058
2722
  const adjustedHeadingLevel = Math.max(1, (format?.headingLevel ?? 1) - (minDepth - 1));
2723
+ const isRootDocFile = usingPathConfig && rootDoc && normalizeDocPathForComparison(filePath) === normalizeDocPathForComparison(rootDoc.path);
2724
+ const fileUsesMarkers = usingPathConfig || customizable;
2059
2725
  const fileRenderer = createCommandRenderer({
2060
2726
  ...format,
2061
- headingLevel: adjustedHeadingLevel
2727
+ headingLevel: adjustedHeadingLevel,
2728
+ markerless: !fileUsesMarkers
2062
2729
  });
2063
2730
  const render = fileConfig.render ?? fileRenderer;
2064
- const isRootDocFile = usingPathConfig && rootDoc && normalizeDocPathForComparison(filePath) === normalizeDocPathForComparison(rootDoc.path);
2065
- if (hasTargetCommands || isRootDocFile) {
2731
+ if (Boolean(isRootDocFile) || hasTargetCommands && fileUsesMarkers) {
2066
2732
  let existingContent = readFile(filePath);
2067
2733
  const sortedCommandPaths = sortDepthFirst(commandPaths, specifiedCommands);
2068
2734
  const effectiveTargetCommands = hasTargetCommands ? fileTargetCommands : commandPaths;
2069
2735
  for (const targetCommand of effectiveTargetCommands) {
2070
- const rawSection = generateCommandSection(targetCommand, allCommands, render, filePath, fileMap, rootDoc?.path, globalOptionDefinitions.size > 0);
2736
+ const rawSection = generateCommandSection(targetCommand, allCommands, render, filePath, templateFileMap, rootDoc?.path, globalOptionDefinitions.size > 0, ignores);
2071
2737
  if (!rawSection) throw new Error(`Target command "${targetCommand}" not found in commands`);
2072
2738
  const generatedSection = await applyFormatter(rawSection, formatter);
2073
2739
  if (!existingContent) {
@@ -2129,23 +2795,23 @@ async function generateDoc(config) {
2129
2795
  diffs.push(formatDiff(existingSection, generatedSectionPart));
2130
2796
  }
2131
2797
  }
2132
- if (doctorMode) {
2798
+ if (doctorMode || customizable) {
2133
2799
  const generatedMarkers = collectSectionMarkers(generatedSection, targetCommand);
2134
2800
  const existingMarkerSet = new Set(existingMarkers);
2135
2801
  for (const sectionType of generatedMarkers) {
2136
2802
  if (existingMarkerSet.has(sectionType)) continue;
2137
2803
  const generatedSectionPart = extractSectionMarker(generatedSection, sectionType, targetCommand);
2138
2804
  if (!generatedSectionPart) continue;
2139
- if (updateMode) {
2805
+ if (doctorMode && updateMode) {
2140
2806
  existingContent = insertSectionMarkerAtOrder(existingContent, sectionType, targetCommand, generatedSectionPart);
2141
2807
  writeFile(filePath, existingContent);
2142
2808
  if (fileStatus !== "created") fileStatus = "updated";
2143
- } else {
2809
+ } else if (doctorMode) {
2144
2810
  hasError = true;
2145
2811
  hasDoctorIssues = true;
2146
2812
  fileStatus = "diff";
2147
2813
  diffs.push(`[doctor] Missing section marker "${sectionType}" for command "${formatCommandPath(targetCommand)}". Run with ${DOCTOR_ENV}=true ${UPDATE_GOLDEN_ENV}=true to insert.\n${generatedSectionPart}`);
2148
- }
2814
+ } else console.warn(`[politty] Missing "${sectionType}" section for command "${formatCommandPath(targetCommand)}" in ${filePath}. Run with ${DOCTOR_ENV}=true ${UPDATE_GOLDEN_ENV}=true to insert it, or leave it removed to opt that section out.`);
2149
2815
  }
2150
2816
  }
2151
2817
  }
@@ -2169,7 +2835,7 @@ async function generateDoc(config) {
2169
2835
  }
2170
2836
  }
2171
2837
  } else {
2172
- const generatedMarkdown = await applyFormatter(generateFileMarkdown(commandPaths, allCommands, render, filePath, fileMap, specifiedCommands, fileConfig, rootDoc?.path, globalOptionDefinitions.size > 0), formatter);
2838
+ const generatedMarkdown = await applyFormatter(generateFileMarkdown(commandPaths, allCommands, render, filePath, templateFileMap, specifiedCommands, fileConfig, rootDoc?.path, globalOptionDefinitions.size > 0, ignores), formatter);
2173
2839
  const comparison = compareWithExisting(generatedMarkdown, filePath);
2174
2840
  if (comparison.match) {} else if (updateMode) {
2175
2841
  writeFile(filePath, generatedMarkdown);
@@ -2187,6 +2853,112 @@ async function generateDoc(config) {
2187
2853
  diff: diffs.length > 0 ? diffs.join("\n\n") : void 0
2188
2854
  });
2189
2855
  }
2856
+ let normalizedTemplateGlobalOptions;
2857
+ if (rootDoc?.globalOptions) normalizedTemplateGlobalOptions = normalizeGlobalOptions(rootDoc.globalOptions);
2858
+ else {
2859
+ const shape = deriveGlobalArgsShape(globalArgs);
2860
+ if (shape) normalizedTemplateGlobalOptions = { args: shape };
2861
+ }
2862
+ for (const [outputPath, templatePath] of templateEntries) {
2863
+ if (!activeTemplateMeta.has(outputPath)) continue;
2864
+ const templateContent = templateContents.get(outputPath) ?? null;
2865
+ if (templateContent === null) {
2866
+ hasError = true;
2867
+ results.push({
2868
+ path: outputPath,
2869
+ status: "diff",
2870
+ diff: `Template file not found: ${templatePath}`
2871
+ });
2872
+ continue;
2873
+ }
2874
+ const meta = templateMeta.get(outputPath);
2875
+ const templateLineEnding = detectLineEnding(templateContent);
2876
+ const outputTemplateContent = stripPolittyFrontMatterForOutput(templateContent);
2877
+ const headingDepths = (meta?.headingScopes ?? []).map((s) => allCommands.get(s)?.depth ?? 1);
2878
+ const minDepth = headingDepths.length > 0 ? Math.min(...headingDepths) : 1;
2879
+ const adjustedHeadingLevel = clampHeadingLevel((format?.headingLevel ?? 1) - (minDepth - 1));
2880
+ const templateRenderer = createCommandRenderer({
2881
+ ...format,
2882
+ headingLevel: adjustedHeadingLevel
2883
+ });
2884
+ const outputEmitsGlobalOptions = meta?.emitsGlobalOptions ?? false;
2885
+ const excludeOptionNames = (rootDoc !== void 0 && globalOptionDefinitions.size > 0 || outputEmitsGlobalOptions || templateGlobalOptionsProviderPath !== void 0) && templateGlobalOptionFields.size > 0 ? new Set(templateGlobalOptionFields.keys()) : void 0;
2886
+ const sectionHasGlobalOptions = excludeOptionNames !== void 0;
2887
+ const effectiveRootDocPath = outputEmitsGlobalOptions ? outputPath : rootDoc?.path ?? templateGlobalOptionsProviderPath;
2888
+ const placeholders = Array.from(new Set(outputTemplateContent.match(TEMPLATE_PLACEHOLDER_REGEX) ?? []));
2889
+ const replacements = /* @__PURE__ */ new Map();
2890
+ const exclusions = templateExclusions.get(outputPath) ?? createTemplateExclusions(/* @__PURE__ */ new Set());
2891
+ for (const placeholder of placeholders) {
2892
+ const placeholderKey = templatePlaceholderKey(placeholder);
2893
+ if (exclusions.rawKeys.has(placeholderKey) || isRawCommandPlaceholderUnderExcludedScope(placeholderKey, exclusions)) {
2894
+ replacements.set(placeholder, "");
2895
+ continue;
2896
+ }
2897
+ const parsed = parsePlaceholder(placeholder, allCommands);
2898
+ if (shouldSkipTemplatePlaceholder(placeholder, parsed, exclusions)) {
2899
+ replacements.set(placeholder, "");
2900
+ continue;
2901
+ }
2902
+ if (parsed.kind === "invalid") throw new Error(`Internal error: unresolved placeholder "${placeholder}" in template "${templatePath}": ${parsed.reason}`);
2903
+ if (parsed.kind === "command") {
2904
+ const { scope, type } = parsed;
2905
+ if (type === void 0) {
2906
+ const rawSection = generateCommandTreeMarkdown(scope, allCommands, templateRenderer, ignores, outputPath, templateFileMap, effectiveRootDocPath, sectionHasGlobalOptions, excludeOptionNames, exclusions);
2907
+ if (rawSection === null) {
2908
+ replacements.set(placeholder, "");
2909
+ continue;
2910
+ }
2911
+ replacements.set(placeholder, stripPolittyMarkers(rawSection));
2912
+ } else {
2913
+ const rawSection = generateCommandSection(scope, allCommands, templateRenderer, outputPath, templateFileMap, effectiveRootDocPath, sectionHasGlobalOptions, ignores, excludeOptionNames, exclusions);
2914
+ if (rawSection === null) {
2915
+ replacements.set(placeholder, "");
2916
+ continue;
2917
+ }
2918
+ const extracted = extractSectionMarker(rawSection, type, scope);
2919
+ replacements.set(placeholder, extracted === null ? "" : stripPolittyMarkers(extracted));
2920
+ }
2921
+ } else if (parsed.kind === "global-options") if (normalizedTemplateGlobalOptions) replacements.set(placeholder, buildGlobalOptionsContent(normalizedTemplateGlobalOptions));
2922
+ else replacements.set(placeholder, "");
2923
+ else if (parsed.kind === "index") {
2924
+ const indexContent = await renderCommandIndex(command, [...deriveIndexFromFiles(files, outputPath, allCommands, ignores), ...deriveIndexFromTemplateOutputs(templateMeta, outputPath, outputPath, allCommands)], rootDoc?.index);
2925
+ replacements.set(placeholder, indexContent);
2926
+ }
2927
+ }
2928
+ let generated = outputTemplateContent.replace(/((?:\r?\n)*)([ \t]*)(\{\{politty:[^{}]*\}\})([ \t]*)((?:\r?\n)*)/g, (match, leadNl, leadWs, placeholder, trailWs, trailNl, offset, fullString) => {
2929
+ const replacement = replacements.get(placeholder);
2930
+ if (replacement === void 0) throw new Error(`Internal error: unresolved placeholder "${placeholder}" in template "${templatePath}".`);
2931
+ const startsLine = leadNl !== "" || offset === 0 || fullString[offset - 1] === "\n";
2932
+ const endsLine = trailNl !== "" || offset + match.length === fullString.length;
2933
+ if (replacement === "" && startsLine && endsLine) {
2934
+ if (leadNl === "" || trailNl === "") return "";
2935
+ const leadBreaks = countLineBreaks(leadNl);
2936
+ const trailBreaks = countLineBreaks(trailNl);
2937
+ const widest = Math.max(leadBreaks, trailBreaks);
2938
+ const lineEnding = leadBreaks >= trailBreaks ? detectLineEnding(leadNl) : detectLineEnding(trailNl);
2939
+ return widest >= 2 ? lineEnding + lineEnding : widest === 1 ? lineEnding : "";
2940
+ }
2941
+ return `${leadNl}${leadWs}${replacement}${trailWs}${trailNl}`;
2942
+ });
2943
+ generated = `${generated.trimEnd()}${templateLineEnding}`;
2944
+ generated = await applyFormatter(generated, formatter);
2945
+ const comparison = compareWithExisting(generated, outputPath);
2946
+ let templateStatus = "match";
2947
+ let templateDiff;
2948
+ if (comparison.match) {} else if (updateMode) {
2949
+ writeFile(outputPath, generated);
2950
+ templateStatus = comparison.fileExists ? "updated" : "created";
2951
+ } else {
2952
+ hasError = true;
2953
+ templateStatus = "diff";
2954
+ if (comparison.diff) templateDiff = comparison.diff;
2955
+ }
2956
+ results.push({
2957
+ path: outputPath,
2958
+ status: templateStatus,
2959
+ diff: templateDiff
2960
+ });
2961
+ }
2190
2962
  if (rootDoc) {
2191
2963
  const rootDocFilePath = rootDoc.path;
2192
2964
  let rootDocStatus = "match";
@@ -2292,7 +3064,19 @@ async function assertDocMatch(config) {
2292
3064
  function initDocFile(config, fileSystem) {
2293
3065
  if (!isTruthyEnv("POLITTY_DOCS_UPDATE")) return;
2294
3066
  if (typeof config === "string") deleteFile(config, fileSystem);
2295
- else if (config.files) for (const filePath of Object.keys(config.files)) deleteFile(filePath, fileSystem);
3067
+ else {
3068
+ const protectedPaths = new Set(Object.values(config.templates ?? {}).map(normalizeDocPathForComparison));
3069
+ if (config.rootDoc) protectedPaths.add(normalizeDocPathForComparison(config.rootDoc.path));
3070
+ const isProtectedPath = (p) => protectedPaths.has(normalizeDocPathForComparison(p));
3071
+ if (config.files) for (const filePath of Object.keys(config.files)) {
3072
+ if (isProtectedPath(filePath)) continue;
3073
+ deleteFile(filePath, fileSystem);
3074
+ }
3075
+ if (config.templates) for (const outputPath of Object.keys(config.templates)) {
3076
+ if (isProtectedPath(outputPath)) continue;
3077
+ deleteFile(outputPath, fileSystem);
3078
+ }
3079
+ }
2296
3080
  }
2297
3081
 
2298
3082
  //#endregion