Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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 |

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
3 changes: 2 additions & 1 deletion src/charts/legend.ts
Original file line number Diff line number Diff line change
@@ -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[],
Expand Down Expand Up @@ -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))}
</text>
`;
}).join('');
Expand Down
3 changes: 2 additions & 1 deletion src/render/svg.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -12,7 +13,7 @@ export function renderSvg(
text-anchor="middle" fill="${textColour}"
font-family="Arial" font-size="${TITLE_STYLES.FONT_SIZE}"
>
${title}
${sanitize(title)}
</text>
` : '';

Expand Down
11 changes: 5 additions & 6 deletions src/utils/params.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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",
Expand Down
7 changes: 7 additions & 0 deletions tests/charts/legend.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,13 @@ describe("createLegend", () => {
expect(result).toContain(`stroke-width="0.5"`);
});

it("escapes markup in language names", () => {
const langs = [{ lang: `<img src=x onerror=alert(1)>`, pct: 100 }];
const result = createLegend(langs, theme, 300, false, 110, "gap");
expect(result).toContain("&lt;img src=x onerror=alert(1)&gt; 100.0%");
expect(result).not.toContain("<img");
});

it("generates rect and text for each language", () => {
const langs = [
{ lang: "C#", pct: 50 },
Expand Down
6 changes: 6 additions & 0 deletions tests/render/error.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(`<script>"x"</script>`, 400, 300);
expect(result).toContain("&lt;script&gt;&quot;x&quot;&lt;/script&gt;");
expect(result).not.toContain("<script>");
});
});
6 changes: 6 additions & 0 deletions tests/render/svg.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,4 +28,10 @@ describe("renderSvg", () => {
expect(result).toContain(chart.segments);
expect(result).toContain(chart.legend);
});

it("escapes markup in the title", () => {
const result = renderSvg(600, 400, "#ffffff", chart, `<script>alert("x")</script>`, "#000000");
expect(result).toContain("&lt;script&gt;alert(&quot;x&quot;)&lt;/script&gt;");
expect(result).not.toContain("<script>");
});
});
6 changes: 3 additions & 3 deletions tests/utils/params.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,9 @@ describe("parseQueryParams", () => {
expect(params.chartTitle).toBe("");
});

it("sanitizes title when provided", () => {
const params = parseQueryParams({ title: `<scripts>alert("x")</script>` });
expect(params.chartTitle).toBe("&lt;scripts&gt;alert(&quot;x&quot;)&lt;/script&gt;");
it("returns title raw", () => {
const params = parseQueryParams({ title: `<b>&"'` });
expect(params.chartTitle).toBe(`<b>&"'`);
});

it("clamps count between 1 and MAX_COUNT", () => {
Expand Down
Loading