@stats-organization/github-readme-stats-core 2.1.4 → 2.1.5

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 (77) hide show
  1. package/build/api/gist.d.ts.map +1 -1
  2. package/build/api/gist.js +34 -0
  3. package/build/api/index.d.ts.map +1 -1
  4. package/build/api/index.js +18 -0
  5. package/build/api/pin.d.ts.map +1 -1
  6. package/build/api/pin.js +17 -0
  7. package/build/api/top-langs.d.ts.map +1 -1
  8. package/build/api/top-langs.js +36 -4
  9. package/build/api/wakatime.d.ts.map +1 -1
  10. package/build/api/wakatime.js +34 -0
  11. package/build/calculateRank.d.ts +12 -11
  12. package/build/calculateRank.d.ts.map +1 -1
  13. package/build/calculateRank.js +18 -18
  14. package/build/cards/repo.d.ts.map +1 -1
  15. package/build/cards/repo.js +29 -20
  16. package/build/cards/stats.d.ts +4 -2
  17. package/build/cards/stats.d.ts.map +1 -1
  18. package/build/cards/stats.js +21 -8
  19. package/build/cards/top-languages.d.ts.map +1 -1
  20. package/build/cards/top-languages.js +39 -11
  21. package/build/cards/wakatime.d.ts.map +1 -1
  22. package/build/cards/wakatime.js +29 -8
  23. package/build/common/Card.d.ts +9 -3
  24. package/build/common/Card.d.ts.map +1 -1
  25. package/build/common/Card.js +47 -19
  26. package/build/common/color.d.ts +42 -8
  27. package/build/common/color.d.ts.map +1 -1
  28. package/build/common/color.js +62 -13
  29. package/build/common/html.js +1 -1
  30. package/build/common/http.d.ts +13 -4
  31. package/build/common/http.d.ts.map +1 -1
  32. package/build/common/http.js +3 -3
  33. package/build/common/languageColors.json +9 -1
  34. package/build/common/ops.d.ts +30 -29
  35. package/build/common/ops.d.ts.map +1 -1
  36. package/build/common/ops.js +32 -37
  37. package/build/common/render.d.ts +115 -111
  38. package/build/common/render.d.ts.map +1 -1
  39. package/build/common/render.js +138 -87
  40. package/build/common/retryer.d.ts +14 -6
  41. package/build/common/retryer.d.ts.map +1 -1
  42. package/build/common/retryer.js +1 -0
  43. package/build/fetchers/gist.d.ts +6 -21
  44. package/build/fetchers/gist.d.ts.map +1 -1
  45. package/build/fetchers/gist.js +30 -35
  46. package/build/fetchers/repo.js +1 -1
  47. package/build/fetchers/stats.d.ts.map +1 -1
  48. package/build/fetchers/stats.js +0 -1
  49. package/build/fetchers/types.d.ts +126 -0
  50. package/build/fetchers/types.d.ts.map +1 -0
  51. package/build/fetchers/types.js +1 -0
  52. package/package.json +4 -4
  53. package/src/_emoji-name-map.d.ts +10 -0
  54. package/src/api/gist.js +36 -0
  55. package/src/api/index.js +19 -0
  56. package/src/api/pin.js +18 -0
  57. package/src/api/top-langs.js +38 -4
  58. package/src/api/wakatime.js +36 -0
  59. package/src/{calculateRank.js → calculateRank.ts} +29 -19
  60. package/src/cards/repo.js +30 -20
  61. package/src/cards/stats.js +22 -8
  62. package/src/cards/top-languages.js +49 -11
  63. package/src/cards/wakatime.js +33 -8
  64. package/src/common/Card.ts +51 -16
  65. package/src/common/color.ts +85 -22
  66. package/src/common/html.ts +1 -1
  67. package/src/common/http.ts +31 -0
  68. package/src/common/languageColors.json +9 -1
  69. package/src/common/{ops.js → ops.ts} +48 -50
  70. package/src/common/{render.js → render.ts} +208 -96
  71. package/src/common/retryer.ts +18 -10
  72. package/src/fetchers/gist.ts +136 -0
  73. package/src/fetchers/repo.js +1 -1
  74. package/src/fetchers/stats.js +0 -1
  75. package/src/common/http.js +0 -19
  76. package/src/fetchers/gist.js +0 -112
  77. /package/src/fetchers/{types.d.ts → types.ts} +0 -0
@@ -3,6 +3,7 @@ import { I18n } from "../common/I18n.js";
3
3
  import { getCardColors } from "../common/color.js";
