From 420ec86358d48624237d77e8863b0a99afea1b67 Mon Sep 17 00:00:00 2001 From: Tane Morgan <464864+tanem@users.noreply.github.com> Date: Tue, 4 Aug 2026 12:48:30 +1200 Subject: [PATCH] Add an evalScripts example `evalScripts` was documented in the README but demonstrated nowhere, and it is the option the Security section largely exists for. The example injects two SVGs whose scripts write counters on the page, one with `'once'` and one with `'always'`, and a button re-injects both so the difference is visible. The script in `always.svg` sits inside a ``, which keeps the nested-script removal fix from 12.0.0 exercised in a real app rather than only in a fixture. Co-Authored-By: Claude Opus 5 --- README.md | 3 +- examples/eval-scripts/README.md | 43 ++++++++++ examples/eval-scripts/index.html | 107 ++++++++++++++++++++++++ examples/eval-scripts/index.ts | 47 +++++++++++ examples/eval-scripts/package.json | 21 +++++ examples/eval-scripts/public/always.svg | 19 +++++ examples/eval-scripts/public/once.svg | 14 ++++ examples/eval-scripts/tsconfig.json | 18 ++++ examples/eval-scripts/vite.config.js | 7 ++ scripts/build-examples.js | 1 + test/examples.test.ts | 46 ++++++++++ 11 files changed, 325 insertions(+), 1 deletion(-) create mode 100644 examples/eval-scripts/README.md create mode 100644 examples/eval-scripts/index.html create mode 100644 examples/eval-scripts/index.ts create mode 100644 examples/eval-scripts/package.json create mode 100644 examples/eval-scripts/public/always.svg create mode 100644 examples/eval-scripts/public/once.svg create mode 100644 examples/eval-scripts/tsconfig.json create mode 100644 examples/eval-scripts/vite.config.js diff --git a/README.md b/README.md index b74f7156..2e421c6e 100644 --- a/README.md +++ b/README.md @@ -121,7 +121,7 @@ Failed loads are not cached, so a URL that errored is refetched next time. The c Whether to run script blocks found in the SVG: `'always'`, `'once'` or `'never'`. `'once'` runs a given URL's scripts on the first injection of that URL only. Leave it at `'never'` for SVGs you don't control: see [Security](#security). -Injected SVGs don't run their ` + + diff --git a/examples/eval-scripts/index.ts b/examples/eval-scripts/index.ts new file mode 100644 index 00000000..721569dd --- /dev/null +++ b/examples/eval-scripts/index.ts @@ -0,0 +1,47 @@ +import { SVGInjector } from '@tanem/svg-injector' + +import type { EvalScripts } from '@tanem/svg-injector' + +const injectionCount = document.getElementById('injection-count')! +const scriptsRemaining = document.getElementById('scripts-remaining')! + +const reportScriptsRemaining = () => { + scriptsRemaining.textContent = String( + document.querySelectorAll('svg.injected-svg script').length, + ) +} + +// Injection replaces the placeholder with the SVG, so injecting the same file +// again means putting a fresh placeholder in the slot rather than reusing the +// element that has already left the document. +const inject = (slotId: string, src: string, evalScripts: EvalScripts) => { + const placeholder = document.createElement('div') + placeholder.setAttribute('data-src', src) + document.getElementById(slotId)!.replaceChildren(placeholder) + + SVGInjector(placeholder, { + evalScripts, + afterEach(error) { + if (error) { + console.error(error) + return + } + reportScriptsRemaining() + }, + }) +} + +const injectBoth = () => { + // `once` is keyed by the URL the SVG was loaded from, not by the element, so + // a second placeholder pointing at the same file does not run the script + // again. The response itself is served from the cache either way, and the + // cached copy keeps its scripts: each injection gets its own clone to strip. + inject('once-slot', 'once.svg', 'once') + inject('always-slot', 'always.svg', 'always') + + injectionCount.textContent = String(Number(injectionCount.textContent) + 1) +} + +document.getElementById('reinject')!.addEventListener('click', injectBoth) + +injectBoth() diff --git a/examples/eval-scripts/package.json b/examples/eval-scripts/package.json new file mode 100644 index 00000000..190ad423 --- /dev/null +++ b/examples/eval-scripts/package.json @@ -0,0 +1,21 @@ +{ + "name": "eval-scripts", + "version": "1.0.0", + "description": "SVGInjector Eval Scripts Example", + "type": "module", + "scripts": { + "build": "vite build", + "dev": "vite", + "preview": "vite preview", + "start": "vite" + }, + "dependencies": { + "@tanem/svg-injector": "latest" + }, + "keywords": [ + "@tanem/svg-injector" + ], + "devDependencies": { + "vite": "^8.2.0" + } +} diff --git a/examples/eval-scripts/public/always.svg b/examples/eval-scripts/public/always.svg new file mode 100644 index 00000000..8090c65d --- /dev/null +++ b/examples/eval-scripts/public/always.svg @@ -0,0 +1,19 @@ + + + + + + + diff --git a/examples/eval-scripts/public/once.svg b/examples/eval-scripts/public/once.svg new file mode 100644 index 00000000..5678870f --- /dev/null +++ b/examples/eval-scripts/public/once.svg @@ -0,0 +1,14 @@ + + + + diff --git a/examples/eval-scripts/tsconfig.json b/examples/eval-scripts/tsconfig.json new file mode 100644 index 00000000..6be689b0 --- /dev/null +++ b/examples/eval-scripts/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "strict": true, + "module": "esnext", + "target": "es2020", + "jsx": "preserve", + "esModuleInterop": true, + "sourceMap": true, + "allowJs": true, + "lib": [ + "es2020", + "dom" + ], + "rootDir": ".", + "moduleResolution": "bundler" + }, + "files": ["index.ts"] +} \ No newline at end of file diff --git a/examples/eval-scripts/vite.config.js b/examples/eval-scripts/vite.config.js new file mode 100644 index 00000000..30e2a3ae --- /dev/null +++ b/examples/eval-scripts/vite.config.js @@ -0,0 +1,7 @@ +import { defineConfig } from 'vite' + +export default defineConfig({ + // The built examples are served from a shared static server under + // /dist/, so asset URLs have to be relative to the page. + base: './', +}) diff --git a/scripts/build-examples.js b/scripts/build-examples.js index e3d055ea..4efe22d0 100644 --- a/scripts/build-examples.js +++ b/scripts/build-examples.js @@ -12,6 +12,7 @@ const examples = [ 'basic-usage', 'data-url-usage', 'error-handling', + 'eval-scripts', 'iri-renumeration', 'sprite-usage', ] diff --git a/test/examples.test.ts b/test/examples.test.ts index ce22cee5..86be73c7 100644 --- a/test/examples.test.ts +++ b/test/examples.test.ts @@ -30,6 +30,10 @@ const examples = [ name: 'error-handling', expectedSvgCount: 1, }, + { + name: 'eval-scripts', + expectedSvgCount: 2, + }, { name: 'iri-renumeration', expectedSvgCount: 3, @@ -124,6 +128,48 @@ test('error-handling: failures report verbatim and render a fallback', async ({ expect(pageErrors).toEqual([]) }) +// The counters are written by the SVGs' own scripts, so they only move if the +// library really evaluated them. `once` is keyed by URL and the page re-injects +// the same two files, which is what separates the two options here. +test('eval-scripts: once runs on the first injection, always runs on every one', async ({ + page, +}) => { + const pageErrors: string[] = [] + page.on('pageerror', (err) => pageErrors.push(err.message)) + + await page.goto('/eval-scripts/dist/') + await waitForInjection(page, 2) + + await expect(page.locator('#injection-count')).toHaveText('1') + await expect(page.locator('#once-runs')).toHaveText('1') + await expect(page.locator('#always-runs')).toHaveText('1') + + await page.locator('#reinject').click() + await waitForInjection(page, 2) + await page.locator('#reinject').click() + await waitForInjection(page, 2) + + await expect(page.locator('#injection-count')).toHaveText('3') + await expect(page.locator('#once-runs')).toHaveText('1') + await expect(page.locator('#always-runs')).toHaveText('3') + + expect(pageErrors).toEqual([]) +}) + +// Scripts are stripped from the injected markup whatever `evalScripts` is set +// to, including the one nested inside a ``, which used to throw on removal. +test('eval-scripts: no script elements survive injection', async ({ page }) => { + await page.goto('/eval-scripts/dist/') + await waitForInjection(page, 2) + + await expect(page.locator('#scripts-remaining')).toHaveText('0') + await expect(page.locator('svg.injected-svg script')).toHaveCount(0) + + // The nested script's sibling is still there, so removal took the script and + // nothing else. + await expect(page.locator('#always-slot svg g rect')).toHaveCount(1) +}) + test('api-usage: beforeEach applies stroke attribute', async ({ page }) => { await page.goto('/api-usage/dist/') await waitForInjection(page, 2)