Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 98 additions & 0 deletions build/shared/inline-svg.mts
Original file line number Diff line number Diff line change
@@ -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 `<svg>` 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 `<svg>` 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 `<svg>` 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 `<svg>` 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;
}
100 changes: 100 additions & 0 deletions build/shared/inline-svg.test.mts
Original file line number Diff line number Diff line change
@@ -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('<p>a</p><svg viewBox="0 0 1 1"/><p>b</p>');

deepStrictEqual(seen, ['<svg viewBox="0 0 1 1"/>']);
deepStrictEqual(out, '<p>a</p>[svg]<p>b</p>');
});

test('replaces each of several', () => {
deepStrictEqual(
walk('<svg>1</svg> and <svg>2</svg>').out,
'[svg] and [svg]'
);
});

test('hands over the outermost of a nested pair, once', () => {
const nested = '<svg a><svg b><circle/></svg></svg>';

deepStrictEqual(walk(`x${nested}y`), { seen: [nested], out: 'x[svg]y' });
});

test('leaves markup with no element exactly as it was', () => {
const html = '<p>nothing here</p>';

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 = '<script>const i = \'<svg viewBox="0 0 1 1"/>\';</script>';

deepStrictEqual(walk(html), { seen: [], out: html });
});

// Canary: a note is not markup.
test('leaves a mark inside a comment alone', () => {
const html = '<!-- <svg><path/></svg> -->';

deepStrictEqual(walk(html), { seen: [], out: html });
});
});

describe('hasViewBox', () => {
test('reads the attribute as written, not lowercased', () => {
deepStrictEqual(hasViewBox('<svg viewBox="0 0 1 1"/>'), true);
deepStrictEqual(hasViewBox('<svg width="1"/>'), false);
});

test('reads the outermost element, not one nested inside it', () => {
deepStrictEqual(
hasViewBox('<svg width="1"><svg viewBox="0 0 1 1"/></svg>'),
false
);
});

test('finds the element in a file that opens with a header comment', () => {
deepStrictEqual(
hasViewBox('<!--*- header -*-->\n<svg viewBox="0 0 100 100"/>'),
true
);
});
});
67 changes: 66 additions & 1 deletion eleventy.config.mjs
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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) {
Expand Down Expand Up @@ -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.
Expand Down
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
Loading
Loading