@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,12 +1,56 @@
1
- import { getCardColors, isPrefixedHexColor, isValidGradient } from './color.js';
2
- import type { CardColors } from './color.js';
3
- import { encodeHTML } from './html.js';
4
- import { flexLayout } from './render.js';
1
+ import type { Child, CssChild, MarkupElement } from '../markup/index.ts';
2
+ import { atRule, cssComment, el, renderMarkup, rule, style } from '../markup/index.ts';
3
+
4
+ import { ACCENT, firefoxFontSize, font, TITLE_BAND, TITLE_FIREFOX_SIZE } from './brand.ts';
5
+ import { getCardColors, isPrefixedHexColor, isValidGradient } from './color.ts';
6
+ import type { CardColors } from './color.ts';
7
+ import { flexLayout } from './render.ts';
5
8
 
6
9
  /**
7
10
  * Builds the card CSS for one color scheme from that scheme's resolved colors.
8
11
  */
9
- type CardCSSBuilder = (colors: CardColors) => string;
12
+ type CardCSSBuilder = (colors: CardColors) => Array<CssChild>;
13
+
14
+ /** Stops every animation, whether the reader asked or the card did. */
15
+ const NO_MOTION = {
16
+ 'animation-duration': '0s !important',
17
+ 'animation-delay': '0s !important',
18
+ } as const;
19
+
20
+ /**
21
+ * Retrieves css animations for a card.
22
+ *
23
+ * @returns Animation css.
24
+ */
25
+ const getAnimations = (): Array<CssChild> => [
26
+ cssComment('Animations'),
27
+ atRule(
28
+ '@keyframes scaleInAnimation',
29
+ rule('from', { transform: 'translate(-5px, 5px) scale(0)' }),
30
+ rule('to', { transform: 'translate(-5px, 5px) scale(1)' }),
31
+ ),
32
+ atRule('@keyframes fadeInAnimation', rule('from', { opacity: 0 }), rule('to', { opacity: 1 })),
33
+ atRule('@media (prefers-reduced-motion: reduce)', rule('*', NO_MOTION)),
34
+ ];
35
+
36
+ /** One `linearGradient` definition, built from an angle followed by its stops. */
37
+ const buildGradientDef = (id: string, bgColor: Array<string>): MarkupElement => {
38
+ const gradients = bgColor.slice(1);
39
+ return el(
40
+ 'linearGradient',
41
+ {
42
+ id,
43
+ gradientTransform: `rotate(${String(bgColor[0])})`,
44
+ gradientUnits: 'userSpaceOnUse',
45
+ },
46
+ gradients.map((grad, index) =>
47
+ el('stop', {
48
+ offset: `${(index * 100) / (gradients.length - 1)}%`,
49
+ 'stop-color': `#${grad}`,
50
+ }),
51
+ ),
52
+ );
53
+ };
10
54
 
