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 ? `${open.slice(1)}` : "",
+ });
+
+ 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;
+};