@stats-forge/github-stats-forge-core 0.0.2 → 0.1.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.
Files changed (208) hide show
  1. package/README.md +0 -10
  2. package/build/api/api-result.d.ts +6 -7
  3. package/build/api/api-result.d.ts.map +1 -1
  4. package/build/api/api-result.js +4 -5
  5. package/build/api/gist.d.ts +3 -10
  6. package/build/api/gist.d.ts.map +1 -1
  7. package/build/api/gist.js +4 -11
  8. package/build/api/index.d.ts +12 -12
  9. package/build/api/params.d.ts +11 -8
  10. package/build/api/params.d.ts.map +1 -1
  11. package/build/api/params.js +16 -14
  12. package/build/api/pin.d.ts +3 -19
  13. package/build/api/pin.d.ts.map +1 -1
  14. package/build/api/pin.js +6 -22
  15. package/build/api/stats.d.ts +10 -4
  16. package/build/api/stats.d.ts.map +1 -1
  17. package/build/api/stats.js +6 -32
  18. package/build/api/top-langs.d.ts +12 -6
  19. package/build/api/top-langs.d.ts.map +1 -1
  20. package/build/api/top-langs.js +6 -26
  21. package/build/api/wakatime.d.ts +13 -5
  22. package/build/api/wakatime.d.ts.map +1 -1
  23. package/build/api/wakatime.js +4 -21
  24. package/build/calculateRank.d.ts +0 -9
  25. package/build/calculateRank.d.ts.map +1 -1
  26. package/build/calculateRank.js +12 -17
  27. package/build/cards/gist.d.ts +2 -4
  28. package/build/cards/gist.d.ts.map +1 -1
  29. package/build/cards/gist.js +27 -36
  30. package/build/cards/options.d.ts +1 -1
  31. package/build/cards/repo.d.ts +2 -4
  32. package/build/cards/repo.d.ts.map +1 -1
  33. package/build/cards/repo.js +61 -93
  34. package/build/cards/stats.d.ts +3 -8
  35. package/build/cards/stats.d.ts.map +1 -1
  36. package/build/cards/stats.js +119 -165
  37. package/build/cards/top-languages.d.ts +5 -31
  38. package/build/cards/top-languages.d.ts.map +1 -1
  39. package/build/cards/top-languages.js +176 -384
  40. package/build/cards/wakatime.d.ts +4 -6
  41. package/build/cards/wakatime.d.ts.map +1 -1
  42. package/build/cards/wakatime.js +118 -185
  43. package/build/common/Card.d.ts +14 -62
  44. package/build/common/Card.d.ts.map +1 -1
  45. package/build/common/Card.js +97 -199
  46. package/build/common/I18n.d.ts +0 -6
  47. package/build/common/I18n.d.ts.map +1 -1
  48. package/build/common/I18n.js +3 -6
  49. package/build/common/color.d.ts +4 -17
  50. package/build/common/color.d.ts.map +1 -1
  51. package/build/common/color.js +67 -49
  52. package/build/common/config.d.ts +4 -14
  53. package/build/common/config.d.ts.map +1 -1
  54. package/build/common/config.js +3 -16
  55. package/build/common/constants.d.ts +4 -2
  56. package/build/common/constants.d.ts.map +1 -1
  57. package/build/common/constants.js +3 -1
  58. package/build/common/date.d.ts +0 -2
  59. package/build/common/date.d.ts.map +1 -1
  60. package/build/common/date.js +0 -2
  61. package/build/common/emojiMap.d.ts +8 -0
  62. package/build/common/emojiMap.d.ts.map +1 -0
  63. package/build/common/emojiMap.js +10 -0
  64. package/build/common/emojiMap.json +1915 -0
  65. package/build/common/error.d.ts +1 -10
  66. package/build/common/error.d.ts.map +1 -1
  67. package/build/common/error.js +1 -10
  68. package/build/common/fmt.d.ts +3 -15
  69. package/build/common/fmt.d.ts.map +1 -1
  70. package/build/common/fmt.js +7 -35
  71. package/build/common/http.d.ts +2 -7
  72. package/build/common/http.d.ts.map +1 -1
  73. package/build/common/http.js +8 -17
  74. package/build/common/icons.d.ts +18 -20
  75. package/build/common/icons.d.ts.map +1 -1
  76. package/build/common/icons.js +103 -67
  77. package/build/common/languageColors.d.ts +0 -1
  78. package/build/common/languageColors.d.ts.map +1 -1
  79. package/build/common/languageColors.js +1 -4
  80. package/build/common/languageColors.json +18 -2
  81. package/build/common/ops.d.ts +3 -14
  82. package/build/common/ops.d.ts.map +1 -1
  83. package/build/common/ops.js +19 -30
  84. package/build/common/render.d.ts +28 -82
  85. package/build/common/render.d.ts.map +1 -1
  86. package/build/common/render.js +175 -211
  87. package/build/common/retryer.d.ts +2 -7
  88. package/build/common/retryer.d.ts.map +1 -1
  89. package/build/common/retryer.js +4 -11
  90. package/build/fetchers/gist.d.ts +2 -5
  91. package/build/fetchers/gist.d.ts.map +1 -1
  92. package/build/fetchers/gist.js +1 -5
  93. package/build/fetchers/index.d.ts +12 -12
  94. package/build/fetchers/repo.d.ts +2 -11
  95. package/build/fetchers/repo.d.ts.map +1 -1
  96. package/build/fetchers/repo.js +20 -33
  97. package/build/fetchers/stats.d.ts +2 -32
  98. package/build/fetchers/stats.d.ts.map +1 -1
  99. package/build/fetchers/stats.js +27 -86
  100. package/build/fetchers/top-languages.d.ts +2 -9
  101. package/build/fetchers/top-languages.d.ts.map +1 -1
  102. package/build/fetchers/top-languages.js +15 -31
  103. package/build/fetchers/types.d.ts +1 -1
  104. package/build/fetchers/wakatime.d.ts +2 -6
  105. package/build/fetchers/wakatime.d.ts.map +1 -1
  106. package/build/fetchers/wakatime.js +1 -5
  107. package/build/graphql/contributionsDocument.d.ts +3 -3
  108. package/build/graphql/contributionsDocument.d.ts.map +1 -1
  109. package/build/graphql/contributionsDocument.js +0 -1
  110. package/build/graphql/generated/gist.d.ts +1 -1
  111. package/build/graphql/generated/repo.d.ts +1 -1
  112. package/build/graphql/generated/stats.d.ts +3 -3
  113. package/build/graphql/generated/top-languages.d.ts +2 -2
  114. package/build/graphql/graphqlDocument.d.ts +0 -1
  115. package/build/graphql/graphqlDocument.d.ts.map +1 -1
  116. package/build/graphql/graphqlDocument.js +0 -1
  117. package/build/graphql/reposContributedToDocument.d.ts +4 -5
  118. package/build/graphql/reposContributedToDocument.d.ts.map +1 -1
  119. package/build/graphql/reposContributedToDocument.js +0 -2
  120. package/build/markup/css.d.ts +35 -0
  121. package/build/markup/css.d.ts.map +1 -0
  122. package/build/markup/css.js +64 -0
  123. package/build/markup/element.d.ts +41 -0
  124. package/build/markup/element.d.ts.map +1 -0
  125. package/build/markup/element.js +127 -0
  126. package/build/markup/html.d.ts +13 -0
  127. package/build/markup/html.d.ts.map +1 -0
  128. package/build/markup/html.js +27 -0
  129. package/build/markup/index.d.ts +9 -0
  130. package/build/markup/index.d.ts.map +1 -0
  131. package/build/markup/index.js +6 -0
  132. package/build/markup/serialize.d.ts +16 -0
  133. package/build/markup/serialize.d.ts.map +1 -0
  134. package/build/markup/serialize.js +17 -0
  135. package/build/themes/index.d.ts +332 -333
  136. package/build/themes/index.d.ts.map +1 -1
  137. package/build/themes/index.js +1 -1
  138. package/build/translations.d.ts +2 -6
  139. package/build/translations.d.ts.map +1 -1
  140. package/build/translations.js +621 -628
  141. package/package.json +17 -26
  142. package/src/api/api-result.ts +10 -9
  143. package/src/api/gist.ts +11 -18
  144. package/src/api/index.ts +12 -12
  145. package/src/api/params.ts +27 -21
  146. package/src/api/pin.ts +13 -28
  147. package/src/api/stats.ts +13 -39
  148. package/src/api/top-langs.ts +13 -33
  149. package/src/api/wakatime.ts +11 -28
  150. package/src/calculateRank.ts +12 -23
  151. package/src/cards/gist.ts +40 -43
  152. package/src/cards/options.ts +1 -1
  153. package/src/cards/repo.ts +97 -107
  154. package/src/cards/stats.ts +165 -184
  155. package/src/cards/top-languages.ts +297 -413
  156. package/src/cards/wakatime.ts +193 -221
  157. package/src/common/Card.ts +165 -217
  158. package/src/common/I18n.ts +3 -6
  159. package/src/common/color.ts +80 -57
  160. package/src/common/config.ts +5 -18
  161. package/src/common/constants.ts +5 -2
  162. package/src/common/date.ts +0 -2
  163. package/src/common/emojiMap.json +1915 -0
  164. package/src/common/emojiMap.ts +13 -0
  165. package/src/common/error.ts +2 -11
  166. package/src/common/fmt.ts +7 -44
  167. package/src/common/http.ts +9 -15
  168. package/src/common/icons.ts +117 -42
  169. package/src/common/languageColors.json +18 -2
  170. package/src/common/languageColors.ts +1 -4
  171. package/src/common/ops.ts +26 -34
  172. package/src/common/render.ts +279 -232
  173. package/src/common/retryer.ts +8 -15
  174. package/src/fetchers/gist.ts +8 -12
  175. package/src/fetchers/index.ts +12 -12
  176. package/src/fetchers/repo.ts +27 -40
  177. package/src/fetchers/stats.ts +44 -102
  178. package/src/fetchers/top-languages.ts +26 -40
  179. package/src/fetchers/types.ts +1 -1
  180. package/src/fetchers/wakatime.ts +5 -9
  181. package/src/graphql/contributionsDocument.ts +7 -5
  182. package/src/graphql/generated/gist.ts +1 -1
  183. package/src/graphql/generated/repo.ts +1 -1
  184. package/src/graphql/generated/stats.ts +2 -2
  185. package/src/graphql/generated/top-languages.ts +2 -2
  186. package/src/graphql/graphqlDocument.ts +0 -1
  187. package/src/graphql/reposContributedToDocument.ts +6 -7
  188. package/src/markup/css.ts +118 -0
  189. package/src/markup/element.ts +246 -0
  190. package/src/markup/html.ts +34 -0
  191. package/src/markup/index.ts +9 -0
  192. package/src/markup/serialize.ts +27 -0
  193. package/src/themes/index.ts +1 -1
  194. package/src/translations.ts +622 -629
  195. package/build/cards/index.d.ts +0 -11
  196. package/build/cards/index.d.ts.map +0 -1
  197. package/build/cards/index.js +0 -7
  198. package/build/common/html.d.ts +0 -8
  199. package/build/common/html.d.ts.map +0 -1
  200. package/build/common/html.js +0 -14
  201. package/build/index.d.ts +0 -15
  202. package/build/index.d.ts.map +0 -1
  203. package/build/index.js +0 -10
  204. package/src/_emoji-name-map.d.ts +0 -10
  205. package/src/_github-username-regex.d.ts +0 -4
  206. package/src/cards/index.ts +0 -21
  207. package/src/common/html.ts +0 -17
  208. package/src/index.ts +0 -18
