diff --git a/.changeset/refactor-inline-ansi-html.md b/.changeset/refactor-inline-ansi-html.md new file mode 100644 index 000000000..caca5f376 --- /dev/null +++ b/.changeset/refactor-inline-ansi-html.md @@ -0,0 +1,34 @@ +--- +"webpack-dev-middleware": patch +--- + +Dropped the `ansi-html-community` dependency. The overlay's ANSI-to-HTML +conversion is `client-src/utils/ansi-html.js` now, which is the third of that +package this project used — the rest was surface it never touched, and it +shipped into every consumer's browser bundle. Four production dependencies +instead of five, and one fewer unmaintained package in the supply chain (its +last release was 0.0.8 in April 2022, itself a fork of the abandoned +`ansi-html`). + +Output is byte-identical for the sequences a build actually produces. Four +things it got wrong are fixed: + +- A palette entry of `"transparent"`, which is how the overlay says to leave + the page's own colour alone, became `color:#transparent`. That is not a + colour, so the reset worked only because browsers drop an invalid + declaration, and the inverse sequence did nothing at all. +- A sequence carrying more than one parameter (`\u001b[1;31m`) matched nothing, + so the escape stayed in the output as text for the reader to see. +- `\u001b[m`, which is `\u001b[0m` written short, was left in the output the + same way. +- A closing sequence with nothing open emitted an unmatched ``. The + highlighters wrap their own spans around this output, so a stray close could + end one of theirs early. +- Every closing tag was a ``, whatever was open. `\u001b[3m` opens an + ``, so an unclosed italic came out as `x`, and an interleaved + sequence crossed its tags: `x`. Each open element now + carries its own closing tag, so the markup nests whatever the sequences do. + +The conversion had no test of its own while it was a dependency. It has 25 now, +one of which walks every three-sequence combination and checks the result +nests. diff --git a/client-src/overlay.js b/client-src/overlay.js index cc3b1900d..b3e14cf98 100644 --- a/client-src/overlay.js +++ b/client-src/overlay.js @@ -1,7 +1,6 @@ -import ansiHTML from "ansi-html-community"; - import { problemLine } from "./problem.js"; import theme from "./theme.js"; +import ansiHTML, { setColors } from "./utils/ansi-html.js"; // Re-exported so a consumer that shows problems in the console as well as in // the overlay has one import for both. `./client/problem` is the same thing @@ -137,6 +136,11 @@ const colors = { darkgrey: "6d7891", }; +// At load, the way the package this replaced initialised itself: a message can +// carry colours before anyone has configured anything, and `ansiColors` below +// only re-reads the palette when it has changed it. +setColors(colors); + /** * @typedef {object} OverlayState * @property {HTMLIFrameElement | null} frame overlay iframe @@ -1000,7 +1004,7 @@ export default function configureOverlay(options) { colors[color] = options.ansiColors[color]; } } - ansiHTML.setColors(colors); + setColors(colors); } if (options.overlayStyles) { diff --git a/client-src/utils/ansi-html.js b/client-src/utils/ansi-html.js new file mode 100644 index 000000000..fa432e5ee --- /dev/null +++ b/client-src/utils/ansi-html.js @@ -0,0 +1,272 @@ +// ANSI colours to HTML, for the overlay. +// +// Inlined from `ansi-html-community`, whose last release was 0.0.8 in April +// 2022 and which is itself a fork of the abandoned `ansi-html`. A third of it +// was surface this project never touched — the `tags` getters, a `reset()` +// nobody called, and the validation branches that a fixed palette cannot +// reach — and it shipped into every consumer's browser bundle. +// +// Two things are different from that package, both deliberate, both covered by +// `test/ansi-html.test.js`: +// +// * A palette entry of `"transparent"` leaves the declaration out rather +// than writing `color:#transparent`, which is not a colour. Browsers drop +// an invalid declaration, so the reset worked by accident; it now says +// what it means. +// * A sequence carrying more than one parameter (`\u001b[1;31m`) is applied +// parameter by parameter. It used to match nothing, so the escape stayed +// in the output as text for the reader to see. +// +// Compiled to an ES5 baseline like the rest of the browser runtime. + +// One SGR sequence: `\u001b[`, the parameters, then `m`. Other escapes (cursor +// moves, screen clears) are left exactly as they came, the way they were +// before. +// eslint-disable-next-line no-control-regex +const SGR = /\u001B\[([0-9;]*)m/g; + +// Which colour each foreground parameter names. The background parameter is +// this plus ten, which is what the loop below relies on. +/** @type {Record} */ +const FOREGROUND = { + 30: "black", + 31: "red", + 32: "green", + 33: "yellow", + 34: "blue", + 35: "magenta", + 36: "cyan", + 37: "lightgrey", +}; + +// What each parameter opens, where it is not a colour. +/** @type {Record} */ +const STATIC_OPEN = { + 1: "font-weight:bold", + 2: "opacity:0.5", + 3: "", + 4: "", + 8: "display:none", + 9: "", +}; + +// ... and what closes one. The rest close whatever span is open. +/** @type {Record} */ +const STATIC_CLOSE = { + 23: "", + 24: "", + 29: "", +}; + +const CLOSES_SPAN = [0, 21, 22, 27, 28, 39, 49]; + +/** @type {Record} */ +let openTags = {}; +/** @type {Record} */ +let closeTags = {}; + +/** + * One colour declaration, or nothing at all. + * + * A palette carries a hex colour without its `#`, so one is added back. Any + * other value is used as it is, and `"transparent"` — which is how a palette + * says to leave the page's own colour alone — produces no declaration rather + * than an invalid one. + * @param {string} property `color` or `background` + * @param {string | undefined} value the palette's value + * @returns {string} the declaration, or an empty string + */ +function colorDeclaration(property, value) { + if (!value || value === "transparent") { + return ""; + } + + return `${property}:${/^[\da-f]{3,8}$/i.test(value) ? `#${value}` : value}`; +} + +/** + * Join the declarations that have something in them. + * @param {string[]} declarations css declarations + * @returns {string} the style + */ +function style(declarations) { + const kept = []; + + for (let index = 0; index < declarations.length; index++) { + if (declarations[index]) { + kept.push(declarations[index]); + } + } + + return kept.join(";"); +} + +/** + * Build the tag tables from a palette. + * @param {Record} colors palette, hex without `#` + */ +export function setColors(colors) { + /** @type {Record} */ + const open = {}; + /** @type {Record} */ + const close = {}; + + for (const parameter of Object.keys(STATIC_OPEN)) { + open[parameter] = STATIC_OPEN[parameter]; + } + + for (const parameter of Object.keys(STATIC_CLOSE)) { + close[parameter] = STATIC_CLOSE[parameter]; + } + + for (let index = 0; index < CLOSES_SPAN.length; index++) { + close[CLOSES_SPAN[index]] = ""; + } + + const reset = Array.isArray(colors.reset) + ? colors.reset + : [/** @type {string} */ (colors.reset)]; + const [foreground, background] = reset; + + // Reset: back to the page's weight and opacity, and to whichever colours the + // palette names for it. + open[0] = style([ + "font-weight:normal", + "opacity:1", + colorDeclaration("color", foreground), + colorDeclaration("background", background), + ]); + + // Inverse: the reset pair, the other way round. + open[7] = style([ + colorDeclaration("color", background), + colorDeclaration("background", foreground), + ]); + + open[90] = style([ + colorDeclaration("color", /** @type {string} */ (colors.darkgrey)), + ]); + + for (const parameter of Object.keys(FOREGROUND)) { + const color = /** @type {string} */ ( + colors[FOREGROUND[parameter]] || "000" + ); + + open[parameter] = style([colorDeclaration("color", color)]); + open[Number(parameter) + 10] = style([ + colorDeclaration("background", color), + ]); + } + + openTags = open; + closeTags = close; +} + +/** + * One element this opened, and the tag that closes it. + * @typedef {object} Open + * @property {string} parameter the parameter that opened it + * @property {string} closing the tag that closes it + */ + +/** + * Apply one SGR parameter. + * + * The stack carries each open element's own closing tag. The package this + * replaced stacked the parameters instead and closed with a hardcoded + * ``, so a `` opened by `\u001b[3m` was closed with `` and an + * interleaved sequence crossed its tags — `x`. Whatever + * this produces nests, because the highlighters wrap their own spans around + * it afterwards and crossed tags there take the card's markup with them. + * @param {string} parameter the parameter, as it was written + * @param {Open[]} stack elements still open, outermost first + * @returns {string} what it becomes + */ +function applyParameter(parameter, stack) { + const open = openTags[parameter]; + + if (typeof open !== "undefined") { + // The same parameter again closes what it opened — what the package did, + // kept — and with it everything opened inside, so the nesting holds. + for (let index = stack.length - 1; index >= 0; index--) { + if (stack[index].parameter === parameter) { + let out = ""; + + while (stack.length > index) { + out += /** @type {Open} */ (stack.pop()).closing; + } + + return out; + } + } + + const isTag = open.charAt(0) === "<"; + + stack.push({ + parameter, + closing: isTag ? `", + }); + + if (isTag) { + return open; + } + + // A palette can leave a parameter with nothing to declare — inverse, with + // a reset pair of `"transparent"`. The span still opens, to keep the stack + // balanced, but without an empty style attribute to carry. + return open === "" ? "" : ``; + } + + if (typeof closeTags[parameter] !== "undefined") { + // Nothing open is nothing to close. The package emitted the tag anyway, + // which put an unmatched `` into the card — and a stray close can + // end a span one of the highlighters opened around this. + if (stack.length === 0) { + return ""; + } + + // The innermost element closes, with its own tag. Which parameter a closer + // belongs to is not tracked: `\u001b[3m\u001b[31mx\u001b[23m` cannot close + // the italic without crossing the colour span, so the italic runs to the + // end of the message instead. Reading further than the sequence asked is + // the lesser of the two. + return /** @type {Open} */ (stack.pop()).closing; + } + + return ""; +} + +/** + * Turn the ANSI colours in some text into HTML. + * @param {string} text text that may carry SGR sequences + * @returns {string} the text, with its colours as markup + */ +export default function ansiHTML(text) { + // Nothing to do, and nothing to scan: a message without an escape in it is + // most messages. + if (text.indexOf("\u001B") === -1) { + return text; + } + + /** @type {Open[]} */ + const stack = []; + let result = text.replace(SGR, (match, parameters) => { + // `\u001b[m` is `\u001b[0m` written short. + const each = (parameters === "" ? "0" : parameters).split(";"); + let out = ""; + + for (let index = 0; index < each.length; index++) { + out += applyParameter(each[index], stack); + } + + return out; + }); + + // Whatever is still open, closed with its own tag and innermost first, so + // the markup cannot leak into the rest of the card. + while (stack.length > 0) { + result += /** @type {Open} */ (stack.pop()).closing; + } + + return result; +} diff --git a/package-lock.json b/package-lock.json index 4bc87d01e..1766b007a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,6 @@ "version": "8.3.0", "license": "MIT", "dependencies": { - "ansi-html-community": "^0.0.8", "memfs": "^4.68.2", "mime-types": "^3.0.2", "range-parser": "^1.3.0", @@ -8139,18 +8138,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/ansi-html-community": { - "version": "0.0.8", - "resolved": "https://registry.npmjs.org/ansi-html-community/-/ansi-html-community-0.0.8.tgz", - "integrity": "sha512-1APHAyr3+PCamwNw3bXCPp4HFLONZt/yIH0sZp0/469KWNTEy+qN5jQ3GVX6DMZ1UXAi34yVwtTeaG/HpBuuzw==", - "engines": [ - "node >= 0.8.0" - ], - "license": "Apache-2.0", - "bin": { - "ansi-html": "bin/ansi-html" - } - }, "node_modules/ansi-regex": { "version": "6.3.0", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.3.0.tgz", diff --git a/package.json b/package.json index c02eff193..a636feea4 100644 --- a/package.json +++ b/package.json @@ -84,7 +84,6 @@ "fix:schema-check": "node ./scripts/generate-schema-check.mjs" }, "dependencies": { - "ansi-html-community": "^0.0.8", "memfs": "^4.68.2", "mime-types": "^3.0.2", "range-parser": "^1.3.0", diff --git a/test/ansi-html.test.js b/test/ansi-html.test.js new file mode 100644 index 000000000..0cfcf3737 --- /dev/null +++ b/test/ansi-html.test.js @@ -0,0 +1,286 @@ +import ansiHTML, { setColors } from "../client-src/utils/ansi-html"; + +const E = "\u001B"; + +// Named, so a case reads as what it is rather than as a run of escapes — and +// so no code sits flush against the text it colours. +const BOLD = `${E}[1m`; +const NO_BOLD = `${E}[22m`; +const RED = `${E}[31m`; +const YELLOW = `${E}[33m`; +const GREY = `${E}[90m`; +const RED_BG = `${E}[41m`; +const NO_COLOR = `${E}[39m`; +const NO_BG = `${E}[49m`; +const RESET = `${E}[0m`; +const RESET_SHORT = `${E}[m`; +const INVERSE = `${E}[7m`; +const NO_INVERSE = `${E}[27m`; +const BOLD_RED = `${E}[1;31m`; +const UNKNOWN = `${E}[99m`; +const ITALIC = `${E}[3m`; +const NO_ITALIC = `${E}[23m`; +const UNDER = `${E}[4m`; +const NO_UNDER = `${E}[24m`; +const STRIKE = `${E}[9m`; +const NO_STRIKE = `${E}[29m`; + +// The palette the overlay uses. `reset` is `"transparent"` twice, which is how +// a palette says to leave the page's own colours alone. +const PALETTE = { + reset: ["transparent", "transparent"], + black: "181818", + red: "ff3348", + green: "3fff4f", + yellow: "ffd30e", + blue: "169be0", + magenta: "f840b7", + cyan: "0ad8e9", + lightgrey: "ebe7e3", + darkgrey: "6d7891", +}; + +// A loader colours its output, and those sequences travel in the error's +// message all the way to the overlay. This is the one part of the overlay with +// real edge cases — nesting, a sequence that never closes — and it had no test +// of its own while it was a dependency. +describe("ansi colours as html", () => { + beforeEach(() => { + setColors(PALETTE); + }); + + it("leaves text with no escapes exactly as it is", () => { + expect(ansiHTML("nothing to colour here")).toBe("nothing to colour here"); + }); + + it("colours a foreground sequence", () => { + expect(ansiHTML(`a ${RED}red${NO_COLOR} b`)).toBe( + 'a red b', + ); + }); + + it("nests a weight inside a colour", () => { + // What babel-loader actually emits: bold, then red, then each closed. + expect(ansiHTML(`${BOLD}${RED}boom${NO_COLOR}${NO_BOLD} tail`)).toBe( + '' + + 'boom tail', + ); + }); + + it("colours a background", () => { + expect(ansiHTML(`${RED_BG}bg${NO_BG}`)).toBe( + 'bg', + ); + }); + + it("uses the palette's own grey for the dim colour", () => { + expect(ansiHTML(`${GREY}dim${NO_COLOR}`)).toBe( + 'dim', + ); + }); + + it("opens the tags that are tags rather than styles", () => { + expect( + ansiHTML( + `${ITALIC}i${NO_ITALIC} ${UNDER}u${NO_UNDER} ${STRIKE}d${NO_STRIKE}`, + ), + ).toBe("i u d"); + }); + + it("closes what a sequence left open", () => { + // Otherwise the span leaks into the rest of the card. + expect(ansiHTML(`${RED}unclosed`)).toBe( + 'unclosed', + ); + }); + + it("drops a parameter it has nothing for", () => { + expect(ansiHTML(`${UNKNOWN}unknown${NO_COLOR}`)).toBe("unknown"); + }); + + it("closes nothing when a sequence repeats itself", () => { + // The second `31` closes the span the first opened, so the `39` that + // follows has nothing left to close. The package emitted its `` + // regardless, leaving an unmatched tag in the middle of the message. + expect(ansiHTML(`${RED}a${RED}b${NO_COLOR}c`)).toBe( + 'abc', + ); + }); + + // The package stacked parameters and closed with a hardcoded ``, so + // an element opened as a tag was closed as a span, and interleaved + // sequences crossed their tags. The highlighters wrap their own spans around + // this output afterwards, and crossed tags there take the card with them. + describe("closes what is actually open", () => { + it("closes an unclosed tag with its own tag", () => { + // Was `x`. + expect(ansiHTML(`${ITALIC}x`)).toBe("x"); + }); + + it("does not close a tag with a span's closer", () => { + // Was `x`, leaving the `` open for the rest of the card. + expect(ansiHTML(`${ITALIC}x${NO_COLOR}`)).toBe("x"); + }); + + it("keeps a colour inside a tag nested", () => { + // Was `x` — crossed. + expect(ansiHTML(`${ITALIC}${RED}x${NO_ITALIC}`)).toBe( + 'x', + ); + }); + + it("keeps a tag inside a colour nested", () => { + // Was `x`. + expect(ansiHTML(`${RED}${ITALIC}x${NO_COLOR}`)).toBe( + 'x', + ); + }); + + it("closes back through everything a repeat opened inside itself", () => { + expect(ansiHTML(`${RED}a${ITALIC}b${RED}c`)).toBe( + 'abc', + ); + }); + + it("never produces markup that does not nest", () => { + // Case by case only covers what someone thought of. This walks the + // output of every short combination and checks each closing tag matches + // the innermost thing still open. + const sequences = [ + ITALIC, + NO_ITALIC, + UNDER, + NO_UNDER, + RED, + NO_COLOR, + BOLD, + NO_BOLD, + RESET, + INVERSE, + RED_BG, + NO_BG, + ]; + + /** + * @param {string} html markup + * @returns {string[]} the tags left open, if any + */ + const unclosed = (html) => { + /** @type {string[]} */ + const open = []; + + for (const tag of html.match(/<\/?[a-z]+/g) || []) { + if (tag.charAt(1) === "/") { + const name = tag.slice(2); + + // A close with nothing open, or one that does not match what is + // innermost, is markup that does not nest. + if (open.pop() !== name) { + return [`mismatched ${tag}`]; + } + } else { + open.push(tag.slice(1)); + } + } + + return open; + }; + + for (const first of sequences) { + for (const second of sequences) { + for (const third of sequences) { + const input = `${first}a${second}b${third}c`; + + expect({ input, unclosed: unclosed(ansiHTML(input)) }).toEqual({ + input, + unclosed: [], + }); + } + } + } + }); + }); + + it("leaves an escape that is not a colour alone", () => { + // A screen clear is not this function's business, and mangling it would + // put an escape in front of the reader. + expect(ansiHTML(`before ${E}[2J after`)).toBe(`before ${E}[2J after`); + }); + + // The palette says `"transparent"`, which is not a colour. Written into a + // hex slot it produced `color:#transparent`, and the overlay's reset worked + // only because a browser drops an invalid declaration. + it("leaves out a declaration the palette has no colour for", () => { + expect(ansiHTML(`${YELLOW}warn${RESET} after`)).toBe( + 'warn' + + ' after', + ); + }); + + it("opens a span with no style at all rather than an empty one", () => { + // Inverse swaps the reset pair, and this palette has no colour in it. + expect(ansiHTML(`${INVERSE}inverse${NO_INVERSE}`)).toBe( + "inverse", + ); + }); + + // One sequence can carry several parameters. It used to match nothing, so + // the escape stayed in the output for the reader to see. + it("applies every parameter of one sequence", () => { + expect(ansiHTML(`${BOLD_RED}both${RESET}`)).toBe( + 'both' + + '', + ); + }); + + it("reads an empty parameter as a reset", () => { + // `\u001b[m` is how `\u001b[0m` is written short. + expect(ansiHTML(`${RESET_SHORT}reset`)).toBe( + 'reset', + ); + }); + + describe("with a palette of its own", () => { + it("takes a hex colour without its hash", () => { + setColors({ ...PALETTE, red: "00ff00" }); + + expect(ansiHTML(`${RED}green now${NO_COLOR}`)).toBe( + 'green now', + ); + }); + + it("takes a colour that is not hex as it is written", () => { + // The `#` is only added back to a hex value, so anything else a palette + // carries reaches the style as it was given. + setColors({ ...PALETTE, red: "rgb(1 2 3)" }); + + expect(ansiHTML(`${RED}written${NO_COLOR}`)).toBe( + 'written', + ); + }); + + it("takes a reset colour, when the palette names one", () => { + setColors({ ...PALETTE, reset: ["ffffff", "000000"] }); + + expect(ansiHTML(`${RESET}reset`)).toBe( + 'reset', + ); + }); + + it("takes a reset given as one colour", () => { + setColors({ ...PALETTE, reset: "ffffff" }); + + expect(ansiHTML(`${RESET}reset`)).toBe( + 'reset', + ); + }); + + it("falls back to black for a colour the palette left out", () => { + setColors({ reset: ["transparent", "transparent"] }); + + expect(ansiHTML(`${RED}no red${NO_COLOR}`)).toBe( + 'no red', + ); + }); + }); +}); diff --git a/types/client/utils/ansi-html.d.ts b/types/client/utils/ansi-html.d.ts new file mode 100644 index 000000000..4af903601 --- /dev/null +++ b/types/client/utils/ansi-html.d.ts @@ -0,0 +1,24 @@ +/** + * Build the tag tables from a palette. + * @param {Record} colors palette, hex without `#` + */ +export function setColors(colors: Record): void; +/** + * Turn the ANSI colours in some text into HTML. + * @param {string} text text that may carry SGR sequences + * @returns {string} the text, with its colours as markup + */ +export default function ansiHTML(text: string): string; +/** + * One element this opened, and the tag that closes it. + */ +export type Open = { + /** + * the parameter that opened it + */ + parameter: string; + /** + * the tag that closes it + */ + closing: string; +};