@johnmorrisdotca/taisen-brackets 0.17.0 → 0.17.2

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.
package/CHANGELOG.md CHANGED
@@ -6,6 +6,22 @@ All notable changes to this project are written here. The format follows
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.17.2] - 2026-10-20
10
+
11
+ Security fix.
12
+
13
+ ### Fixed
14
+
15
+ - **The background's `id` option is made safe.** `backgroundSvg` (and the `background` option of the drawings that use it, which already passed a safe id) wrote the `id` it was given as it came into element ids and `url(#...)`, so a hostile `id` (`x"><script>…`) could break out of the attribute when `backgroundSvg` is called directly. It now keeps only letters, digits, `_` and `-` (every other character becomes `-`) and puts `bg-` in front of an id that does not start with a letter. An id that is already safe is unchanged. The shared module `family-backgrounds` is now version 1.0.1; `test/family-backgrounds.test.ts` draws every preset with eight hostile ids.
16
+
17
+ ## [0.17.1] - 2026-10-20
18
+
19
+ Security fix.
20
+
21
+ ### Fixed
22
+
23
+ - **The `id` option is made safe in every drawing that takes one.** The judo drawings (`judoScoreboard`, `judoContestCard`, `judoMatQueue`, `judoRoadPanel`, and `drawBracket` with judo ties; 0.7.0 to 0.17.0) wrote the caller's `id` as it came into the `<style>` text, a class name and element ids, so a hostile `id` (`x"><script>…`, or `a}</style>…`) could break out of the style block or the attribute and put markup into the SVG. `drawBracket` and `roadToFinal` did the same. They now keep only letters, digits, `_` and `-` (every other character becomes `-`) and put the drawing's own prefix in front of an id that does not start with a letter, as the 0.15.0 boards already did. An id made of letters, digits, `_` and `-` that starts with a letter is unchanged, so most pages see no difference. `test/hostile-id.test.ts` draws every drawing that takes an `id` with six hostile ids.
24
+
9
25
  ## [0.17.0] - 2026-10-20
10
26
 
11
27
  Broadcast boards, step 3: the medal table.
@@ -1,4 +1,4 @@
1
- export declare const FAMILY_BACKGROUNDS_VERSION = "1.0.0";
1
+ export declare const FAMILY_BACKGROUNDS_VERSION = "1.0.1";
2
2
  /** The presets, by name. The five that end in a playing surface read as one: pitch (soccer), ice (hockey), hardwood (basketball), infield (baseball) and turf (football). */
3
3
  export declare const BACKGROUND_PRESETS: readonly ["midnight", "crimson", "royal", "emerald", "pitch", "ice", "hardwood", "infield", "turf", "ball-panels", "swirl", "waves", "glow", "floodlights", "confetti", "carbon", "aurora", "sunburst", "chevrons", "halftone", "argyle", "contour", "stardust", "mesh", "spotlight"];
4
4
  export type BackgroundPresetName = (typeof BACKGROUND_PRESETS)[number];
@@ -15,7 +15,7 @@ export interface BackgroundSvgOptions {
15
15
  intensity?: number;
16
16
  /** Moves the scattered parts (confetti, stars, scratches, contours). Default 1. */
17
17
  seed?: number;
18
- /** The prefix of the ids the background writes. Default: a hash of its options. */
18
+ /** The prefix of the ids the background writes. Default: a hash of its options. Only letters, digits, `_` and `-` are kept (every other character becomes `-`), and `bg-` goes in front of one that does not start with a letter; a safe id is used as written. */
19
19
  id?: string;
20
20
  }
21
21
  /** The colours to draw over a background. Every text colour is checked against `dominant`, the colours the background mostly is, for a contrast of 4.5 to 1 (3 to 1 for `muted`). */
@@ -8,7 +8,7 @@
8
8
  // backgroundSvg("midnight", { width: 1080, height: 1350, theme: "dark" }) -> { defs, body, ink }
9
9
  //
10
10
  // `defs` goes inside the drawing's `<defs>`, `body` right after a plain rectangle of `ink.base`, and `ink` says which colours to draw text, lines, boxes and marks in.