@@ -1,21 +1,16 @@
1
- import { getCardColors, isPrefixedHexColor } from './color.js';
2
- import { SECONDARY_ERROR_MESSAGES, TRY_AGAIN_LATER } from './error.js';
3
- import { kFormatter } from './fmt.js';
4
- import { encodeHTML } from './html.js';
5
- import { clampValue } from './ops.js';
1
+ import type { Child, Declarations, MarkupElement } from '../markup/index.ts';
2
+ import { el, renderMarkup, rule, style } from '../markup/index.ts';
3
+
4
+ import { getCardColors, isPrefixedHexColor } from './color.ts';
5
+ import { SECONDARY_ERROR_MESSAGES, TRY_AGAIN_LATER } from './error.ts';
6
+ import { kFormatter } from './fmt.ts';
7
+ import { clampValue } from './ops.ts';
6
8
 
7
9
  /**
8
10
  * Auto layout utility, allows us to layout things vertically or horizontally with
9
11
  * proper gaping.
10
12
  *
11
- * The caller must ensure that the passed `items` are properly sanitized!
12
- *
13
- * @param props Function properties.
14
- * @param props.items Array of sanitized items to layout.
15
- * @param props.gap Gap between items.
16
- * @param props.direction Direction to layout items.
17
- * @param props.sizes Array of sizes for each item.
18
- * @returns Array of items with proper layout.
13
+ * @returns The items, each in the group that positions it.
19
14
  */
