@optique/core 0.10.7 → 1.0.0-dev.1116

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/README.md +4 -6
  2. package/dist/annotations.cjs +209 -1
  3. package/dist/annotations.d.cts +78 -1
  4. package/dist/annotations.d.ts +78 -1
  5. package/dist/annotations.js +201 -1
  6. package/dist/completion.cjs +194 -52
  7. package/dist/completion.js +194 -52
  8. package/dist/constructs.cjs +310 -78
  9. package/dist/constructs.d.cts +525 -644
  10. package/dist/constructs.d.ts +525 -644
  11. package/dist/constructs.js +311 -79
  12. package/dist/context.cjs +43 -3
  13. package/dist/context.d.cts +113 -5
  14. package/dist/context.d.ts +113 -5
  15. package/dist/context.js +41 -3
  16. package/dist/dependency.cjs +172 -66
  17. package/dist/dependency.d.cts +22 -2
  18. package/dist/dependency.d.ts +22 -2
  19. package/dist/dependency.js +172 -66
  20. package/dist/doc.cjs +46 -1
  21. package/dist/doc.d.cts +24 -0
  22. package/dist/doc.d.ts +24 -0
  23. package/dist/doc.js +46 -1
  24. package/dist/facade.cjs +702 -322
  25. package/dist/facade.d.cts +124 -190
  26. package/dist/facade.d.ts +124 -190
  27. package/dist/facade.js +703 -323
  28. package/dist/index.cjs +5 -0
  29. package/dist/index.d.cts +5 -5
  30. package/dist/index.d.ts +5 -5
  31. package/dist/index.js +3 -3
  32. package/dist/message.cjs +7 -4
  33. package/dist/message.js +7 -4
  34. package/dist/mode-dispatch.cjs +23 -1
  35. package/dist/mode-dispatch.d.cts +55 -0
  36. package/dist/mode-dispatch.d.ts +55 -0
  37. package/dist/mode-dispatch.js +21 -1
  38. package/dist/modifiers.cjs +210 -55
  39. package/dist/modifiers.js +211 -56
  40. package/dist/parser.cjs +80 -47
  41. package/dist/parser.d.cts +18 -3
  42. package/dist/parser.d.ts +18 -3
  43. package/dist/parser.js +82 -50
  44. package/dist/primitives.cjs +102 -37
  45. package/dist/primitives.d.cts +81 -24
  46. package/dist/primitives.d.ts +81 -24
  47. package/dist/primitives.js +103 -39
  48. package/dist/usage.cjs +88 -6
  49. package/dist/usage.d.cts +51 -13
  50. package/dist/usage.d.ts +51 -13
  51. package/dist/usage.js +85 -7
  52. package/dist/valueparser.cjs +391 -106
  53. package/dist/valueparser.d.cts +62 -10
  54. package/dist/valueparser.d.ts +62 -10
  55. package/dist/valueparser.js +391 -106
  56. package/package.json +10 -1
