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