@stats-forge/github-stats-forge-cli 0.3.2 → 0.4.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.
package/src/prompts.ts CHANGED
@@ -1,17 +1,20 @@
1
- import { checkbox, confirm, input, password, select } from '@inquirer/prompts';
2
-
3
- import type { CardKind, CardOption } from './cards.ts';
4
- import { cards } from './cards.ts';
5
- import type { Answer } from './query.ts';
6
- import { describeAnswer } from './query.ts';
7
-
8
1
  /**
9
2
  * @file The navigation itself.
10
3
  *
11
- * A card first, then its required options, then a menu of every other option:
12
- * pick one, answer it, and land back on the menu with the answer beside it.
4
+ * A card first, then its required options, then a menu of every other option
5
+ * under the section it belongs to: pick one, answer it, and land back on the
6
+ * menu with the answer beside it.
13
7
  */
14
8
 
9
+ import { styleText } from 'node:util';
10
+
11
+ import { checkbox, confirm, input, password, select, Separator } from '@inquirer/prompts';
12
+
13
+ import type { CardKind, CardOption } from './cards.ts';
14
+ import { cards, OPTION_GROUPS } from './cards.ts';
15
+ import type { Answer } from './query.ts';
16
+ import { describeAnswer, UNSET } from './query.ts';
17
+
15
18
  /** @returns The card to render. */
16
19
  export const pickCard = (): Promise<CardKind> =>
