diff --git a/README.md b/README.md
index ab94dab..cbcb3e8 100644
--- a/README.md
+++ b/README.md
@@ -33,6 +33,10 @@ Shipped in the package but not part of the public API:
| `charts/legend.js` | Legend element generation |
| `charts/layout.js` | Shared layout calculations |
+### Escaping
+`renderSvg`, `renderError`, and legend generation HTML-escape their text
+inputs (title, error message, language names).
+
## Query parameters
All parsing lives in `parseQueryParams`(utils/params.js), invalid params fall back to defaults.
@@ -44,7 +48,7 @@ All parsing lives in `parseQueryParams`(utils/params.js), invalid params fall ba
| bg, text, gap, c1 - c16 | from theme | Accepts a theme name, or a hex value (3-8 digits with or without #) |
| gap_type | gap | `gap`, `grow`, or `adapt` |
| stroke | false | Adds a black outline to slices and legend squares. |
-| title | Top Languages | HTML-escaped |
+| title | Top Languages | Custom SVG title |
| hide_title | false | If `true` title is not rendered |
| width / height | 400 / 300 | Integers, minimums: width 400, height 265 |
diff --git a/package.json b/package.json
index cccdc67..d58060c 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "@gh-top-languages/lib",
- "version": "1.2.2",
+ "version": "1.2.3",
"description": "Library for gh-top-languages — chart generation, SVG output, and parameter parsing",
"keywords": [
"svg",
diff --git a/src/charts/legend.ts b/src/charts/legend.ts
index 426e112..c8e62bc 100644
--- a/src/charts/legend.ts
+++ b/src/charts/legend.ts
@@ -1,6 +1,7 @@
import { LEGEND_SHIFT_THRESHOLD, LEGEND_STYLES } from "../constants/styles.js";
import type { Theme, Language, GapType } from "./types.js";
import { formatLegendEntry, resolveColour } from "./helpers.js";
+import { sanitize } from "../utils/sanitize.js";
export function createLegend(
languages: Language[],
@@ -49,7 +50,7 @@ export function createLegend(
font-size="${LEGEND_STYLES.FONT_SIZE}"
font-family="Arial"
>
- ${formatLegendEntry(lang, totalPct, gapType)}
+ ${sanitize(formatLegendEntry(lang, totalPct, gapType))}
`;
}).join('');
diff --git a/src/render/svg.ts b/src/render/svg.ts
index 24847a1..249ea05 100644
--- a/src/render/svg.ts
+++ b/src/render/svg.ts
@@ -1,5 +1,6 @@
import { TITLE_STYLES } from "../constants/styles.js";
import type { ChartResult } from "../charts/types.js";
+import { sanitize } from "../utils/sanitize.js";
export function renderSvg(
width: number, height: number, background: string,
@@ -12,7 +13,7 @@ export function renderSvg(
text-anchor="middle" fill="${textColour}"
font-family="Arial" font-size="${TITLE_STYLES.FONT_SIZE}"
>
- ${title}
+ ${sanitize(title)}
` : '';
diff --git a/src/utils/params.ts b/src/utils/params.ts
index 59f33e7..87236f3 100644
--- a/src/utils/params.ts
+++ b/src/utils/params.ts
@@ -2,7 +2,6 @@ import { VALID_TYPES } from "../constants/types.js";
import { DEFAULT_CONFIG } from "../constants/config.js";
import { THEMES } from "../constants/themes.js";
import type { ChartType, GapType, Theme } from "../charts/types.js";
-import { sanitize } from "./sanitize.js";
export interface ParsedParams {
chartType: ChartType;
@@ -31,7 +30,7 @@ const parseHex = (val: string | undefined, fallback: string): string => {
return /^#[0-9a-f]{3,8}$/i.test(hex) ? hex : fallback;
};
-const resolveColour = (
+const resolveThemeColour = (
query: QueryParams,
theme: Theme,
key: "bg" | "text" | "gap"
@@ -62,14 +61,14 @@ export function parseQueryParams(query: QueryParams): ParsedParams {
return {
chartType,
- chartTitle: query["hide_title"] === "true" ? '' : sanitize(query["title"] ?? DEFAULT_CONFIG.TITLE),
+ chartTitle: query["hide_title"] === "true" ? '' : query["title"] ?? DEFAULT_CONFIG.TITLE,
width: Math.max(parseIntSafe(query["width"], DEFAULT_CONFIG.WIDTH), DEFAULT_CONFIG.MIN_WIDTH ),
height: Math.max(parseIntSafe(query["height"], DEFAULT_CONFIG.HEIGHT), DEFAULT_CONFIG.MIN_HEIGHT),
count: Math.min(Math.max(count, 1), DEFAULT_CONFIG.MAX_COUNT),
selectedTheme: {
- bg: resolveColour(query, baseTheme, "bg"),
- text: resolveColour(query, baseTheme, "text"),
- gap: resolveColour(query, baseTheme, "gap"),
+ bg: resolveThemeColour(query, baseTheme, "bg"),
+ text: resolveThemeColour(query, baseTheme, "text"),
+ gap: resolveThemeColour(query, baseTheme, "gap"),
colours,
},
gapType: (["gap", "grow", "adapt"] as const).includes(query["gap_type"] as GapType) ? query["gap_type"] as GapType : "gap",
diff --git a/tests/charts/legend.test.ts b/tests/charts/legend.test.ts
index 79c82ad..cbe1ce2 100644
--- a/tests/charts/legend.test.ts
+++ b/tests/charts/legend.test.ts
@@ -43,6 +43,13 @@ describe("createLegend", () => {
expect(result).toContain(`stroke-width="0.5"`);
});
+ it("escapes markup in language names", () => {
+ const langs = [{ lang: `
`, pct: 100 }];
+ const result = createLegend(langs, theme, 300, false, 110, "gap");
+ expect(result).toContain("<img src=x onerror=alert(1)> 100.0%");
+ expect(result).not.toContain("
{
const langs = [
{ lang: "C#", pct: 50 },
diff --git a/tests/render/error.test.ts b/tests/render/error.test.ts
index cfe3cd3..d7b5a9a 100644
--- a/tests/render/error.test.ts
+++ b/tests/render/error.test.ts
@@ -27,4 +27,10 @@ describe("renderError", () => {
expect(result).toContain("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA...");
expect(result).not.toContain("A".repeat(50));
});
+
+ it("escapes markup in the message", () => {
+ const result = renderError(``, 400, 300);
+ expect(result).toContain("<script>"x"</script>");
+ expect(result).not.toContain("`, "#000000");
+ expect(result).toContain("<script>alert("x")</script>");
+ expect(result).not.toContain("` });
- expect(params.chartTitle).toBe("<scripts>alert("x")</script>");
+ it("returns title raw", () => {
+ const params = parseQueryParams({ title: `&"'` });
+ expect(params.chartTitle).toBe(`&"'`);
});
it("clamps count between 1 and MAX_COUNT", () => {