From 3663931bc11c915a8636e078f3157c32fc6635a6 Mon Sep 17 00:00:00 2001 From: Derek Lewis Date: Tue, 25 Aug 2026 22:46:39 +0000 Subject: [PATCH] =?UTF-8?q?=F0=9F=8F=97=EF=B8=8F=F0=9F=9A=80=EF=BC=9Aoptim?= =?UTF-8?q?ize=20the=20SVG=20the=20site=20publishes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two in five bytes of a built page are an inlined mark, and the HTML minifier does not read inside one. The eight images copied alongside carry editor leftovers as well -- thirty-odd generated ids in the flower of life alone. Both are optimized on the way out, which leaves the sources as they are written: a mark in the repository keeps its file header and its line breaks, and only what a reader downloads is squeezed. Inline marks are handled by a transform ahead of the minifier, images by a pass over the output, since nothing copied rather than rendered meets a transform. A mark here carries no width or height and takes its ratio from the box, so a lost `viewBox` leaves it drawing at no size, on a page that still validates and still loads. The build stops if one goes missing rather than trusting a plugin list to keep it, and it reads the opening tag: a comment saying the word is not the attribute, and the comment does not survive being optimized either. Where a mark begins and ends is a question about HTML, and htmlparser2 answers it: nesting, quoting, a tag that closes itself, and the fact that an `` inside a script or a comment is text somebody wrote rather than markup to rewrite. Only the offsets are taken from it, and the markup handed on is cut from the page as it was written, so nothing outside a mark is rewritten by having been read. Ids are left alone in a page and shortened only in a file. A file is the only thing that can hold its own ids; a page holds every mark at once, and the shortest name free in each is the same name, so two marks that arrived with different ids would leave with one between them. svgo is the version `postcss-svgo` already resolves to, which leaves one copy in the tree rather than a second major line beside it. The 3.x release this started on answers to CVE-2026-29074. Renders are unchanged: nine of ten pages are identical to the pixel, and the tenth differs by two of 4.75 million, where rounded path coordinates land either side of the same edge. Signed-off-by: Derek Lewis Assisted-by: Claude-Code:claude-opus-5 Refs: https://github.com/OpenINF/openinf.github.io/issues/1548 --- build/shared/inline-svg.mts | 98 ++++++++++++++++++++++++++++++ build/shared/inline-svg.test.mts | 100 +++++++++++++++++++++++++++++++ eleventy.config.mjs | 67 ++++++++++++++++++++- package.json | 3 + pnpm-lock.yaml | 57 ++++++++++++++++++ project-terms.txt | 3 + 6 files changed, 327 insertions(+), 1 deletion(-) create mode 100644 build/shared/inline-svg.mts create mode 100644 build/shared/inline-svg.test.mts diff --git a/build/shared/inline-svg.mts b/build/shared/inline-svg.mts new file mode 100644 index 000000000..4cbcc208b --- /dev/null +++ b/build/shared/inline-svg.mts @@ -0,0 +1,98 @@ +/** + * @file Finding the SVG elements pasted into a page. + * @author The OpenINF Authors & Friends + * @license MIT OR Apache-2.0 OR BlueOak-1.0.0 + * @module {type ES6Module} build/shared/inline-svg + * + * A mark inlined into a page is a complete `` element in the middle of + * HTML. Where one begins and ends is a question about HTML, so htmlparser2 + * answers it: nesting, quoting, a tag that closes itself, and the fact that + * an `` written inside a script or a comment is text rather than markup. + * + * Only the offsets are taken from it. The markup handed on is cut from the + * page as it was written, so nothing outside a mark is rewritten by having + * been read. + */ + +import { Parser } from 'htmlparser2'; + +/** Attribute names are read as written, so `viewBox` survives being read. */ +const AS_WRITTEN = { + lowerCaseAttributeNames: false, + recognizeSelfClosing: true, +}; + +/** + * Replaces every outermost `` element in some markup. + * @param {string} html The markup to walk. + * @param {(svg: string) => string} replace What to do with each element. + * @returns {string} The markup, with each element replaced. + */ +export function replaceInlineSvg( + html: string, + replace: (svg: string) => string +) { + const ranges: [number, number][] = []; + let depth = 0; + let start = 0; + + const parser = new Parser( + { + onopentag(name) { + if (name !== 'svg') return; + + if (depth === 0) start = parser.startIndex; + + depth += 1; + }, + onclosetag(name) { + if (name !== 'svg') return; + + depth -= 1; + + if (depth === 0) ranges.push([start, parser.endIndex + 1]); + }, + }, + AS_WRITTEN + ); + + parser.write(html); + parser.end(); + + let out = ''; + let taken = 0; + + for (const [from, to] of ranges) { + out += html.slice(taken, from) + replace(html.slice(from, to)); + taken = to; + } + + return out + html.slice(taken); +} + +/** + * Says whether the outermost `` element carries a `viewBox`. + * @param {string} svg The markup to read. + * @returns {boolean} Whether the outermost element has the attribute. + */ +export function hasViewBox(svg: string) { + let carries = false; + let seen = false; + + const parser = new Parser( + { + onopentag(name, attributes) { + if (seen || name !== 'svg') return; + + seen = true; + carries = 'viewBox' in attributes; + }, + }, + AS_WRITTEN + ); + + parser.write(svg); + parser.end(); + + return carries; +} diff --git a/build/shared/inline-svg.test.mts b/build/shared/inline-svg.test.mts new file mode 100644 index 000000000..fde34a7a7 --- /dev/null +++ b/build/shared/inline-svg.test.mts @@ -0,0 +1,100 @@ +/** + * @file Tests for finding the SVG elements pasted into a page. + * @author The OpenINF Authors & Friends + * @license MIT OR Apache-2.0 OR BlueOak-1.0.0 + * @module {type ES6Module} build/shared/inline-svg.test + * + * Where an element begins and ends is htmlparser2's answer, and testing it + * again here would only restate its own tests. What is tested is what this + * module decides: which element a nested pair is, and that the replacement + * lands where the element was. + * + * The two canaries are the exception. Dependency updates land on green here, + * so a parser that stopped treating a script as text would rewrite a program + * with nothing to say so. + */ + +import { deepStrictEqual } from 'node:assert/strict'; +import { describe, test } from 'node:test'; +import { hasViewBox, replaceInlineSvg } from '@openinf/portal/build/inline-svg'; + +/** + * Collects what each call was handed, since what an element is replaced with + * matters less than which elements were found whole. + * @param {string} html The markup to walk. + * @returns {{ seen: string[], out: string }} The elements, and the result. + */ +const walk = (html: string) => { + const seen: string[] = []; + + const out = replaceInlineSvg(html, (svg) => { + seen.push(svg); + + return '[svg]'; + }); + + return { seen, out }; +}; + +describe('replaceInlineSvg', () => { + test('puts the replacement where the element was', () => { + const { seen, out } = walk('