20
15
  const flexLayout = ({
21
16
  items,
@@ -23,59 +18,60 @@ const flexLayout = ({
23
18
  direction,
24
19
  sizes = [],
25
20
  }: {
26
- items: Array<string>;
21
+ items: Array<Child>;
27
22
  gap: number;
28
23
  direction?: 'column' | 'row';
29
24
  sizes?: Array<number>;
30
- }): Array<string> => {
25
+ }): Array<Child> => {
31
26
  if (sizes.some((size) => !Number.isFinite(size))) {
32
27
  throw new Error('flexLayout: `sizes` must contain only numbers');
33
28
  }
34
29
 
30
+ const laidOut = items.filter(Boolean);
31
+
32
+ // A lone item is already where it belongs, so it needs no group to move it.
33
+ if (laidOut.length === 1) {
34
+ return laidOut;
35
+ }
36
+
35
37
  let lastSize = 0;
36
- // filter() for filtering out empty strings
37
- return items.filter(Boolean).map((item, i) => {
38
- const size = sizes[i] || 0;
39
- let transform = `translate(${lastSize}, 0)`;
40
- if (direction === 'column') {
41
- transform = `translate(0, ${lastSize})`;
42
- }
43
- lastSize += size + gap;
44
- return `<g transform="${transform}">${item}</g>`;
38
+ return laidOut.map((item, i) => {
39
+ const offset = lastSize;
40
+ lastSize += (sizes[i] ?? 0) + gap;
41
+
42
+ // The first item never moves, so it gets no transform to carry.
43
+ const transform =
44
+ offset === 0
45
+ ? undefined
46
+ : direction === 'column'
47
+ ? `translate(0, ${offset})`
48
+ : `translate(${offset}, 0)`;
49
+
50
+ return el('g', { transform }, item);
45
51
  });
46
52
  };
47
53
 
48
54
  /**
49
55
  * Creates a node to display the primary programming language of the repository/gist.
50
56
  *
51
- * @param langName Language name.
52
- * @param langColor Language color.
53
57
  * @returns Language display SVG object.
54
58
  */
55
- const createLanguageNode = (langName: string, langColor: string): string => {
59
+ const createLanguageNode = (langName: string, langColor: string): MarkupElement => {
56
60
  if (!isPrefixedHexColor(langColor)) {
57
61
  throw new Error(`Invalid language color: "${langColor}"`);
58
62
  }
59
63
 
60
- return `
61
- <g data-testid="primary-lang">
62
- <circle data-testid="lang-color" cx="0" cy="-5" r="6" fill="${langColor}" />
63
- <text data-testid="lang-name" class="gray" x="15">${encodeHTML(langName)}</text>
64
- </g>
65
- `;
64
+ return el(
65
+ 'g',
66
+ { 'data-testid': 'primary-lang' },
67
+ el('circle', { 'data-testid': 'lang-color', cx: 0, cy: -5, r: 6, fill: langColor }),
68
+ el('text', { 'data-testid': 'lang-name', class: 'gray', x: 15 }, langName),
69
+ );
66
70
  };
67
71
 
68
72
  /**
69
73
  * Create a node to indicate progress in percentage along a horizontal line.
70
74
  *
71
- * @param params Object that contains the createProgressNode parameters.
72
- * @param params.x X-axis position.
73
- * @param params.y Y-axis position.
74
- * @param params.width Width of progress bar.
75
- * @param params.color Optional foreground color as an inline fill fallback.
76
- * When omitted, the color must be set via a `.lang-progress` CSS rule.
77
- * @param params.progress Progress value.
78
- * @param params.delay Delay before animation starts.
79
75
  * @returns Progress node.
80
76
  */
81
77
  const createProgressNode = ({
@@ -89,42 +85,57 @@ const createProgressNode = ({
89
85
  x: number;
90
86
  y: number;
91
87
  width: number;
88
+ /** When omitted, the color must be set via a `.lang-progress` CSS rule. */
92
89
  color?: string;
93
90
  progress: number;
94
91
  delay: number;
95
- }): string => {
92
+ }): MarkupElement => {
96
93
  if (color !== undefined && !isPrefixedHexColor(color)) {
97
94
  throw new Error(`Invalid progress color: "${color}"`);
98
95
  }
99
96
  if (!Number.isFinite(width)) {
100
- throw new Error(`Invalid width: "${width}"`);
97
+ throw new TypeError(`Invalid width: "${width}"`);
101
98
  }
102
99
  if (!Number.isFinite(x)) {
103
- throw new Error(`Invalid x: "${x}"`);
100
+ throw new TypeError(`Invalid x: "${x}"`);
104
101
  }
105
102
  if (!Number.isFinite(y)) {
106
- throw new Error(`Invalid y: "${y}"`);
103
+ throw new TypeError(`Invalid y: "${y}"`);
107
104
  }
108
105
  if (!Number.isFinite(delay)) {
109
- throw new Error(`Invalid delay: "${delay}"`);
106
+ throw new TypeError(`Invalid delay: "${delay}"`);
110
107
  }
111
108
 
112
109
  const progressPercentage = clampValue(progress, 2, 100);
113
110
 
114
- return `
115
- <svg width="${width}" x="${x}" y="${y}">
116
- <rect data-testid="progress-background" class="progress-background" rx="5" ry="5" x="0" y="0" width="${width}" height="8"></rect>
117
- <svg data-testid="lang-progress" width="${progressPercentage}%">
118
- <rect
119
- height="8"
120
- ${color === undefined ? '' : `fill="${color}"`}
121
- rx="5" ry="5" x="0" y="0"
122
- class="lang-progress"
123
- style="animation-delay: ${delay}ms;"
124
- />
125
- </svg>
126
- </svg>
127
- `;
111
+ return el(
112
+ 'svg',
113
+ { width, x, y },
114
+ el('rect', {
115
+ 'data-testid': 'progress-background',
116
+ class: 'progress-background',
117
+ rx: 5,
118
+ ry: 5,
119
+ x: 0,
120
+ y: 0,
121
+ width,
122
+ height: 8,
123
+ }),
124
+ el(
125
+ 'svg',
126
+ { 'data-testid': 'lang-progress', width: `${progressPercentage}%` },
127
+ el('rect', {
128
+ height: 8,
129
+ fill: color,
130
+ rx: 5,
131
+ ry: 5,
132
+ x: 0,
133
+ y: 0,
134
+ class: 'lang-progress',
135
+ style: `animation-delay: ${delay}ms;`,
136
+ }),
137
+ ),
138
+ );
128
139
  };
129
140
 
130
141
  /**
@@ -132,15 +143,6 @@ const createProgressNode = ({
132
143
  * native, font-aware wrapping. Content overflowing `lineCount` lines is
133
144
  * clipped (with an ellipsis on the last visible line) by CSS line-clamp.
134
145
  *
135
- * @param props Function properties.
136
- * @param props.text Text to render (will be HTML-encoded).
137
- * @param props.x X position of the foreignObject.
138
- * @param props.y Y position of the foreignObject.
139
- * @param props.width Width of the wrap box.
140
- * @param props.height Height of the wrap box.
141
- * @param props.lineCount Maximum number of lines to display.
142
- * @param props.className CSS class applied to the inner element.
143
- * @param props.testId Optional test id for the inner element.
144
146
  * @returns foreignObject SVG node.
145
147
  */
146
148
  const wrappedTextNode = ({
@@ -161,31 +163,37 @@ const wrappedTextNode = ({
161
163
  lineCount: number;
162
164
  className: string;
163
165
  testId?: string;
164
- }): string => {
166
+ }): MarkupElement => {
165
167
  if (!Number.isFinite(x)) {
166
- throw new Error(`Invalid x: "${x}"`);
168
+ throw new TypeError(`Invalid x: "${x}"`);
167
169
  }
168
170
  if (!Number.isFinite(y)) {
169
- throw new Error(`Invalid y: "${y}"`);
171
+ throw new TypeError(`Invalid y: "${y}"`);
170
172
  }
171
173
  if (!Number.isFinite(width)) {
172
- throw new Error(`Invalid width: "${width}"`);
174
+ throw new TypeError(`Invalid width: "${width}"`);
173
175
  }
174
176
  if (!Number.isFinite(height)) {
175
- throw new Error(`Invalid height: "${height}"`);
177
+ throw new TypeError(`Invalid height: "${height}"`);
176
178
  }
177
179
  if (!Number.isFinite(lineCount)) {
178
- throw new Error(`Invalid lineCount: "${lineCount}"`);
180
+ throw new TypeError(`Invalid lineCount: "${lineCount}"`);
179
181
  }
180
182
 
181
- const testIdAttr = testId ? ` data-testid="${encodeHTML(testId)}"` : '';
182
- return `
183
- <foreignObject x="${x}" y="${y}" width="${width}" height="${height}">
184
- <div xmlns="http://www.w3.org/1999/xhtml" class="${encodeHTML(className)}" style="--lines: ${lineCount};"${testIdAttr}>${encodeHTML(
185
- text,
186
- )}</div>
187
- </foreignObject>
188
- `;
183
+ return el(
184
+ 'foreignObject',
185
+ { x, y, width, height },
186
+ el(
187
+ 'div',
188
+ {
189
+ xmlns: 'http://www.w3.org/1999/xhtml',
190
+ class: className,
191
+ style: `--lines: ${lineCount};`,
192
+ 'data-testid': testId,
193
+ },
194
+ text,
195
+ ),
196
+ );
189
197
  };
190
198
 
191
199
  /**
@@ -194,69 +202,67 @@ const wrappedTextNode = ({
194
202
  * browser handles wrapping and the line count is taken from the `--lines`
195
203
  * custom property set on the element.
196
204
  *
197
- * @param color Text color (CSS `color` property).
198
- * @returns CSS rules block (without the surrounding selector).
205
+ * @returns The declarations, to merge into the class's own rule.
199
206
  */
200
- const wrappedTextStyles = (color: string): string => {
207
+ const wrappedTextStyles = (color: string): Declarations => {
201
208
  if (!isPrefixedHexColor(color)) {
202
209
  throw new Error(`Invalid text color: "${color}"`);
203
210
  }
204
211
 
205
- return `
206
- color: ${color};
207
- margin: 0;
208
- line-height: 1.2;
209
- overflow-wrap: anywhere;
210
- word-break: break-word;
211
- display: -webkit-box;
212
- -webkit-box-orient: vertical;
213
- -webkit-line-clamp: var(--lines);
214
- line-clamp: var(--lines);
215
- overflow: hidden;
216
- text-overflow: ellipsis;
217
- padding-bottom: 0.15em;
218
- `;
212
+ return {
213
+ color,
214
+ margin: 0,
215
+ 'line-height': 1.2,
216
+ 'overflow-wrap': 'anywhere',
217
+ 'word-break': 'break-word',
218
+ display: '-webkit-box',
219
+ '-webkit-box-orient': 'vertical',
220
+ '-webkit-line-clamp': 'var(--lines)',
221
+ 'line-clamp': 'var(--lines)',
222
+ overflow: 'hidden',
223
+ 'text-overflow': 'ellipsis',
224
+ 'padding-bottom': '0.15em',
225
+ };
219
226
  };
220
227
 
221
228
  /**
222
229
  * Creates an icon with label to display repository/gist stats like forks, stars, etc.
230
+ * Draws nothing when the label is not positive.
223
231
  *
224
- * The caller must ensure that the passed `icon` is properly sanitized!
225
- *
226
- * @param icon The sanitized icon to display.
227
- * @param label The label to display.
228
- * @param testid The testid to assign to the label.
229
- * @param iconSize The size of the icon.
230
- * @returns Icon with label SVG object.
232
+ * @returns Icon with label SVG object, or nothing when the label is not positive.
231
233
  */
232
234
  const iconWithLabel = (
233
- icon: string,
235
+ icon: Child,
234
236
  label: number | string,
235
237
  testid: string,
236
238
  iconSize: number,
237
- ): string => {
239
+ ): Child => {
238
240
  if (typeof label === 'number' && label <= 0) {
239
- return '';
241
+ return undefined;
240
242
  }
241
243
 
242
244
  if (!Number.isFinite(iconSize)) {
243
- throw new Error(`Invalid iconSize: "${iconSize}"`);
245
+ throw new TypeError(`Invalid iconSize: "${iconSize}"`);
244
246
  }
245
247
 
246
- const iconSvg = `
247
- <svg
248
- class="icon"
249
- y="-12"
250
- viewBox="0 0 16 16"
251
- version="1.1"
252
- width="${iconSize}"
253
- height="${iconSize}"
254
- >
255
- ${icon}
256
- </svg>
257
- `;
258
- const text = `<text data-testid="${encodeHTML(testid)}" class="gray">${encodeHTML(String(label))}</text>`;
259
- return flexLayout({ items: [iconSvg, text], gap: 20 }).join('');
248
+ return flexLayout({
249
+ items: [
250
+ el(
251
+ 'svg',
252
+ {
253
+ class: 'icon',
254
+ y: -12,
255
+ viewBox: '0 0 16 16',
256
+ version: '1.1',
257
+ width: iconSize,
258
+ height: iconSize,
259
+ },
260
+ icon,
261
+ ),
262
+ el('text', { 'data-testid': testid, class: 'gray' }, String(label)),
263
+ ],
264
+ gap: 20,
265
+ });
260
266
  };
261
267
 
262
268
  /**
@@ -264,22 +270,8 @@ const iconWithLabel = (
264
270
  * (a label and its value, optionally with an icon and link).
265
271
  * Shared by the stats and repo cards.
266
272
  *
267
- * The caller must ensure that the passed `icon` and `link` are properly sanitized!
273
+ * The caller must ensure that the passed `link` is properly sanitized!
268
274
  *
269
- * @param params Object that contains the createTextNode parameters.
270
- * @param params.icon The sanitized icon to display.
271
- * @param params.label The label to display.
272
- * @param params.value The value to display.
273
- * @param params.id The id of the stat.
274
- * @param params.unitSymbol The unit symbol of the stat.
275
- * @param params.index The index of the stat.
276
- * @param params.showIcons Whether to show icons.
277
- * @param params.shiftValuePos Number of pixels the value has to be shifted to the right.
278
- * @param params.bold Whether to bold the label.
279
- * @param params.numberFormat The format of numbers on card.
280
- * @param params.numberPrecision The precision of numbers on card.
281
- * @param params.link Sanitized url to link to.
282
- * @param params.labelXOffset horizontal offset for label.
283
275
  * @returns The stats card text item SVG object.
284
276
  */
285
277
  const createTextNode = ({
@@ -291,13 +283,15 @@ const createTextNode = ({
291
283
  index,
292
284
  showIcons,
293
285
  shiftValuePos,
286
+ valueAnchorX,
294
287
  bold,
288
+ labelBold = bold,
295
289
  numberFormat,
296
290
  numberPrecision,
297
291
  link,
298
292
  labelXOffset = 25,
299
293
  }: {
300
- icon: string;
294
+ icon: Child;
301
295
  label: string;
302
296
  value: number | string;
303
297
  id: string;
@@ -306,20 +300,25 @@ const createTextNode = ({
306
300
  index: number;
307
301
  showIcons: boolean;
308
302
  shiftValuePos: number;
303
+ valueAnchorX?: number | undefined;
309
304
  bold: boolean;
305
+ labelBold?: boolean;
310
306
  numberFormat: string;
311
307
  numberPrecision?: number | undefined;
312
308
  link?: string | undefined;
313
309
  labelXOffset?: number;
314
- }): string => {
310
+ }): MarkupElement => {
315
311
  if (!Number.isFinite(labelXOffset)) {
316
- throw new Error(`Invalid labelXOffset: "${labelXOffset}"`);
312
+ throw new TypeError(`Invalid labelXOffset: "${labelXOffset}"`);
317
313
  }
318
314
  if (!Number.isFinite(shiftValuePos)) {
319
- throw new Error(`Invalid shiftValuePos: "${shiftValuePos}"`);
315
+ throw new TypeError(`Invalid shiftValuePos: "${shiftValuePos}"`);
320
316
  }
321
317
  if (!Number.isFinite(index)) {
322
- throw new Error(`Invalid index: "${index}"`);
318
+ throw new TypeError(`Invalid index: "${index}"`);
319
+ }
320
+ if (valueAnchorX !== undefined && !Number.isFinite(valueAnchorX)) {
321
+ throw new Error(`Invalid valueAnchorX: "${valueAnchorX}"`);
323
322
  }
324
323
 
325
324
  const precision =
@@ -331,54 +330,62 @@ const createTextNode = ({
331
330
  const rawValue = numberFormat.toLowerCase() === 'long' || id === 'prs_merged_percentage';
332
331
  const kValue = rawValue || typeof value !== 'number' ? value : kFormatter(value, precision);
333
332
 
334
- const staggerDelay = (index + 3) * 150;
335
- const boldClass = bold ? ' bold' : 'not_bold';
333
+ const staggerDelay = 120 + index * 40;
334
+ const boldClass = bold ? 'bold' : 'not_bold';
335
+ const labelBoldClass = labelBold ? 'bold' : 'not_bold';
336
336
  const valueX = (showIcons ? 140 : 120) + (bold ? 5 : 0) + shiftValuePos;
337
337
  const unit = unitSymbol ? ` ${unitSymbol}` : '';
338
- const labelOffset = showIcons ? `x="${labelXOffset}"` : '';
339
- const iconSvg = showIcons
340
- ? `
341
- <svg data-testid="icon" class="icon" viewBox="0 0 16 16" version="1.1" width="16" height="16">
342
- ${icon}
343
- </svg>
344
- `
345
- : '';
346
-
347
- const content = `
348
- ${iconSvg}
349
- <text class="stat ${boldClass}" ${labelOffset} y="12.5">${encodeHTML(label)}:</text>
350
- <text
351
- class="stat ${boldClass}"
352
- x="${valueX}"
353
- y="12.5"
354
- data-testid="${id}"
355
- >${kValue}${unit}</text>`;
356
- const inner = link ? `<a href="${link}">${content}</a>` : content;
357
-
358
- return `
359
- <g class="stagger" style="animation-delay: ${staggerDelay}ms" transform="translate(25, 0)">${inner}
360
- </g>
361
- `;
338
+
339
+ const content = [
340
+ showIcons &&
341
+ el(
342
+ 'svg',
343
+ {
344
+ 'data-testid': 'icon',
345
+ class: 'icon',
346
+ viewBox: '0 0 16 16',
347
+ version: '1.1',
348
+ width: 16,
349
+ height: 16,
350
+ },
351
+ icon,
352
+ ),
353
+ el(
354
+ 'text',
355
+ { class: `stat ${labelBoldClass}`, x: showIcons ? labelXOffset : undefined, y: 12.5 },
356
+ `${label}:`,
357
+ ),
358
+ el(
359
+ 'text',
360
+ {
361
+ class: `stat ${boldClass}`,
362
+ x: valueAnchorX ?? valueX,
363
+ 'text-anchor': valueAnchorX === undefined ? undefined : 'end',
364
+ y: 12.5,
365
+ 'data-testid': id,
366
+ },
367
+ `${String(kValue)}${unit}`,
368
+ ),
369
+ ];
370
+
371
+ return el(
372
+ 'g',
373
+ {
374
+ class: 'stagger',
375
+ style: `animation-delay: ${staggerDelay}ms`,
376
+ transform: 'translate(25, 0)',
377
+ },
378
+ link ? el('a', { href: link }, content) : content,
379
+ );
362
380
  };
363
381
 
364
- // Script parameters.
365
382
  const ERROR_CARD_LENGTH = 576.5;
366
383
 
367
- const UPSTREAM_API_ERRORS = [TRY_AGAIN_LATER, SECONDARY_ERROR_MESSAGES.rate_limited];
384
+ const UPSTREAM_API_ERRORS = new Set([TRY_AGAIN_LATER, SECONDARY_ERROR_MESSAGES.rate_limited]);
368
385
 
369
386
  /**
370
387
  * Renders error message on the card.
371
388
  *
372
- * @param args Function arguments.
373
- * @param args.message Main error message.
374
- * @param args.secondaryMessage The secondary error message.
375
- * @param args.renderOptions Render options.
376
- * @param args.renderOptions.title_color Card title color.
377
- * @param args.renderOptions.text_color Card text color.
378
- * @param args.renderOptions.bg_color Card background color.
379
- * @param args.renderOptions.border_color Card border color.
380
- * @param args.renderOptions.theme Card theme.
381
- * @param args.renderOptions.show_repo_link Whether to show repo link or not.
382
389
  * @returns The SVG markup.
383
390
  */
384
391
  const renderError = ({
@@ -417,37 +424,55 @@ const renderError = ({
417
424
  theme,
418
425
  });
419
426
 
420
- return `
421
- <svg width="${ERROR_CARD_LENGTH}" height="120" viewBox="0 0 ${ERROR_CARD_LENGTH} 120" fill="${String(bgColor)}" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="titleId descId">
422
- <title id="titleId">${encodeHTML(message)}</title>
423
- <desc id="descId">${encodeHTML(secondaryMessage)}</desc>
424
- <style>
425
- .text { font: 600 16px 'Segoe UI', Ubuntu, Sans-Serif; fill: ${titleColor} }
426
- .small { font: 600 12px 'Segoe UI', Ubuntu, Sans-Serif; fill: ${textColor} }
427
- .gray { fill: #858585 }
428
- </style>
429
- <rect x="0.5" y="0.5" width="${
430
- ERROR_CARD_LENGTH - 1
431
- }" height="99%" rx="4.5" fill="${String(bgColor)}" stroke="${borderColor}"/>
432
- <text x="25" y="45" class="text">Something went wrong!${
433
- UPSTREAM_API_ERRORS.includes(secondaryMessage) || !show_repo_link
434
- ? ''
435
- : ' file an issue at https://tinyurl.com/github-stats'
436
- }</text>
437
- <text data-testid="message" x="25" y="55" class="text small">
438
- <tspan x="25" dy="18">${encodeHTML(message)}</tspan>
439
- <tspan x="25" dy="18" class="gray">${encodeHTML(secondaryMessage)}</tspan>
440
- </text>
441
- </svg>
442
- `;
427
+ const hint =
428
+ UPSTREAM_API_ERRORS.has(secondaryMessage) || !show_repo_link
429
+ ? ''
430
+ : ' file an issue at https://tinyurl.com/github-stats';
431
+
432
+ return renderMarkup(
433
+ el(
434
+ 'svg',
435
+ {
436
+ width: ERROR_CARD_LENGTH,
437
+ height: 120,
438
+ viewBox: `0 0 ${ERROR_CARD_LENGTH} 120`,
439
+ fill: String(bgColor),
440
+ xmlns: 'http://www.w3.org/2000/svg',
441
+ role: 'img',
442
+ 'aria-labelledby': 'titleId descId',
443
+ },
444
+ el('title', { id: 'titleId' }, message),
445
+ el('desc', { id: 'descId' }, secondaryMessage),
446
+ style(
447
+ rule('.text', { font: "600 16px 'Segoe UI', Ubuntu, Sans-Serif", fill: titleColor }),
448
+ rule('.small', { font: "600 12px 'Segoe UI', Ubuntu, Sans-Serif", fill: textColor }),
449
+ rule('.gray', { fill: '#858585' }),
450
+ ),
451
+ el('rect', {
452
+ x: 0.5,
453
+ y: 0.5,
454
+ width: ERROR_CARD_LENGTH - 1,
455
+ height: '99%',
456
+ rx: 8,
457
+ fill: String(bgColor),
458
+ stroke: borderColor,
459
+ }),
460
+ el('text', { x: 25, y: 45, class: 'text' }, `Something went wrong!${hint}`),
461
+ el(
462
+ 'text',
463
+ { 'data-testid': 'message', x: 25, y: 55, class: 'text small' },
464
+ el('tspan', { x: 25, dy: 18 }, message),
465
+ el('tspan', { x: 25, dy: 18, class: 'gray' }, secondaryMessage),
466
+ ),
467
+ ),
468
+ );
443
469
  };
444
470
 
445
471
  /**
446
472
  * Retrieve text length based on Segoe UI font.
447
473
  *
448
474
  * @see https://stackoverflow.com/a/48172630/10629172
449
- * @param str String to measure.
450
- * @param fontSize Font size.
475
+ *
451
476
  * @returns Text length.
452
477
  */
453
478
  const measureText = (str: string, fontSize = 10): number => {
@@ -494,18 +519,17 @@ const measureText = (str: string, fontSize = 10): number => {
494
519
  const cjkRange = /[\u3000-\u9FFF\uFF00-\uFFEF]/;
495
520
 
496
521
  return (
497
- str
498
- .split('')
499
- .map((c) => {
500
- if (cjkRange.test(c) || c === '\u3000') {
522
+ [...str]
523
+ .map((char) => {
524
+ if (cjkRange.test(char) || char === '\u3000') {
501
525
  // CJK glyphs and U+3000 IDEOGRAPHIC SPACE are full-width by default;
502
526
  return 1;
503
527
  }
504
- if (c.charCodeAt(0) < widths.length) {
505
- return widths[c.charCodeAt(0)] ?? avg;
506
- } else {
507
- return avg;
528
+ const code = char.codePointAt(0) ?? 0;
529
+ if (code < widths.length) {
530
+ return widths[code] ?? avg;
508
531
  }
532
+ return avg;
509
533
  })
510
534
  .reduce((cur, acc) => acc + cur) * fontSize
511
535
  );
@@ -518,9 +542,6 @@ const measureText = (str: string, fontSize = 10): number => {
518
542
  * The browser still does the real wrap inside the foreignObject; this is only
519
543
  * used to size the SVG.
520
544
  *
521
- * @param text Text to split.
522
- * @param fontSize Font size in px (matches `measureText`).
523
- * @param maxWidth Available wrap width in px.
524
545
  * @returns Estimated wrapped lines.
525
546
  */
526
547
  const splitWrappedText = (text: string, fontSize: number, maxWidth: number): Array<string> => {
@@ -538,7 +559,7 @@ const splitWrappedText = (text: string, fontSize: number, maxWidth: number): Arr
538
559
  // Korean Hangul (U+AC00–U+D7AF) is intentionally NOT in the CJK range
539
560
  // because Korean wraps at word boundaries by default in HTML.
540
561
  // ASCII whitespace is collapsed to a single space per CSS `white-space: normal;`
541
- const normalizedText = text.replace(/[\t\n\r ]+/g, ' ');
562
+ const normalizedText = text.replaceAll(/[\t\n\r ]+/g, ' ');
542
563
  const tokens = normalizedText.match(
543
564
  /\s|[\u3000-\u9FFF\uFF00-\uFFEF]|[^\s\u3000-\u9FFF\uFF00-\uFFEF]+/g,
544
565
  );
@@ -546,8 +567,11 @@ const splitWrappedText = (text: string, fontSize: number, maxWidth: number): Arr
546
567
  return [];
547
568
  }
548
569
 
549
- const takeFittingSegment = (token: string, availableWidth: number) => {
550
- const characters = token.split('');
570
+ const takeFittingSegment = (
571
+ token: string,
572
+ availableWidth: number,
573
+ ): { segment: string; width: number } => {
574
+ const characters = [...token];
551
575
  let segment = '';
552
576
  let width = 0;
553
577
 
@@ -575,7 +599,7 @@ const splitWrappedText = (text: string, fontSize: number, maxWidth: number): Arr
575
599
  if (currentWidth === 0) {
576
600
  continue;
577
601
  }
578
- lines[lines.length - 1] = (lines[lines.length - 1] ?? '') + token;
602
+ lines[lines.length - 1] = (lines.at(-1) ?? '') + token;
579
603
  currentWidth += measureText(token, fontSize);
580
604
  continue;
581
605
  }
@@ -585,7 +609,7 @@ const splitWrappedText = (text: string, fontSize: number, maxWidth: number): Arr
585
609
  while (remaining) {
586
610
  const w = measureText(remaining, fontSize);
587
611
  if (currentWidth + w <= maxWidth) {
588
- lines[lines.length - 1] = (lines[lines.length - 1] ?? '') + remaining;
612
+ lines[lines.length - 1] = (lines.at(-1) ?? '') + remaining;
589
613
  currentWidth += w;
590
614
  break;
591
615
  }
@@ -598,7 +622,7 @@ const splitWrappedText = (text: string, fontSize: number, maxWidth: number): Arr
598
622
 
599
623
  // An atom wider than the box wraps mid-glyph (overflow-wrap: anywhere).
600
624
  const { segment, width } = takeFittingSegment(remaining, maxWidth);
601
- lines[lines.length - 1] = (lines[lines.length - 1] ?? '') + segment;
625
+ lines[lines.length - 1] = (lines.at(-1) ?? '') + segment;
602
626
  currentWidth = width;
603
627
  remaining = remaining.slice(segment.length);
604
628
  }
@@ -607,14 +631,38 @@ const splitWrappedText = (text: string, fontSize: number, maxWidth: number): Arr
607
631
  return lines.map((line) => line.replace(/[\t\n\r ]+$/, ''));
608
632
  };
609
633
 
634
+ /**
635
+ * Split text over multiple lines based on the card width.
636
+ *
637
+ * @returns Array of lines.
638
+ */
639
+ const wrapTextMultiline = (
640
+ text: string,
641
+ width: number,
642
+ fontSize: number,
643
+ maxLines = 3,
644
+ ): Array<string> => {
645
+ const wrapped = splitWrappedText(text, fontSize, width);
646
+ const lines = wrapped.map((line) => line.trim()).slice(0, maxLines); // Only consider maxLines lines
647
+
648
+ // Add "..." to the last line if the text exceeds maxLines
649
+ if (wrapped.length > maxLines) {
650
+ const lastIndex = maxLines - 1;
651
+ const lastLine = lines[lastIndex];
652
+ if (lastLine !== undefined) {
653
+ lines[lastIndex] = `${lastLine}...`;
654
+ }
655
+ }
656
+
657
+ // Remove empty lines if text fits in less than maxLines lines
658
+ const multiLineText = lines.filter(Boolean);
659
+ return multiLineText;
660
+ };
661
+
610
662
  /**
611
663
  * Estimate how many lines a string will wrap to when laid out greedily at the
612
664
  * given font size inside a box of width `maxWidth`, capped at `maxLines`.
613
665
  *
614
- * @param text Text to estimate.
615
- * @param fontSize Font size in px (matches `measureText`).
616
- * @param maxWidth Available wrap width in px.
617
- * @param maxLines Cap on the returned line count.
618
666
  * @returns Estimated line count, at least 1, at most `maxLines`.
619
667
  */
620
668
  const countWrappedLines = (
@@ -622,9 +670,7 @@ const countWrappedLines = (
622
670
  fontSize: number,
623
671
  maxWidth: number,
624
672
  maxLines: number,
625
- ): number => {
626
- return Math.min(Math.max(1, splitWrappedText(text, fontSize, maxWidth).length), maxLines);
627
- };
673
+ ): number => Math.min(Math.max(1, splitWrappedText(text, fontSize, maxWidth).length), maxLines);
628
674
 
629
675
  export {
630
676
  renderError,
@@ -635,6 +681,7 @@ export {
635
681
  flexLayout,
636
682
  measureText,
637
683
  splitWrappedText,
684
+ wrapTextMultiline,
638
685
  countWrappedLines,
639
686
  wrappedTextNode,
640
687
  wrappedTextStyles,