11
- export const FAMILY_BACKGROUNDS_VERSION = "1.0.0";
11
+ export const FAMILY_BACKGROUNDS_VERSION = "1.0.1";
12
12
  /** The presets, by name. The five that end in a playing surface read as one: pitch (soccer), ice (hockey), hardwood (basketball), infield (baseball) and turf (football). */
13
13
  export const BACKGROUND_PRESETS = [
14
14
  "midnight", "crimson", "royal", "emerald", "pitch", "ice", "hardwood", "infield", "turf", "ball-panels", "swirl", "waves", "glow",
@@ -58,6 +58,11 @@ function seeded(seed) {
58
58
  return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
59
59
  };
60
60
  }
61
+ /** The caller's id made safe to write into an `id` attribute and a `url(#...)`: letters, digits, `_` and `-`, starting with a letter. An id that is already safe is unchanged. */
62
+ function safeId(id) {
63
+ const clean = id.replace(/[^\w-]/g, "-");
64
+ return /^[A-Za-z]/.test(clean) ? clean : `bg-${clean}`;
65
+ }
61
66
  /** A small stable hash of a string, as base 36. */
62
67
  function hashOf(text) {
63
68
  let h = 0x811c9dc5;
@@ -437,7 +442,7 @@ export function backgroundSvg(preset, options) {
437
442
  palette[1] = toHex(mix(parseColor(given[0]), dark ? BLACK : WHITE, 0.35));
438
443
  const intensity = clamp(options.intensity ?? 0.6, 0, 1);
439
444
  const k = intensity / 0.6;
440
- const id = options.id ?? `bg${hashOf(JSON.stringify([preset, w, h, dark, given, options.angle, intensity, options.seed]))}`;
445
+ const id = options.id != null ? safeId(String(options.id)) : `bg${hashOf(JSON.stringify([preset, w, h, dark, given, options.angle, intensity, options.seed]))}`;
441
446
  const angle = options.angle ?? def.angle;
442
447
  const ground = { w, h, c: palette, k, rnd: seeded(options.seed ?? 1), id, dark, long: Math.max(w, h), short: Math.min(w, h) };
443
448
  const made = def.draw(ground);
@@ -3,7 +3,7 @@ import { judoMethodWord, judoSideLong } from "./judo-text.js";
3
3
  import { fill, voiceOf } from "./locales.js";
4
4
  import { drawMark, markDefs } from "./marks.js";
5
5
  import { JUDO_COLORS } from "./theme.js";
6
- import { capitals, emWidth, esc, fit, fitLines, hash, num } from "./text.js";
6
+ import { capitals, emWidth, esc, fit, fitLines, hash, num, safeId } from "./text.js";
7
7
  /** The sizes: a TV's 1920 by 1080, a tablet on its side, 1280 by 800, and a tablet held upright, 800 by 1280. */
8
8
  export const JUDO_BOARD_SIZES = Object.freeze({
9
9
  tv: { width: 1920, height: 1080 },
@@ -148,7 +148,7 @@ export function judoScoreboard(contest, options = {}) {
148
148
  const osa = contest.osaekomi;
149
149
  if (osa && (!(osa.side === 0 || osa.side === 1) || !Number.isFinite(osa.seconds) || osa.seconds < 0))
150
150
  throw new RangeError("taisen-brackets: osaekomi is { side: 0 | 1, seconds } with seconds from 0");
151
- const prefix = options.id ?? `jb-${hash(JSON.stringify([contest, options.size, options.style, theme, options.swap, options.coaches, v.locale.code, options.flags ? 1 : 0, options.flagHref ? 1 : 0, options.title]))}`;
151
+ const prefix = safeId(options.id, "jb") ?? `jb-${hash(JSON.stringify([contest, options.size, options.style, theme, options.swap, options.coaches, v.locale.code, options.flags ? 1 : 0, options.flagHref ? 1 : 0, options.title]))}`;
152
152
  const ctx = { radii: new Set(), symbols: new Map(), prefix, shape: "round", theme: "inherit", flagCache: new Map(), ...(options.flags ? { flags: options.flags } : {}), ...(options.flagHref ? { flagHref: options.flagHref } : {}) };
153
153
  const landscape = W >= H;
154
154
  const unit = Math.min(W, H);
@@ -1,7 +1,7 @@
1
1
  import { BLUE_PANEL, DARK, FONT, JUDO_BOARD_SIZES, LIGHT, WHITE_PANEL, bw, clampN, fitSize, rulesFor } from "./judo-board.js";
2
2
  import { fill, voiceOf } from "./locales.js";
3
3
  import { drawMark, markDefs } from "./marks.js";
4
- import { emWidth, esc, fit, fitLines, hash, num } from "./text.js";
4
+ import { emWidth, esc, fit, fitLines, hash, num, safeId } from "./text.js";
5
5
  /** The IJF-style mat colours, in turn: red, green, yellow, purple, orange, teal. Each reads with the ink the board picks for it. */
6
6
  export const JUDO_MAT_COLORS = Object.freeze(["#d2232a", "#1e9c4c", "#e7b800", "#7a3fb4", "#e8741a", "#12877f"]);
7
7
  /** White or near-black, whichever reads better on a hex colour. */
@@ -47,7 +47,7 @@ export function frameOf(options, seed) {
47
47
  const theme = options.theme ?? "auto";
48
48
  if (theme !== "auto" && theme !== "light" && theme !== "dark")
49
49
  throw new RangeError(`taisen-brackets: a theme is "auto", "light" or "dark", not ${JSON.stringify(theme)}`);
50
- const scope = options.id ?? `jb-${hash(JSON.stringify([seed, options.size, theme, options.swap, v.locale.code, options.flags ? 1 : 0, options.flagHref ? 1 : 0, options.title]))}`;
50
+ const scope = safeId(options.id, "jb") ?? `jb-${hash(JSON.stringify([seed, options.size, theme, options.swap, v.locale.code, options.flags ? 1 : 0, options.flagHref ? 1 : 0, options.title]))}`;
51
51
  const ctx = { radii: new Set(), symbols: new Map(), prefix: scope, shape: "round", theme: "inherit", flagCache: new Map(), ...(options.flags ? { flags: options.flags } : {}), ...(options.flagHref ? { flagHref: options.flagHref } : {}) };
52
52
  return { W: size.width, H: size.height, v, scope, theme, ctx, options };
53
53
  }
package/dist/render.js CHANGED
@@ -5,7 +5,7 @@ import { cleanSvg, drawEmblem, drawMark, hasTrophy as hasTrophyOf, isEmblemId, m
5
5
  import { roundLabel } from "./rounds.js";
6
6
  import { styleFor } from "./theme.js";
7
7
  import { resolveBackground } from "./background.js";
8
- import { emWidth, esc, fit, hash, num } from "./text.js";
8
+ import { emWidth, esc, fit, hash, num, safeId } from "./text.js";
9
9
  import { fill, voiceOf } from "./locales.js";
10
10
  import { paintVariant, seriesLineWidth } from "./layouts.js";
11
11
  import { drawGroups } from "./groups.js";
@@ -116,7 +116,7 @@ export function drawBracket(input, options = {}) {
116
116
  const orientation = options.orientation && options.orientation !== "auto" ? options.orientation : kind === "series" || sizeName === "phone" || sizeName === "card" || (size.height !== null && size.width < size.height * 0.8) ? "vertical" : "horizontal";
117
117
  const road = options.look === "road";
118
118
  const hasBg = typeof options.background === "string" || (typeof options.background === "object" && options.background !== null);
119
- const prefix = options.id ?? `tb-${hash(JSON.stringify([v.locale.code, layout.teams.map((t) => t.name), layout.ties.map((t) => [t.id, t.sides.map((s) => s?.id ?? null), t.winner, t.result.score, t.round, ...(t.judo ? [t.judo.method, t.judo.goldenScore, t.judo.seconds, t.judo.sides] : [])]), options.size, orientation, options.detail, theme, options.title, options.subtitle, options.seeds, options.scores, options.champion, options.markShape, options.roundLabels, options.thirdPlace, options.trophy ? 1 : 0, road ? 1 : 0, options.highlightPath ?? null, options.flags ? 1 : 0, ...(hasBg ? [options.background, options.vs, options.codes, options.seeds] : []), ...(options.flagHref ? ["h"] : []), ...(kind !== "tree" ? [kind, options.at, options.standings, options.qualify] : []), ...(options.headers ? ["head"] : [])]))}`;
119
+ const prefix = safeId(options.id, "tb") ?? `tb-${hash(JSON.stringify([v.locale.code, layout.teams.map((t) => t.name), layout.ties.map((t) => [t.id, t.sides.map((s) => s?.id ?? null), t.winner, t.result.score, t.round, ...(t.judo ? [t.judo.method, t.judo.goldenScore, t.judo.seconds, t.judo.sides] : [])]), options.size, orientation, options.detail, theme, options.title, options.subtitle, options.seeds, options.scores, options.champion, options.markShape, options.roundLabels, options.thirdPlace, options.trophy ? 1 : 0, road ? 1 : 0, options.highlightPath ?? null, options.flags ? 1 : 0, ...(hasBg ? [options.background, options.vs, options.codes, options.seeds] : []), ...(options.flagHref ? ["h"] : []), ...(kind !== "tree" ? [kind, options.at, options.standings, options.qualify] : []), ...(options.headers ? ["head"] : [])]))}`;
120
120
  const ctx = { radii: new Set(), symbols: new Map(), prefix, shape: options.markShape ?? "round", look: road ? "road" : "classic", flags: options.flags, flagHref: options.flagHref, theme: theme === "light" ? "light" : theme === "dark" || theme === "poster" ? "dark" : "inherit", flagCache: new Map(), defs: new Map() };
121
121
  const path = pathOf(layout, options.highlightPath);
122
122
  const linked = { count: 0 };
package/dist/road.js CHANGED
@@ -5,7 +5,7 @@
5
5
  import { SIZES } from "./render.js";
6
6
  import { drawRoad } from "./road.draw.js";
7
7
  import { nameOf, readModel, summaryOf, tableOf, totalsOf } from "./road.model.js";
8
- import { hash } from "./text.js";
8
+ import { hash, safeId } from "./text.js";
9
9
  const check = (o) => {
10
10
  if (o.size !== undefined && !Object.prototype.hasOwnProperty.call(SIZES, o.size))
11
11
  throw new RangeError(`Unknown size "${String(o.size)}": use one of ${Object.keys(SIZES).join(", ")}`);
@@ -44,7 +44,7 @@ export function roadToFinal(input, options = {}) {
44
44
  const merged = { ...options, ...extra };
45
45
  check(merged);
46
46
  const m = readModel(input, merged);
47
- const prefix = merged.id ?? `tb-${hash(JSON.stringify([m.voice.locale.code, m.finalists.map((t) => t.name), [...m.path[0].values()].map((v) => [v.round, v.mine, v.theirs]), [...m.path[1].values()].map((v) => [v.round, v.mine, v.theirs]), m.rounds, merged.size, merged.theme, merged.title, merged.subtitle, merged.final ?? null, merged.endnote, merged.winner, merged.codes, merged.markShape, ...(typeof merged.background === "string" || typeof merged.background === "object" ? [merged.background] : [])]))}`;
47
+ const prefix = safeId(merged.id, "tb") ?? `tb-${hash(JSON.stringify([m.voice.locale.code, m.finalists.map((t) => t.name), [...m.path[0].values()].map((v) => [v.round, v.mine, v.theirs]), [...m.path[1].values()].map((v) => [v.round, v.mine, v.theirs]), m.rounds, merged.size, merged.theme, merged.title, merged.subtitle, merged.final ?? null, merged.endnote, merged.winner, merged.codes, merged.markShape, ...(typeof merged.background === "string" || typeof merged.background === "object" ? [merged.background] : [])]))}`;
48
48
  return drawRoad(m, merged, prefix);
49
49
  };
50
50
  return {
package/dist/text.d.ts CHANGED
@@ -27,3 +27,8 @@ export interface FittedLines {
27
27
  export declare function fitLines(name: string, maxWidth: number, maxHeight: number, maxSize: number): FittedLines;
28
28
  /** A number written without float noise: at most two decimals, no trailing zeros. */
29
29
  export declare function num(n: number): string;
30
+ /**
31
+ * A caller's `id` option made safe to use as a class name, an element id and a CSS selector: only letters, digits, `_` and `-` stay (every other character becomes `-`),
32
+ * and an id that does not start with a letter gets `fallback` in front (an id may not start with a digit, and a CSS selector may not either). Left out, it is `undefined`.
33
+ */
34
+ export declare function safeId(id: unknown, fallback: string): string | undefined;
package/dist/text.js CHANGED
@@ -106,3 +106,13 @@ export function num(n) {
106
106
  const r = Math.round(n * 100) / 100;
107
107
  return Object.is(r, -0) ? "0" : String(r);
108
108
  }
109
+ /**
110
+ * A caller's `id` option made safe to use as a class name, an element id and a CSS selector: only letters, digits, `_` and `-` stay (every other character becomes `-`),
111
+ * and an id that does not start with a letter gets `fallback` in front (an id may not start with a digit, and a CSS selector may not either). Left out, it is `undefined`.
112
+ */
113
+ export function safeId(id, fallback) {
114
+ if (id === undefined)
115
+ return undefined;
116
+ const clean = String(id).replace(/[^\w-]/g, "-");
117
+ return /^[A-Za-z]/.test(clean) ? clean : `${fallback}-${clean}`;
118
+ }
package/dist/types.d.ts CHANGED
@@ -636,7 +636,7 @@ export interface DrawOptions extends LayoutOptions {
636
636
  words?: Partial<Words>;
637
637
  /** The name to draw for a team in this language: `undefined` keeps `Team.names[locale]` or `Team.name`. `countryName(team.region, locale)` makes a country's name from Intl, with no list of your own. */
638
638
  teamName?: (team: Team, locale: Locale) => string | undefined;
639
- /** Prefix for the drawing's ids. Default: a hash of the bracket and the options. */
639
+ /** Prefix for the drawing's ids. Default: a hash of the bracket and the options. Only letters, digits, `_` and `-` are kept (any other character becomes `-`), and an id that does not start with a letter gets the drawing's own prefix in front. */
640
640
  id?: string;
641
641
  /** Extra class names for the root `<svg>`. */
642
642
  className?: string;
package/dist/version.d.ts CHANGED
@@ -1 +1 @@
1
- export declare const VERSION = "0.17.0";
1
+ export declare const VERSION = "0.17.2";
package/dist/version.js CHANGED
@@ -1 +1 @@
1
- export const VERSION = "0.17.0";
1
+ export const VERSION = "0.17.2";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@johnmorrisdotca/taisen-brackets",
3
- "version": "0.17.0",
3
+ "version": "0.17.2",
4
4
  "description": "Taisen Brackets 対戦, knockout brackets as SVG: from a list of ties (games, best-of series, two legs, replays, byes, a third-place match) it works out the rounds and halves and draws the bracket with the halves meeting at the final, or left to right, wide, square, as a tall card or for a phone, in a classic or a road look (large round flags, big scores, a team's dotted path, a link on every box), in light, dark or a gold-on-navy poster look, in eight layouts (the tree, rows, shields, a score-update card, tall columns, the NBA's play-in, a series card and group tables), on one of twenty-five poster backgrounds (the playing surfaces of five sports among them) or your own, with each team's logo or round flag, the champion in the centre, and an accessible table; and the road to the final of either finalist. NHL, NBA, MLB, NFL, CFL, MLS and World Cup formats by era, in English, French, Japanese, Spanish and German. Zero dependencies.",
5
5
  "keywords": [
6
6
  "bracket",