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