@stats-forge/github-stats-forge-core 0.1.0 → 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 (142) hide show
  1. package/build/api/contributed-to.d.ts +23 -0
  2. package/build/api/contributed-to.d.ts.map +1 -0
  3. package/build/api/contributed-to.js +43 -0
  4. package/build/api/gist.d.ts +8 -15
  5. package/build/api/gist.d.ts.map +1 -1
  6. package/build/api/gist.js +14 -29
  7. package/build/api/handler.d.ts +16 -0
  8. package/build/api/handler.d.ts.map +1 -0
  9. package/build/api/handler.js +29 -0
  10. package/build/api/index.d.ts +1 -0
  11. package/build/api/index.d.ts.map +1 -1
  12. package/build/api/index.js +1 -0
  13. package/build/api/params.d.ts +11 -5
  14. package/build/api/params.d.ts.map +1 -1
  15. package/build/api/params.js +26 -6
  16. package/build/api/pin.d.ts +11 -15
  17. package/build/api/pin.d.ts.map +1 -1
  18. package/build/api/pin.js +34 -46
  19. package/build/api/stats.d.ts +15 -22
  20. package/build/api/stats.d.ts.map +1 -1
  21. package/build/api/stats.js +59 -72
  22. package/build/api/top-langs.d.ts +11 -22
  23. package/build/api/top-langs.d.ts.map +1 -1
  24. package/build/api/top-langs.js +34 -51
  25. package/build/api/wakatime.d.ts +10 -24
  26. package/build/api/wakatime.d.ts.map +1 -1
  27. package/build/api/wakatime.js +27 -47
  28. package/build/cards/contributed-to.d.ts +19 -0
  29. package/build/cards/contributed-to.d.ts.map +1 -0
  30. package/build/cards/contributed-to.js +242 -0
  31. package/build/cards/gist.d.ts +1 -0
  32. package/build/cards/gist.d.ts.map +1 -1
  33. package/build/cards/gist.js +13 -9
  34. package/build/cards/repo.d.ts +12 -1
  35. package/build/cards/repo.d.ts.map +1 -1
  36. package/build/cards/repo.js +40 -18
  37. package/build/cards/stats.d.ts +15 -3
  38. package/build/cards/stats.d.ts.map +1 -1
  39. package/build/cards/stats.js +91 -76
  40. package/build/cards/top-languages.d.ts +13 -2
  41. package/build/cards/top-languages.d.ts.map +1 -1
  42. package/build/cards/top-languages.js +26 -9
  43. package/build/cards/wakatime.d.ts +13 -2
  44. package/build/cards/wakatime.d.ts.map +1 -1
  45. package/build/cards/wakatime.js +22 -9
  46. package/build/common/Card.d.ts +9 -0
  47. package/build/common/Card.d.ts.map +1 -1
  48. package/build/common/Card.js +44 -9
  49. package/build/common/I18n.d.ts +1 -1
  50. package/build/common/I18n.d.ts.map +1 -1
  51. package/build/common/I18n.js +4 -2
  52. package/build/common/brand.d.ts +76 -0
  53. package/build/common/brand.d.ts.map +1 -0
  54. package/build/common/brand.js +73 -0
  55. package/build/common/constants.d.ts +3 -1
  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 +50 -3
  59. package/build/common/date.d.ts.map +1 -1
  60. package/build/common/date.js +104 -2
  61. package/build/common/icons.d.ts +4 -2
  62. package/build/common/icons.d.ts.map +1 -1
  63. package/build/common/icons.js +10 -5
  64. package/build/common/ops.d.ts +8 -3
  65. package/build/common/ops.d.ts.map +1 -1
  66. package/build/common/ops.js +8 -14
  67. package/build/common/render.d.ts +7 -1
  68. package/build/common/render.d.ts.map +1 -1
  69. package/build/common/render.js +60 -58
  70. package/build/fetchers/contributed-to.d.ts +62 -0
  71. package/build/fetchers/contributed-to.d.ts.map +1 -0
  72. package/build/fetchers/contributed-to.js +184 -0
  73. package/build/fetchers/graphql-error.d.ts +13 -0
  74. package/build/fetchers/graphql-error.d.ts.map +1 -0
  75. package/build/fetchers/graphql-error.js +29 -0
  76. package/build/fetchers/index.d.ts +2 -1
  77. package/build/fetchers/index.d.ts.map +1 -1
  78. package/build/fetchers/index.js +1 -0
  79. package/build/fetchers/stats.d.ts +14 -8
  80. package/build/fetchers/stats.d.ts.map +1 -1
  81. package/build/fetchers/stats.js +72 -195
  82. package/build/fetchers/top-languages.d.ts.map +1 -1
  83. package/build/fetchers/top-languages.js +3 -18
  84. package/build/fetchers/types.d.ts +24 -0
  85. package/build/fetchers/types.d.ts.map +1 -1
  86. package/build/graphql/contributionsCollection.d.ts +9 -0
  87. package/build/graphql/contributionsCollection.d.ts.map +1 -0
  88. package/build/graphql/contributionsCollection.js +12 -0
  89. package/build/graphql/contributionsDocument.d.ts +5 -5
  90. package/build/graphql/contributionsDocument.d.ts.map +1 -1
  91. package/build/graphql/contributionsDocument.js +8 -15
  92. package/build/graphql/generated/contributed-to.d.ts +22 -0
  93. package/build/graphql/generated/contributed-to.d.ts.map +1 -0
  94. package/build/graphql/generated/contributed-to.js +11 -0
  95. package/build/graphql/generated/stats.d.ts +13 -1
  96. package/build/graphql/generated/stats.d.ts.map +1 -1
  97. package/build/graphql/generated/stats.js +2 -2
  98. package/build/graphql/graphqlDocument.d.ts +1 -1
  99. package/build/graphql/reposContributedToDocument.d.ts.map +1 -1
  100. package/build/graphql/reposContributedToDocument.js +11 -5
  101. package/build/translations.d.ts +64 -1
  102. package/build/translations.d.ts.map +1 -1
  103. package/build/translations.js +65 -1
  104. package/package.json +1 -1
  105. package/src/api/contributed-to.ts +84 -0
  106. package/src/api/gist.ts +20 -37
  107. package/src/api/handler.ts +42 -0
  108. package/src/api/index.ts +1 -0
  109. package/src/api/params.ts +48 -9
  110. package/src/api/pin.ts +33 -45
  111. package/src/api/stats.ts +78 -89
  112. package/src/api/top-langs.ts +34 -56
  113. package/src/api/wakatime.ts +33 -54
  114. package/src/cards/contributed-to.ts +373 -0
  115. package/src/cards/gist.ts +17 -8
  116. package/src/cards/repo.ts +44 -20
  117. package/src/cards/stats.ts +119 -90
  118. package/src/cards/top-languages.ts +27 -18
  119. package/src/cards/wakatime.ts +23 -13
  120. package/src/common/Card.ts +56 -11
  121. package/src/common/I18n.ts +4 -2
  122. package/src/common/brand.ts +109 -0
  123. package/src/common/constants.ts +4 -1
  124. package/src/common/date.ts +135 -3
  125. package/src/common/icons.ts +10 -5
  126. package/src/common/ops.ts +10 -19
  127. package/src/common/render.ts +65 -62
  128. package/src/fetchers/contributed-to.ts +271 -0
  129. package/src/fetchers/graphql-error.ts +36 -0
  130. package/src/fetchers/index.ts +3 -0
  131. package/src/fetchers/stats.ts +123 -274
  132. package/src/fetchers/top-languages.ts +5 -19
  133. package/src/fetchers/types.ts +26 -0
  134. package/src/graphql/contributionsCollection.ts +18 -0
  135. package/src/graphql/contributionsDocument.ts +11 -18
  136. package/src/graphql/generated/contributed-to.ts +25 -0
  137. package/src/graphql/generated/stats.ts +19 -6
  138. package/src/graphql/graphqlDocument.ts +1 -1
  139. package/src/graphql/queries/contributed-to.graphql +8 -0
  140. package/src/graphql/queries/stats.graphql +14 -2
  141. package/src/graphql/reposContributedToDocument.ts +11 -9
  142. package/src/translations.ts +77 -0
