@stats-forge/github-stats-forge-core 0.0.2 → 0.2.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 (249) 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/contributed-to.d.ts +23 -0
  6. package/build/api/contributed-to.d.ts.map +1 -0
  7. package/build/api/contributed-to.js +43 -0
  8. package/build/api/gist.d.ts +8 -22
  9. package/build/api/gist.d.ts.map +1 -1
  10. package/build/api/gist.js +14 -36
  11. package/build/api/handler.d.ts +16 -0
  12. package/build/api/handler.d.ts.map +1 -0
  13. package/build/api/handler.js +29 -0
  14. package/build/api/index.d.ts +13 -12
  15. package/build/api/index.d.ts.map +1 -1
  16. package/build/api/index.js +1 -0
  17. package/build/api/params.d.ts +21 -12
  18. package/build/api/params.d.ts.map +1 -1
  19. package/build/api/params.js +41 -19
  20. package/build/api/pin.d.ts +11 -31
  21. package/build/api/pin.d.ts.map +1 -1
  22. package/build/api/pin.js +35 -63
  23. package/build/api/stats.d.ts +15 -16
  24. package/build/api/stats.d.ts.map +1 -1
  25. package/build/api/stats.js +60 -99
  26. package/build/api/top-langs.d.ts +13 -18
  27. package/build/api/top-langs.d.ts.map +1 -1
  28. package/build/api/top-langs.js +35 -72
  29. package/build/api/wakatime.d.ts +11 -17
  30. package/build/api/wakatime.d.ts.map +1 -1
  31. package/build/api/wakatime.js +27 -64
  32. package/build/calculateRank.d.ts +0 -9
  33. package/build/calculateRank.d.ts.map +1 -1
  34. package/build/calculateRank.js +12 -17
  35. package/build/cards/contributed-to.d.ts +19 -0
  36. package/build/cards/contributed-to.d.ts.map +1 -0
  37. package/build/cards/contributed-to.js +242 -0
  38. package/build/cards/gist.d.ts +3 -4
  39. package/build/cards/gist.d.ts.map +1 -1
  40. package/build/cards/gist.js +38 -43
  41. package/build/cards/options.d.ts +1 -1
  42. package/build/cards/repo.d.ts +14 -5
  43. package/build/cards/repo.d.ts.map +1 -1
  44. package/build/cards/repo.js +93 -103
  45. package/build/cards/stats.d.ts +18 -11
  46. package/build/cards/stats.d.ts.map +1 -1
  47. package/build/cards/stats.js +191 -222
  48. package/build/cards/top-languages.d.ts +18 -33
  49. package/build/cards/top-languages.d.ts.map +1 -1
  50. package/build/cards/top-languages.js +196 -387
  51. package/build/cards/wakatime.d.ts +17 -8
  52. package/build/cards/wakatime.d.ts.map +1 -1
  53. package/build/cards/wakatime.js +133 -187
  54. package/build/common/Card.d.ts +20 -59
  55. package/build/common/Card.d.ts.map +1 -1
  56. package/build/common/Card.js +131 -198
  57. package/build/common/I18n.d.ts +1 -7
  58. package/build/common/I18n.d.ts.map +1 -1
  59. package/build/common/I18n.js +7 -8
  60. package/build/common/brand.d.ts +76 -0
  61. package/build/common/brand.d.ts.map +1 -0
  62. package/build/common/brand.js +73 -0
  63. package/build/common/color.d.ts +4 -17
  64. package/build/common/color.d.ts.map +1 -1
  65. package/build/common/color.js +67 -49
  66. package/build/common/config.d.ts +4 -14
  67. package/build/common/config.d.ts.map +1 -1
  68. package/build/common/config.js +3 -16
  69. package/build/common/constants.d.ts +6 -2
  70. package/build/common/constants.d.ts.map +1 -1
  71. package/build/common/constants.js +5 -1
  72. package/build/common/date.d.ts +50 -5
  73. package/build/common/date.d.ts.map +1 -1
  74. package/build/common/date.js +104 -4
  75. package/build/common/emojiMap.d.ts +8 -0
  76. package/build/common/emojiMap.d.ts.map +1 -0
  77. package/build/common/emojiMap.js +10 -0
  78. package/build/common/emojiMap.json +1915 -0
  79. package/build/common/error.d.ts +1 -10
  80. package/build/common/error.d.ts.map +1 -1
  81. package/build/common/error.js +1 -10
  82. package/build/common/fmt.d.ts +3 -15
  83. package/build/common/fmt.d.ts.map +1 -1
  84. package/build/common/fmt.js +7 -35
  85. package/build/common/http.d.ts +2 -7
  86. package/build/common/http.d.ts.map +1 -1
  87. package/build/common/http.js +8 -17
  88. package/build/common/icons.d.ts +20 -20
  89. package/build/common/icons.d.ts.map +1 -1
  90. package/build/common/icons.js +108 -67
  91. package/build/common/languageColors.d.ts +0 -1
  92. package/build/common/languageColors.d.ts.map +1 -1
  93. package/build/common/languageColors.js +1 -4
  94. package/build/common/languageColors.json +18 -2
  95. package/build/common/ops.d.ts +11 -17
  96. package/build/common/ops.d.ts.map +1 -1
  97. package/build/common/ops.js +22 -39
  98. package/build/common/render.d.ts +34 -82
  99. package/build/common/render.d.ts.map +1 -1
  100. package/build/common/render.js +225 -259
  101. package/build/common/retryer.d.ts +2 -7
  102. package/build/common/retryer.d.ts.map +1 -1
  103. package/build/common/retryer.js +4 -11
  104. package/build/fetchers/contributed-to.d.ts +62 -0
  105. package/build/fetchers/contributed-to.d.ts.map +1 -0
  106. package/build/fetchers/contributed-to.js +184 -0
  107. package/build/fetchers/gist.d.ts +2 -5
  108. package/build/fetchers/gist.d.ts.map +1 -1
  109. package/build/fetchers/gist.js +1 -5
  110. package/build/fetchers/graphql-error.d.ts +13 -0
  111. package/build/fetchers/graphql-error.d.ts.map +1 -0
  112. package/build/fetchers/graphql-error.js +29 -0
  113. package/build/fetchers/index.d.ts +13 -12
  114. package/build/fetchers/index.d.ts.map +1 -1
  115. package/build/fetchers/index.js +1 -0
  116. package/build/fetchers/repo.d.ts +2 -11
  117. package/build/fetchers/repo.d.ts.map +1 -1
  118. package/build/fetchers/repo.js +20 -33
  119. package/build/fetchers/stats.d.ts +16 -40
  120. package/build/fetchers/stats.d.ts.map +1 -1
  121. package/build/fetchers/stats.js +89 -271
  122. package/build/fetchers/top-languages.d.ts +2 -9
  123. package/build/fetchers/top-languages.d.ts.map +1 -1
  124. package/build/fetchers/top-languages.js +16 -47
  125. package/build/fetchers/types.d.ts +25 -1
  126. package/build/fetchers/types.d.ts.map +1 -1
  127. package/build/fetchers/wakatime.d.ts +2 -6
  128. package/build/fetchers/wakatime.d.ts.map +1 -1
  129. package/build/fetchers/wakatime.js +1 -5
  130. package/build/graphql/contributionsCollection.d.ts +9 -0
  131. package/build/graphql/contributionsCollection.d.ts.map +1 -0
  132. package/build/graphql/contributionsCollection.js +12 -0
  133. package/build/graphql/contributionsDocument.d.ts +6 -6
  134. package/build/graphql/contributionsDocument.d.ts.map +1 -1
  135. package/build/graphql/contributionsDocument.js +8 -16
  136. package/build/graphql/generated/contributed-to.d.ts +22 -0
  137. package/build/graphql/generated/contributed-to.d.ts.map +1 -0
  138. package/build/graphql/generated/contributed-to.js +11 -0
  139. package/build/graphql/generated/gist.d.ts +1 -1
  140. package/build/graphql/generated/repo.d.ts +1 -1
  141. package/build/graphql/generated/stats.d.ts +16 -4
  142. package/build/graphql/generated/stats.d.ts.map +1 -1
  143. package/build/graphql/generated/stats.js +2 -2
  144. package/build/graphql/generated/top-languages.d.ts +2 -2
  145. package/build/graphql/graphqlDocument.d.ts +1 -2
  146. package/build/graphql/graphqlDocument.d.ts.map +1 -1
  147. package/build/graphql/graphqlDocument.js +0 -1
  148. package/build/graphql/reposContributedToDocument.d.ts +4 -5
  149. package/build/graphql/reposContributedToDocument.d.ts.map +1 -1
  150. package/build/graphql/reposContributedToDocument.js +11 -7
  151. package/build/markup/css.d.ts +35 -0
  152. package/build/markup/css.d.ts.map +1 -0
  153. package/build/markup/css.js +64 -0
  154. package/build/markup/element.d.ts +41 -0
  155. package/build/markup/element.d.ts.map +1 -0
  156. package/build/markup/element.js +127 -0
  157. package/build/markup/html.d.ts +13 -0
  158. package/build/markup/html.d.ts.map +1 -0
  159. package/build/markup/html.js +27 -0
  160. package/build/markup/index.d.ts +9 -0
  161. package/build/markup/index.d.ts.map +1 -0
  162. package/build/markup/index.js +6 -0
  163. package/build/markup/serialize.d.ts +16 -0
  164. package/build/markup/serialize.d.ts.map +1 -0
  165. package/build/markup/serialize.js +17 -0
  166. package/build/themes/index.d.ts +332 -333
  167. package/build/themes/index.d.ts.map +1 -1
  168. package/build/themes/index.js +1 -1
  169. package/build/translations.d.ts +66 -7
  170. package/build/translations.d.ts.map +1 -1
  171. package/build/translations.js +686 -629
  172. package/package.json +17 -26
  173. package/src/api/api-result.ts +10 -9
  174. package/src/api/contributed-to.ts +84 -0
  175. package/src/api/gist.ts +22 -46
  176. package/src/api/handler.ts +42 -0
  177. package/src/api/index.ts +13 -12
  178. package/src/api/params.ts +74 -29
  179. package/src/api/pin.ts +38 -65
  180. package/src/api/stats.ts +81 -118
  181. package/src/api/top-langs.ts +38 -80
  182. package/src/api/wakatime.ts +35 -73
  183. package/src/calculateRank.ts +12 -23
  184. package/src/cards/contributed-to.ts +373 -0
  185. package/src/cards/gist.ts +55 -49
  186. package/src/cards/options.ts +1 -1
  187. package/src/cards/repo.ts +134 -120
  188. package/src/cards/stats.ts +262 -252
  189. package/src/cards/top-languages.ts +315 -422
  190. package/src/cards/wakatime.ts +207 -225
  191. package/src/common/Card.ts +207 -214
  192. package/src/common/I18n.ts +7 -8
  193. package/src/common/brand.ts +109 -0
  194. package/src/common/color.ts +80 -57
  195. package/src/common/config.ts +5 -18
  196. package/src/common/constants.ts +8 -2
  197. package/src/common/date.ts +135 -5
  198. package/src/common/emojiMap.json +1915 -0
  199. package/src/common/emojiMap.ts +13 -0
  200. package/src/common/error.ts +2 -11
  201. package/src/common/fmt.ts +7 -44
  202. package/src/common/http.ts +9 -15
  203. package/src/common/icons.ts +122 -42
  204. package/src/common/languageColors.json +18 -2
  205. package/src/common/languageColors.ts +1 -4
  206. package/src/common/ops.ts +31 -48
  207. package/src/common/render.ts +334 -284
  208. package/src/common/retryer.ts +8 -15
  209. package/src/fetchers/contributed-to.ts +271 -0
  210. package/src/fetchers/gist.ts +8 -12
  211. package/src/fetchers/graphql-error.ts +36 -0
  212. package/src/fetchers/index.ts +15 -12
  213. package/src/fetchers/repo.ts +27 -40
  214. package/src/fetchers/stats.ts +157 -366
  215. package/src/fetchers/top-languages.ts +27 -55
  216. package/src/fetchers/types.ts +27 -1
  217. package/src/fetchers/wakatime.ts +5 -9
  218. package/src/graphql/contributionsCollection.ts +18 -0
  219. package/src/graphql/contributionsDocument.ts +14 -19
  220. package/src/graphql/generated/contributed-to.ts +25 -0
  221. package/src/graphql/generated/gist.ts +1 -1
  222. package/src/graphql/generated/repo.ts +1 -1
  223. package/src/graphql/generated/stats.ts +21 -8
  224. package/src/graphql/generated/top-languages.ts +2 -2
  225. package/src/graphql/graphqlDocument.ts +1 -2
  226. package/src/graphql/queries/contributed-to.graphql +8 -0
  227. package/src/graphql/queries/stats.graphql +14 -2
  228. package/src/graphql/reposContributedToDocument.ts +16 -15
  229. package/src/markup/css.ts +118 -0
  230. package/src/markup/element.ts +246 -0
  231. package/src/markup/html.ts +34 -0
  232. package/src/markup/index.ts +9 -0
  233. package/src/markup/serialize.ts +27 -0
  234. package/src/themes/index.ts +1 -1
  235. package/src/translations.ts +699 -629
  236. package/build/cards/index.d.ts +0 -11
  237. package/build/cards/index.d.ts.map +0 -1
  238. package/build/cards/index.js +0 -7
  239. package/build/common/html.d.ts +0 -8
  240. package/build/common/html.d.ts.map +0 -1
  241. package/build/common/html.js +0 -14
  242. package/build/index.d.ts +0 -15
  243. package/build/index.d.ts.map +0 -1
  244. package/build/index.js +0 -10
  245. package/src/_emoji-name-map.d.ts +0 -10
  246. package/src/_github-username-regex.d.ts +0 -4
  247. package/src/cards/index.ts +0 -21
  248. package/src/common/html.ts +0 -17
  249. 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,92 +202,83 @@ 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
 