@@ -66,6 +66,7 @@ const suggestWithDependency = Symbol.for("@optique/core/dependency/suggestWithDe
66
66
  * // Create a derived parser that depends on the directory
67
67
  * const branchParser = cwdParser.derive({
68
68
  * metavar: "BRANCH",
69
+ * mode: "sync",
69
70
  * factory: (dir) => gitBranch({ dir }),
70
71
  * defaultValue: () => process.cwd(),
71
72
  * });
@@ -79,14 +80,15 @@ function dependency(parser) {
79
80
  [dependencySourceMarker]: true,
80
81
  [dependencyId]: id,
81
82
  derive(options) {
82
- return createDerivedValueParser(id, parser, options);
83
+ if (options.mode !== "sync" && options.mode !== "async") throw new TypeError("derive() requires an explicit mode field (\"sync\" or \"async\").");
84
+ return createDerivedValueParser(id, parser, options, options.mode);
83
85
  },
84
86
  deriveSync(options) {
85
87
  if (parser.$mode === "async") return createAsyncDerivedParserFromSyncFactory(id, options);
86
88
  return createSyncDerivedParser(id, options);
87
89
  },
88
90
  deriveAsync(options) {
89
- return createDerivedValueParser(id, parser, options);
91
+ return createAsyncDerivedParserFromAsyncFactory(id, options);
90
92
  }
91
93
  };
92
94
  return result;
@@ -132,6 +134,7 @@ function isDerivedValueParser(parser) {
132
134
  *
133
135
  * const configParser = deriveFrom({
134
136
  * metavar: "CONFIG",
137
+ * mode: "sync",
135
138
  * dependencies: [dirParser, modeParser] as const,
136
139
  * factory: (dir, mode) =>
137
140
  * choice(mode === "dev"
@@ -140,12 +143,14 @@ function isDerivedValueParser(parser) {
140
143
  * defaultValues: () => ["/config", "dev"],
141
144
  * });
142
145
  * ```
146
+ * @throws {TypeError} If the `mode` field is missing or invalid.
143
147
  * @since 0.10.0
144
148
  */
145
149
  function deriveFrom(options) {
150
+ if (options.mode !== "sync" && options.mode !== "async") throw new TypeError("deriveFrom() requires an explicit mode field (\"sync\" or \"async\").");
146
151
  const depsAsync = options.dependencies.some((dep) => dep.$mode === "async");
147
- const factoryReturnsAsync = determineFactoryModeForDeriveFrom(options);
148
152
  const sourceId = options.dependencies.length > 0 ? options.dependencies[0][dependencyId] : Symbol();
153
+ const factoryReturnsAsync = options.mode === "async";
149
154
  const isAsync = depsAsync || factoryReturnsAsync;
150
155
  if (isAsync) {
151
156
  if (factoryReturnsAsync) return createAsyncDerivedFromParserFromAsyncFactory(sourceId, options);
@@ -191,14 +196,6 @@ function deriveFromAsync(options) {
191
196
  const sourceId = options.dependencies.length > 0 ? options.dependencies[0][dependencyId] : Symbol();
192
197
  return createAsyncDerivedFromParserFromAsyncFactory(sourceId, options);
193
198
  }
194
- /**
195
- * Determines if the factory returns an async parser for deriveFrom options.
196
- */
197
- function determineFactoryModeForDeriveFrom(options) {
198
- const defaultValues$1 = options.defaultValues();
199
- const parser = options.factory(...defaultValues$1);
200
- return parser.$mode === "async";
201
- }
202
199
  function isAsyncModeParser(parser) {
203
200
  return parser.$mode === "async";
204
201
  }
@@ -212,8 +209,17 @@ function createSyncDerivedFromParser(sourceId, options) {
212
209
  [dependencyIds]: alldependencyIds,
213
210
  [defaultValues]: options.defaultValues,
214
211
  parse(input) {
215
- const sourceValues = options.defaultValues();
216
- const derivedParser = options.factory(...sourceValues);
212
+ let derivedParser;
213
+ try {
214
+ const sourceValues = options.defaultValues();
215
+ derivedParser = options.factory(...sourceValues);
216
+ } catch (e) {
217
+ const msg = e instanceof Error ? e.message : String(e);
218
+ return {
219
+ success: false,
220
+ error: message`Derived parser error: ${msg}`
221
+ };
222
+ }
217
223
  if (isAsyncModeParser(derivedParser)) return {
218
224
  success: false,
219
225
  error: message`Factory returned an async parser where a sync parser is required.`
@@ -238,14 +244,25 @@ function createSyncDerivedFromParser(sourceId, options) {
238
244
  return derivedParser.parse(input);
239
245
  },
240
246
  format(value) {
241
- const sourceValues = options.defaultValues();
242
- const derivedParser = options.factory(...sourceValues);
247
+ let derivedParser;
248
+ try {
249
+ const sourceValues = options.defaultValues();
250
+ derivedParser = options.factory(...sourceValues);
251
+ } catch {
252
+ return String(value);
253
+ }
243
254
  return derivedParser.format(value);
244
255
  },
245
256
  *suggest(prefix) {
246
- const sourceValues = options.defaultValues();
247
- const derivedParser = options.factory(...sourceValues);
248
- if (derivedParser.suggest) yield* derivedParser.suggest(prefix);
257
+ let derivedParser;
258
+ try {
259
+ const sourceValues = options.defaultValues();
260
+ derivedParser = options.factory(...sourceValues);
261
+ } catch {
262
+ return;
263
+ }
264
+ if (isAsyncModeParser(derivedParser) || !derivedParser.suggest) return;
265
+ yield* derivedParser.suggest(prefix);
249
266
  },
250
267
  *[suggestWithDependency](prefix, dependencyValue) {
251
268
  let derivedParser;
@@ -259,7 +276,8 @@ function createSyncDerivedFromParser(sourceId, options) {
259
276
  return;
260
277
  }
261
278
  }
262
- if (derivedParser.suggest) yield* derivedParser.suggest(prefix);
279
+ if (isAsyncModeParser(derivedParser) || !derivedParser.suggest) return;
280
+ yield* derivedParser.suggest(prefix);
263
281
  }
264
282
  };
265
283
  }
@@ -277,9 +295,18 @@ function createAsyncDerivedFromParserFromAsyncFactory(sourceId, options) {
277
295
  [dependencyIds]: alldependencyIds,
278
296
  [defaultValues]: options.defaultValues,
279
297
  parse(input) {
280
- const sourceValues = options.defaultValues();
281
- const derivedParser = options.factory(...sourceValues);
282
- return derivedParser.parse(input);
298
+ let derivedParser;
299
+ try {
300
+ const sourceValues = options.defaultValues();
301
+ derivedParser = options.factory(...sourceValues);
302
+ } catch (e) {
303
+ const msg = e instanceof Error ? e.message : String(e);
304
+ return Promise.resolve({
305
+ success: false,
306
+ error: message`Derived parser error: ${msg}`
307
+ });
308
+ }
309
+ return Promise.resolve(derivedParser.parse(input));
283
310
  },
284
311
  [parseWithDependency](input, dependencyValue) {
285
312
  let derivedParser;
@@ -292,16 +319,26 @@ function createAsyncDerivedFromParserFromAsyncFactory(sourceId, options) {
292
319
  error: message`Factory error: ${msg}`
293
320
  });
294
321
  }
295
- return derivedParser.parse(input);
322
+ return Promise.resolve(derivedParser.parse(input));
296
323
  },
297
324
  format(value) {
298
- const sourceValues = options.defaultValues();
299
- const derivedParser = options.factory(...sourceValues);
325
+ let derivedParser;
326
+ try {
327
+ const sourceValues = options.defaultValues();
328
+ derivedParser = options.factory(...sourceValues);
329
+ } catch {
330
+ return String(value);
331
+ }
300
332
  return derivedParser.format(value);
301
333
  },
302
334
  async *suggest(prefix) {
303
- const sourceValues = options.defaultValues();
304
- const derivedParser = options.factory(...sourceValues);
335
+ let derivedParser;
336
+ try {
337
+ const sourceValues = options.defaultValues();
338
+ derivedParser = options.factory(...sourceValues);
339
+ } catch {
340
+ return;
341
+ }
305
342
  if (derivedParser.suggest) for await (const suggestion of derivedParser.suggest(prefix)) yield suggestion;
306
343
  },
307
344
  async *[suggestWithDependency](prefix, dependencyValue) {
@@ -334,8 +371,17 @@ function createAsyncDerivedFromParserFromSyncFactory(sourceId, options) {
334
371
  [dependencyIds]: alldependencyIds,
335
372
  [defaultValues]: options.defaultValues,
336
373
  parse(input) {
337
- const sourceValues = options.defaultValues();
338
- const derivedParser = options.factory(...sourceValues);
374
+ let derivedParser;
375
+ try {
376
+ const sourceValues = options.defaultValues();
377
+ derivedParser = options.factory(...sourceValues);
378
+ } catch (e) {
379
+ const msg = e instanceof Error ? e.message : String(e);
380
+ return Promise.resolve({
381
+ success: false,
382
+ error: message`Derived parser error: ${msg}`
383
+ });
384
+ }
339
385
  return Promise.resolve(derivedParser.parse(input));
340
386
  },
341
387
  [parseWithDependency](input, dependencyValue) {
@@ -352,13 +398,23 @@ function createAsyncDerivedFromParserFromSyncFactory(sourceId, options) {
352
398
  return Promise.resolve(derivedParser.parse(input));
353
399
  },
354
400
  format(value) {
355
- const sourceValues = options.defaultValues();
356
- const derivedParser = options.factory(...sourceValues);
401
+ let derivedParser;
402
+ try {
403
+ const sourceValues = options.defaultValues();
404
+ derivedParser = options.factory(...sourceValues);
405
+ } catch {
406
+ return String(value);
407
+ }
357
408
  return derivedParser.format(value);
358
409
  },
359
410
  async *suggest(prefix) {
360
- const sourceValues = options.defaultValues();
361
- const derivedParser = options.factory(...sourceValues);
411
+ let derivedParser;
412
+ try {
413
+ const sourceValues = options.defaultValues();
414
+ derivedParser = options.factory(...sourceValues);
415
+ } catch {
416
+ return;
417
+ }
362
418
  if (derivedParser.suggest) yield* derivedParser.suggest(prefix);
363
419
  },
364
420
  *[suggestWithDependency](prefix, dependencyValue) {
@@ -377,8 +433,8 @@ function createAsyncDerivedFromParserFromSyncFactory(sourceId, options) {
377
433
  }
378
434
  };
379
435
  }
380
- function createDerivedValueParser(sourceId, sourceParser, options) {
381
- const factoryReturnsAsync = determineFactoryMode(options);
436
+ function createDerivedValueParser(sourceId, sourceParser, options, factoryMode) {
437
+ const factoryReturnsAsync = factoryMode === "async";
382
438
  const isAsync = sourceParser.$mode === "async" || factoryReturnsAsync;
383
439
  if (isAsync) {
384
440
  if (factoryReturnsAsync) return createAsyncDerivedParserFromAsyncFactory(sourceId, options);
@@ -386,15 +442,6 @@ function createDerivedValueParser(sourceId, sourceParser, options) {
386
442
  }
387
443
  return createSyncDerivedParser(sourceId, options);
388
444
  }
389
- /**
390
- * Determines if the factory returns an async parser by calling it with
391
- * the default value and checking the mode.
392
- */
393
- function determineFactoryMode(options) {
394
- const defaultValue = options.defaultValue();
395
- const parser = options.factory(defaultValue);
396
- return parser.$mode === "async";
397
- }
398
445
  function createSyncDerivedParser(sourceId, options) {
399
446
  return {
400
447
  $mode: "sync",
@@ -402,8 +449,17 @@ function createSyncDerivedParser(sourceId, options) {
402
449
  [derivedValueParserMarker]: true,
403
450
  [dependencyId]: sourceId,
404
451
  parse(input) {
405
- const sourceValue = options.defaultValue();
406
- const derivedParser = options.factory(sourceValue);
452
+ let derivedParser;
453
+ try {
454
+ const sourceValue = options.defaultValue();
455
+ derivedParser = options.factory(sourceValue);
456
+ } catch (e) {
457
+ const msg = e instanceof Error ? e.message : String(e);
458
+ return {
459
+ success: false,
460
+ error: message`Derived parser error: ${msg}`
461
+ };
462
+ }
407
463
  if (isAsyncModeParser(derivedParser)) return {
408
464
  success: false,
409
465
  error: message`Factory returned an async parser where a sync parser is required.`
@@ -428,14 +484,25 @@ function createSyncDerivedParser(sourceId, options) {
428
484
  return derivedParser.parse(input);
429
485
  },
430
486
  format(value) {
431
- const sourceValue = options.defaultValue();
432
- const derivedParser = options.factory(sourceValue);
487
+ let derivedParser;
488
+ try {
489
+ const sourceValue = options.defaultValue();
490
+ derivedParser = options.factory(sourceValue);
491
+ } catch {
492
+ return String(value);
493
+ }
433
494
  return derivedParser.format(value);
434
495
  },
435
496
  *suggest(prefix) {
436
- const sourceValue = options.defaultValue();
437
- const derivedParser = options.factory(sourceValue);
438
- if (derivedParser.suggest) yield* derivedParser.suggest(prefix);
497
+ let derivedParser;
498
+ try {
499
+ const sourceValue = options.defaultValue();
500
+ derivedParser = options.factory(sourceValue);
501
+ } catch {
502
+ return;
503
+ }
504
+ if (isAsyncModeParser(derivedParser) || !derivedParser.suggest) return;
505
+ yield* derivedParser.suggest(prefix);
439
506
  },
440
507
  *[suggestWithDependency](prefix, dependencyValue) {
441
508
  let derivedParser;
@@ -448,7 +515,8 @@ function createSyncDerivedParser(sourceId, options) {
448
515
  return;
449
516
  }
450
517
  }
451
- if (derivedParser.suggest) yield* derivedParser.suggest(prefix);
518
+ if (isAsyncModeParser(derivedParser) || !derivedParser.suggest) return;
519
+ yield* derivedParser.suggest(prefix);
452
520
  }
453
521
  };
454
522
  }
@@ -463,9 +531,18 @@ function createAsyncDerivedParserFromAsyncFactory(sourceId, options) {
463
531
  [derivedValueParserMarker]: true,
464
532
  [dependencyId]: sourceId,
465
533
  parse(input) {
466
- const sourceValue = options.defaultValue();
467
- const derivedParser = options.factory(sourceValue);
468
- return derivedParser.parse(input);
534
+ let derivedParser;
535
+ try {
536
+ const sourceValue = options.defaultValue();
537
+ derivedParser = options.factory(sourceValue);
538
+ } catch (e) {
539
+ const msg = e instanceof Error ? e.message : String(e);
540
+ return Promise.resolve({
541
+ success: false,
542
+ error: message`Derived parser error: ${msg}`
543
+ });
544
+ }
545
+ return Promise.resolve(derivedParser.parse(input));
469
546
  },
470
547
  [parseWithDependency](input, dependencyValue) {
471
548
  let derivedParser;
@@ -478,16 +555,26 @@ function createAsyncDerivedParserFromAsyncFactory(sourceId, options) {
478
555
  error: message`Factory error: ${msg}`
479
556
  });
480
557
  }
481
- return derivedParser.parse(input);
558
+ return Promise.resolve(derivedParser.parse(input));
482
559
  },
483
560
  format(value) {
484
- const sourceValue = options.defaultValue();
485
- const derivedParser = options.factory(sourceValue);
561
+ let derivedParser;
562
+ try {
563
+ const sourceValue = options.defaultValue();
564
+ derivedParser = options.factory(sourceValue);
565
+ } catch {
566
+ return String(value);
567
+ }
486
568
  return derivedParser.format(value);
487
569
  },
488
570
  async *suggest(prefix) {
489
- const sourceValue = options.defaultValue();
490
- const derivedParser = options.factory(sourceValue);
571
+ let derivedParser;
572
+ try {
573
+ const sourceValue = options.defaultValue();
574
+ derivedParser = options.factory(sourceValue);
575
+ } catch {
576
+ return;
577
+ }
491
578
  if (derivedParser.suggest) for await (const suggestion of derivedParser.suggest(prefix)) yield suggestion;
492
579
  },
493
580
  async *[suggestWithDependency](prefix, dependencyValue) {
@@ -516,8 +603,17 @@ function createAsyncDerivedParserFromSyncFactory(sourceId, options) {
516
603
  [derivedValueParserMarker]: true,
517
604
  [dependencyId]: sourceId,
518
605
  parse(input) {
519
- const sourceValue = options.defaultValue();
520
- const derivedParser = options.factory(sourceValue);
606
+ let derivedParser;
607
+ try {
608
+ const sourceValue = options.defaultValue();
609
+ derivedParser = options.factory(sourceValue);
610
+ } catch (e) {
611
+ const msg = e instanceof Error ? e.message : String(e);
612
+ return Promise.resolve({
613
+ success: false,
614
+ error: message`Derived parser error: ${msg}`
615
+ });
616
+ }
521
617
  return Promise.resolve(derivedParser.parse(input));
522
618
  },
523
619
  [parseWithDependency](input, dependencyValue) {
@@ -534,13 +630,23 @@ function createAsyncDerivedParserFromSyncFactory(sourceId, options) {
534
630
  return Promise.resolve(derivedParser.parse(input));
535
631
  },
536
632
  format(value) {
537
- const sourceValue = options.defaultValue();
538
- const derivedParser = options.factory(sourceValue);
633
+ let derivedParser;
634
+ try {
635
+ const sourceValue = options.defaultValue();
636
+ derivedParser = options.factory(sourceValue);
637
+ } catch {
638
+ return String(value);
639
+ }
539
640
  return derivedParser.format(value);
540
641
  },
541
642
  async *suggest(prefix) {
542
- const sourceValue = options.defaultValue();
543
- const derivedParser = options.factory(sourceValue);
643
+ let derivedParser;
644
+ try {
645
+ const sourceValue = options.defaultValue();
646
+ derivedParser = options.factory(sourceValue);
647
+ } catch {
648
+ return;
649
+ }
544
650
  if (derivedParser.suggest) yield* derivedParser.suggest(prefix);
545
651
  },
546
652
  *[suggestWithDependency](prefix, dependencyValue) {
package/dist/doc.cjs CHANGED
@@ -3,6 +3,39 @@ const require_usage = require('./usage.cjs');
3
3
 
4
4
  //#region src/doc.ts
5
5
  /**
6
+ * Classifies a {@link DocSection} by its content type for use in the
7
+ * default smart sort.
8
+ *
9
+ * @returns `0` for command-only sections, `1` for mixed sections, `2` for
10
+ * option/argument/passthrough-only sections.
11
+ */
12
+ function classifySection(section) {
13
+ const hasCommand = section.entries.some((e) => e.term.type === "command");
14
+ const hasNonCommand = section.entries.some((e) => e.term.type !== "command");
15
+ if (hasCommand && !hasNonCommand) return 0;
16
+ if (hasCommand && hasNonCommand) return 1;
17
+ return 2;
18
+ }
19
+ /**
20
+ * Scores a section for the default smart sort. Untitled sections receive
21
+ * a bonus of `-1` so that the main (untitled) section appears before titled
22
+ * sections of a similar classification.
23
+ */
24
+ function scoreSection(section) {
25
+ return classifySection(section) + (section.title == null ? -1 : 0);
26
+ }
27
+ /**
28
+ * The default section comparator: command-only sections come first, then
29
+ * mixed sections, then option/argument-only sections. Untitled sections
30
+ * receive a score bonus of -1 via {@link scoreSection} so that untitled
31
+ * command-only sections naturally sort before titled command-only sections.
32
+ * Sections with the same score preserve their original relative order
33
+ * (stable sort).
34
+ */
35
+ function defaultSectionOrder(a, b) {
36
+ return scoreSection(a) - scoreSection(b);
37
+ }
38
+ /**
6
39
  * Formats a documentation page into a human-readable string.
7
40
  *
8
41
  * This function takes a structured {@link DocPage} and converts it into
@@ -14,6 +47,8 @@ const require_usage = require('./usage.cjs');
14
47
  * @param page The documentation page to format
15
48
  * @param options Formatting options to customize the output
16
49
  * @returns A formatted string representation of the documentation page
50
+ * @throws {TypeError} If `programName` or any non-empty section's title
51
+ * contains a CR or LF character.
17
52
  *
18
53
  * @example
19
54
  * ```typescript
@@ -34,6 +69,7 @@ const require_usage = require('./usage.cjs');
34
69
  * ```
35
70
  */
36
71
  function formatDocPage(programName, page, options = {}) {
72
+ if (/[\r\n]/.test(programName)) throw new TypeError("Program name must not contain newlines.");
37
73
  const termIndent = options.termIndent ?? 2;
38
74
  const termWidth = options.termWidth ?? 26;
39
75
  let output = "";
@@ -64,11 +100,20 @@ function formatDocPage(programName, page, options = {}) {
64
100
  });
65
101
  output += "\n";
66
102
  }
67
- const sections = page.sections.toSorted((a, b) => a.title == null && b.title == null ? 0 : a.title == null ? -1 : 1);
103
+ const comparator = options.sectionOrder ?? defaultSectionOrder;
104
+ const sections = page.sections.map((s, i) => ({
105
+ section: s,
106
+ index: i
107
+ })).toSorted((a, b) => {
108
+ const cmp = comparator(a.section, b.section);
109
+ if (cmp !== 0) return cmp;
110
+ return a.index - b.index;
111
+ }).map(({ section }) => section);
68
112
  for (const section of sections) {
69
113
  if (section.entries.length < 1) continue;
70
114
  output += "\n";
71
115
  if (section.title != null) {
116
+ if (/[\r\n]/.test(section.title)) throw new TypeError("Section title must not contain newlines.");
72
117
  const sectionLabel = options.colors ? `\x1b[1;2m${section.title}:\x1b[0m\n` : `${section.title}:\n`;
73
118
  output += sectionLabel;
74
119
  }
package/dist/doc.d.cts CHANGED
@@ -228,6 +228,28 @@ interface DocPageFormatOptions {
228
228
  * ```
229
229
  */
230
230
  showChoices?: boolean | ShowChoicesOptions;
231
+ /**
232
+ * A custom comparator function to control the order of sections in the
233
+ * help output. When provided, it is used instead of the default smart
234
+ * sort (command-only sections first, then mixed, then option/argument-only
235
+ * sections). Sections that compare equal (return `0`) preserve their
236
+ * original relative order (stable sort).
237
+ *
238
+ * @param a The first section to compare.
239
+ * @param b The second section to compare.
240
+ * @returns A negative number if `a` should appear before `b`, a positive
241
+ * number if `a` should appear after `b`, or `0` if they are equal.
242
+ * @since 1.0.0
243
+ *
244
+ * @example
245
+ * ```typescript
246
+ * // Sort sections alphabetically by title
247
+ * {
248
+ * sectionOrder: (a, b) => (a.title ?? "").localeCompare(b.title ?? "")
249
+ * }
250
+ * ```
251
+ */
252
+ sectionOrder?: (a: DocSection, b: DocSection) => number;
231
253
  }
232
254
  /**
233
255
  * Formats a documentation page into a human-readable string.
@@ -241,6 +263,8 @@ interface DocPageFormatOptions {
241
263
  * @param page The documentation page to format
242
264
  * @param options Formatting options to customize the output
243
265
  * @returns A formatted string representation of the documentation page
266
+ * @throws {TypeError} If `programName` or any non-empty section's title
267
+ * contains a CR or LF character.
244
268
  *
245
269
  * @example
246
270
  * ```typescript
package/dist/doc.d.ts CHANGED
@@ -228,6 +228,28 @@ interface DocPageFormatOptions {
228
228
  * ```
229
229
  */
230
230
  showChoices?: boolean | ShowChoicesOptions;
231
+ /**
232
+ * A custom comparator function to control the order of sections in the
233
+ * help output. When provided, it is used instead of the default smart
234
+ * sort (command-only sections first, then mixed, then option/argument-only
235
+ * sections). Sections that compare equal (return `0`) preserve their
236
+ * original relative order (stable sort).
237
+ *
238
+ * @param a The first section to compare.
239
+ * @param b The second section to compare.
240
+ * @returns A negative number if `a` should appear before `b`, a positive
241
+ * number if `a` should appear after `b`, or `0` if they are equal.
242
+ * @since 1.0.0
243
+ *
244
+ * @example
245
+ * ```typescript
246
+ * // Sort sections alphabetically by title
247
+ * {
248
+ * sectionOrder: (a, b) => (a.title ?? "").localeCompare(b.title ?? "")
249
+ * }
250
+ * ```
251
+ */
252
+ sectionOrder?: (a: DocSection, b: DocSection) => number;
231
253
  }
232
254
  /**
233
255
  * Formats a documentation page into a human-readable string.
@@ -241,6 +263,8 @@ interface DocPageFormatOptions {
241
263
  * @param page The documentation page to format
242
264
  * @param options Formatting options to customize the output
243
265
  * @returns A formatted string representation of the documentation page
266
+ * @throws {TypeError} If `programName` or any non-empty section's title
267
+ * contains a CR or LF character.
244
268
  *
245
269
  * @example
246
270
  * ```typescript
package/dist/doc.js CHANGED
@@ -3,6 +3,39 @@ import { formatUsage, formatUsageTerm } from "./usage.js";
3
3
 
4
4
  //#region src/doc.ts
5
5
  /**
6
+ * Classifies a {@link DocSection} by its content type for use in the
7
+ * default smart sort.
8
+ *
9
+ * @returns `0` for command-only sections, `1` for mixed sections, `2` for
10
+ * option/argument/passthrough-only sections.
11
+ */
12
+ function classifySection(section) {
13
+ const hasCommand = section.entries.some((e) => e.term.type === "command");
14
+ const hasNonCommand = section.entries.some((e) => e.term.type !== "command");
15
+ if (hasCommand && !hasNonCommand) return 0;
16
+ if (hasCommand && hasNonCommand) return 1;
17
+ return 2;
18
+ }
19
+ /**
20
+ * Scores a section for the default smart sort. Untitled sections receive
21
+ * a bonus of `-1` so that the main (untitled) section appears before titled
22
+ * sections of a similar classification.
23
+ */
24
+ function scoreSection(section) {
25
+ return classifySection(section) + (section.title == null ? -1 : 0);
26
+ }
27
+ /**
28
+ * The default section comparator: command-only sections come first, then
29
+ * mixed sections, then option/argument-only sections. Untitled sections
30
+ * receive a score bonus of -1 via {@link scoreSection} so that untitled
31
+ * command-only sections naturally sort before titled command-only sections.
32
+ * Sections with the same score preserve their original relative order
33
+ * (stable sort).
34
+ */
35
+ function defaultSectionOrder(a, b) {
36
+ return scoreSection(a) - scoreSection(b);
37
+ }
38
+ /**
6
39
  * Formats a documentation page into a human-readable string.
7
40
  *
8
41
  * This function takes a structured {@link DocPage} and converts it into
@@ -14,6 +47,8 @@ import { formatUsage, formatUsageTerm } from "./usage.js";
14
47
  * @param page The documentation page to format
15
48
  * @param options Formatting options to customize the output
16
49
  * @returns A formatted string representation of the documentation page
50
+ * @throws {TypeError} If `programName` or any non-empty section's title
51
+ * contains a CR or LF character.
17
52
  *
18
53
  * @example
19
54
  * ```typescript
@@ -34,6 +69,7 @@ import { formatUsage, formatUsageTerm } from "./usage.js";
34
69
  * ```
35
70
  */
36
71
  function formatDocPage(programName, page, options = {}) {
72
+ if (/[\r\n]/.test(programName)) throw new TypeError("Program name must not contain newlines.");
37
73
  const termIndent = options.termIndent ?? 2;
38
74
  const termWidth = options.termWidth ?? 26;
39
75
  let output = "";
@@ -64,11 +100,20 @@ function formatDocPage(programName, page, options = {}) {
64
100
  });
65
101
  output += "\n";
66
102
  }
67
- const sections = page.sections.toSorted((a, b) => a.title == null && b.title == null ? 0 : a.title == null ? -1 : 1);
103
+ const comparator = options.sectionOrder ?? defaultSectionOrder;
104
+ const sections = page.sections.map((s, i) => ({
105
+ section: s,
106
+ index: i
107
+ })).toSorted((a, b) => {
108
+ const cmp = comparator(a.section, b.section);
109
+ if (cmp !== 0) return cmp;
110
+ return a.index - b.index;
111
+ }).map(({ section }) => section);
68
112
  for (const section of sections) {
69
113
  if (section.entries.length < 1) continue;
70
114
  output += "\n";
71
115
  if (section.title != null) {
116
+ if (/[\r\n]/.test(section.title)) throw new TypeError("Section title must not contain newlines.");
72
117
  const sectionLabel = options.colors ? `\x1b[1;2m${section.title}:\x1b[0m\n` : `${section.title}:\n`;
73
118
  output += sectionLabel;
74
119
  }