@@ -1,3 +1,4 @@
1
+ import { CARD_ICON, CARD_WIDTH, FONT_WEIGHT, firefoxFontSize, font } from '../common/brand.ts';
1
2
  import { Card } from '../common/Card.ts';
2
3
  import { getLightDarkColors, isPrefixedHexColor } from '../common/color.ts';
3
4
  import { formatBytes } from '../common/fmt.ts';
@@ -7,12 +8,12 @@ import { chunkArray, clampValue, lowercaseTrim } from '../common/ops.ts';
7
8
  import { createProgressNode, flexLayout, measureText } from '../common/render.ts';
8
9
  import type { Lang, TopLangData } from '../fetchers/types.ts';
9
10
  import type { Child, MarkupElement } from '../markup/index.ts';
10
- import { atRule, cssComment, el, rule } from '../markup/index.ts';
11
+ import { atRule, el, rule } from '../markup/index.ts';
11
12
  import { langCardLocales } from '../translations.ts';
12
13
 
13
14
  import type { CardOptions, CommonCardOptions } from './options.ts';
14
15
 
15
- const DEFAULT_CARD_WIDTH = 300;
16
+ const DEFAULT_CARD_WIDTH = CARD_WIDTH.compact;
16
17
  const MIN_CARD_WIDTH = 280;