a

b

'); + + deepStrictEqual(seen, ['']); + deepStrictEqual(out, '

a

[svg]

b

'); + }); + + test('replaces each of several', () => { + deepStrictEqual( + walk('1 and 2').out, + '[svg] and [svg]' + ); + }); + + test('hands over the outermost of a nested pair, once', () => { + const nested = ''; + + deepStrictEqual(walk(`x${nested}y`), { seen: [nested], out: 'x[svg]y' }); + }); + + test('leaves markup with no element exactly as it was', () => { + const html = '

nothing here

'; + + deepStrictEqual(walk(html), { seen: [], out: html }); + }); + + // Canary: rewriting a mark inside a script would edit the program. + test('leaves a mark inside a script alone', () => { + const html = ''; + + deepStrictEqual(walk(html), { seen: [], out: html }); + }); + + // Canary: a note is not markup. + test('leaves a mark inside a comment alone', () => { + const html = ''; + + deepStrictEqual(walk(html), { seen: [], out: html }); + }); +}); + +describe('hasViewBox', () => { + test('reads the attribute as written, not lowercased', () => { + deepStrictEqual(hasViewBox(''), true); + deepStrictEqual(hasViewBox(''), false); + }); + + test('reads the outermost element, not one nested inside it', () => { + deepStrictEqual( + hasViewBox(''), + false + ); + }); + + test('finds the element in a file that opens with a header comment', () => { + deepStrictEqual( + hasViewBox('\n'), + true + ); + }); +}); diff --git a/eleventy.config.mjs b/eleventy.config.mjs index 78bbd0415..91dc34b46 100644 --- a/eleventy.config.mjs +++ b/eleventy.config.mjs @@ -1,7 +1,9 @@ import { execFileSync } from 'node:child_process'; -import { parse as pathParse } from 'node:path'; +import { readdir, readFile, writeFile } from 'node:fs/promises'; +import { join, parse as pathParse } from 'node:path'; import { EleventyI18nPlugin } from '@11ty/eleventy'; import { PATHS } from '@openinf/portal/build/constants'; +import { hasViewBox, replaceInlineSvg } from '@openinf/portal/build/inline-svg'; import autoprefixer from 'autoprefixer'; import cssnano from 'cssnano'; import { minify as minifyHtml } from 'html-minifier-terser'; @@ -10,6 +12,7 @@ import markdownItFootnote from 'markdown-it-footnote'; import markdownItGitHubAlerts from 'markdown-it-github-alerts'; import postcss from 'postcss'; import { compileString } from 'sass'; +import { optimize as optimizeSvg } from 'svgo'; // skipcq: JS-0116 export default async function (eleventyConfig) { @@ -171,9 +174,71 @@ export default async function (eleventyConfig) { }, }); + // Shortening an id is safe in a file of its own, where it is the only + // thing that can hold one. A page holds every mark at once, and the + // shortest name free in each is the same name, so two marks that arrived + // with different ids would leave with one between them and `url(#…)` in + // the second would reach into the first. + const inline = { + multipass: true, + plugins: [ + { name: 'preset-default', params: { overrides: { cleanupIds: false } } }, + ], + }; + + /** + * Optimizes one SVG. Every mark here is written in this repository, so a + * failure is a broken file rather than bad input, and stopping the build + * is the answer to both that and a dropped `viewBox`: these marks are + * sized on one axis and take their ratio from the box, so losing it + * leaves one drawing at no size on a page that still validates. + * @param {string} svg The markup to optimize. + * @param {object} [config] What to pass the optimizer. + * @returns {string} The optimized markup. + */ + const shrinkSvg = (svg, config = { multipass: true }) => { + const optimized = optimizeSvg(svg, config).data; + + if (hasViewBox(svg) && !hasViewBox(optimized)) { + throw new Error('SVG optimization dropped a viewBox'); + } + + return optimized; + }; + // Only the build that publishes. A reader downloads this output; a // developer reads `_site` in a diff. if (isProduction) { + // Two in five bytes of a page here are an inlined mark, and the HTML + // minifier does not read inside one. + eleventyConfig.addTransform('optimizeInlineSvg', function (content) { + const output = this.page.outputPath; + + if (typeof output !== 'string' || !output.endsWith('.html')) { + return content; + } + + return replaceInlineSvg(content, (svg) => shrinkSvg(svg, inline)); + }); + + // Images are copied rather than rendered, so no transform reaches them. + eleventyConfig.on('eleventy.after', async ({ dir }) => { + const entries = await readdir(dir.output, { + recursive: true, + withFileTypes: true, + }); + + await Promise.all( + entries + .filter((entry) => entry.isFile() && entry.name.endsWith('.svg')) + .map(async (entry) => { + const file = join(entry.parentPath, entry.name); + + await writeFile(file, shrinkSvg(await readFile(file, 'utf8'))); + }) + ); + }); + eleventyConfig.addTransform('minifyHtml', function (content) { // Sass partials output nothing, and their `outputPath` is `false` // rather than absent, which optional chaining does not catch. diff --git a/package.json b/package.json index 72e7f29f4..9a9e356ac 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "exports": { "./build/commit-message": "./build/shared/commit-message.mts", "./build/constants": "./build/shared/constants.mts", + "./build/inline-svg": "./build/shared/inline-svg.mts", "./build/landing": "./build/shared/landing.mts", "./build/pull-request": "./build/shared/pull-request.mts", "./build/utils": "./build/utils.mts" @@ -41,6 +42,7 @@ "dprint": "0.55.2", "editorconfig-checker": "6.1.1", "html-minifier-terser": "7.2.0", + "htmlparser2": "12.0.0", "js-yaml": "5.3.0", "markdown-it-abbr": "2.0.0", "markdown-it-anchor": "9.2.1", @@ -88,6 +90,7 @@ "stylelint": "17.14.1", "stylelint-config-recess-order": "7.8.0", "stylelint-config-standard-scss": "17.0.0", + "svgo": "4.0.2", "typescript": "7.0.2", "unified": "11.0.5", "vnu-jar": "26.8.15", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 31dc71742..5760cd320 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -62,6 +62,9 @@ importers: html-minifier-terser: specifier: 7.2.0 version: 7.2.0 + htmlparser2: + specifier: 12.0.0 + version: 12.0.0 js-yaml: specifier: 5.3.0 version: 5.3.0 @@ -203,6 +206,9 @@ importers: stylelint-config-standard-scss: specifier: 17.0.0 version: 17.0.0(postcss@8.5.26)(stylelint@17.14.1(supports-color@10.2.2)(typescript@7.0.2)) + svgo: + specifier: 4.0.2 + version: 4.0.2 typescript: specifier: 7.0.2 version: 7.0.2 @@ -1576,9 +1582,17 @@ packages: dom-serializer@2.0.0: resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} + dom-serializer@3.1.1: + resolution: {integrity: sha512-4MEa38/QexBob6gFNwu+EGdWvhJ1OKuNwdYY3Y3NyeWDQfnGeDYQUDfIRzWu5B5gsv03so2Uxd28YC6zrsx3Lw==} + engines: {node: '>=20.19.0'} + domelementtype@2.3.0: resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==} + domelementtype@3.0.0: + resolution: {integrity: sha512-umCQid3jKbDmVjx8jGaW7uUykm4DEUeyV21hPxNMo2nV955DhUThwqyOIDtreepP31hl84X7G5U9ZfsWvIB3Pg==} + engines: {node: '>=20.19.0'} + domhandler@4.3.1: resolution: {integrity: sha512-GrwoxYN+uWlzO8uhUXRl0P+kHE4GtVPfYzVLcUxPL7KNdHKj66vvlhiweIHqYYXWlw+T8iLMp42Lm67ghw4WMQ==} engines: {node: '>= 4'} @@ -1587,12 +1601,20 @@ packages: resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==} engines: {node: '>= 4'} + domhandler@6.0.1: + resolution: {integrity: sha512-gYzvtM72ZtxQO0T048kd6HWSbbGCNOUwcnfQ01cqIJ4X2IYKFFHZ5mKvrQETcFXxsRObZulDaKmy//R7TPtsBg==} + engines: {node: '>=20.19.0'} + domutils@2.8.0: resolution: {integrity: sha512-w96Cjofp72M5IIhpjgobBimYEfoPjx1Vx0BSX9P30WBdZW2WIKU0T1Bd0kz2eNZ9ikjKgHbEyKx8BB6H1L3h3A==} domutils@3.2.2: resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==} + domutils@4.0.2: + resolution: {integrity: sha512-qI4JLRKnSzqFqr7hAlS5xQDusBCjKSEG4t4+7aNrIQMHBcsC2TGEhuyABJdYkgSewL57PNLYEiibY2iPKhKpaA==} + engines: {node: '>=20.19.0'} + dot-case@3.0.4: resolution: {integrity: sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w==} @@ -1648,6 +1670,10 @@ packages: resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} engines: {node: '>=0.12'} + entities@8.0.0: + resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==} + engines: {node: '>=20.19.0'} + env-paths@2.2.1: resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} engines: {node: '>=6'} @@ -1917,6 +1943,10 @@ packages: resolution: {integrity: sha512-n6l5uca7/y5joxZ3LUePhzmBFUJ+U2YWzhMa8XUTecSeSlQiZdF5XAd/Q3/WUl0VsXgUwWi8I7CNIwdI5WN1SQ==} engines: {node: '>=20.10'} + htmlparser2@12.0.0: + resolution: {integrity: sha512-Tz7u1i95/g2x2jz81+x0FBVhBhY5aRTvD3tXXdFaljuNdzDLJ8UGNRrTcj2cgQvAg3iW/h77Fz15nLW0L0CrZw==} + engines: {node: '>=20.19.0'} + htmlparser2@7.2.0: resolution: {integrity: sha512-H7MImA4MS6cw7nbyURtLPO1Tms7C5H602LRETv95z1MxO/7CP7rDVROehUYeYBUYEON94NXXDEPmZuq+hX4sog==} @@ -5115,8 +5145,16 @@ snapshots: domhandler: 5.0.3 entities: 4.5.0 + dom-serializer@3.1.1: + dependencies: + domelementtype: 3.0.0 + domhandler: 6.0.1 + entities: 8.0.0 + domelementtype@2.3.0: {} + domelementtype@3.0.0: {} + domhandler@4.3.1: dependencies: domelementtype: 2.3.0 @@ -5125,6 +5163,10 @@ snapshots: dependencies: domelementtype: 2.3.0 + domhandler@6.0.1: + dependencies: + domelementtype: 3.0.0 + domutils@2.8.0: dependencies: dom-serializer: 1.4.1 @@ -5137,6 +5179,12 @@ snapshots: domelementtype: 2.3.0 domhandler: 5.0.3 + domutils@4.0.2: + dependencies: + dom-serializer: 3.1.1 + domelementtype: 3.0.0 + domhandler: 6.0.1 + dot-case@3.0.4: dependencies: no-case: 3.0.4 @@ -5188,6 +5236,8 @@ snapshots: entities@6.0.1: {} + entities@8.0.0: {} + env-paths@2.2.1: {} env-paths@4.0.0: @@ -5463,6 +5513,13 @@ snapshots: html-tags@5.1.0: {} + htmlparser2@12.0.0: + dependencies: + domelementtype: 3.0.0 + domhandler: 6.0.1 + domutils: 4.0.2 + entities: 8.0.0 + htmlparser2@7.2.0: dependencies: domelementtype: 2.3.0 diff --git a/project-terms.txt b/project-terms.txt index 70623ac52..bc5b90c4d 100644 --- a/project-terms.txt +++ b/project-terms.txt @@ -35,6 +35,7 @@ gpgsign gpgtunnel Gruntfuggly hrefs +htmlparser itemprop itemscope itemtype @@ -53,6 +54,8 @@ noreferrer nvmrc objc onclick +onclosetag +onopentag openinf openpgp recompiles