17
20
  select({
@@ -59,6 +62,34 @@ const askOption = async (option: CardOption, current: Answer): Promise<Answer> =
59
62
  /** How a trip through the option menu ended. */
60
63
  export type MenuChoice = 'generate' | 'save' | 'quit';
61
64
 
65
+ /** Rows the list may use: the terminal, less the message, the help line and some air. */
66
+ const menuHeight = (): number =>
67
+ process.stdout.isTTY ? Math.max(10, process.stdout.rows - 6) : 15;
68
+
69
+ /**
70
+ * A section heading, ruled out to the width of the labels under it.
71
+ *
72
+ * @returns A `Separator`, so the cursor steps straight over it.
73
+ */
74
+ const heading = (label: string, width: number): Separator =>
75
+ new Separator(styleText('dim', `── ${label} ${'─'.repeat(Math.max(2, width - label.length))}`));
76
+
77
+ /**
78
+ * Typing jumps to the first row whose label starts with what was typed, which is
79
+ * what keeps the three actions one key away however far down the list the cursor sits.
80
+ *
81
+ * @returns The line under the list.
82
+ */
83
+ const keysHelpTip = (keys: ReadonlyArray<[key: string, action: string]>): string => {
84
+ const all: ReadonlyArray<[string, string]> = [
85
+ ...keys,
86
+ ['type', 'to jump — g generate, s save, q quit'],
87
+ ];
88
+ return all
89
+ .map(([key, action]) => `${styleText('bold', key)} ${styleText('dim', action)}`)
90
+ .join(styleText('dim', ' • '));
91
+ };
92
+
62
93
  /** What the menu carries between trips through it. */
63
94
  export interface Menu {
64
95
  /** Answers so far, edited in place. */
@@ -86,22 +117,41 @@ export const navigateOptions = async (
86
117
  ): Promise<MenuChoice> => {
87
118
  // The label carries a description after an em dash; the menu wants the name.
88
119
  const [name = card.id] = card.label.split(' — ');
120
+ const width = Math.max(...card.options.map((option) => option.label.length));
89
121
 
90
122
  for (;;) {
123
+ // A section none of this card's options sit under is dropped, heading and all.
124
+ const sections = OPTION_GROUPS.flatMap<Separator | { name: string; value: CardOption }>(
125
+ ({ group, label }) => {
126
+ const options = card.options.filter((option) => option.group === group);
127
+ if (options.length === 0) {
128
+ return [];
129
+ }
130
+ return [
131
+ heading(label, width),
132
+ ...options.map((option) => {
133
+ // An unanswered option recedes, so what is set reads as the foreground.
134
+ const answer = describeAnswer(option, menu.answers.get(option.name));
135
+ const value = answer === UNSET ? styleText('dim', answer) : answer;
136
+ return { name: `${option.label.padEnd(width)} ${value}`, value: option };
137
+ }),
138
+ ];
139
+ },
140
+ );
141
+
91
142
  const choice = await select<CardOption | MenuChoice>({
92
143
  message: status ? `${name} — ${status}` : `${name} — set an option, or generate`,
93
- pageSize: 15,
144
+ pageSize: menuHeight(),
145
+ theme: { style: { keysHelpTip } },
94
146
  // Matched by reference against the values below, so the option objects work.
95
147
  // `default` does not accept an explicit undefined, so an unset cursor omits it.
96
148
  ...(menu.cursor !== undefined && { default: menu.cursor }),
97
149
  choices: [
150
+ heading('Actions', width),
98
151
  { name: 'Generate the card', value: 'generate' as const },
99
152
  { name: 'Save these options', value: 'save' as const },
100
153
  { name: 'Quit', value: 'quit' as const },
101
- ...card.options.map((option) => ({
102
- name: `${option.label.padEnd(38)} ${describeAnswer(option, menu.answers.get(option.name))}`,
103
- value: option,
104
- })),
154
+ ...sections,
105
155
  ],
106
156
  });
107
157
 
package/src/query.ts CHANGED
@@ -1,5 +1,3 @@
1
- import type { CardKind, CardOption } from './cards.ts';
2
-
3
1
  /**
4
2
  * @file Answers in, query params out.
5
3
  *
@@ -7,6 +5,8 @@ import type { CardKind, CardOption } from './cards.ts';
7
5
  * answer becomes one here, and an unanswered option is simply absent.
8
6
  */
9
7
 
8
+ import type { CardKind, CardOption } from './cards.ts';
9
+
10
10
  /** What a prompt answered, before it becomes a query param. */
11
11
  export type Answer = string | number | boolean | Array<string> | undefined;
12
12
 
@@ -37,15 +37,18 @@ export const toQuery = (answers: ReadonlyMap<string, Answer>): Record<string, st
37
37
  return query;
38
38
  };
39
39
 
40
+ /** What the menu shows for an option nothing has answered. */
41
+ export const UNSET = '—';
42
+
40
43
  /**
41
44
  * How an answer reads back in the option menu.
42
45
  *
43
- * @returns The value as the menu shows it.
46
+ * @returns The value as the menu shows it, or {@link UNSET}.
44
47
  */
45
48
  export const describeAnswer = (option: CardOption, value: Answer): string => {
46
49
  const param = toParam(value);
47
50
  if (param === undefined) {
48
- return '—';
51
+ return UNSET;
49
52
  }
50
53
  return option.kind === 'boolean' ? (value === true ? 'yes' : 'no') : param;
51
54
  };
package/src/saved-card.ts CHANGED
@@ -1,3 +1,10 @@
1
+ /**
2
+ * @file A card, written down.
3
+ *
4
+ * The file holds what a query string would hold — the card and its options as
5
+ * strings — so it reads like the URL it stands for, and can be edited by hand.
6
+ */
7
+
1
8
  import { existsSync } from 'node:fs';
2
9
  import { readFile, writeFile } from 'node:fs/promises';
3
10
  import { resolve } from 'node:path';
@@ -6,13 +13,6 @@ import type { CardKind } from './cards.ts';
6
13
  import { findCard } from './cards.ts';
7
14
  import type { Answer } from './query.ts';
8
15
 
9
- /**
10
- * @file A card, written down.
11
- *
12
- * The file holds what a query string would hold — the card and its options as
13
- * strings — so it reads like the URL it stands for, and can be edited by hand.
14
- */
15
-
16
16
  /** A card and the answers it was rendered from: the shape of the file. */
17
17
  interface SavedCard {
18
18
  /** Which card, by the id the catalog gives it. */
package/src/tokens.ts CHANGED
@@ -1,8 +1,3 @@
1
- import { existsSync } from 'node:fs';
2
- import { resolve } from 'node:path';
3
-
4
- import type { PersonalAccessToken } from '@stats-forge/github-stats-forge-core/api';
5
-
6
1
  /**
7
2
  * @file Where the GitHub token comes from.
8
3
  *
@@ -11,6 +6,11 @@ import type { PersonalAccessToken } from '@stats-forge/github-stats-forge-core/a
11
6
  * Whatever is left over is asked for interactively, so a first run needs no setup.
12
7
  */
13
8
 
9
+ import { existsSync } from 'node:fs';
10
+ import { resolve } from 'node:path';
11
+
12
+ import type { PersonalAccessToken } from '@stats-forge/github-stats-forge-core/api';
13
+
14
14
  /** The file loaded when `--env-file` is not given. */
15
15
  export const DEFAULT_ENV_FILE = '.env';
16
16