17
18
  const CARD_PADDING = 25;
18
19
  const COMPACT_LAYOUT_BASE_HEIGHT = 90;
@@ -21,6 +22,8 @@ const MAXIMUM_LANGS_COUNT = 20;
21
22
  const NORMAL_LAYOUT_DEFAULT_LANGS_COUNT = 5;
22
23
  const COMPACT_LAYOUT_DEFAULT_LANGS_COUNT = 6;
23
24
  const DONUT_LAYOUT_DEFAULT_LANGS_COUNT = 5;
25
+ /** Room for the chart that sits beside the language list. */
26
+ const DONUT_EXTRA_WIDTH = 50;
24
27
  const PIE_LAYOUT_DEFAULT_LANGS_COUNT = 6;
25
28
  const DONUT_VERTICAL_LAYOUT_DEFAULT_LANGS_COUNT = 6;
26
29
 
@@ -801,10 +804,7 @@ const getDefaultLanguagesCountByLayout = ({
801
804
  *
802
805
  * @returns Language card SVG object.
803
806
  */
804
- const renderTopLanguages = (
805
- topLangs: TopLangData,
806
- options: CardOptions<TopLangOptions> = {},
807
- ): string => {
807
+ const renderCard = (topLangs: TopLangData, options: CardOptions<TopLangOptions> = {}): string => {
808
808
  const {
809
809
  hide_title = false,
810
810
  hide_border = false,
@@ -865,7 +865,9 @@ const renderTopLanguages = (
865
865
  );
866
866
  } else if (layout === 'donut') {
867
867
  height = calculateDonutLayoutHeight(langs.length);
868
- width += 50; // padding
868
+ // A donut defaults a step wider so the chart has its room; a width asked for
869
+ // explicitly keeps the extra it has always been given instead of being snapped.
870
+ width = width === DEFAULT_CARD_WIDTH ? CARD_WIDTH.standard : width + DONUT_EXTRA_WIDTH;
869
871
  finalLayout = renderDonutLayout(langs, width, totalLanguageSize, stats_format, hide_values);
870
872
  } else {
871
873
  finalLayout = renderNormalLayout(langs, width, totalLanguageSize, stats_format, hide_values);
@@ -874,6 +876,7 @@ const renderTopLanguages = (
874
876
  const card = new Card({
875
877
  customTitle: custom_title,
876
878
  defaultTitle: i18n.t('langcard.title'),
879
+ titlePrefixIcon: CARD_ICON.topLanguages,
877
880
  width,
878
881
  height,
879
882
  border_radius,
@@ -901,16 +904,12 @@ const renderTopLanguages = (
901
904
  rule('to', { width: '100%' }),
902
905
  ),
903
906
  rule('.stat', {
904
- font: `600 14px 'Segoe UI', Ubuntu, "Helvetica Neue", Sans-Serif`,
907
+ font: font('regular', 'body'),
905
908
  fill: textColor,
906
909
  }),
907
- atRule(
908
- '@supports(-moz-appearance: auto)',
909
- cssComment('Selector detects Firefox'),
910
- rule('.stat', { 'font-size': '12px' }),
911
- ),
912
- rule('.bold', { 'font-weight': 700 }),
913
- rule('.lang-name', { font: '400 11px "Segoe UI", Ubuntu, Sans-Serif', fill: textColor }),
910
+ firefoxFontSize(['.stat'], 'small'),
911
+ rule('.bold', { 'font-weight': FONT_WEIGHT.semibold }),
912
+ rule('.lang-name', { font: font('regular', 'micro'), fill: textColor }),
914
913
  rule('.stagger', { opacity: 0, animation: 'fadeInAnimation 0.3s ease-in-out forwards' }),
915
914
  rule('#rect-mask rect', { animation: 'slideInAnimation 1s ease-in-out forwards' }),
916
915
  rule('.lang-progress', { animation: 'growWidthAnimation 0.6s ease-in-out forwards' }),
@@ -939,6 +938,18 @@ const renderTopLanguages = (
939
938
  return card.render(el('svg', { 'data-testid': 'lang-items', x: CARD_PADDING }, finalLayout));
940
939
  };
941
940
 
941
+ /**
942
+ * The card, and the values each of its options accepts, keyed by the option's own name.
943
+ * They ride on the renderer so a list cannot be found without what draws it;
944
+ * the api handler forwards them onto its own export, which is what a UI reads.
945
+ */
946
+ const renderTopLanguages = Object.assign(renderCard, {
947
+ OPTIONS: {
948
+ layout: TOP_LANG_LAYOUTS,
949
+ stats_format: TOP_LANG_STATS_FORMATS,
950
+ },
951
+ });
952
+
942
953
  export {
943
954
  getLongestLang,
944
955
  degreesToRadians,
@@ -953,9 +964,7 @@ export {
953
964
  calculatePieLayoutHeight,
954
965
  donutCenterTranslation,
955
966
  trimTopLanguages,
956
- renderTopLanguages,
957
967
  MIN_CARD_WIDTH,
958
968
  getDefaultLanguagesCountByLayout,
959
- TOP_LANG_LAYOUTS,
960
- TOP_LANG_STATS_FORMATS,
969
+ renderTopLanguages,
961
970
  };
@@ -1,3 +1,4 @@
1
+ import { CARD_ICON, CARD_WIDTH, FONT_WEIGHT, firefoxFontSize, font } from '../common/brand.ts';
1
2
  import { Card } from '../common/Card.ts';
2
3
  import { getLightDarkColors, isPrefixedHexColor } from '../common/color.ts';
3
4
  import { I18n } from '../common/I18n.ts';
@@ -6,12 +7,12 @@ import { clampValue, lowercaseTrim } from '../common/ops.ts';
6
7
  import { createProgressNode, flexLayout } from '../common/render.ts';
7
8
  import type { WakaTimeData, WakaTimeLang } from '../fetchers/types.ts';
8
9
  import type { Child, CssChild, MarkupElement } from '../markup/index.ts';
9
- import { atRule, cssComment, el, rule } from '../markup/index.ts';
10
+ import { atRule, el, rule } from '../markup/index.ts';
10
11
  import { wakatimeCardLocales } from '../translations.ts';
11
12
 
12
13
  import type { CardOptions, CommonCardOptions } from './options.ts';
13
14
 
14
- const DEFAULT_CARD_WIDTH = 495;
15
+ const DEFAULT_CARD_WIDTH = CARD_WIDTH.wide;
15
16
  const MIN_CARD_WIDTH = 250;
16
17
  const COMPACT_LAYOUT_MIN_WIDTH = 400;
17
18
  const DEFAULT_LINE_HEIGHT = 25;
@@ -216,17 +217,13 @@ const getStyles = ({ textColor }: { textColor: string }): Array<CssChild> => {
216
217
 
217
218
  return [
218
219
  rule('.stat', {
219
- font: `600 14px 'Segoe UI', Ubuntu, "Helvetica Neue", Sans-Serif`,
220
+ font: font('regular', 'body'),
220
221
  fill: textColor,
221
222
  }),
222
- atRule(
223
- '@supports(-moz-appearance: auto)',
224
- cssComment('Selector detects Firefox'),
225
- rule('.stat', { 'font-size': '12px' }),
226
- ),
223
+ firefoxFontSize(['.stat'], 'small'),
227
224
  rule('.stagger', { opacity: 0, animation: 'fadeInAnimation 0.3s ease-in-out forwards' }),
228
- rule('.not_bold', { 'font-weight': 400 }),
229
- rule('.bold', { 'font-weight': 700 }),
225
+ rule('.not_bold', { 'font-weight': FONT_WEIGHT.regular }),
226
+ rule('.bold', { 'font-weight': FONT_WEIGHT.semibold }),
230
227
  ];
231
228
  };
232
229
 
@@ -257,7 +254,7 @@ const normalizeCardWidth = ({
257
254
  *
258
255
  * @returns WakaTime card SVG.
259
256
  */
260
- const renderWakatimeCard = (
257
+ const renderCard = (
261
258
  stats: Partial<WakaTimeData> = {},
262
259
  options: CardOptions<WakaTimeOptions> = {},
263
260
  ): string => {
@@ -412,6 +409,7 @@ const renderWakatimeCard = (
412
409
  const card = new Card({
413
410
  customTitle: custom_title,
414
411
  defaultTitle: titleText,
412
+ titlePrefixIcon: CARD_ICON.wakatime,
415
413
  width: normalizedWidth,
416
414
  height,
417
415
  border_radius,
@@ -439,7 +437,7 @@ const renderWakatimeCard = (
439
437
  rule('from', { width: 0 }),
440
438
  rule('to', { width: '100%' }),
441
439
  ),
442
- rule('.lang-name', { font: "400 11px 'Segoe UI', Ubuntu, Sans-Serif", fill: textColor }),
440
+ rule('.lang-name', { font: font('regular', 'micro'), fill: textColor }),
443
441
  rule('#rect-mask rect', { animation: 'slideInAnimation 1s ease-in-out forwards' }),
444
442
  rule('.lang-progress', {
445
443
  animation: 'growWidthAnimation 0.6s ease-in-out forwards',
@@ -470,4 +468,16 @@ const renderWakatimeCard = (
470
468
  return card.render(el('svg', { x: 0, y: 0, width: '100%' }, finalLayout));
471
469
  };
472
470
 
473
- export { DISPLAY_FORMATS, WAKATIME_LAYOUTS, renderWakatimeCard };
471
+ /**
472
+ * The card, and the values each of its options accepts, keyed by the option's own name.
473
+ * They ride on the renderer so a list cannot be found without what draws it;
474
+ * the api handler forwards them onto its own export, which is what a UI reads.
475
+ */
476
+ const renderWakatimeCard = Object.assign(renderCard, {
477
+ OPTIONS: {
478
+ layout: WAKATIME_LAYOUTS,
479
+ display_format: DISPLAY_FORMATS,
480
+ },
481
+ });
482
+
483
+ export { renderWakatimeCard };
@@ -1,6 +1,7 @@
1
1
  import type { Child, CssChild, MarkupElement } from '../markup/index.ts';
2
2
  import { atRule, cssComment, el, renderMarkup, rule, style } from '../markup/index.ts';
3
3
 
4
+ import { ACCENT, firefoxFontSize, font, TITLE_BAND, TITLE_FIREFOX_SIZE } from './brand.ts';
4
5
  import { getCardColors, isPrefixedHexColor, isValidGradient } from './color.ts';
5
6
  import type { CardColors } from './color.ts';
6
7
  import { flexLayout } from './render.ts';
@@ -104,7 +105,7 @@ class Card {
104
105
  this.darkCss = [];
105
106
 
106
107
  this.paddingX = 25;
107
- this.paddingY = 35;
108
+ this.paddingY = 30;
108
109
  this.titlePrefixIcon = titlePrefixIcon;
109
110
  this.animations = true;
110
111
  this.a11yTitle = '';
@@ -164,7 +165,7 @@ class Card {
164
165
  el(
165
166
  'svg',
166
167
  {
167
- class: 'icon',
168
+ class: 'title-icon',
168
169
  x: 0,
169
170
  y: -13,
170
171
  viewBox: '0 0 16 16',
@@ -178,9 +179,41 @@ class Card {
178
179
  ],
179
180
  gap: 25,
180
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
+ }),
181
191
  );
182
192
  }
183
193
 
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;
197
+ }
198
+
199
+ /**
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.
204
+ */
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);
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
+
184
217
  /** @returns The card's gradient definitions, or nothing when no color is a gradient. */
185
218
  renderGradient(): Child {
186
219
  if (
@@ -225,6 +258,9 @@ class Card {
225
258
  return atRule(
226
259
  '@media (prefers-color-scheme: dark)',
227
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 }),
228
264
  rule('.card-bg', { fill: bgFill, stroke: this.colors.dark.borderColor }),
229
265
  this.darkCss,
230
266
  );
@@ -259,21 +295,29 @@ class Card {
259
295
  fill: 'none',
260
296
  xmlns: 'http://www.w3.org/2000/svg',
261
297
  role: 'img',
262
- 'aria-labelledby': 'titleId descId',
298
+ 'aria-labelledby': 'title-id desc-id',
263
299
  },
264
- el('title', { id: 'titleId' }, this.a11yTitle),
265
- el('desc', { id: 'descId' }, this.a11yDesc),
300
+ el('title', { id: 'title-id' }, this.a11yTitle),
301
+ el('desc', { id: 'desc-id' }, this.a11yDesc),
266
302
  style(
267
303
  rule('.header', {
268
- font: "600 18px 'Segoe UI', Ubuntu, Sans-Serif",
304
+ font: font('semibold', 'title'),
269
305
  fill: this.colors.light.titleColor,
270
306
  animation: 'fadeInAnimation 0.8s ease-in-out forwards',
271
307
  }),
272
- atRule(
273
- '@supports(-moz-appearance: auto)',
274
- cssComment('Selector detects Firefox'),
275
- rule('.header', { 'font-size': '15.5px' }),
308
+ cssComment(
309
+ 'The icon reads against the title rather than with it, so it keeps its own color.',
276
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),
277
321
  this.css,
278
322
  this.renderDarkMediaBlock(),
279
323
  getAnimations(),
@@ -295,12 +339,13 @@ class Card {
295
339
  : this.colors.light.bgColor,
296
340
  'stroke-opacity': this.hideBorder ? 0 : 1,
297
341
  }),
342
+ !this.hideTitle && this.renderTitleBand(),
298
343
  !this.hideTitle && this.renderTitle(),
299
344
  el(
300
345
  'g',
301
346
  {
302
347
  'data-testid': 'main-card-body',
303
- transform: `translate(0, ${this.hideTitle ? this.paddingX : this.paddingY + 20})`,
348
+ transform: `translate(0, ${this.bodyOffset})`,
304
349
  },
305
350
  body,
306
351
  ),
@@ -23,7 +23,7 @@ class I18n<Translations extends TranslationsMap = TranslationsMap> {
23
23
  }
24
24
 
25
25
  /**
26
- * Get translation.
26
+ * Get translation, falling back to the English string when the locale has no entry for the key.
27
27
  *
28
28
  * @returns Translated string.
29
29
  */
@@ -36,7 +36,9 @@ class I18n<Translations extends TranslationsMap = TranslationsMap> {
36
36
  throw new Error(`${str} Translation string not found`);
37
37
  }
38
38
 
39
- 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];
40
42
  if (!localized) {
41
43
  throw new Error(`'${str}' translation not found for locale '${this.locale}'`);
42
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
+ };
@@ -1,5 +1,8 @@
1
1
  import type { RepositoryAffiliation } from '../graphql/generated/common.ts';
2
2
 
3
+ /** The year GitHub launched, and so the earliest one a card can be asked to count from. */
4
+ const GITHUB_EPOCH_YEAR = 2008;
5
+
3
6
  /** A GitHub login: alphanumerics and single inner hyphens, 39 characters at most. */
4
7
  const GITHUB_USERNAME_PATTERN = /^[a-z\d](?:[a-z\d]|-(?=[a-z\d])){0,38}$/i;
5
8
 
@@ -12,4 +15,4 @@ const OWNER_AFFILIATIONS: Array<RepositoryAffiliation> = [
12
15
  'ORGANIZATION_MEMBER',
13
16
  ];
14
17
 
15
- export { GITHUB_USERNAME_PATTERN, OWNER_AFFILIATIONS };
18
+ export { GITHUB_EPOCH_YEAR, GITHUB_USERNAME_PATTERN, OWNER_AFFILIATIONS };
@@ -1,4 +1,8 @@
1
- /** A span between two dates, as GitHub's range arguments take it. */
1
+ /** @file A range reaches GitHub one calendar year at a time; `contributionsCollection` refuses a longer one. */
2
+
3
+ import { GITHUB_EPOCH_YEAR } from './constants.ts';
4
+
5
+ /** A range between two dates, as GitHub's range arguments take it. */
2
6
  interface GitHubDateRange {
3
7
  /** Start of the range, inclusive. */
4
8
  from: Date;
@@ -6,6 +10,9 @@ interface GitHubDateRange {
6
10
  to: Date;
7
11
  }
8
12
 
13
+ /** A year, a year and month, or a full date. */
14
+ const RANGE_DATE_PATTERN = /^(?<year>\d{4})(?:-(?<month>\d{2})(?:-(?<day>\d{2}))?)?$/;
15
+
9
16
  /**
10
17
  * Format a date as a GitHub `DateTime` scalar.
11
18
  * Seconds precision, no milliseconds.
@@ -15,7 +22,7 @@ interface GitHubDateRange {
15
22
  const toGitHubDateTime = (date: Date): string => `${date.toISOString().slice(0, 19)}Z`;
16
23
 
17
24
  /**
18
- * The full UTC span of a calendar year, both ends inclusive.
25
+ * The full UTC range of a calendar year, both ends inclusive.
19
26
  *
20
27
  * The end matters to callers that would otherwise leave a range open:
21
28
  * GitHub's `contributionsCollection` defaults `to` to one year after `from`,
@@ -28,5 +35,130 @@ const getGitHubYearRange = (year: number): GitHubDateRange => ({
28
35
  to: new Date(Date.UTC(year, 11, 31, 23, 59, 59)),
29
36
  });
30
37
 
31
- export { getGitHubYearRange, toGitHubDateTime };
38
+ /**
39
+ * The widest range a card can be asked for: GitHub's first year through the end of this one.
40
+ * The far end is the end of the year, not today, because a bare `to=2026` widens to its 31st of December.
41
+ *
42
+ * @returns The range, in UTC.
43
+ */
44
+ const getWidestRange = (): GitHubDateRange => ({
45
+ from: getGitHubYearRange(GITHUB_EPOCH_YEAR).from,
46
+ to: getGitHubYearRange(new Date().getUTCFullYear()).to,
47
+ });
48
+
49
+ /**
50
+ * The range a card counts within, from the ends the query named.
51
+ *
52
+ * @returns The range, each open end filled from {@link getWidestRange}.
53
+ */
54
+ const toRange = (from: Date | undefined, to: Date | undefined): GitHubDateRange => {
55
+ const widest = getWidestRange();
56
+ return { from: from ?? widest.from, to: to ?? widest.to };
57
+ };
58
+
59
+ /**
60
+ * Read one end of a range, written as `2024`, `2024-03` or `2024-03-15`.
61
+ * A partial date covers the whole of what it names, so `2024` starts on the 1st of January
62
+ * and ends on the 31st of December.
63
+ *
64
+ * @returns The instant, or `undefined` when the text does not name a date that exists.
65
+ */
66
+ const parseRangeDate = (value: string, end: 'from' | 'to'): Date | undefined => {
67
+ const match = RANGE_DATE_PATTERN.exec(value);
68
+ if (!match) {
69
+ return undefined;
70
+ }
71
+
72
+ const { year: yearText, month: monthText, day: dayText } = match.groups ?? {};
73
+ const year = Number(yearText);
74
+ const month = monthText === undefined ? 1 : Number(monthText);
75
+ const day = dayText === undefined ? 1 : Number(dayText);
76
+
77
+ const start = new Date(Date.UTC(year, month - 1, day));
78
+ // Date.UTC rolls an impossible date forward rather than refusing it
79
+ if (start.getUTCMonth() + 1 !== month || start.getUTCDate() !== day) {
80
+ return undefined;
81
+ }
82
+ if (end === 'from') {
83
+ return start;
84
+ }
85
+
86
+ const nextUnit =
87
+ dayText !== undefined
88
+ ? Date.UTC(year, month - 1, day + 1)
89
+ : monthText !== undefined
90
+ ? Date.UTC(year, month, 1)
91
+ : Date.UTC(year + 1, 0, 1);
92
+ return new Date(nextUnit - 1000);
93
+ };
94
+
95
+ /**
96
+ * Split a range into one per calendar year, each clipped to it.
97
+ *
98
+ * @returns Them, oldest first; empty when the range is inverted.
99
+ */
100
+ const toYearRanges = ({ from, to }: GitHubDateRange): Array<GitHubDateRange> => {
101
+ const ranges: Array<GitHubDateRange> = [];
102
+ for (let year = from.getUTCFullYear(); year <= to.getUTCFullYear(); year += 1) {
103
+ const yearRange = getGitHubYearRange(year);
104
+ const start = from > yearRange.from ? from : yearRange.from;
105
+ const end = to < yearRange.to ? to : yearRange.to;
106
+ if (start <= end) {
107
+ ranges.push({ from: start, to: end });
108
+ }
109
+ }
110
+ return ranges;
111
+ };
112
+
113
+ /**
114
+ * Whole years as a card writes them.
115
+ *
116
+ * @returns e.g. `2024` or `2020–2024`.
117
+ */
118
+ const formatYears = (first: number, last: number): string =>
119
+ first === last ? `${first}` : `${first}–${last}`;
120
+
121
+ /**
122
+ * A range as a card writes it, at the coarsest granularity that stays true.
123
+ *
124
+ * @returns e.g. `2024`, `2020–2024` or `2024-03-15 – 2024-06-30`.
125
+ */
126
+ const formatRange = ({ from, to }: GitHubDateRange): string => {
127
+ const firstYear = from.getUTCFullYear();
128
+ const lastYear = to.getUTCFullYear();
129
+ const wholeYears =
130
+ from.getTime() === getGitHubYearRange(firstYear).from.getTime() &&
131
+ to.getTime() === getGitHubYearRange(lastYear).to.getTime();
132
+
133
+ if (!wholeYears) {
134
+ return `${from.toISOString().slice(0, 10)} – ${to.toISOString().slice(0, 10)}`;
135
+ }
136
+ return formatYears(firstYear, lastYear);
137
+ };
138
+
139
+ /**
140
+ * The ranges a walk asks GitHub for: one per calendar year the account contributed in.
141
+ * Ordered here, which is what makes a walk's ties chronological.
142
+ *
143
+ * @returns Them, oldest first.
144
+ */
145
+ const toContributionRanges = (
146
+ contributionYears: ReadonlyArray<number>,
147
+ range: GitHubDateRange,
148
+ ): Array<GitHubDateRange> => {
149
+ const contributed = new Set(contributionYears);
150
+ return toYearRanges(range).filter((year) => contributed.has(year.from.getUTCFullYear()));
151
+ };
152
+
153
+ export {
154
+ formatRange,
155
+ formatYears,
156
+ getWidestRange,
157
+ getGitHubYearRange,
158
+ parseRangeDate,
159
+ toContributionRanges,
160
+ toGitHubDateTime,
161
+ toRange,
162
+ toYearRanges,
163
+ };
32
164
  export type { GitHubDateRange };
@@ -51,11 +51,7 @@ const icons = {
51
51
  'fill-rule': 'evenodd',
52
52
  d: 'M8 1.5a6.5 6.5 0 100 13 6.5 6.5 0 000-13zM0 8a8 8 0 1116 0A8 8 0 010 8zm9 3a1 1 0 11-2 0 1 1 0 012 0zm-.25-6.25a.75.75 0 00-1.5 0v3.5a.75.75 0 001.5 0v-3.5z',
53
53
  }),
54
- icon: el('path', {
55
- 'fill-rule': 'evenodd',
56
- d: 'M2 2.5A2.5 2.5 0 014.5 0h8.75a.75.75 0 01.75.75v12.5a.75.75 0 01-.75.75h-2.5a.75.75 0 110-1.5h1.75v-2h-8a1 1 0 00-.714 1.7.75.75 0 01-1.072 1.05A2.495 2.495 0 012 11.5v-9zm10.5-1V9h-8c-.356 0-.694.074-1 .208V2.5a1 1 0 011-1h8zM5 12.25v3.25a.25.25 0 00.4.2l1.45-1.087a.25.25 0 01.3 0L8.6 15.7a.25.25 0 00.4-.2v-3.25a.25.25 0 00-.25-.25h-3.5a.25.25 0 00-.25.25z',
57
- }),
58
- contribs: el('path', {
54
+ repo: el('path', {
59
55
  'fill-rule': 'evenodd',
60
56
  d: 'M2 2.5A2.5 2.5 0 014.5 0h8.75a.75.75 0 01.75.75v12.5a.75.75 0 01-.75.75h-2.5a.75.75 0 110-1.5h1.75v-2h-8a1 1 0 00-.714 1.7.75.75 0 01-1.072 1.05A2.495 2.495 0 012 11.5v-9zm10.5-1V9h-8c-.356 0-.694.074-1 .208V2.5a1 1 0 011-1h8zM5 12.25v3.25a.25.25 0 00.4.2l1.45-1.087a.25.25 0 01.3 0L8.6 15.7a.25.25 0 00.4-.2v-3.25a.25.25 0 00-.25-.25h-3.5a.25.25 0 00-.25.25z',
61
57
  }),
@@ -85,6 +81,15 @@ const icons = {
85
81
  'fill-rule': 'evenodd',
86
82
  d: 'M0 1.75C0 .784.784 0 1.75 0h12.5C15.216 0 16 .784 16 1.75v12.5A1.75 1.75 0 0 1 14.25 16H1.75A1.75 1.75 0 0 1 0 14.25Zm1.75-.25a.25.25 0 0 0-.25.25v12.5c0 .138.112.25.25.25h12.5a.25.25 0 0 0 .25-.25V1.75a.25.25 0 0 0-.25-.25Zm7.47 3.97a.75.75 0 0 1 1.06 0l2 2a.75.75 0 0 1 0 1.06l-2 2a.749.749 0 0 1-1.275-.326.749.749 0 0 1 .215-.734L10.69 8 9.22 6.53a.75.75 0 0 1 0-1.06ZM6.78 6.53 5.31 8l1.47 1.47a.749.749 0 0 1-.326 1.275.749.749 0 0 1-.734-.215l-2-2a.75.75 0 0 1 0-1.06l2-2a.751.751 0 0 1 1.042.018.751.751 0 0 1 .018 1.042Z',
87
83
  }),
84
+ pulse: el('path', {
85
+ d: 'M6 2c.306 0 .582.187.696.471L10 10.731l1.304-3.26A.751.751 0 0 1 12 7h3.25a.75.75 0 0 1 0 1.5h-2.742l-1.812 4.529a.751.751 0 0 1-1.392 0L6 4.769l-1.304 3.26A.751.751 0 0 1 4 8.5H.75a.75.75 0 0 1 0-1.5h2.742l1.812-4.529A.751.751 0 0 1 6 2Z',
86
+ }),
87
+ code: el('path', {
88
+ d: 'M4.72 3.22a.75.75 0 0 1 1.06 1.06L2.06 8l3.72 3.72a.75.75 0 1 1-1.06 1.06L.47 8.53a.75.75 0 0 1 0-1.06Zm6.56 0a.75.75 0 1 0-1.06 1.06L13.94 8l-3.72 3.72a.75.75 0 1 0 1.06 1.06l4.25-4.25a.75.75 0 0 0 0-1.06Z',
89
+ }),
90
+ clock: el('path', {
91
+ d: 'M8 0a8 8 0 1 1 0 16A8 8 0 0 1 8 0ZM1.5 8a6.5 6.5 0 1 0 13 0 6.5 6.5 0 0 0-13 0Zm7-3.25v2.992l2.028.812a.75.75 0 0 1-.557 1.392l-2.5-1A.75.75 0 0 1 7 8.25v-3.5a.75.75 0 0 1 1.5 0Z',
92
+ }),
88
93
  };
89
94
 
90
95
  /** Attributes every rank glyph shares, so the three variants differ only where they mean to. */