authorclover caruso <git@paperclover.net> 2026-10-14 02:08:36-07:00
committerclover caruso <git@paperclover.net> 2026-10-14 12:34:18-07:00
logf41985e407890a57c4171d43bc272abf7dd1e22f
tree45cd38683c0eeacb9b30ea6ab07fc406e556abf8
parentd3185018db6284979ba12696d8b8e34be6985f39
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU (~clover)

feat(lib/progress): attachToDocument and formatHtml

Beautiful Windows7 progress bars in the browser! Assisted-by: claude-opus-5.5

6 files changed, 620 insertions(+), 34 deletions(-)

lib/Events.test.ts created+19
...@@ -0,0 +1,19 @@
1test("disposing a listener leaves the others", () => {
2 const events = new Events<{ ping: [] }>();
3 const calls: string[] = [];
4 const offA = events.on("ping", () => calls.push("a"));
5 events.on("ping", () => calls.push("b"));
6 const offAny = events.onAny(() => calls.push("any a"));
7 events.onAny(() => calls.push("any b"));
8
9 offA();
10 offA();
11 offAny();
12 offAny();
13 events.emit("ping");
14 assert.deepEqual(calls, ["b", "any b"]);
15});
16
17import assert from "node:assert/strict";
18import { test } from "vitest";
19import { Events } from "./Events.ts";
lib/Events.ts+7-2
...@@ -31,7 +31,7 @@ export class Events<Map extends Events.Map> {...@@ -31,7 +31,7 @@ export class Events<Map extends Events.Map> {
31 on<C extends keyof Map>(channel: C, cb: Events.Handler<Map[C]>): ts.Dispose {31 on<C extends keyof Map>(channel: C, cb: Events.Handler<Map[C]>): ts.Dispose {
32 const list = this.#channels[channel] ??= [];32 const list = this.#channels[channel] ??= [];
33 list.push(cb);33 list.push(cb);
34 return ts.defer(() => void (list.splice(list.indexOf(cb)), 1));34 return ts.defer(() => remove(list, cb));
35 }35 }
36 /**36 /**
37 * listen for one event on the specified channel37 * listen for one event on the specified channel
...@@ -77,7 +77,7 @@ export class Events<Map extends Events.Map> {...@@ -77,7 +77,7 @@ export class Events<Map extends Events.Map> {
77 /** listen for any event */77 /** listen for any event */
78 onAny(cb: (channel: keyof Map, args: unknown[]) => void): ts.Dispose {78 onAny(cb: (channel: keyof Map, args: unknown[]) => void): ts.Dispose {
79 this.#global.push(cb);79 this.#global.push(cb);
80 return ts.defer(() => void (this.#global.splice(this.#global.indexOf(cb)), 1));80 return ts.defer(() => remove(this.#global, cb));
81 }81 }
82 /** emit an event on the specified channel */82 /** emit an event on the specified channel */
83 emit<C extends keyof Map>(channel: C, ...args: Map[C]): void {83 emit<C extends keyof Map>(channel: C, ...args: Map[C]): void {
...@@ -95,6 +95,11 @@ export class Events<Map extends Events.Map> {...@@ -95,6 +95,11 @@ export class Events<Map extends Events.Map> {
95 }95 }
96}96}
9797
98function remove<T>(list: T[], item: T) {
99 const i = list.indexOf(item);
100 if (i !== -1) list.splice(i, 1);
101}
102
98export namespace Events {103export namespace Events {
99 export type Map = { [key: string]: unknown[] };104 export type Map = { [key: string]: unknown[] };
100 export type Handler<Args extends unknown[]> = (...args: Args) => void;105 export type Handler<Args extends unknown[]> = (...args: Args) => void;
lib/progress.test.ts+127
...@@ -309,6 +309,132 @@ test("Ema tracks warmup samples without resetting the first sample", () => {...@@ -309,6 +309,132 @@ test("Ema tracks warmup samples without resetting the first sample", () => {
309 assert.ok(Math.abs(completion - 26_737.70491803279) < 1e-9);309 assert.ok(Math.abs(completion - 26_737.70491803279) < 1e-9);
310});310});
311311
312// ## html
313test("formatHtml draws visible nodes into a declarative shadow root", () => {
314 const root = new progress.Root();
315 const build = root.start("build <site>");
316 build.start("secret", { hidden: true });
317 root.start("empty group", { passive: true });
318
319 const html = progress.formatHtml(root.active);
320 assert.match(html, /^<template shadowrootmode="open"><style>[^]*<\/style><ol class=tree role=list>/);
321 assert.match(html, /<\/ol><\/template>$/);
322 assert(html.includes(">build &lt;site&gt;</span>"));
323 assert(!html.includes("secret"));
324 assert(!html.includes("empty group"));
325});
326
327test("bars carry their value, busy rings do not", () => {
328 const root = new progress.Root();
329 root.start("download", { total: 200, value: 50 });
330 root.start("connect");
331 root.start("done", { total: 3, value: 3 });
332
333 const [download, connect, done] = items(progress.formatHtml(root.active));
334 assert.match(download!, /^<li class="bar">/);
335 assert.match(
336 download!,
337 /role=progressbar aria-labelledby=(n\d+) aria-valuenow="50" aria-valuemax="200" style="--p:0.25">/,
338 );
339 assert.match(download!, /<span class=label id=n\d+>download<\/span>/);
340 assert.match(connect!, /^<li class="spin">/);
341 assert(!connect!.includes("aria-valuenow"));
342 // a full bar stops its glow
343 assert.match(done!, /^<li class="bar full">/);
344});
345
346test("counts split at the slash, so the total can be styled apart", () => {
347 const root = new progress.Root();
348 root.start("count", { total: 48, value: 31 });
349 root.start("unstarted", { total: 48 });
350 root.start("no total", { total: 48, value: 3, showTotal: false });
351 root.start("bytes", { units: "bytes", total: 2_370_000_000, value: 1_020_000_000 });
352 root.start("percent", { units: "percent", total: 7, value: 2 });
353 root.start("custom", { value: 42, valueFormatter: (value) => `${value} fps` });
354
355 assert.deepEqual(items(progress.formatHtml(root.active)).map(count), [
356 "<b>31</b><span>48</span>",
357 "<b></b><span></span>",
358 "<b>3</b><span></span>",
359 "<b>1.02GB</b><span>2.37GB</span>",
360 "<b>28.6%</b><span></span>",
361 "<b>42 fps</b><span></span>",
362 ]);
363});
364
365test("estimates count down, and only the latest in a subtree shows", () => {
366 vi.useFakeTimers({ now: 1_000_000_000 });
367 try {
368 const root = new progress.Root();
369 const parent = root.start("parent", { estimateCompletion: false });
370 parent.estimatedTime = Date.now() + 30_000;
371 const child = parent.start("child", { estimateCompletion: false });
372 child.estimatedTime = Date.now() + 124_000;
373
374 const [parentHtml, childHtml] = items(progress.formatHtml(root.active));
375 assert.match(parentHtml!, /<span class=eta><\/span>/);
376 assert.match(childHtml!, /<span class=eta>2m 4s left<\/span>/);
377 } finally {
378 vi.useRealTimers();
379 }
380});
381
382test("logs show their last three lines", () => {
383 const root = new progress.Root();
384 const node = root.start("job");
385 node.log.info("first");
386 node.log.write("par");
387 node.log.write("tial\n");
388 node.log.warn("careful");
389 node.log.error("broke\n at frame");
390
391 assert.equal(
392 logs(progress.formatHtml(root.active)),
393 "<div class=warn>careful</div><div class=error>broke</div><div class=more> at frame</div>",
394 );
395});
396
397test("partial writes join into lines of their own", () => {
398 const root = new progress.Root();
399 const node = root.start("job");
400 node.log.info("hello");
401 node.log.write("one ");
402 node.log.write("two\nthree");
403 node.log.scoped("db").info("<connected>");
404
405 assert.equal(
406 logs(progress.formatHtml(root.active)),
407 "<div class=raw>one two</div><div class=raw>three</div>"
408 + "<div class=info><span class=scope>db</span> &lt;connected&gt;</div>",
409 );
410});
411
412test("ansi styles become spans, and carry across lines of a message", () => {
413 const root = new progress.Root();
414 const node = root.start("job");
415 node.log.write(`${ansi.fgGreen}ok${ansi.fgReset} \x1b[1;38;5;196mhot\nstill hot${ansi.reset}\x1b[2K`);
416
417 assert.equal(
418 logs(progress.formatHtml(root.active)),
419 "<div class=raw><span style=\"color:var(--ansi2);\">ok</span> "
420 + "<span style=\"color:rgb(255 0 0);font-weight:bold;\">hot</span></div>"
421 + "<div class=raw><span style=\"color:rgb(255 0 0);font-weight:bold;\">still hot</span></div>",
422 );
423});
424
425/** the markup of each drawn node, in order */
426function items(html: string) {
427 return html.split("<li ").slice(1).map((item) => "<li " + item);
428}
429
430function count(item: string) {
431 return UNWRAP(/<span class=count>(.*?)<\/span><span class=eta>/.exec(item))[1];
432}
433
434function logs(html: string) {
435 return UNWRAP(/<div class=logs>(.*?)<\/div><\/li>/.exec(html))[1];
436}
437
312// ## internals438// ## internals
313test("event encoding round trip cases", async () => {439test("event encoding round trip cases", async () => {
314 const fakeNode = progress.nullNode;440 const fakeNode = progress.nullNode;
...@@ -408,6 +534,7 @@ test("event encoding round trip cases", async () => {...@@ -408,6 +534,7 @@ test("event encoding round trip cases", async () => {
408 l: [534 l: [
409 { level: "warn", text: "deprecated pixel format", time: 1768210850000 },535 { level: "warn", text: "deprecated pixel format", time: 1768210850000 },
410 { level: "info", text: "hello", time: 1768210851000 },536 { level: "info", text: "hello", time: 1768210851000 },
537 { level: "info", text: "partial ", time: 1768210851500, newline: false },
411 {538 {
412 level: "error",539 level: "error",
413 text: "scoped + stacked",540 text: "scoped + stacked",
lib/progress.ts+461-31
...@@ -2,8 +2,9 @@...@@ -2,8 +2,9 @@
2 * a progress reporting API that allows creating CLI spinners and progress bars2 * a progress reporting API that allows creating CLI spinners and progress bars
3 * that can be easily nested to visualize complex, parallel work. in addition3 * that can be easily nested to visualize complex, parallel work. in addition
4 * to providing rich feedback in the terminal, a headless {@linkcode Root} node4 * to providing rich feedback in the terminal, a headless {@linkcode Root} node
5 * can be constructed, which can be used to bind to {@link formatHtml|the DOM}5 * can be constructed, which can be used to bind to
6 * or transmit progress information {@link encodeStream|across a network}.6 * {@link attachToDocument|the DOM} or transmit progress information
7 * {@link encodeEventStream|across a network}.
7 *8 *
8 * the progress API takes advantage of the `using` keyword to make nodes9 * the progress API takes advantage of the `using` keyword to make nodes
9 * scoped. the convention is to make functions take a {@linkcode Ref|progress.Ref},10 * scoped. the convention is to make functions take a {@linkcode Ref|progress.Ref},
...@@ -636,14 +637,28 @@ function endNode<R, M extends Events.Map>(owner: Root<R, M>, state: Internal) {...@@ -636,14 +637,28 @@ function endNode<R, M extends Events.Map>(owner: Root<R, M>, state: Internal) {
636export function formatAnsi(now: number, list: readonly ReadOnlyNode[]): string {637export function formatAnsi(now: number, list: readonly ReadOnlyNode[]): string {
637 let out = "";638 let out = "";
638 for (const top of list) {639 for (const top of list) {
639 if (top.passive && !hasChildren(top)) continue;640 if (!isVisible(top)) continue;
640 out += renderAnsiMainLine(top, now, 0) + "\n";641 out += renderAnsiMainLine(top, now, 0) + "\n";
641 out += renderChildren(top, now, []);642 out += renderChildren(top, now, []);
642 }643 }
643 return out.trimEnd();644 return out.trimEnd();
644}645}
645646
647function isVisible(node: ReadOnlyNode): boolean {
648 return !node.hidden && (!node.passive || node.children.some(isVisible));
649}
650
651function remainingSeconds(node: ReadOnlyNode): number | null {
652 const { estimatedTime } = node;
653 if (!estimatedTime) return null;
654 const ms = estimatedTime - Date.now();
655 return ms > 1000 && !findLongerEstimate(node, estimatedTime)
656 ? Math.round(ms / 1000)
657 : null;
658}
659
646const spinnerFps = 12.5;660const spinnerFps = 12.5;
661const maxLogLines = 3;
647const box = { tee: "├─ ", line: "│ ", langle: "└─ " };662const box = { tee: "├─ ", line: "│ ", langle: "└─ " };
648const barChars = [" ", "▏", "▎", "▍", "▌", "▋", "▊", "▉"];663const barChars = [" ", "▏", "▎", "▍", "▌", "▋", "▊", "▉"];
649const fullBar = "█";664const fullBar = "█";
...@@ -651,13 +666,9 @@ const spinner = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇",...@@ -651,13 +666,9 @@ const spinner = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇",
651 .map((frame) => ansi.style(ansi.fgBlue, frame));666 .map((frame) => ansi.style(ansi.fgBlue, frame));
652667
653function renderAnsiMainLine(state: ReadOnlyNode, now: number, depth: number) {668function renderAnsiMainLine(state: ReadOnlyNode, now: number, depth: number) {
654 const { text, total, showTotal, value, estimatedTime } = state;669 const { text, total, showTotal, value } = state;
655 const dateNow = Date.now();670 const remaining = remainingSeconds(state);
656 const showEstimate = estimatedTime && !findLongerEstimate(state, estimatedTime);671 const estimate = remaining ? ", " + string.formatDurationLetters(remaining) : "";
657 const estimate = showEstimate && (estimatedTime > dateNow + 1000)
658 ? ", "
659 + string.formatDurationLetters(Math.round((estimatedTime - dateNow) / 1000))
660 : "";
661 const valueFormatted = state.valueFormatter(value, total);672 const valueFormatted = state.valueFormatter(value, total);
662 if (total && showTotal) {673 if (total && showTotal) {
663 return ansi.style(674 return ansi.style(
...@@ -675,30 +686,18 @@ function renderAnsiMainLine(state: ReadOnlyNode, now: number, depth: number) {...@@ -675,30 +686,18 @@ function renderAnsiMainLine(state: ReadOnlyNode, now: number, depth: number) {
675 + (valueFormatted ? `[${valueFormatted}] ` : "") + text);686 + (valueFormatted ? `[${valueFormatted}] ` : "") + text);
676}687}
677688
678function hasChildren(states: ReadOnlyNode): boolean {689function findLongerEstimate(state: ReadOnlyNode, estimatedTime: number): boolean {
679 return states.children.some((x) => !x.hidden && (!x.passive || hasChildren(x)));690 return state.children.some((child) =>
680}691 isVisible(child)
681692 && ((child.estimatedTime ?? 0) > estimatedTime || findLongerEstimate(child, estimatedTime))
682function findLongerEstimate(693 );
683 state: ReadOnlyNode,
684 estimatedTime: number,
685): boolean {
686 for (const child of state.children) {
687 if (child.hidden || (child.passive && !hasChildren(child))) continue;
688 if (child.estimatedTime != null && child.estimatedTime > estimatedTime) {
689 return true;
690 }
691 if (findLongerEstimate(child, estimatedTime)) return true;
692 }
693 return false;
694}694}
695695
696function renderChildren(state: ReadOnlyNode, now: number, depth: boolean[]) {696function renderChildren(state: ReadOnlyNode, now: number, depth: boolean[]) {
697 let maxHeight = 50; // TODO: flexible layout697 let maxHeight = 50; // TODO: flexible layout
698 let truncated = 0;698 let truncated = 0;
699 let out = "";699 let out = "";
700 let { children } = state;700 const children = state.children.filter(isVisible);
701 children = children.filter((x) => !x.hidden && (!x.passive || hasChildren(x)));
702 if (children.length === 0) return "";701 if (children.length === 0) return "";
703 for (let i = 0, { length } = children; i < length; i += 1) {702 for (let i = 0, { length } = children; i < length; i += 1) {
704 const child = UNWRAP(children[i]);703 const child = UNWRAP(children[i]);
...@@ -717,7 +716,7 @@ function renderChildren(state: ReadOnlyNode, now: number, depth: boolean[]) {...@@ -717,7 +716,7 @@ function renderChildren(state: ReadOnlyNode, now: number, depth: boolean[]) {
717 .join("")716 .join("")
718 .trim();717 .trim();
719 if (logLines) {718 if (logLines) {
720 for (const line of logLines.split("\n").slice(-Math.min(3, maxHeight))) {719 for (const line of logLines.split("\n").slice(-Math.min(maxLogLines, maxHeight))) {
721 item += left + (i === length - 1 && !truncated ? " " : box.line)720 item += left + (i === length - 1 && !truncated ? " " : box.line)
722 + " "721 + " "
723 + ansi.style(ansi.fgBrightBlack, ">")722 + ansi.style(ansi.fgBrightBlack, ">")
...@@ -829,6 +828,435 @@ function hasEstimate(nodes: readonly ReadOnlyNode[]): boolean {...@@ -829,6 +828,435 @@ function hasEstimate(nodes: readonly ReadOnlyNode[]): boolean {
829 return nodes.some((n) => n.estimatedTime != null || hasEstimate(n.children));828 return nodes.some((n) => n.estimatedTime != null || hasEstimate(n.children));
830}829}
831830
831/**
832 * draw `root` into the shadow root of `host`, after the Windows 7 progress bar
833 * and busy cursor, on the animation frame after each change, until disposed.
834 * the shadow root keeps the page and the tree from styling each other, and the
835 * tree follows the page's `color-scheme`. `host` must accept a shadow root,
836 * such as a `div`; one rendered by {@linkcode formatHtml} is taken over.
837 * ```ts
838 * const root = new progress.Root();
839 * const detach = progress.attachToDocument(root, document.getElementById("progress")!);
840 * await build(root);
841 * detach();
842 * ```
843 */
844export function attachToDocument<R, M extends Events.Map>(
845 root: Root<R, M>,
846 host: Element,
847): ts.Dispose {
848 const shadow = host.shadowRoot ?? host.attachShadow({ mode: "open" });
849 shadow.innerHTML = formatShadow("");
850 const tree = UNWRAP(shadow.lastElementChild);
851 const template = document.createElement("template");
852 const elements = new WeakMap<ReadOnlyNode, Element>();
853 const changedAt = new WeakMap<ReadOnlyNode, number>();
854 const staleLogs = new Set<ReadOnlyNode>();
855 let frame: number | null = null;
856 let tick: ts.Timer | undefined;
857 let tickIn = Infinity;
858
859 const redraw = () => void (frame ??= requestAnimationFrame(draw));
860 function draw() {
861 frame = null;
862 for (const node of staleLogs) {
863 const logs = elements.get(node)?.children[2];
864 if (logs) logs.innerHTML = formatLogs(node.logs);
865 }
866 staleLogs.clear();
867 clearTimeout(tick);
868 tickIn = Infinity;
869 sync(tree, root.active);
870 if (tickIn !== Infinity) tick = setTimeout(redraw, tickIn);
871 }
872 function sync(list: Element, nodes: readonly ReadOnlyNode[]) {
873 let next = list.firstElementChild;
874 for (const node of nodes) {
875 if (!isVisible(node)) continue;
876 // estimates show whole seconds, rounded, so each changes on a half second
877 const ms = (node.estimatedTime ?? 0) - Date.now();
878 if (ms > 0) tickIn = Math.min(tickIn, (ms + 500) % 1000 + 5);
879 let li = elements.get(node);
880 if (li) {
881 update(li, node, changedAt);
882 } else {
883 template.innerHTML = formatNode(node, "");
884 elements.set(node, li = UNWRAP(template.content.firstElementChild));
885 changedAt.set(node, performance.now());
886 }
887 if (li === next) next = li.nextElementSibling;
888 else list.insertBefore(li, next);
889 sync(UNWRAP(li.children[1]), node.children);
890 }
891 while (next) {
892 const stale = next;
893 next = next.nextElementSibling;
894 stale.remove();
895 }
896 }
897
898 const listeners = [
899 root.on("node-start", redraw),
900 root.on("node-change", (node, key) => {
901 if (key === "logs") staleLogs.add(node);
902 redraw();
903 }),
904 root.on("node-end", redraw),
905 ];
906 draw();
907 return ts.defer(() => {
908 for (const off of listeners) off();
909 if (frame !== null) cancelAnimationFrame(frame);
910 clearTimeout(tick);
911 shadow.replaceChildren();
912 });
913}
914
915/**
916 * the markup {@linkcode attachToDocument} draws for `list`, as a declarative
917 * shadow root to place inside the element it later takes over. browsers attach
918 * it only while parsing a document, such as a server rendered page.
919 */
920export function formatHtml(list: readonly ReadOnlyNode[]): string {
921 const format = (nodes: readonly ReadOnlyNode[]): string =>
922 nodes.filter(isVisible)
923 .map((node) => formatNode(node, format(node.children)))
924 .join("");
925 return `<template shadowrootmode="open">${formatShadow(format(list))}</template>`;
926}
927
928const formatShadow = (items: string) => `<style>${css}</style><ol class=tree role=list>${items}</ol>`;
929
930/** the parts of a node's markup that change after it is drawn */
931function fields(node: ReadOnlyNode) {
932 const fraction = node.showTotal && node.total > 0
933 ? math.clamp(node.value / node.total, 0, 1)
934 : null;
935 const remaining = remainingSeconds(node);
936 return {
937 className: fraction === null ? "spin" : fraction === 1 ? "bar full" : "bar",
938 fraction,
939 count: formatCount(node),
940 eta: remaining
941 ? string.formatDurationLetters(remaining).replace(/(?<=\D)(?=\d)/g, " ") + " left"
942 : "",
943 };
944}
945
946/** `attachToDocument` relies on this layout: row, children, logs */
947function formatNode(node: ReadOnlyNode, children: string): string {
948 const { className, fraction, count, eta } = fields(node);
949 const id = `n${node.key}`;
950 const value = fraction === null
951 ? ""
952 : ` aria-valuenow="${node.value}" aria-valuemax="${node.total}" style="--p:${fraction}"`;
953 return `<li class="${className}"><div class=row>`
954 + `<div class=indicator role=progressbar aria-labelledby=${id}${value}><i></i></div>`
955 + `<span class=count><b>${esc(count[0])}</b><span>${esc(count[1])}</span></span>`
956 + `<span class=eta>${eta}</span>`
957 + `<span class=label id=${id}>${esc(node.text)}</span>`
958 + `</div><ol role=list>${children}</ol><div class=logs>${formatLogs(node.logs)}</div></li>`;
959}
960
961/**
962 * bring `li` from {@linkcode formatNode} up to date, except its children and
963 * logs. `changedAt` holds when each bar last moved.
964 */
965function update(
966 li: Element,
967 node: ReadOnlyNode,
968 changedAt: WeakMap<ReadOnlyNode, number>,
969) {
970 const { className, fraction, count, eta } = fields(node);
971 const [indicator, counter, estimate, label] = UNWRAP(li.firstElementChild).children;
972 const [value, total] = UNWRAP(counter).children;
973 const progressbar = UNWRAP(indicator);
974 setAttribute(li, "class", className);
975 setText(UNWRAP(value), count[0]);
976 setText(UNWRAP(total), count[1]);
977 setText(UNWRAP(estimate), eta);
978 setText(UNWRAP(label), node.text);
979 if (fraction === null) {
980 for (const name of ["aria-valuenow", "aria-valuemax", "style"]) setAttribute(progressbar, name, null);
981 return;
982 }
983 const shown = math.clamp(
984 Number(progressbar.getAttribute("aria-valuenow")) / Number(progressbar.getAttribute("aria-valuemax")),
985 0,
986 1,
987 );
988 setAttribute(progressbar, "aria-valuenow", String(node.value));
989 setAttribute(progressbar, "aria-valuemax", String(node.total));
990 if (fraction === shown) return;
991 // a forward glide lasts as long as the last update took to arrive, so steady
992 // progress moves steadily; going back snaps, as the original does
993 const now = performance.now();
994 const glide = fraction > shown ? Math.min(now - (changedAt.get(node) ?? now), 1000) : 0;
995 changedAt.set(node, now);
996 setAttribute(progressbar, "style", `--p:${fraction};--glide:${Math.round(glide)}ms`);
997}
998
999function setAttribute(element: Element, name: string, value: string | null) {
1000 if (element.getAttribute(name) === value) return;
1001 if (value === null) element.removeAttribute(name);
1002 else element.setAttribute(name, value);
1003}
1004
1005function setText(element: Element, text: string) {
1006 if (element.textContent !== text) element.textContent = text;
1007}
1008
1009/** `[value, total]`, split at the formatter's slash so the total can be styled apart */
1010function formatCount({ value, total, showTotal, valueFormatter }: ReadOnlyNode): [string, string] {
1011 const text = valueFormatter(value, total);
1012 const slash = text.lastIndexOf("/");
1013 if (slash === -1) return [text, ""];
1014 return [text.slice(0, slash), showTotal ? text.slice(slash + 1) : ""];
1015}
1016
1017/** the last lines of `logs`. partial writes join into lines of their own. */
1018function formatLogs(logs: readonly log.Message[]): string {
1019 let start = logs.length;
1020 for (let breaks = 0; start > 0 && breaks <= maxLogLines;) {
1021 const { text, newline } = UNWRAP(logs[--start]);
1022 breaks += string.countNewlines(text) + (newline === false ? 0 : 1);
1023 }
1024 const lines: { kind: string; html: string }[] = [];
1025 let partial: { kind: string; html: string } | null = null;
1026 for (const { text, newline, level = "info", scope } of logs.slice(start)) {
1027 const style = plainStyle();
1028 text.split("\n").forEach((part, i) => {
1029 const html = ansiLineToHtml(part, style);
1030 if (newline !== false) {
1031 // a message starts its own line, so a write's trailing newline leaves no blank one
1032 if (i === 0 && partial?.html === "") lines.pop();
1033 const prefix = i === 0 && scope ? `<span class=scope>${esc(scope)}</span> ` : "";
1034 lines.push({ kind: i === 0 ? level : "more", html: prefix + html });
1035 } else if (i === 0 && partial) {
1036 partial.html += html;
1037 } else {
1038 lines.push(partial = { kind: "raw", html });
1039 }
1040 });
1041 if (newline !== false) partial = null;
1042 }
1043 while (lines.at(-1)?.html === "") lines.pop();
1044 return lines.slice(-maxLogLines)
1045 .map(({ kind, html }) => `<div class=${kind}>${html}</div>`)
1046 .join("");
1047}
1048
1049interface Style {
1050 fg: string;
1051 bg: string;
1052 weight: string;
1053 italic: boolean;
1054 underline: boolean;
1055}
1056const plainStyle = (): Style => ({ fg: "", bg: "", weight: "", italic: false, underline: false });
1057
1058const escapes = /\x1b(?:\[([\x30-\x3f]*)[\x20-\x2f]*([\x40-\x7e])|\][^\x07\x1b]*(?:\x07|\x1b\\)?|.)/g;
1059
1060/** one line of ansi text as html. `style` carries over, so a style may span lines. */
1061function ansiLineToHtml(text: string, style: Style): string {
1062 let html = "";
1063 let last = 0;
1064 for (const match of text.matchAll(escapes)) {
1065 html += formatRun(text.slice(last, match.index), style);
1066 last = match.index + match[0].length;
1067 if (match[2] === "m") applySgr(style, (match[1] || "0").split(/[;:]/).map(Number));
1068 }
1069 return html + formatRun(text.slice(last), style);
1070}
1071
1072function formatRun(text: string, { fg, bg, weight, italic, underline }: Style) {
1073 if (!text) return "";
1074 const css = (fg && `color:${fg};`) + (bg && `background:${bg};`) + weight
1075 + (italic ? "font-style:italic;" : "") + (underline ? "text-decoration:underline;" : "");
1076 return css ? `<span style="${css}">${esc(text)}</span>` : esc(text);
1077}
1078
1079function applySgr(style: Style, codes: number[]) {
1080 for (let i = 0; i < codes.length; i += 1) {
1081 const code = UNWRAP(codes[i]);
1082 if (code === 0) Object.assign(style, plainStyle());
1083 else if (code === 1) style.weight = "font-weight:bold;";
1084 else if (code === 2) style.weight = "opacity:.65;";
1085 else if (code === 22) style.weight = "";
1086 else if (code === 3 || code === 23) style.italic = code === 3;
1087 else if (code === 4 || code === 24) style.underline = code === 4;
1088 else if (code === 39) style.fg = "";
1089 else if (code === 49) style.bg = "";
1090 else if ((code >= 30 && code <= 37) || (code >= 90 && code <= 97)) {
1091 style.fg = `var(--ansi${code % 10 + (code >= 90 ? 8 : 0)})`;
1092 } else if ((code >= 40 && code <= 47) || (code >= 100 && code <= 107)) {
1093 style.bg = `var(--ansi${code % 10 + (code >= 100 ? 8 : 0)})`;
1094 } else if (code === 38 || code === 48) {
1095 const indexed = codes[i + 1] === 5;
1096 const [r = 0, g = 0, b = 0] = codes.slice(i + 2, i + 5);
1097 const color = indexed ? color256(r) : `rgb(${r} ${g} ${b})`;
1098 i += indexed ? 2 : 4;
1099 if (code === 38) style.fg = color;
1100 else style.bg = color;
1101 }
1102 }
1103}
1104
1105function color256(n: number) {
1106 if (n < 16) return `var(--ansi${n})`;
1107 if (n >= 232) return `rgb(${Array(3).fill(8 + (n - 232) * 10).join(" ")})`;
1108 n -= 16;
1109 const level = (c: number) => c && 55 + c * 40;
1110 return `rgb(${level(Math.floor(n / 36))} ${level(Math.floor(n / 6) % 6)} ${level(n % 6)})`;
1111}
1112
1113const esc = string.escapeHtmlContent;
1114
1115/** inline svg for a background image */
1116const svg = (body: string) =>
1117 `url("data:image/svg+xml,${
1118 encodeURIComponent(`<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12'>${body}</svg>`)
1119 }")`;
1120
1121// light-dark() resolves against `color-scheme`, which is inherited from the
1122// host; `all: initial` keeps every other inherited property out.
1123const css = /* @__PURE__ */ (() => `
1124:host{display:block}
1125.tree{all:initial;display:block;color-scheme:inherit;container-type:inline-size;
1126 font:12px/23px "Segoe UI",system-ui,sans-serif;color:light-dark(#1e1e1e,#e3e5e8);
1127 --line:light-dark(#a9b3bd,#5a626c);--muted:light-dark(#56616c,#a4aeb9);
1128 --ansi0:light-dark(#000,#666);--ansi1:#cd3131;--ansi2:light-dark(#00a000,#0dbc79);
1129 --ansi3:light-dark(#949800,#e5e510);--ansi4:light-dark(#0451a5,#2472c8);--ansi5:#bc3fbc;
1130 --ansi6:light-dark(#0598bc,#11a8cd);--ansi7:light-dark(#555,#e5e5e5);--ansi8:#666;
1131 --ansi9:light-dark(#cd3131,#f14c4c);--ansi10:light-dark(#14ce14,#23d18b);
1132 --ansi11:light-dark(#b5ba00,#f5f543);--ansi12:light-dark(#0451a5,#3b8eea);
1133 --ansi13:light-dark(#bc05bc,#d670d6);--ansi14:light-dark(#0598bc,#29b8db);
1134 --ansi15:light-dark(#a5a5a5,#e5e5e5)}
1135ol{margin:0;padding:0}
1136li{display:flow-root;position:relative}
1137.tree>li+li{margin-top:4px}
1138.row{display:flex;flex-wrap:wrap;align-items:center;column-gap:6px;min-height:23px;white-space:nowrap}
1139.label{overflow:hidden;text-overflow:ellipsis;min-width:0}
1140.tree>li>.row>.label{font-size:13px;color:light-dark(#039,#8fb4ff)}
1141.count,.eta{flex:none;margin:4px 0;padding:0 6px;border-radius:8px;font-size:11px;line-height:15px;
1142 font-variant-numeric:tabular-nums;color:var(--muted);
1143 background:linear-gradient(light-dark(#fdfeff,#363b43),light-dark(#edf1f5,#2b3037));
1144 box-shadow:inset 0 0 0 1px light-dark(#d5dce4,#40474f)}
1145.count:has(>b:empty),.eta:empty{display:none}
1146.count>b{font-weight:600;color:light-dark(#2c3640,#d5dbe1)}
1147.count>span:not(:empty)::before{content:"/";margin:0 .2em;opacity:.55}
1148
1149/* bars lead the row, wider at the top level, as in the terminal. the track is
1150 a 1px frame, 6px of gloss, a stretched body, and 5px of bottom light */
1151.tree>li{--bar:176}
1152li li{--bar:96}
1153@container (width < 400px){.tree>li{--bar:112}li li{--bar:64}}
1154.bar>.row>.indicator{position:relative;flex:none;box-sizing:border-box;width:calc(var(--bar) * 1px);
1155 height:15px;margin:4px 0;border:1px solid transparent;border-radius:2px;
1156 background:
1157 linear-gradient(light-dark(#f3f3f3,#34383e),light-dark(#fcfcfc,#3b3f45) 1.5px,
1158 light-dark(#dbdbdb,#2f3237) 6px,light-dark(#cacaca,#26292d) 6px,
1159 light-dark(#cacaca,#26292d) calc(100% - 5px),light-dark(#d5d5d5,#2b2e33) calc(100% - 1px),
1160 light-dark(#eaeaea,#33373c)) padding-box,
1161 linear-gradient(light-dark(#b2b2b2,#4b5058) 0 1px,light-dark(#bdbdbd,#464b53) 1px,
1162 light-dark(#919191,#393d44) calc(100% - 1px),light-dark(#8c8c8c,#393d44) calc(100% - 1px))
1163 border-box}
1164.indicator>i{position:absolute;top:0;bottom:0;left:0;width:calc(var(--p) * 100%);overflow-x:clip;
1165 --gloss:#d2f1fc 1.5px,#addcf3 3.5px,#9ad3ef;--body:#0794d1;--rest:#1ea0d8;--edge:#035f8f;
1166 --glow:160 225 255;--shade:color-mix(in srgb,var(--edge) 40%,#0000);
1167 background:
1168 linear-gradient(90deg,#0000 1px,var(--shade) 1px,var(--shade) 5px,#0000 27px),
1169 linear-gradient(270deg,#0000 1px,var(--shade) 1px,#0000 14px),
1170 linear-gradient(var(--gloss) 6px,var(--body) 6px,var(--body) calc(100% - 5px),var(--rest));
1171 transition:width var(--glide,0s) linear}
1172/* a glow sweeps the fill at one speed for every bar, over the frame too, while
1173 the bottom light swells under it */
1174.indicator>i::before{content:"";position:absolute;left:0;right:0;bottom:0;height:5px;
1175 background:linear-gradient(#0000,rgb(var(--glow)/.6));animation:swell 2.4s ease-in-out infinite}
1176.indicator>i::after{content:"";position:absolute;top:-1px;bottom:-1px;left:0;width:64px;
1177 background:linear-gradient(90deg,#0000,rgb(var(--glow)/.03) 8.3%,rgb(var(--glow)/.08) 16.7%,
1178 rgb(var(--glow)/.2) 25%,rgb(var(--glow)/.36) 33.3%,rgb(var(--glow)/.53) 41.7%,
1179 rgb(var(--glow)/.6),rgb(var(--glow)/.53) 58.3%,rgb(var(--glow)/.36) 66.7%,
1180 rgb(var(--glow)/.2) 75%,rgb(var(--glow)/.08) 83.3%,rgb(var(--glow)/.03) 91.7%,#0000);
1181 animation:glow 2.4s linear infinite}
1182@keyframes glow{from{translate:-100%}50%,to{translate:176px}}
1183@keyframes swell{from{opacity:.4}25%{opacity:1}65%,88%{opacity:0}to{opacity:.4}}
1184:is(.full,li:has(>.logs>:is(.warn,.error)))>.row>.indicator>i::before,
1185:is(.full,li:has(>.logs>:is(.warn,.error)))>.row>.indicator>i::after{content:none}
1186li:has(>.logs>.warn)>.row>.indicator>i{
1187 --gloss:#fffdcd 1.5px,#fffba1 3.5px,#fffa8b;--body:#d2ca00;--rest:#fef400;--edge:#8f8600}
1188li:has(>.logs>.error)>.row>.indicator>i{
1189 --gloss:#ffcdcd 1.5px,#ffa1a1 3.5px,#ff8b8b;--body:#d20000;--rest:#fe0000;--edge:#8f0000}
1190
1191/* the busy ring leads a top-level row, lit from above, with a highlight
1192 running clockwise; below the top level the terminal shows no spinner */
1193.spin>.row>.indicator{position:relative;flex:none;width:15px;height:15px;margin:4px 0;border-radius:50%;
1194 background:conic-gradient(#9bc6da,#8dc2d0 30deg,#6daebd 60deg,#2b8982 90deg,#319f9d 120deg,
1195 #4dc7bb 150deg,#5ecfc5 180deg,#4fc6bb 210deg,#2da09e 240deg,#238382 270deg,#6db2bb 300deg,
1196 #91c5d1 330deg,#9bc6da);
1197 mask:radial-gradient(closest-side,#0000 61%,#000 67%)}
1198li li.spin>.row>.indicator{display:none}
1199.spin>.row>.indicator::before,.spin>.row>.indicator::after{content:"";position:absolute;inset:0;border-radius:50%}
1200.spin>.row>.indicator::before{animation:spin .9s linear infinite;
1201 background:conic-gradient(from -150deg,#0000,#2fd4dc99 40deg,#4fe6eae6 90deg,#8ff3f4 125deg,
1202 #e8fcfd 150deg,#b4eef0b3 162deg,#0000 180deg)}
1203.spin>.row>.indicator::after{background:radial-gradient(closest-side,#00323c73 64%,#00323c00 76%,
1204 #fff3 86%,#00323c00 92%,#00323c80)}
1205.spin>.row>.indicator>i{display:none}
1206@keyframes spin{to{rotate:1turn}}
1207
1208/* tree lines: a node's children hang from the start of its row, and each child
1209 is drawn with the terminal's ├─, │ and └─ */
1210li li{padding-left:22px}
1211li>ol{position:relative}
1212li>ol:not(:empty)::before{content:"";position:absolute;left:7px;top:-4px;height:5px;
1213 border-left:1px solid var(--line)}
1214li li::before,li li::after{content:"";position:absolute;left:7px;border:0 solid var(--line)}
1215li li::before{top:0;bottom:-1px;border-left-width:1px}
1216li li::after{top:11px;width:11px;border-top-width:1px}
1217li li:last-child::before{bottom:auto;height:12px}
1218
1219.logs{margin:1px 0 4px;padding:3px 8px;border-radius:4px;
1220 background:light-dark(#f3f5f8,#24282e);box-shadow:inset 0 0 0 1px light-dark(#e1e6ec,#31363d);
1221 font:11px/16px ui-monospace,"Cascadia Mono",Consolas,"SF Mono",Menlo,monospace;color:var(--muted)}
1222.logs:empty{display:none}
1223.logs>div{overflow:hidden;text-overflow:ellipsis;white-space:pre}
1224.logs>.more{padding-left:16px}
1225.logs>.debug{opacity:.7}
1226.logs>:is(.info,.warn,.error)::before{content:"";display:inline-block;width:11px;height:11px;
1227 margin-right:5px;vertical-align:-2px;background:center/contain no-repeat}
1228.logs>.info::before{background-image:${
1229 svg(
1230 "<defs><radialGradient id='g' cx='.35' cy='.3' r='.8'><stop stop-color='#7cbaf6'/><stop offset='1' stop-color='#1557b8'/></radialGradient></defs><circle cx='6' cy='6' r='5.5' fill='url(#g)' stroke='#0c3f8c' stroke-width='.6'/><rect x='5.25' y='5' width='1.5' height='4.2' rx='.5' fill='#fff'/><circle cx='6' cy='3.3' r='.9' fill='#fff'/>",
1231 )
1232}}
1233.logs>.warn::before{background-image:${
1234 svg(
1235 "<defs><linearGradient id='g' x2='0' y2='1'><stop stop-color='#ffe680'/><stop offset='1' stop-color='#f0b400'/></linearGradient></defs><path d='M6 .8l5.4 10H.6z' stroke-linejoin='round' fill='url(#g)' stroke='#b58300' stroke-width='.7'/><rect x='5.3' y='4' width='1.4' height='3.8' rx='.5' fill='#333'/><circle cx='6' cy='9.2' r='.8' fill='#333'/>",
1236 )
1237}}
1238.logs>.error::before{background-image:${
1239 svg(
1240 "<defs><radialGradient id='g' cx='.35' cy='.3' r='.8'><stop stop-color='#ff8a80'/><stop offset='1' stop-color='#c41a1a'/></radialGradient></defs><circle cx='6' cy='6' r='5.5' fill='url(#g)' stroke='#8a0f0f' stroke-width='.6'/><path d='M4 4l4 4m0-4l-4 4' stroke='#fff' stroke-width='1.5' stroke-linecap='round'/>",
1241 )
1242}}
1243.scope{color:light-dark(#7a6a9e,#b5a6d8)}
1244
1245@media (prefers-reduced-motion:reduce){
1246 .indicator>i{transition:none}
1247 .indicator>i::before,.indicator>i::after{content:none}
1248 .spin>.row>.indicator::before{animation:pulse 1.8s ease-in-out infinite alternate}
1249}
1250@keyframes pulse{from{opacity:.25}}
1251@media (forced-colors:active){
1252 .indicator{forced-color-adjust:none}
1253 .bar>.row>.indicator{background:Canvas;border-color:CanvasText}
1254 .indicator>i{background:Highlight}
1255 .indicator>i::before,.indicator>i::after{content:none}
1256 .spin>.row>.indicator{background:Highlight}
1257}
1258`)();
1259
832const header = /* @__PURE__ */ string.encodeUtf8("clover's progress <3\n");1260const header = /* @__PURE__ */ string.encodeUtf8("clover's progress <3\n");
8331261
834/** the 'Content-Type' of {@linkcode encodeByteStream}'s return value */1262/** the 'Content-Type' of {@linkcode encodeByteStream}'s return value */
...@@ -1524,6 +1952,7 @@ function writeStreamEvent(...@@ -1524,6 +1952,7 @@ function writeStreamEvent(
1524 if (level === -1) level = 0;1952 if (level === -1) level = 0;
1525 w.u8(1953 w.u8(
1526 level1954 level
1955 + (msg.newline === false ? 1 << 4 : 0)
1527 + (msg.scope ? 1 << 5 : 0)1956 + (msg.scope ? 1 << 5 : 0)
1528 + (msg.stack && msg.stack.length > 0 ? 1 << 6 : 0)1957 + (msg.stack && msg.stack.length > 0 ? 1 << 6 : 0)
1529 + (msg.custom !== undefined ? 1 << 7 : 0),1958 + (msg.custom !== undefined ? 1 << 7 : 0),
...@@ -1543,7 +1972,6 @@ function writeStreamEvent(...@@ -1543,7 +1972,6 @@ function writeStreamEvent(
1543 if (msg.custom !== undefined) {1972 if (msg.custom !== undefined) {
1544 w.stringWithLength(JSON.stringify(msg.custom));1973 w.stringWithLength(JSON.stringify(msg.custom));
1545 }1974 }
1546 // TODO: serialize newline attribute
1547 }1975 }
1548 }1976 }
1549 if (childrenSet !== undefined) {1977 if (childrenSet !== undefined) {
...@@ -1655,6 +2083,7 @@ async function readStreamEvent(r: stream.BufferedReader): Promise<StreamEvent> {...@@ -1655,6 +2083,7 @@ async function readStreamEvent(r: stream.BufferedReader): Promise<StreamEvent> {
1655 // bitwise tests: `msgFlags && (1 << 5) > 0` parsed as2083 // bitwise tests: `msgFlags && (1 << 5) > 0` parsed as
1656 // `msgFlags && true`, which fabricated scope/stack/custom reads for2084 // `msgFlags && true`, which fabricated scope/stack/custom reads for
1657 // any message with a non-zero level and desynced the entire stream2085 // any message with a non-zero level and desynced the entire stream
2086 const partial = (msgFlags & (1 << 4)) !== 0;
1658 const hasScope = (msgFlags & (1 << 5)) !== 0;2087 const hasScope = (msgFlags & (1 << 5)) !== 0;
1659 const hasStack = (msgFlags & (1 << 6)) !== 0;2088 const hasStack = (msgFlags & (1 << 6)) !== 0;
1660 const hasCustom = (msgFlags & (1 << 7)) !== 0;2089 const hasCustom = (msgFlags & (1 << 7)) !== 0;
...@@ -1681,7 +2110,7 @@ async function readStreamEvent(r: stream.BufferedReader): Promise<StreamEvent> {...@@ -1681,7 +2110,7 @@ async function readStreamEvent(r: stream.BufferedReader): Promise<StreamEvent> {
1681 const custom = hasCustom2110 const custom = hasCustom
1682 ? JSON.parse(await r.stringWithLength())2111 ? JSON.parse(await r.stringWithLength())
1683 : undefined;2112 : undefined;
1684 l.push({ level, text, time, scope, stack, custom });2113 l.push({ level, text, time, scope, stack, custom, newline: partial ? false : undefined });
1685 }2114 }
1686 }2115 }
1687 let c: undefined | EncodedKey[] = undefined;2116 let c: undefined | EncodedKey[] = undefined;
...@@ -1945,6 +2374,7 @@ import { Events } from "./Events.ts";...@@ -1945,6 +2374,7 @@ import { Events } from "./Events.ts";
1945import * as exceptions from "./exception.ts";2374import * as exceptions from "./exception.ts";
1946import * as log from "./log.ts";2375import * as log from "./log.ts";
1947import * as stack from "./log/stack.ts";2376import * as stack from "./log/stack.ts";
2377import * as math from "./math.ts";
1948import * as node from "./node.ts";2378import * as node from "./node.ts";
1949import * as stream from "./stream.ts";2379import * as stream from "./stream.ts";
1950import * as string from "./string.ts";2380import * as string from "./string.ts";
lib/readme.changes.md+5
...@@ -19,6 +19,8 @@...@@ -19,6 +19,8 @@
19 - finally fixes resize corruptions for long running widgets19 - finally fixes resize corruptions for long running widgets
20- `string/ansi`: add `inverse`, `resetInverse`, and `cursorHome`.20- `string/ansi`: add `inverse`, `resetInverse`, and `cursorHome`.
21- `meta` auto-embed will now set `twitter:url`.21- `meta` auto-embed will now set `twitter:url`.
22- `progress.attachToDocument` draws progress in the browser, and
23 `progress.formatHtml` renders the same markup for server rendering.
2224
23### fixes25### fixes
2426
...@@ -26,6 +28,9 @@...@@ -26,6 +28,9 @@
26 caller error handling.28 caller error handling.
27- `progress`: `StartOptions.messages` is no longer ignored, so a decoded stream29- `progress`: `StartOptions.messages` is no longer ignored, so a decoded stream
28 keeps the logs that a node had when it started.30 keeps the logs that a node had when it started.
31- `progress`: the byte stream keeps partial writes (`node.log.write`) apart from
32 log messages, and the terminal no longer draws a hidden top-level node.
33- `Events`: disposing a listener no longer removes the listeners added after it.
2934
30## v735## v7
3136
lib/readme.md+1-1
...@@ -25,7 +25,7 @@ depend on each other. there are no external dependencies; code is easy to audit....@@ -25,7 +25,7 @@ depend on each other. there are no external dependencies; code is easy to audit.
25- `math` - trivial helpers25- `math` - trivial helpers
26- `mime` - trimmed database of ext -> mime types26- `mime` - trimmed database of ext -> mime types
27- `node` - the Node.js javascript runtime27- `node` - the Node.js javascript runtime
28- `progress` - cli progress bars, nesting status indication28- `progress` - cli and dom progress bars, nesting status indication
29- `queue` - priority queue with rescheduling29- `queue` - priority queue with rescheduling
30- `repl` - interactive prompt for long running programs30- `repl` - interactive prompt for long running programs
31- `stream` - ReadableStream helpers31- `stream` - ReadableStream helpers