268
+ /**
269
+ * How a stat value is written.
270
+ * Not validated at the boundary: anything but `long` reads as `short`,
271
+ * so an unknown value falls back rather than failing.
272
+ */
273
+ const NUMBER_FORMATS = ['short', 'long'] as const;
274
+
262
275
  /**
263
276
  * Create a labelled stat text item
264
277
  * (a label and its value, optionally with an icon and link).
265
278
  * Shared by the stats and repo cards.
266
279
  *
267
- * The caller must ensure that the passed `icon` and `link` are properly sanitized!
280
+ * The caller must ensure that the passed `link` is properly sanitized!
268
281
  *
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
282
  * @returns The stats card text item SVG object.
284
283
  */
285
284
  const createTextNode = ({
@@ -291,13 +290,15 @@ const createTextNode = ({
291
290
  index,
292
291
  showIcons,
293
292
  shiftValuePos,
293
+ valueAnchorX,
294
294
  bold,
295
+ labelBold = bold,
295
296
  numberFormat,
296
297
  numberPrecision,
297
298
  link,
298
299
  labelXOffset = 25,
299
300
  }: {
300
- icon: string;
301
+ icon: Child;
301
302
  label: string;
302
303
  value: number | string;
303
304
  id: string;
@@ -306,20 +307,25 @@ const createTextNode = ({
306
307
  index: number;
307
308
  showIcons: boolean;
308
309
  shiftValuePos: number;
310
+ valueAnchorX?: number | undefined;
309
311
  bold: boolean;
312
+ labelBold?: boolean;
310
313
  numberFormat: string;
311
314
  numberPrecision?: number | undefined;
312
315
  link?: string | undefined;
313
316
  labelXOffset?: number;
314
- }): string => {
317
+ }): MarkupElement => {
315
318
  if (!Number.isFinite(labelXOffset)) {
316
- throw new Error(`Invalid labelXOffset: "${labelXOffset}"`);
319
+ throw new TypeError(`Invalid labelXOffset: "${labelXOffset}"`);
317
320
  }
318
321
  if (!Number.isFinite(shiftValuePos)) {
319
- throw new Error(`Invalid shiftValuePos: "${shiftValuePos}"`);
322
+ throw new TypeError(`Invalid shiftValuePos: "${shiftValuePos}"`);
320
323
  }
321
324
  if (!Number.isFinite(index)) {
322
- throw new Error(`Invalid index: "${index}"`);
325
+ throw new TypeError(`Invalid index: "${index}"`);
326
+ }
327
+ if (valueAnchorX !== undefined && !Number.isFinite(valueAnchorX)) {
328
+ throw new Error(`Invalid valueAnchorX: "${valueAnchorX}"`);
323
329
  }
324
330
 
325
331
  const precision =
@@ -331,54 +337,62 @@ const createTextNode = ({
331
337
  const rawValue = numberFormat.toLowerCase() === 'long' || id === 'prs_merged_percentage';
332
338
  const kValue = rawValue || typeof value !== 'number' ? value : kFormatter(value, precision);
333
339
 
334
- const staggerDelay = (index + 3) * 150;
335
- const boldClass = bold ? ' bold' : 'not_bold';
340
+ const staggerDelay = 120 + index * 40;
341
+ const boldClass = bold ? 'bold' : 'not_bold';
342
+ const labelBoldClass = labelBold ? 'bold' : 'not_bold';
336
343
  const valueX = (showIcons ? 140 : 120) + (bold ? 5 : 0) + shiftValuePos;
337
344
  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
- `;
345
+
346
+ const content = [
347
+ showIcons &&
348
+ el(
349
+ 'svg',
350
+ {
351
+ 'data-testid': 'icon',
352
+ class: 'icon',
353
+ viewBox: '0 0 16 16',
354
+ version: '1.1',
355
+ width: 16,
356
+ height: 16,
357
+ },
358
+ icon,
359
+ ),
360
+ el(
361
+ 'text',
362
+ { class: `stat ${labelBoldClass}`, x: showIcons ? labelXOffset : undefined, y: 12.5 },
363
+ `${label}:`,
364
+ ),
365
+ el(
366
+ 'text',
367
+ {
368
+ class: `stat ${boldClass}`,
369
+ x: valueAnchorX ?? valueX,
370
+ 'text-anchor': valueAnchorX === undefined ? undefined : 'end',
371
+ y: 12.5,
372
+ 'data-testid': id,
373
+ },
374
+ `${String(kValue)}${unit}`,
375
+ ),
376
+ ];
377
+
378
+ return el(
379
+ 'g',
380
+ {
381
+ class: 'stagger',
382
+ style: `animation-delay: ${staggerDelay}ms`,
383
+ transform: 'translate(25, 0)',
384
+ },
385
+ link ? el('a', { href: link }, content) : content,
386
+ );
362
387
  };
363
388
 
364
- // Script parameters.
365
389
  const ERROR_CARD_LENGTH = 576.5;
366
390
 
367
- const UPSTREAM_API_ERRORS = [TRY_AGAIN_LATER, SECONDARY_ERROR_MESSAGES.rate_limited];
391
+ const UPSTREAM_API_ERRORS = new Set([TRY_AGAIN_LATER, SECONDARY_ERROR_MESSAGES.rate_limited]);
368
392
 
369
393
  /**
370
394
  * Renders error message on the card.
371
395
  *
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
396
  * @returns The SVG markup.
383
397
  */
384
398
  const renderError = ({
@@ -417,99 +431,111 @@ const renderError = ({
417
431
  theme,
418
432
  });
419
433
 
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
- `;
434
+ const hint =
435
+ UPSTREAM_API_ERRORS.has(secondaryMessage) || !show_repo_link
436
+ ? ''
437
+ : ' file an issue at https://tinyurl.com/github-stats';
438
+
439
+ return renderMarkup(
440
+ el(
441
+ 'svg',
442
+ {
443
+ width: ERROR_CARD_LENGTH,
444
+ height: 120,
445
+ viewBox: `0 0 ${ERROR_CARD_LENGTH} 120`,
446
+ fill: String(bgColor),
447
+ xmlns: 'http://www.w3.org/2000/svg',
448
+ role: 'img',
449
+ 'aria-labelledby': 'title-id desc-id',
450
+ },
451
+ el('title', { id: 'title-id' }, message),
452
+ el('desc', { id: 'desc-id' }, secondaryMessage),
453
+ style(
454
+ rule('.text', { font: "600 16px 'Segoe UI', Ubuntu, Sans-Serif", fill: titleColor }),
455
+ rule('.small', { font: "600 12px 'Segoe UI', Ubuntu, Sans-Serif", fill: textColor }),
456
+ rule('.gray', { fill: '#858585' }),
457
+ ),
458
+ el('rect', {
459
+ x: 0.5,
460
+ y: 0.5,
461
+ width: ERROR_CARD_LENGTH - 1,
462
+ height: '99%',
463
+ rx: 8,
464
+ fill: String(bgColor),
465
+ stroke: borderColor,
466
+ }),
467
+ el('text', { x: 25, y: 45, class: 'text' }, `Something went wrong!${hint}`),
468
+ el(
469
+ 'text',
470
+ { 'data-testid': 'message', x: 25, y: 55, class: 'text small' },
471
+ el('tspan', { x: 25, dy: 18 }, message),
472
+ el('tspan', { x: 25, dy: 18, class: 'gray' }, secondaryMessage),
473
+ ),
474
+ ),
475
+ );
443
476
  };
444
477
 
478
+ /** Advance of each ASCII code point, as a fraction of the font size. */
479
+ // prettier-ignore
480
+ const CHAR_WIDTHS = [
481
+ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
482
+ 0, 0, 0, 0, 0, 0, 0, 0.2733333110809326, 0.28499999046325686,
483
+ 0.39166667461395266, 0.5900000095367431, 0.5383333206176758,
484
+ 0.8183333396911621, 0.8, 0.22999999523162842, 0.30166666507720946,
485
+ 0.30166666507720946, 0.41666665077209475, 0.6833333492279052,
486
+ 0.21666667461395264, 0.4, 0.21666667461395264, 0.3900000095367432,
487
+ 0.5383333206176758, 0.5383333206176758, 0.5383333206176758,
488
+ 0.5383333206176758, 0.5383333206176758, 0.5383333206176758,
489
+ 0.5383333206176758, 0.5383333206176758, 0.5383333206176758,
490
+ 0.5383333206176758, 0.21666667461395264, 0.21666667461395264,
491
+ 0.6833333492279052, 0.6833333492279052, 0.6833333492279052,
492
+ 0.4483333110809326, 0.9550000190734863, 0.6449999809265137,
493
+ 0.5733333110809327, 0.6183333396911621, 0.7016666889190674,
494
+ 0.5066666603088379, 0.48833332061767576, 0.6866666793823242,
495
+ 0.7099999904632568, 0.26666667461395266, 0.35666666030883787,
496
+ 0.5800000190734863, 0.4699999809265137, 0.8983333587646485,
497
+ 0.7483333110809326, 0.753333330154419, 0.5599999904632569,
498
+ 0.753333330154419, 0.5983333110809326, 0.5316666603088379,
499
+ 0.5233333110809326, 0.6866666793823242, 0.621666669845581,
500
+ 0.9333333015441895, 0.5900000095367431, 0.553333330154419,
501
+ 0.5699999809265137, 0.30166666507720946, 0.37833333015441895,
502
+ 0.30166666507720946, 0.6833333492279052, 0.41500000953674315,
503
+ 0.26833333969116213, 0.5083333492279053, 0.5883333206176757,
504
+ 0.4616666793823242, 0.5883333206176757, 0.5233333110809326,
505
+ 0.3133333444595337, 0.5883333206176757, 0.5666666507720948,
506
+ 0.24166667461395264, 0.24166667461395264, 0.49666666984558105,
507
+ 0.24166667461395264, 0.8616666793823242, 0.5666666507720948,
508
+ 0.5866666793823242, 0.5883333206176757, 0.5883333206176757,
509
+ 0.3483333349227905, 0.425, 0.33833334445953367, 0.5666666507720948,
510
+ 0.4783333301544189, 0.7233333110809326, 0.45833334922790525,
511
+ 0.4833333492279053, 0.45166668891906736, 0.30166666507720946,
512
+ 0.24000000953674316, 0.30166666507720946, 0.6833333492279052,
513
+ ];
514
+
515
+ /** Advance of a code point the table does not cover. */
516
+ const AVERAGE_CHAR_WIDTH = 0.5131403493881227;
517
+ /**
518
+ * Full-width glyphs: U+3000–U+9FFF (CJK symbols and punctuation, Hiragana, Katakana,
519
+ * CJK Unified Ideographs incl. Extension A) plus U+FF00–U+FFEF (halfwidth and fullwidth forms).
520
+ */
521
+ const FULL_WIDTH_PATTERN = /[\u3000-\u9FFF\uFF00-\uFFEF]/;
522
+
445
523
  /**
446
524
  * Retrieve text length based on Segoe UI font.
447
525
  *
448
526
  * @see https://stackoverflow.com/a/48172630/10629172
449
- * @param str String to measure.
450
- * @param fontSize Font size.
527
+ *
451
528
  * @returns Text length.
452
529
  */
453
- const measureText = (str: string, fontSize = 10): number => {
454
- // prettier-ignore
455
- const widths = [
456
- 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
457
- 0, 0, 0, 0, 0, 0, 0, 0.2733333110809326, 0.28499999046325686,
458
- 0.39166667461395266, 0.5900000095367431, 0.5383333206176758,
459
- 0.8183333396911621, 0.8, 0.22999999523162842, 0.30166666507720946,
460
- 0.30166666507720946, 0.41666665077209475, 0.6833333492279052,
461
- 0.21666667461395264, 0.4, 0.21666667461395264, 0.3900000095367432,
462
- 0.5383333206176758, 0.5383333206176758, 0.5383333206176758,
463
- 0.5383333206176758, 0.5383333206176758, 0.5383333206176758,
464
- 0.5383333206176758, 0.5383333206176758, 0.5383333206176758,
465
- 0.5383333206176758, 0.21666667461395264, 0.21666667461395264,
466
- 0.6833333492279052, 0.6833333492279052, 0.6833333492279052,
467
- 0.4483333110809326, 0.9550000190734863, 0.6449999809265137,
468
- 0.5733333110809327, 0.6183333396911621, 0.7016666889190674,
469
- 0.5066666603088379, 0.48833332061767576, 0.6866666793823242,
470
- 0.7099999904632568, 0.26666667461395266, 0.35666666030883787,
471
- 0.5800000190734863, 0.4699999809265137, 0.8983333587646485,
472
- 0.7483333110809326, 0.753333330154419, 0.5599999904632569,
473
- 0.753333330154419, 0.5983333110809326, 0.5316666603088379,
474
- 0.5233333110809326, 0.6866666793823242, 0.621666669845581,
475
- 0.9333333015441895, 0.5900000095367431, 0.553333330154419,
476
- 0.5699999809265137, 0.30166666507720946, 0.37833333015441895,
477
- 0.30166666507720946, 0.6833333492279052, 0.41500000953674315,
478
- 0.26833333969116213, 0.5083333492279053, 0.5883333206176757,
479
- 0.4616666793823242, 0.5883333206176757, 0.5233333110809326,
480
- 0.3133333444595337, 0.5883333206176757, 0.5666666507720948,
481
- 0.24166667461395264, 0.24166667461395264, 0.49666666984558105,
482
- 0.24166667461395264, 0.8616666793823242, 0.5666666507720948,
483
- 0.5866666793823242, 0.5883333206176757, 0.5883333206176757,
484
- 0.3483333349227905, 0.425, 0.33833334445953367, 0.5666666507720948,
485
- 0.4783333301544189, 0.7233333110809326, 0.45833334922790525,
486
- 0.4833333492279053, 0.45166668891906736, 0.30166666507720946,
487
- 0.24000000953674316, 0.30166666507720946, 0.6833333492279052,
488
- ];
489
-
490
- const avg = 0.5131403493881227;
491
- // CJK character range: U+3000–U+9FFF (CJK Symbols/Punctuation, Hiragana,
492
- // Katakana, CJK Unified Ideographs incl. Extension A) plus U+FF00–U+FFEF
493
- // (Halfwidth/Fullwidth Forms — fullwidth ASCII, fullwidth punctuation).
494
- const cjkRange = /[\u3000-\u9FFF\uFF00-\uFFEF]/;
495
-
496
- return (
497
- str
498
- .split('')
499
- .map((c) => {
500
- if (cjkRange.test(c) || c === '\u3000') {
501
- // CJK glyphs and U+3000 IDEOGRAPHIC SPACE are full-width by default;
502
- return 1;
503
- }
504
- if (c.charCodeAt(0) < widths.length) {
505
- return widths[c.charCodeAt(0)] ?? avg;
506
- } else {
507
- return avg;
508
- }
509
- })
510
- .reduce((cur, acc) => acc + cur) * fontSize
511
- );
512
- };
530
+ const measureText = (str: string, fontSize = 10): number =>
531
+ [...str]
532
+ .map((char) => {
533
+ if (FULL_WIDTH_PATTERN.test(char)) {
534
+ return 1;
535
+ }
536
+ return CHAR_WIDTHS[char.codePointAt(0) ?? 0] ?? AVERAGE_CHAR_WIDTH;
537
+ })
538
+ .reduce((total, width) => total + width, 0) * fontSize;
513
539
 
514
540
  /**
515
541
  * Split text into the lines it would wrap to when laid out greedily at the
@@ -518,9 +544,6 @@ const measureText = (str: string, fontSize = 10): number => {
518
544
  * The browser still does the real wrap inside the foreignObject; this is only
519
545
  * used to size the SVG.
520
546
  *
521
- * @param text Text to split.
522
- * @param fontSize Font size in px (matches `measureText`).
523
- * @param maxWidth Available wrap width in px.
524
547
  * @returns Estimated wrapped lines.
525
548
  */
526
549
  const splitWrappedText = (text: string, fontSize: number, maxWidth: number): Array<string> => {
@@ -538,7 +561,7 @@ const splitWrappedText = (text: string, fontSize: number, maxWidth: number): Arr
538
561
  // Korean Hangul (U+AC00–U+D7AF) is intentionally NOT in the CJK range
539
562
  // because Korean wraps at word boundaries by default in HTML.
540
563
  // ASCII whitespace is collapsed to a single space per CSS `white-space: normal;`
541
- const normalizedText = text.replace(/[\t\n\r ]+/g, ' ');
564
+ const normalizedText = text.replaceAll(/[\t\n\r ]+/g, ' ');
542
565
  const tokens = normalizedText.match(
543
566
  /\s|[\u3000-\u9FFF\uFF00-\uFFEF]|[^\s\u3000-\u9FFF\uFF00-\uFFEF]+/g,
544
567
  );
@@ -546,8 +569,11 @@ const splitWrappedText = (text: string, fontSize: number, maxWidth: number): Arr
546
569
  return [];
547
570
  }
548
571
 
549
- const takeFittingSegment = (token: string, availableWidth: number) => {
550
- const characters = token.split('');
572
+ const takeFittingSegment = (
573
+ token: string,
574
+ availableWidth: number,
575
+ ): { segment: string; width: number } => {
576
+ const characters = [...token];
551
577
  let segment = '';
552
578
  let width = 0;
553
579
 
@@ -575,7 +601,7 @@ const splitWrappedText = (text: string, fontSize: number, maxWidth: number): Arr
575
601
  if (currentWidth === 0) {
576
602
  continue;
577
603
  }
578
- lines[lines.length - 1] = (lines[lines.length - 1] ?? '') + token;
604
+ lines[lines.length - 1] = (lines.at(-1) ?? '') + token;
579
605
  currentWidth += measureText(token, fontSize);
580
606
  continue;
581
607
  }
@@ -585,7 +611,7 @@ const splitWrappedText = (text: string, fontSize: number, maxWidth: number): Arr
585
611
  while (remaining) {
586
612
  const w = measureText(remaining, fontSize);
587
613
  if (currentWidth + w <= maxWidth) {
588
- lines[lines.length - 1] = (lines[lines.length - 1] ?? '') + remaining;
614
+ lines[lines.length - 1] = (lines.at(-1) ?? '') + remaining;
589
615
  currentWidth += w;
590
616
  break;
591
617
  }
@@ -598,7 +624,7 @@ const splitWrappedText = (text: string, fontSize: number, maxWidth: number): Arr
598
624
 
599
625
  // An atom wider than the box wraps mid-glyph (overflow-wrap: anywhere).
600
626
  const { segment, width } = takeFittingSegment(remaining, maxWidth);
601
- lines[lines.length - 1] = (lines[lines.length - 1] ?? '') + segment;
627
+ lines[lines.length - 1] = (lines.at(-1) ?? '') + segment;
602
628
  currentWidth = width;
603
629
  remaining = remaining.slice(segment.length);
604
630
  }
@@ -607,14 +633,38 @@ const splitWrappedText = (text: string, fontSize: number, maxWidth: number): Arr
607
633
  return lines.map((line) => line.replace(/[\t\n\r ]+$/, ''));
608
634
  };
609
635
 
636
+ /**
637
+ * Split text over multiple lines based on the card width.
638
+ *
639
+ * @returns Array of lines.
640
+ */
641
+ const wrapTextMultiline = (
642
+ text: string,
643
+ width: number,
644
+ fontSize: number,
645
+ maxLines = 3,
646
+ ): Array<string> => {
647
+ const wrapped = splitWrappedText(text, fontSize, width);
648
+ const lines = wrapped.map((line) => line.trim()).slice(0, maxLines); // Only consider maxLines lines
649
+
650
+ // Add "..." to the last line if the text exceeds maxLines
651
+ if (wrapped.length > maxLines) {
652
+ const lastIndex = maxLines - 1;
653
+ const lastLine = lines[lastIndex];
654
+ if (lastLine !== undefined) {
655
+ lines[lastIndex] = `${lastLine}...`;
656
+ }
657
+ }
658
+
659
+ // Remove empty lines if text fits in less than maxLines lines
660
+ const multiLineText = lines.filter(Boolean);
661
+ return multiLineText;
662
+ };
663
+
610
664
  /**
611
665
  * Estimate how many lines a string will wrap to when laid out greedily at the
612
666
  * given font size inside a box of width `maxWidth`, capped at `maxLines`.
613
667
  *
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
668
  * @returns Estimated line count, at least 1, at most `maxLines`.
619
669
  */
620
670
  const countWrappedLines = (
@@ -622,11 +672,10 @@ const countWrappedLines = (
622
672
  fontSize: number,
623
673
  maxWidth: number,
624
674
  maxLines: number,
625
- ): number => {
626
- return Math.min(Math.max(1, splitWrappedText(text, fontSize, maxWidth).length), maxLines);
627
- };
675
+ ): number => Math.min(Math.max(1, splitWrappedText(text, fontSize, maxWidth).length), maxLines);
628
676
 
629
677
  export {
678
+ NUMBER_FORMATS,
630
679
  renderError,
631
680
  createLanguageNode,
632
681
  createProgressNode,
@@ -635,6 +684,7 @@ export {
635
684
  flexLayout,
636
685
  measureText,
637
686
  splitWrappedText,
687
+ wrapTextMultiline,
638
688
  countWrappedLines,
639
689
  wrappedTextNode,
640
690
  wrappedTextStyles,