11
55
  class Card {
12
56
  width: number;
@@ -16,35 +60,22 @@ class Card {
16
60
  border_radius: number;
17
61
  colors: { light: CardColors; dark: CardColors | null };
18
62
  title: string;
19
- css: string;
20
- darkCss: string;
63
+ css: Array<CssChild>;
64
+ darkCss: Array<CssChild>;
21
65
  paddingX: number;
22
66
  paddingY: number;
23
- titlePrefixIcon: string | undefined;
67
+ titlePrefixIcon: Child;
24
68
  animations: boolean;
25
69
  a11yTitle: string;
26
70
  a11yDesc: string;
27
71
 
28
72
  /**
29
73
  * Creates a new card instance.
30
- *
31
- * The caller must ensure that the passed `titlePrefixIcon` is properly sanitized!
32
- *
33
- * @param props Card arguments.
34
- * @param props.width Card width.
35
- * @param props.height Card height.
36
- * @param props.border_radius Card border radius.
37
- * @param props.colors Card colors for light and dark mode.
38
- * @param props.colors.light Card colors for light mode.
39
- * @param props.colors.dark Card colors for dark mode, or `null` when no dark-mode override is needed.
40
- * @param props.customTitle Card custom title.
41
- * @param props.defaultTitle Card default title.
42
- * @param props.titlePrefixIcon Sanitized card title prefix icon.
43
74
  */
44
75
  constructor({
45
76
  width = 100,
46
77
  height = 100,
47
- border_radius = 4.5,
78
+ border_radius = 8,
48
79
  colors = { light: getCardColors({}), dark: null },
49
80
  customTitle,
50
81
  defaultTitle = '',
@@ -57,7 +88,7 @@ class Card {
57
88
  colors?: { light: CardColors; dark: CardColors | null };
58
89
  customTitle?: string | undefined;
59
90
  defaultTitle?: string;
60
- titlePrefixIcon?: string;
91
+ titlePrefixIcon?: Child;
61
92
  }) {
62
93
  this.width = width;
63
94
  this.height = height;
@@ -65,16 +96,16 @@ class Card {
65
96
  this.hideBorder = false;
66
97
  this.hideTitle = false;
67
98
 
68
- this.border_radius = parseFloat(String(border_radius));
99
+ this.border_radius = Number.parseFloat(String(border_radius));
69
100
 
70
101
  this.colors = colors;
71
- this.title = customTitle === undefined ? defaultTitle : customTitle;
102
+ this.title = customTitle ?? defaultTitle;
72
103
 
73
- this.css = '';
74
- this.darkCss = '';
104
+ this.css = [];
105
+ this.darkCss = [];
75
106
 
76
107
  this.paddingX = 25;
77
- this.paddingY = 35;
108
+ this.paddingY = 30;
78
109
  this.titlePrefixIcon = titlePrefixIcon;
79
110
  this.animations = true;
80
111
  this.a11yTitle = '';
@@ -85,11 +116,6 @@ class Card {
85
116
  this.animations = false;
86
117
  }
87
118
 
88
- /**
89
- * @param props The props object.
90
- * @param props.title Accessibility title.
91
- * @param props.desc Accessibility description.
92
- */
93
119
  setAccessibilityLabel({ title, desc }: { title: string; desc: string }): void {
94
120
  this.a11yTitle = title;
95
121
  this.a11yDesc = desc;
@@ -101,28 +127,16 @@ class Card {
101
127
  * Each builder receives the colors of its own color scheme,
102
128
  * so a card never handles a missing dark palette itself:
103
129
  * `dark` runs only when the card has dark colors, and always with non-null ones.
104
- *
105
- * @warning The caller must ensure that the returned CSS strings are properly sanitized!
106
- *
107
- * @param props The props object.
108
- * @param props.light Builds the CSS applied unconditionally (light/default mode).
109
- * @param props.dark Builds the CSS placed inside a `@media (prefers-color-scheme: dark)` block.
110
130
  */
111
131
  setCSS({ light, dark }: { light: CardCSSBuilder; dark: CardCSSBuilder }): void {
112
132
  this.css = light(this.colors.light);
113
- this.darkCss = this.colors.dark ? dark(this.colors.dark) : '';
133
+ this.darkCss = this.colors.dark ? dark(this.colors.dark) : [];
114
134
  }
115
135
 
116
- /**
117
- * @param value Whether to hide the border or not.
118
- */
119
136
  setHideBorder(value: boolean): void {
120
137
  this.hideBorder = value;
121
138
  }
122
139
 
123
- /**
124
- * @param value Whether to hide the title or not.
125
- */
126
140
  setHideTitle(value: boolean): void {
127
141
  if (value && !this.hideTitle) {
128
142
  this.height -= 30;
@@ -133,73 +147,75 @@ class Card {
133
147
  this.hideTitle = value;
134
148
  }
135
149
 
136
- /**
137
- * @param text The title to set.
138
- */
139
150
  setTitle(text: string): void {
140
151
  this.title = text;
141
152
  }
142
153
 
143
- /**
144
- * @returns The rendered card title.
145
- */
146
- renderTitle(): string {
147
- const titleText = `
148
- <text
149
- x="0"
150
- y="0"
151
- class="header"
152
- data-testid="header"
153
- >${encodeHTML(this.title)}</text>
154
- `;
154
+ /** @returns The rendered card title. */
155
+ renderTitle(): MarkupElement {
156
+ return el(
157
+ 'g',
158
+ {
159
+ 'data-testid': 'card-title',
160
+ transform: `translate(${this.paddingX}, ${this.paddingY})`,
161
+ },
162
+ flexLayout({
163
+ items: [
164
+ this.titlePrefixIcon &&
165
+ el(
166
+ 'svg',
167
+ {
168
+ class: 'title-icon',
169
+ x: 0,
170
+ y: -13,
171
+ viewBox: '0 0 16 16',
172
+ version: '1.1',
173
+ width: 16,
174
+ height: 16,
175
+ },
176
+ this.titlePrefixIcon,
177
+ ),
178
+ el('text', { x: 0, y: 0, class: 'header', 'data-testid': 'header' }, this.title),
179
+ ],
180
+ gap: 25,
181
+ }),
182
+ el('rect', {
183
+ 'data-testid': 'title-accent',
184
+ class: 'title-accent',
185
+ x: 0,
186
+ y: ACCENT.y,
187
+ width: ACCENT.width,
188
+ height: ACCENT.height,
189
+ rx: ACCENT.height / 2,
190
+ }),
191
+ );
192
+ }
155
193
 
156
- const prefixIcon = `
157
- <svg
158
- class="icon"
159
- x="0"
160
- y="-13"
161
- viewBox="0 0 16 16"
162
- version="1.1"
163
- width="16"
164
- height="16"
165
- >
166
- ${String(this.titlePrefixIcon)}
167
- </svg>
168
- `;
169
- return `
170
- <g
171
- data-testid="card-title"
172
- transform="translate(${this.paddingX}, ${this.paddingY})"
173
- >
174
- ${flexLayout({
175
- items: [this.titlePrefixIcon ? prefixIcon : '', titleText],
176
- gap: 25,
177
- }).join('')}
178
- </g>
179
- `;
194
+ /** Where the body starts, and so where the title band ends. */
195
+ private get bodyOffset(): number {
196
+ return this.hideTitle ? this.paddingX : this.paddingY + 25;
180
197
  }
181
198
 
182
199
  /**
183
- * @returns The rendered card gradient.
200
+ * The band behind the title. Its top corners follow the card's own and its bottom
201
+ * ones stay square, so it meets the body as an edge rather than a floating box.
202
+ *
203
+ * @returns The rendered title band.
184
204
  */
185
- renderGradient(): string {
186
- const buildGradientDef = (id: string, bgColor: Array<string>): string => {
187
- const gradients = bgColor.slice(1);
188
- return `
189
- <linearGradient
190
- id="${id}"
191
- gradientTransform="rotate(${String(bgColor[0])})"
192
- gradientUnits="userSpaceOnUse"
193
- >
194
- ${gradients
195
- .map((grad, index) => {
196
- const offset = (index * 100) / (gradients.length - 1);
197
- return `<stop offset="${offset}%" stop-color="#${grad}" />`;
198
- })
199
- .join(',')}
200
- </linearGradient>`;
201
- };
205
+ private renderTitleBand(): MarkupElement {
206
+ const height = this.bodyOffset - TITLE_BAND.gap;
207
+ const innerWidth = this.width - 1;
208
+ const r = Math.min(this.border_radius, height, innerWidth / 2);
202
209
 
210
+ return el('path', {
211
+ 'data-testid': 'title-band',
212
+ class: 'title-band',
213
+ d: `M0.5 ${0.5 + r}a${r} ${r} 0 0 1 ${r} ${-r}h${innerWidth - 2 * r}a${r} ${r} 0 0 1 ${r} ${r}v${height - r - 0.5}h${-innerWidth}z`,
214
+ });
215
+ }
216
+
217
+ /** @returns The card's gradient definitions, or nothing when no color is a gradient. */
218
+ renderGradient(): Child {
203
219
  if (
204
220
  typeof this.colors.light.bgColor === 'object' &&
205
221
  !isValidGradient(this.colors.light.bgColor)
@@ -214,48 +230,24 @@ class Card {
214
230
  throw new Error(`Invalid dark gradient: ${this.colors.dark.bgColor.join(',')}`);
215
231
  }
216
232
 
217
- return `
218
- <defs>
219
- ${typeof this.colors.light.bgColor === 'object' ? buildGradientDef('gradient', this.colors.light.bgColor) : ''}
220
- ${this.colors.dark && typeof this.colors.dark.bgColor === 'object' ? buildGradientDef('gradient-dark', this.colors.dark.bgColor) : ''}
221
- </defs>
222
- `;
223
- }
233
+ const defs = [
234
+ typeof this.colors.light.bgColor === 'object' &&
235
+ buildGradientDef('gradient', this.colors.light.bgColor),
236
+ this.colors.dark &&
237
+ typeof this.colors.dark.bgColor === 'object' &&
238
+ buildGradientDef('gradient-dark', this.colors.dark.bgColor),
239
+ ].filter(Boolean);
224
240
 
225
- /**
226
- * Retrieves css animations for a card.
227
- *
228
- * @returns Animation css.
229
- */
230
- getAnimations = (): string => {
231
- return `
232
- /* Animations */
233
- @keyframes scaleInAnimation {
234
- from {
235
- transform: translate(-5px, 5px) scale(0);
236
- }
237
- to {
238
- transform: translate(-5px, 5px) scale(1);
239
- }
240
- }
241
- @keyframes fadeInAnimation {
242
- from {
243
- opacity: 0;
244
- }
245
- to {
246
- opacity: 1;
247
- }
248
- }
249
- `;
250
- };
241
+ return defs.length === 0 ? undefined : el('defs', {}, defs);
242
+ }
251
243
 
252
244
  /**
253
245
  * Builds the @media (prefers-color-scheme: dark) CSS block for the card.
254
- * Returns an empty string when no dark colors are set.
246
+ * Returns nothing when no dark colors are set.
255
247
  */
256
- private renderDarkMediaBlock(): string {
248
+ private renderDarkMediaBlock(): CssChild {
257
249
  if (!this.colors.dark) {
258
- return '';
250
+ return undefined;
259
251
  }
260
252
 
261
253
  const bgFill =
@@ -263,23 +255,21 @@ class Card {
263
255
  ? 'url(#gradient-dark)'
264
256
  : this.colors.dark.bgColor;
265
257
 
266
- return `
267
- @media (prefers-color-scheme: dark) {
268
- .header { fill: ${this.colors.dark.titleColor}; }
269
- .card-bg { fill: ${bgFill}; stroke: ${this.colors.dark.borderColor}; }
270
- ${this.darkCss}
271
- }`;
258
+ return atRule(
259
+ '@media (prefers-color-scheme: dark)',
260
+ rule('.header', { fill: this.colors.dark.titleColor }),
261
+ rule('.title-icon', { fill: this.colors.dark.iconColor }),
262
+ rule('.title-accent', { fill: this.colors.dark.iconColor }),
263
+ rule('.title-band', { fill: this.colors.dark.titleColor }),
264
+ rule('.card-bg', { fill: bgFill, stroke: this.colors.dark.borderColor }),
265
+ this.darkCss,
266
+ );
272
267
  }
273
268
 
274
- /**
275
- * The caller must ensure that the passed `body` string is properly sanitized!
276
- *
277
- * @param body The sanitized inner body of the card.
278
- * @returns The rendered card.
279
- */
280
- render(body: string): string {
269
+ /** @returns The rendered card. */
270
+ render(body: Child): string {
281
271
  if (!Number.isFinite(this.border_radius)) {
282
- throw new Error(`Invalid border radius: "${this.border_radius}"`);
272
+ throw new TypeError(`Invalid border radius: "${this.border_radius}"`);
283
273
  }
284
274
  if (!isPrefixedHexColor(this.colors.light.titleColor)) {
285
275
  throw new Error(`Invalid title color: "${this.colors.light.titleColor}"`);
@@ -295,70 +285,73 @@ class Card {
295
285
  throw new Error(`Invalid background color: ${String(this.colors.light.bgColor)}`);
296
286
  }
297
287
 
298
- return `
299
- <svg
300
- width="${this.width}"
301
- height="${this.height}"
302
- viewBox="0 0 ${this.width} ${this.height}"
303
- fill="none"
304
- xmlns="http://www.w3.org/2000/svg"
305
- role="img"
306
- aria-labelledby="titleId descId"
307
- >
308
- <title id="titleId">${encodeHTML(this.a11yTitle)}</title>
309
- <desc id="descId">${encodeHTML(this.a11yDesc)}</desc>
310
- <style>
311
- .header {
312
- font: 600 18px 'Segoe UI', Ubuntu, Sans-Serif;
313
- fill: ${this.colors.light.titleColor};
314
- animation: fadeInAnimation 0.8s ease-in-out forwards;
315
- }
316
- @supports(-moz-appearance: auto) {
317
- /* Selector detects Firefox */
318
- .header { font-size: 15.5px; }
319
- }
320
- ${this.css}
321
- ${this.renderDarkMediaBlock()}
322
-
323
- ${this.getAnimations()}
324
- ${
325
- this.animations
326
- ? ''
327
- : `* { animation-duration: 0s !important; animation-delay: 0s !important; }`
328
- }
329
- </style>
330
-
331
- ${this.renderGradient()}
332
-
333
- <rect
334
- data-testid="card-bg"
335
- class="card-bg"
336
- x="0.5"
337
- y="0.5"
338
- rx="${this.border_radius}"
339
- height="99%"
340
- stroke="${this.colors.light.borderColor}"
341
- width="${this.width - 1}"
342
- fill="${
288
+ return renderMarkup(
289
+ el(
290
+ 'svg',
291
+ {
292
+ width: this.width,
293
+ height: this.height,
294
+ viewBox: `0 0 ${this.width} ${this.height}`,
295
+ fill: 'none',
296
+ xmlns: 'http://www.w3.org/2000/svg',
297
+ role: 'img',
298
+ 'aria-labelledby': 'title-id desc-id',
299
+ },
300
+ el('title', { id: 'title-id' }, this.a11yTitle),
301
+ el('desc', { id: 'desc-id' }, this.a11yDesc),
302
+ style(
303
+ rule('.header', {
304
+ font: font('semibold', 'title'),
305
+ fill: this.colors.light.titleColor,
306
+ animation: 'fadeInAnimation 0.8s ease-in-out forwards',
307
+ }),
308
+ cssComment(
309
+ 'The icon reads against the title rather than with it, so it keeps its own color.',
310
+ ),
311
+ rule('.title-icon', {
312
+ fill: this.colors.light.iconColor,
313
+ animation: 'fadeInAnimation 0.8s ease-in-out forwards',
314
+ }),
315
+ rule('.title-accent', {
316
+ fill: this.colors.light.iconColor,
317
+ animation: 'fadeInAnimation 0.8s ease-in-out forwards',
318
+ }),
319
+ rule('.title-band', { fill: this.colors.light.titleColor, opacity: TITLE_BAND.opacity }),
320
+ firefoxFontSize(['.header'], TITLE_FIREFOX_SIZE),
321
+ this.css,
322
+ this.renderDarkMediaBlock(),
323
+ getAnimations(),
324
+ !this.animations && rule('*', NO_MOTION),
325
+ ),
326
+ this.renderGradient(),
327
+ el('rect', {
328
+ 'data-testid': 'card-bg',
329
+ class: 'card-bg',
330
+ x: 0.5,
331
+ y: 0.5,
332
+ rx: this.border_radius,
333
+ height: '99%',
334
+ stroke: this.colors.light.borderColor,
335
+ width: this.width - 1,
336
+ fill:
343
337
  typeof this.colors.light.bgColor === 'object'
344
338
  ? 'url(#gradient)'
345
- : this.colors.light.bgColor
346
- }"
347
- stroke-opacity="${this.hideBorder ? 0 : 1}"
348
- />
349
-
350
- ${this.hideTitle ? '' : this.renderTitle()}
351
-
352
- <g
353
- data-testid="main-card-body"
354
- transform="translate(0, ${this.hideTitle ? this.paddingX : this.paddingY + 20})"
355
- >
356
- ${body}
357
- </g>
358
- </svg>
359
- `;
339
+ : this.colors.light.bgColor,
340
+ 'stroke-opacity': this.hideBorder ? 0 : 1,
341
+ }),
342
+ !this.hideTitle && this.renderTitleBand(),
343
+ !this.hideTitle && this.renderTitle(),
344
+ el(
345
+ 'g',
346
+ {
347
+ 'data-testid': 'main-card-body',
348
+ transform: `translate(0, ${this.bodyOffset})`,
349
+ },
350
+ body,
351
+ ),
352
+ ),
353
+ );
360
354
  }
361
355
  }
362
356
 
363
357
  export { Card };
364
- export default Card;
@@ -10,11 +10,6 @@ class I18n<Translations extends TranslationsMap = TranslationsMap> {
10
10
  locale: string;
11
11
  translations: Translations;
12
12
 
13
- /**
14
- * @param props Constructor arguments.
15
- * @param props.locale Locale.
16
- * @param props.translations Translations.
17
- */
18
13
  constructor({
19
14
  locale,
20
15
  translations,
@@ -28,18 +23,22 @@ class I18n<Translations extends TranslationsMap = TranslationsMap> {
28
23
  }
29
24
 
30
25
  /**
31
- * Get translation.
26
+ * Get translation, falling back to the English string when the locale has no entry for the key.
32
27
  *
33
- * @param str String to translate.
34
28
  * @returns Translated string.
35
29
  */
36
30
  t(str: keyof Translations & string): string {
37
31
  const translation = this.translations[str];
32
+ // A key the type says is present can still be missing at runtime: a card passes
33
+ // a locale table it built, and `i18n.test.ts` covers exactly that.
34
+ // oxlint-disable-next-line typescript/no-unnecessary-condition
38
35
  if (!translation) {
39
36
  throw new Error(`${str} Translation string not found`);
40
37
  }
41
38
 
42
- const localized = translation[this.locale];
39
+ // A key is written in `en` first and translated afterwards, so a locale the key has
40
+ // not reached yet reads the English string rather than failing the whole card.
41
+ const localized = translation[this.locale] ?? translation[FALLBACK_LOCALE];
43
42
  if (!localized) {
44
43
  throw new Error(`'${str}' translation not found for locale '${this.locale}'`);
45
44
  }
@@ -0,0 +1,109 @@
1
+ /**
2
+ * The visual vocabulary every card draws with: one font stack, one type scale,
3
+ * one set of widths and the accent rule that marks a card as this tool's.
4
+ * A card composes from here; it never writes a `font` shorthand or a size of its own.
5
+ */
6
+ import type { Child, CssChild } from '../markup/index.ts';
7
+ import { atRule, cssComment, rule } from '../markup/index.ts';
8
+
9
+ import { icons } from './icons.ts';
10
+
11
+ const FONT_STACK = `'Segoe UI', Ubuntu, "Helvetica Neue", Sans-Serif`;
12
+
13
+ /** The type scale, in px. */
14
+ const FONT_SIZE = {
15
+ display: 22,
16
+ title: 18,
17
+ lead: 16,
18
+ body: 14,
19
+ meta: 13,
20
+ small: 12,
21
+ micro: 11,
22
+ } as const;
23
+
24
+ /**
25
+ * The one size off the scale. Firefox is given 15.5px rather than the scale's next step down,
26
+ * because that half pixel is inherited tuning for long titles and nothing here can test the
27
+ * wrapping it protects; it lives with the scale so `brand.ts` still owns every size a card draws.
28
+ */
29
+ const TITLE_FIREFOX_SIZE = 15.5;
30
+
31
+ /** `regular` labels, `semibold` the values they label, `bold` the rank glyph alone. */
32
+ const FONT_WEIGHT = {
33
+ regular: 400,
34
+ semibold: 600,
35
+ bold: 700,
36
+ } as const;
37
+
38
+ type FontSize = keyof typeof FONT_SIZE;
39
+ type FontWeight = keyof typeof FONT_WEIGHT;
40
+
41
+ /** @returns The `font` shorthand for one step of the scale. */
42
+ const font = (weight: FontWeight, size: FontSize): string =>
43
+ `${String(FONT_WEIGHT[weight])} ${String(FONT_SIZE[size])}px ${FONT_STACK}`;
44
+
45
+ /**
46
+ * Firefox lays Segoe UI out wider than the other engines, so text that fits
47
+ * elsewhere overflows there; each affected selector steps down a size.
48
+ *
49
+ * @returns The `@supports` block that detects Firefox and resizes those selectors.
50
+ */
51
+ const firefoxFontSize = (selectors: Array<string>, size: FontSize | number): CssChild => {
52
+ const px = typeof size === 'number' ? size : FONT_SIZE[size];
53
+ return atRule(
54
+ '@supports(-moz-appearance: auto)',
55
+ cssComment('Selector detects Firefox'),
56
+ selectors.map((selector) => rule(selector, { 'font-size': `${String(px)}px` })),
57
+ );
58
+ };
59
+
60
+ /**
61
+ * The widths a card may default to. A card picks the step nearest what its layout
62
+ * needs, so cards stacked in a README share an edge instead of forming a ragged one.
63
+ */
64
+ const CARD_WIDTH = {
65
+ compact: 300,
66
+ standard: 400,
67
+ wide: 500,
68
+ } as const;
69
+
70
+ /** The tinted band behind every card title: the mark a card is recognized by. */
71
+ const TITLE_BAND = {
72
+ /** Enough to read as a band on every theme, little enough to leave the title legible. */
73
+ opacity: 0.08,
74
+ /** Clear air the band leaves between its foot and the first row of the body. */
75
+ gap: 9,
76
+ } as const;
77
+
78
+ /** The rule under the title icon, sitting inside the band. */
79
+ const ACCENT = {
80
+ width: 32,
81
+ height: 2,
82
+ /** Below the title's descenders, clear of the band's foot. */
83
+ y: 8,
84
+ } as const;
85
+
86
+ /**
87
+ * The title icon each card wears. They are distinct on purpose: with the band
88
+ * and the shared type, the icon is what tells one card from another at a glance.
89
+ */
90
+ const CARD_ICON = {
91
+ contributedTo: icons.prs_merged,
92
+ gist: icons.gist,
93
+ repo: icons.repo,
94
+ stats: icons.pulse,
95
+ topLanguages: icons.code,
96
+ wakatime: icons.clock,
97
+ } satisfies Record<string, Child>;
98
+
99
+ export {
100
+ ACCENT,
101
+ CARD_ICON,
102
+ CARD_WIDTH,
103
+ FONT_SIZE,
104
+ FONT_WEIGHT,
105
+ firefoxFontSize,
106
+ font,
107
+ TITLE_BAND,
108
+ TITLE_FIREFOX_SIZE,
109
+ };