4
4
  import { CustomError } from "../common/error.js";
5
5
  import { kFormatter } from "../common/fmt.js";
6
+ import { encodeHTML } from "../common/html.js";
6
7
  import { icons, rankIcon } from "../common/icons.js";
7
8
  import { buildSearchFilter, clampValue } from "../common/ops.js";
8
9
  import { flexLayout, measureText } from "../common/render.js";
@@ -52,8 +53,10 @@ const LONG_LOCALES = [
52
53
  /**
53
54
  * Create a stats card text item.
54
55
  *
56
+ * The caller must ensure that the passed `icon` and `link` are properly sanitized!
57
+ *
55
58
  * @param {object} params Object that contains the createTextNode parameters.
56
- * @param {string} params.icon The icon to display.
59
+ * @param {string} params.icon The sanitized icon to display.
57
60
  * @param {string} params.label The label to display.
58
61
  * @param {number} params.value The value to display.
59
62
  * @param {string} params.id The id of the stat.
@@ -64,7 +67,7 @@ const LONG_LOCALES = [
64
67
  * @param {boolean} params.bold Whether to bold the label.
65
68
  * @param {string} params.numberFormat The format of numbers on card.
66
69
  * @param {number=} params.numberPrecision The precision of numbers on card.
67
- * @param {string} params.link Url to link to.
70
+ * @param {string} params.link Sanitized url to link to.
68
71
  * @param {number} params.labelXOffset horizontal offset for label.
69
72
  * @returns {string} The stats card text item SVG object.
70
73
  */
@@ -83,6 +86,16 @@ const createTextNode = ({
83
86
  link,
84
87
  labelXOffset = 25,
85
88
  }) => {
89
+ if (!Number.isFinite(labelXOffset)) {
90
+ throw new Error(`Invalid labelXOffset: "${labelXOffset}"`);
91
+ }
92
+ if (!Number.isFinite(shiftValuePos)) {
93
+ throw new Error(`Invalid shiftValuePos: "${shiftValuePos}"`);
94
+ }
95
+ if (!Number.isFinite(index)) {
96
+ throw new Error(`Invalid index: "${index}"`);
97
+ }
98
+
86
99
  const precision =
87
100
  typeof numberPrecision === "number" && !isNaN(numberPrecision)
88
101
  ? clampValue(numberPrecision, 0, 2)
@@ -109,7 +122,7 @@ const createTextNode = ({
109
122
  ${iconSvg}
110
123
  <text class="stat ${
111
124
  bold ? " bold" : "not_bold"
112
- }" ${labelOffset} y="12.5">${label}:</text>
125
+ }" ${labelOffset} y="12.5">${encodeHTML(label)}:</text>
113
126
  <text
114
127
  class="stat ${bold ? " bold" : "not_bold"}"
115
128
  x="${(showIcons ? 140 : 120) + (bold ? 5 : 0) + shiftValuePos}"
@@ -422,13 +435,14 @@ const renderStatsCard = (
422
435
  }
423
436
 
424
437
  let repoFilter = encodeURIComponent(buildSearchFilter(repo, owner));
438
+ const encodedUsername = encodeURIComponent(username);
425
439
  if (show.includes("prs_authored")) {
426
440
  STATS.prs_authored = {
427
441
  icon: icons.prs,
428
442
  label: i18n.t("statcard.prs-authored"),
429
443
  value: totalPRsAuthored,
430
444
  id: "prs_authored",
431
- link: `https://github.com/search?q=${repoFilter}author%3A${username}&amp;type=pullrequests`,
445
+ link: `https://github.com/search?q=${repoFilter}author%3A${encodedUsername}&amp;type=pullrequests`,
432
446
  };
433
447
  }
434
448
  if (show.includes("prs_commented")) {
@@ -437,7 +451,7 @@ const renderStatsCard = (
437
451
  label: i18n.t("statcard.prs-commented"),
438
452
  value: totalPRsCommented,
439
453
  id: "prs_commented",
440
- link: `https://github.com/search?q=${repoFilter}commenter%3A${username}+-author%3A${username}&amp;type=pullrequests`,
454
+ link: `https://github.com/search?q=${repoFilter}commenter%3A${encodedUsername}+-author%3A${encodedUsername}&amp;type=pullrequests`,
441
455
  };
442
456
  }
443
457
  if (show.includes("prs_reviewed")) {
@@ -446,7 +460,7 @@ const renderStatsCard = (
446
460
  label: i18n.t("statcard.prs-reviewed"),
447
461
  value: totalPRsReviewed,
448
462
  id: "prs_reviewed",
449
- link: `https://github.com/search?q=${repoFilter}reviewed-by%3A${username}+-author%3A${username}&amp;type=pullrequests`,
463
+ link: `https://github.com/search?q=${repoFilter}reviewed-by%3A${encodedUsername}+-author%3A${encodedUsername}&amp;type=pullrequests`,
450
464
  };
451
465
  }
452
466
  if (show.includes("issues_authored")) {
@@ -455,7 +469,7 @@ const renderStatsCard = (
455
469
  label: i18n.t("statcard.issues-authored"),
456
470
  value: totalIssuesAuthored,
457
471
  id: "issues_authored",
458
- link: `https://github.com/search?q=${repoFilter}author%3A${username}&amp;type=issues`,
472
+ link: `https://github.com/search?q=${repoFilter}author%3A${encodedUsername}&amp;type=issues`,
459
473
  };
460
474
  }
461
475
  if (show.includes("issues_commented")) {
@@ -464,7 +478,7 @@ const renderStatsCard = (
464
478
  label: i18n.t("statcard.issues-commented"),
465
479
  value: totalIssuesCommented,
466
480
  id: "issues_commented",
467
- link: `https://github.com/search?q=${repoFilter}commenter%3A${username}+-author%3A${username}&amp;type=issues`,
481
+ link: `https://github.com/search?q=${repoFilter}commenter%3A${encodedUsername}+-author%3A${encodedUsername}&amp;type=issues`,
468
482
  };
469
483
  }
470
484
 
@@ -1,7 +1,12 @@
1
1
  import { Card } from "../common/Card.js";
2
2
  import { I18n } from "../common/I18n.js";
3
- import { fallbackColor, getCardColors } from "../common/color.js";
3
+ import {
4
+ fallbackColor,
5
+ getCardColors,
6
+ isPrefixedHexColor,
7
+ } from "../common/color.js";
4
8
  import { formatBytes } from "../common/fmt.js";
9
+ import { encodeHTML } from "../common/html.js";
5
10
  import { chunkArray, clampValue, lowercaseTrim } from "../common/ops.js";
6
11
  import {
7
12
  createProgressNode,
@@ -244,8 +249,8 @@ const createProgressTextNode = ({
244
249
 
245
250
  return `
246
251
  <g class="stagger" style="animation-delay: ${staggerDelay}ms">
247
- <text data-testid="lang-name" x="2" y="15" class="lang-name">${name}</text>
248
- ${hideValues ? "" : `<text x="${progressTextX}" y="34" class="lang-name">${displayValue}</text>`}
252
+ <text data-testid="lang-name" x="2" y="15" class="lang-name">${encodeHTML(name)}</text>
253
+ ${hideValues ? "" : `<text x="${progressTextX}" y="34" class="lang-name">${encodeHTML(displayValue)}</text>`}
249
254
  ${createProgressNode({
250
255
  x: 0,
251
256
  y: 25,
@@ -285,11 +290,15 @@ const createCompactLangNode = ({
285
290
  const staggerDelay = (index + 3) * 150;
286
291
  const color = lang.color || "#858585";
287
292
 
293
+ if (!isPrefixedHexColor(color)) {
294
+ throw new Error(`Invalid language color: "${color}"`);
295
+ }
296
+
288
297
  return `
289
298
  <g class="stagger" style="animation-delay: ${staggerDelay}ms">
290
299
  <circle cx="5" cy="6" r="5" fill="${color}" />
291
300
  <text data-testid="lang-name" x="15" y="10" class='lang-name'>
292
- ${lang.name} ${hideProgress || hideValues ? "" : displayValue}
301
+ ${encodeHTML(lang.name)} ${hideProgress || hideValues ? "" : encodeHTML(displayValue)}
293
302
  </text>
294
303
  </g>
295
304
  `;
@@ -439,6 +448,11 @@ const renderCompactLayout = (
439
448
  let progressOffset = 0;
440
449
  const compactProgressBar = langs
441
450
  .map((lang) => {
451
+ const langColor = lang.color || DEFAULT_LANG_COLOR;
452
+ if (!isPrefixedHexColor(langColor)) {
453
+ throw new Error(`Invalid language color: "${langColor}"`);
454
+ }
455
+
442
456
  const percentage = parseFloat(
443
457
  ((lang.size / totalLanguageSize) * offsetWidth).toFixed(2),
444
458
  );
@@ -453,7 +467,7 @@ const renderCompactLayout = (
453
467
  y="0"
454
468
  width="${progress}"
455
469
  height="8"
456
- fill="${lang.color || "#858585"}"
470
+ fill="${langColor}"
457
471
  />
458
472
  `;
459
473
  progressOffset += percentage;
@@ -514,18 +528,23 @@ const renderDonutVerticalLayout = (
514
528
 
515
529
  // Generate each donut vertical chart part
516
530
  for (const lang of langs) {
531
+ const langColor = lang.color || DEFAULT_LANG_COLOR;
532
+ if (!isPrefixedHexColor(langColor)) {
533
+ throw new Error(`Invalid language color: "${langColor}"`);
534
+ }
535
+
517
536
  const percentage = (lang.size / totalLanguageSize) * 100;
518
537
  const circleLength = totalCircleLength * (percentage / 100);
519
538
  const delay = startDelayCoefficient * 100;
520
539
 
521
540
  circles.push(`
522
541
  <g class="stagger" style="animation-delay: ${delay}ms">
523
- <circle
542
+ <circle
524
543
  cx="150"
525
544
  cy="100"
526
545
  r="${radius}"
527
546
  fill="transparent"
528
- stroke="${lang.color}"
547
+ stroke="${langColor}"
529
548
  stroke-width="25"
530
549
  stroke-dasharray="${totalCircleLength}"
531
550
  stroke-dashoffset="${indent}"
@@ -589,6 +608,11 @@ const renderPieLayout = (langs, totalLanguageSize, statsFormat, hideValues) => {
589
608
 
590
609
  // Generate each pie chart part
591
610
  for (const lang of langs) {
611
+ const langColor = lang.color || DEFAULT_LANG_COLOR;
612
+ if (!isPrefixedHexColor(langColor)) {
613
+ throw new Error(`Invalid language color: "${langColor}"`);
614
+ }
615
+
592
616
  if (langs.length === 1) {
593
617
  paths.push(`
594
618
  <circle
@@ -596,7 +620,7 @@ const renderPieLayout = (langs, totalLanguageSize, statsFormat, hideValues) => {
596
620
  cy="${centerY}"
597
621
  r="${radius}"
598
622
  stroke="none"
599
- fill="${lang.color}"
623
+ fill="${langColor}"
600
624
  data-testid="lang-pie"
601
625
  size="100"
602
626
  />
@@ -629,7 +653,7 @@ const renderPieLayout = (langs, totalLanguageSize, statsFormat, hideValues) => {
629
653
  data-testid="lang-pie"
630
654
  size="${percentage}"
631
655
  d="M ${centerX} ${centerY} L ${startPoint.x} ${startPoint.y} A ${radius} ${radius} 0 ${largeArcFlag} 1 ${endPoint.x} ${endPoint.y} Z"
632
- fill="${lang.color}"
656
+ fill="${langColor}"
633
657
  />
634
658
  </g>
635
659
  `);
@@ -716,12 +740,22 @@ const renderDonutLayout = (
716
740
  statsFormat,
717
741
  hideValues,
718
742
  ) => {
743
+ if (!Number.isFinite(width)) {
744
+ throw new Error(`Invalid width: "${width}"`);
745
+ }
746
+
719
747
  const centerX = width / 3;
720
748
  const centerY = width / 3;
721
749
  const radius = centerX - 60;
722
750
  const strokeWidth = 12;
723
751
 
724
- const colors = langs.map((lang) => lang.color);
752
+ const colors = langs.map((lang) => {
753
+ const langColor = lang.color || DEFAULT_LANG_COLOR;
754
+ if (!isPrefixedHexColor(langColor)) {
755
+ throw new Error(`Invalid language color: "${langColor}"`);
756
+ }
757
+ return langColor;
758
+ });
725
759
  const langsPercents = langs.map((lang) =>
726
760
  parseFloat(((lang.size / totalLanguageSize) * 100).toFixed(2)),
727
761
  );
@@ -783,10 +817,14 @@ const renderDonutLayout = (
783
817
  * @returns {string} No languages data SVG node string.
784
818
  */
785
819
  const noLanguagesDataNode = ({ color, text, layout }) => {
820
+ if (!isPrefixedHexColor(color)) {
821
+ throw new Error(`Invalid text color: "${color}"`);
822
+ }
823
+
786
824
  return `
787
825
  <text x="${
788
826
  layout === "pie" || layout === "donut-vertical" ? CARD_PADDING : 0
789
- }" y="11" class="stat bold" fill="${color}">${text}</text>
827
+ }" y="11" class="stat bold" fill="${color}">${encodeHTML(text)}</text>
790
828
  `;
791
829
  };
792
830
 
@@ -1,6 +1,7 @@
1
1
  import { Card } from "../common/Card.js";
2
2
  import { I18n } from "../common/I18n.js";
3
- import { getCardColors } from "../common/color.js";
3
+ import { getCardColors, isPrefixedHexColor } from "../common/color.js";
4
+ import { encodeHTML } from "../common/html.js";
4
5
  import languageColors from "../common/languageColors.json" with { type: "json" };
5
6
  import { clampValue, lowercaseTrim } from "../common/ops.js";
6
7
  import { createProgressNode, flexLayout } from "../common/render.js";
@@ -24,8 +25,12 @@ const TOTAL_TEXT_WIDTH = 275;
24
25
  * @returns {string} No coding activity SVG node string.
25
26
  */
26
27
  const noCodingActivityNode = ({ color, text }) => {
28
+ if (!isPrefixedHexColor(color)) {
29
+ throw new Error(`Invalid text color: "${color}"`);
30
+ }
31
+
27
32
  return `
28
- <text x="25" y="11" class="stat bold" fill="${color}">${text}</text>
33
+ <text x="25" y="11" class="stat bold" fill="${color}">${encodeHTML(text)}</text>
29
34
  `;
30
35
  };
31
36
 
@@ -58,6 +63,13 @@ const formatLanguageValue = ({ display_format, lang }) => {
58
63
  * @returns {string} The compact layout language SVG node.
59
64
  */
60
65
  const createCompactLangNode = ({ lang, x, y, display_format }) => {
66
+ if (!Number.isFinite(x)) {
67
+ throw new Error(`Invalid x: "${x}"`);
68
+ }
69
+ if (!Number.isFinite(y)) {
70
+ throw new Error(`Invalid y: "${y}"`);
71
+ }
72
+
61
73
  // @ts-ignore
62
74
  const color = languageColors[lang.name] || "#858585";
63
75
  const value = formatLanguageValue({ display_format, lang });
@@ -66,7 +78,7 @@ const createCompactLangNode = ({ lang, x, y, display_format }) => {
66
78
  <g transform="translate(${x}, ${y})">
67
79
  <circle cx="5" cy="6" r="5" fill="${color}" />
68
80
  <text data-testid="lang-name" x="15" y="10" class='lang-name'>
69
- ${lang.name} - ${value}
81
+ ${encodeHTML(lang.name)} - ${encodeHTML(value)}
70
82
  </text>
71
83
  </g>
72
84
  `;
@@ -125,6 +137,13 @@ const createTextNode = ({
125
137
  progressBarBackgroundColor,
126
138
  progressBarWidth,
127
139
  }) => {
140
+ if (!Number.isFinite(index)) {
141
+ throw new Error(`Invalid index: "${index}"`);
142
+ }
143
+ if (!Number.isFinite(progressBarWidth)) {
144
+ throw new Error(`Invalid progressBarWidth: "${progressBarWidth}"`);
145
+ }
146
+
128
147
  const staggerDelay = (index + 3) * 150;
129
148
  const cardProgress = hideProgress
130
149
  ? null
@@ -142,12 +161,12 @@ const createTextNode = ({
142
161
 
143
162
  return `
144
163
  <g class="stagger" style="animation-delay: ${staggerDelay}ms" transform="translate(25, 0)">
145
- <text class="stat bold" y="12.5" data-testid="${id}">${label}:</text>
164
+ <text class="stat bold" y="12.5" data-testid="${encodeHTML(id)}">${encodeHTML(label)}:</text>
146
165
  <text
147
166
  class="stat"
148
167
  x="${hideProgress ? HIDDEN_PROGRESSBAR_PADDING : PROGRESSBAR_PADDING + progressBarWidth}"
149
168
  y="12.5"
150
- >${value}</text>
169
+ >${encodeHTML(value)}</text>
151
170
  ${cardProgress}
152
171
  </g>
153
172
  `;
@@ -179,11 +198,15 @@ const recalculatePercentages = (languages) => {
179
198
  * @param {string} colors.textColor The text color.
180
199
  * @returns {string} Card CSS styles.
181
200
  */
182
- const getStyles = ({
201
+ const getStyles = function ({
183
202
  // eslint-disable-next-line no-unused-vars
184
203
  titleColor,
185
204
  textColor,
186
- }) => {
205
+ }) {
206
+ if (!isPrefixedHexColor(textColor)) {
207
+ throw new Error(`Invalid text color: "${textColor}"`);
208
+ }
209
+
187
210
  return `
188
211
  .stat {
189
212
  font: 600 14px 'Segoe UI', Ubuntu, "Helvetica Neue", Sans-Serif; fill: ${textColor};
@@ -377,7 +400,9 @@ const renderWakatimeCard = (stats = {}, options = { hide: [] }) => {
377
400
  progressBarColor: titleColor,
378
401
  // @ts-ignore
379
402
  progressBarBackgroundColor:
380
- textColor === titleColor ? bgColor : textColor,
403
+ textColor === titleColor
404
+ ? "#fff0" // transparent
405
+ : textColor,
381
406
  hideProgress: hide_progress,
382
407
  progressBarWidth: normalizedWidth - TOTAL_TEXT_WIDTH,
383
408
  });
@@ -1,3 +1,4 @@
1
+ import { isPrefixedHexColor, isValidGradient } from "./color.js";
1
2
  import { encodeHTML } from "./html.js";
2
3
  import { flexLayout } from "./render.js";
3
4
 
@@ -33,6 +34,8 @@ class Card {
33
34
  /**
34
35
  * Creates a new card instance.
35
36
  *
37
+ * The caller must ensure that the passed `titlePrefixIcon` is properly sanitized!
38
+ *
36
39
  * @param props Card arguments.
37
40
  * @param props.width Card width.
38
41
  * @param props.height Card height.
@@ -40,7 +43,7 @@ class Card {
40
43
  * @param props.colors Card colors arguments.
41
44
  * @param props.customTitle Card custom title.
42
45
  * @param props.defaultTitle Card default title.
43
- * @param props.titlePrefixIcon Card title prefix icon.
46
+ * @param props.titlePrefixIcon Sanitized card title prefix icon.
44
47
  */
45
48
  constructor({
46
49
  width = 100,
@@ -65,13 +68,11 @@ class Card {
65
68
  this.hideBorder = false;
66
69
  this.hideTitle = false;
67
70
 
68
- this.border_radius = border_radius;
71
+ this.border_radius = parseFloat(String(border_radius));
69
72
 
70
73
  // returns theme based colors with proper overrides and defaults
71
74
  this.colors = colors;
72
- this.title = encodeHTML(
73
- customTitle === undefined ? defaultTitle : customTitle,
74
- );
75
+ this.title = customTitle === undefined ? defaultTitle : customTitle;
75
76
 
76
77
  this.css = "";
77
78
 
@@ -104,7 +105,9 @@ class Card {
104
105
  }
105
106
 
106
107
  /**
107
- * @param value The CSS to add to the card.
108
+ * The caller must ensure that the passed `css` string is properly sanitized!
109
+ *
110
+ * @param value The sanitized CSS to add to the card.
108
111
  */
109
112
  setCSS(value: string): void {
110
113
  this.css = value;
@@ -121,10 +124,13 @@ class Card {
121
124
  * @param value Whether to hide the title or not.
122
125
  */
123
126
  setHideTitle(value: boolean): void {
124
- this.hideTitle = value;
125
- if (value) {
127
+ if (value && !this.hideTitle) {
126
128
  this.height -= 30;
127
129
  }
130
+ if (!value && this.hideTitle) {
131
+ this.height += 30;
132
+ }
133
+ this.hideTitle = value;
128
134
  }
129
135
 
130
136
  /**
@@ -144,7 +150,7 @@ class Card {
144
150
  y="0"
145
151
  class="header"
146
152
  data-testid="header"
147
- >${this.title}</text>
153
+ >${encodeHTML(this.title)}</text>
148
154
  `;
149
155
 
150
156
  const prefixIcon = `
@@ -180,10 +186,12 @@ class Card {
180
186
  if (typeof this.colors.bgColor !== "object") {
181
187
  return "";
182
188
  }
189
+ if (!isValidGradient(this.colors.bgColor)) {
190
+ throw new Error(`Invalid gradient: ${this.colors.bgColor.join(",")}`);
191
+ }
183
192
 
184
193
  const gradients = this.colors.bgColor.slice(1);
185
- return typeof this.colors.bgColor === "object"
186
- ? `
194
+ return `
187
195
  <defs>
188
196
  <linearGradient
189
197
  id="gradient"
@@ -198,8 +206,7 @@ class Card {
198
206
  .join(",")}
199
207
  </linearGradient>
200
208
  </defs>
201
- `
202
- : "";
209
+ `;
203
210
  }
204
211
 
205
212
  /**
@@ -230,10 +237,38 @@ class Card {
230
237
  };
231
238
 
232
239
  /**
233
- * @param body The inner body of the card.
240
+ * The caller must ensure that the passed `body` string is properly sanitized!
241
+ *
242
+ * @param body The sanitized inner body of the card.
234
243
  * @returns The rendered card.
235
244
  */
236
245
  render(body: string): string {
246
+ if (!Number.isFinite(this.border_radius)) {
247
+ throw new Error(`Invalid border radius: "${this.border_radius}"`);
248
+ }
249
+ if (
250
+ this.colors.titleColor !== undefined &&
251
+ !isPrefixedHexColor(this.colors.titleColor)
252
+ ) {
253
+ throw new Error(`Invalid title color: "${this.colors.titleColor}"`);
254
+ }
255
+ if (
256
+ this.colors.borderColor !== undefined &&
257
+ !isPrefixedHexColor(this.colors.borderColor)
258
+ ) {
259
+ throw new Error(`Invalid border color: "${this.colors.borderColor}"`);
260
+ }
261
+ if (
262
+ this.colors.bgColor !== undefined &&
263
+ !(typeof this.colors.bgColor === "object"
264
+ ? isValidGradient(this.colors.bgColor)
265
+ : isPrefixedHexColor(this.colors.bgColor))
266
+ ) {
267
+ throw new Error(
268
+ `Invalid background color: ${String(this.colors.bgColor)}`,
269
+ );
270
+ }
271
+
237
272
  return `
238
273
  <svg
239
274
  width="${this.width}"
@@ -244,8 +279,8 @@ class Card {
244
279
  role="img"
245
280
  aria-labelledby="descId"
246
281
  >
247
- <title id="titleId">${this.a11yTitle}</title>
248
- <desc id="descId">${this.a11yDesc}</desc>
282
+ <title id="titleId">${encodeHTML(this.a11yTitle)}</title>
283
+ <desc id="descId">${encodeHTML(this.a11yDesc)}</desc>
249
284
  <style>
250
285
  .header {
251
286
  font: 600 18px 'Segoe UI', Ubuntu, Sans-Serif;
@@ -1,30 +1,86 @@
1
1
  import { themes } from "../themes/index.js";
2
2
 
3
+ /** Matches a 3-, 4-, 6-, or 8-digit hex color with no leading `#`. */
4
+ const HEX_COLOR =
5
+ /^([A-Fa-f0-9]{8}|[A-Fa-f0-9]{6}|[A-Fa-f0-9]{4}|[A-Fa-f0-9]{3})$/;
6
+
3
7
  /**
4
- * Checks if a string is a valid hex color.
8
+ * Checks if a value is a bare hex color, i.e. hex digits with no `#` prefix
9
+ * (`"f00"`, `"ffffff"`). This is the form user-supplied color params and
10
+ * gradient stops arrive in.
5
11
  *
6
- * @param hexColor String to check.
7
- * @returns True if the given string is a valid hex color.
12
+ * @param value Value to check.
13
+ * @returns True if the value is a bare hex color.
8
14
  */
9
- const isValidHexColor = (hexColor: string): boolean => {
10
- return new RegExp(
11
- /^([A-Fa-f0-9]{8}|[A-Fa-f0-9]{6}|[A-Fa-f0-9]{3}|[A-Fa-f0-9]{4})$/,
12
- ).test(hexColor);
15
+ const isBareHexColor = (value: unknown): boolean => {
16
+ return typeof value === "string" && HEX_COLOR.test(value);
13
17
  };
14
18
 
15
19
  /**
16
- * Check if the given string is a valid gradient.
20
+ * Checks if a value is a `#`-prefixed hex color (`"#f00"`, `"#ffffff"`). This
21
+ * is the form colors take once resolved by {@link getCardColors}, i.e. right
22
+ * before they are written into the SVG.
17
23
  *
18
- * @param colors Array of colors.
19
- * @returns True if the given string is a valid gradient.
24
+ * @param value Value to check.
25
+ * @returns True if the value is a `#`-prefixed hex color.
20
26
  */
21
- const isValidGradient = (colors: Array<string>): boolean => {
27
+ const isPrefixedHexColor = (value: unknown): boolean => {
22
28
  return (
23
- colors.length > 2 &&
24
- colors.slice(1).every((color) => isValidHexColor(color))
29
+ typeof value === "string" &&
30
+ value.startsWith("#") &&
31
+ HEX_COLOR.test(value.slice(1))
25
32
  );
26
33
  };
27
34
 
35
+ /**
36
+ * Checks if the given parts form a valid gradient: a finite numeric angle
37
+ * followed by at least two bare-hex color stops, e.g. `["90", "f00", "0f0"]`.
38
+ * The angle is written into the SVG `gradientTransform="rotate(...)"`.
39
+ *
40
+ * @param parts Gradient parts: `[angle, ...stops]`.
41
+ * @returns True if the parts form a valid gradient.
42
+ */
43
+ const isValidGradient = (parts: Array<string>): boolean => {
44
+ const [angle, ...stops] = parts;
45
+ return (
46
+ stops.length >= 2 &&
47
+ angle !== undefined &&
48
+ angle.trim() !== "" &&
49
+ Number.isFinite(Number(angle)) &&
50
+ stops.every(isBareHexColor)
51
+ );
52
+ };
53
+
54
+ /**
55
+ * Checks if a string is a valid input for a color or gradient.
56
+ *
57
+ * @param color String to check, may be null or undefined.
58
+ * @returns True if the given string is a valid input.
59
+ */
60
+ const isValidColorInput = (color: string | null | undefined): boolean => {
61
+ if (color === null || color === undefined) {
62
+ return true;
63
+ }
64
+ return isValidGradient(color.split(",")) || isBareHexColor(color);
65
+ };
66
+
67
+ /**
68
+ * Iterates over a collection of colors inputs and verifies that each is a valid color or gradient.
69
+ *
70
+ * @param colors Object whose values are checked as valid color inputs.
71
+ * @return The first key where the associated input value is not valid. null if all inputs are valid.
72
+ */
73
+ const findInvalidColor = (
74
+ colors: Record<string, string | null | undefined>,
75
+ ): string | null => {
76
+ for (const [key, value] of Object.entries(colors)) {
77
+ if (!isValidColorInput(value)) {
78
+ return key;
79
+ }
80
+ }
81
+ return null;
82
+ };
83
+
28
84
  /**
29
85
  * Retrieves a gradient if color has more than one valid hex codes else a single color.
30
86
  *
@@ -41,7 +97,7 @@ const fallbackColor = (
41
97
  return colors;
42
98
  }
43
99
 
44
- if (color !== undefined && isValidHexColor(color)) {
100
+ if (color !== undefined && isBareHexColor(color)) {
45
101
  return `#${color}`;
46
102
  }
47
103
 
@@ -82,13 +138,13 @@ const getCardColors = ({
82
138
  ring_color,
83
139
  theme,
84
140
  }: {
85
- title_color?: string;
86
- text_color?: string;
87
- icon_color?: string;
88
- bg_color?: string;
89
- border_color?: string;
90
- ring_color?: string;
91
- theme?: string;
141
+ title_color?: string | undefined;
142
+ text_color?: string | undefined;
143
+ icon_color?: string | undefined;
144
+ bg_color?: string | undefined;
145
+ border_color?: string | undefined;
146
+ ring_color?: string | undefined;
147
+ theme?: string | undefined;
92
148
  }): CardColors => {
93
149
  const defaultTheme = themes.default;
94
150
  const isThemeProvided = theme !== undefined && theme in themes;
@@ -146,4 +202,11 @@ const getCardColors = ({
146
202
  return { titleColor, iconColor, textColor, bgColor, borderColor, ringColor };
147
203
  };
148
204
 
149
- export { fallbackColor, getCardColors };
205
+ export {
206
+ fallbackColor,
207
+ getCardColors,
208
+ findInvalidColor,
209
+ isValidGradient,
210
+ isBareHexColor,
211
+ isPrefixedHexColor,
212
+ };
@@ -6,7 +6,7 @@
6
6
  const encodeHTML = (str: string): string => {
7
7
  return (
8
8
  str
9
- .replace(/[\u00A0-\u9999<>&](?!#)/gim, (i: string) => {
9
+ .replace(/[\u00A0-\u9999<>&"'](?!#)/gim, (i: string) => {
10
10
  return `&#${i.charCodeAt(0)};`;
11
11
  })
12
12
  // eslint-disable-next-line no-control-regex