diff --git a/src/components/npc-tray.js b/src/components/npc-tray.js index 19a3022..63c0bec 100644 --- a/src/components/npc-tray.js +++ b/src/components/npc-tray.js @@ -1,14 +1,6 @@ import { generateNpc } from '../npc/generator.js'; import { getGenrePack, listGenrePacks } from '../npc/packs/index.js'; - -function escapeHtml(s) { - return String(s ?? '') - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, '''); -} +import { escapeHtml } from '../core/escape-html.js'; export class NpcTray extends HTMLElement { constructor() { diff --git a/src/components/prop-viewer.js b/src/components/prop-viewer.js index 9e41e22..52cd826 100644 --- a/src/components/prop-viewer.js +++ b/src/components/prop-viewer.js @@ -1,3 +1,5 @@ +import { escapeHtml } from '../core/escape-html.js'; + export class PropViewer extends HTMLElement { constructor() { super(); this._src = ''; this._label = ''; } connectedCallback() { if (!this.hasAttribute('hidden')) this.setAttribute('hidden', ''); this.render(); } @@ -14,7 +16,7 @@ export class PropViewer extends HTMLElement { render() { this.innerHTML = `
- ${this._label} + ${escapeHtml(this._label)}
`; this.querySelector('[data-role=overlay]').addEventListener('click', () => this.dismiss()); } diff --git a/src/core/escape-html.js b/src/core/escape-html.js new file mode 100644 index 0000000..96aafa8 --- /dev/null +++ b/src/core/escape-html.js @@ -0,0 +1,8 @@ +export function escapeHtml(s) { + return String(s ?? '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} diff --git a/tests/components/prop-viewer.test.js b/tests/components/prop-viewer.test.js index 4dddb87..5cd72a0 100644 --- a/tests/components/prop-viewer.test.js +++ b/tests/components/prop-viewer.test.js @@ -26,4 +26,11 @@ describe('', () => { el.querySelector('[data-role=overlay]').click(); expect(el.isOpen()).toBe(false); }); + + it('escapes a label containing a quote (no attribute break)', () => { + el.show('/art/x.png', 'x" onerror="boom'); + const img = el.querySelector('[data-role=prop-img]'); + expect(img.getAttribute('alt')).toBe('x" onerror="boom'); + expect(img.hasAttribute('onerror')).toBe(false); + }); }); diff --git a/tests/core/escape-html.test.js b/tests/core/escape-html.test.js new file mode 100644 index 0000000..1dc2f5b --- /dev/null +++ b/tests/core/escape-html.test.js @@ -0,0 +1,16 @@ +import { describe, it, expect } from 'vitest'; +import { escapeHtml } from '../../src/core/escape-html.js'; + +describe('escapeHtml', () => { + it('escapes the five HTML-significant characters', () => { + expect(escapeHtml(`a & b < c > d " e ' f`)).toBe('a & b < c > d " e ' f'); + }); + it('replaces & first so entities are not double-escaped', () => { + expect(escapeHtml('<')).toBe('<'); + expect(escapeHtml('<')).toBe('&lt;'); + }); + it('coerces null/undefined to empty string', () => { + expect(escapeHtml(null)).toBe(''); + expect(escapeHtml(undefined)).toBe(''); + }); +});