| author | |
| committer | |
| log | 6b198b1e34ecf21148334df6eb9d79edf857b4db |
| tree | d040e191b916154458d0fda0ffed526bd76dbf21 |
| parent | 82917100494eb8ac5a92d06fbc0dddadac986cef |
| signature | Commit is signed but in an unrecognized format. |
15 files changed, 173 insertions(+), 48 deletions(-)
lib/Events.ts+30-8| ... | ... | @@ -50,16 +50,28 @@ export class Events<Map extends Events.Map> { |
| 50 | 50 | channel: C, |
| 51 | 51 | cb?: Events.Handler<Map[C]>, |
| 52 | 52 | ): async.Cancelable<Map[C]> { |
| 53 | let dispose: ts.Dispose | null = null; | |
| 53 | let disposeEvent: ts.Dispose | null = null; | |
| 54 | let disposeError: ts.Dispose | null = null; | |
| 55 | function dispose() { | |
| 56 | if (!disposeEvent) return; | |
| 57 | disposeEvent?.(); | |
| 58 | UNWRAP(disposeError)(); | |
| 59 | disposeEvent = disposeError = null; | |
| 60 | } | |
| 54 | 61 | return async.makeCancelable( |
| 55 | new Promise((resolve) => | |
| 56 | dispose = this.on(channel, (...args) => { | |
| 57 | UNWRAP(dispose)(); | |
| 62 | new Promise((resolve, reject) => { | |
| 63 | disposeEvent = this.on(channel, (...args) => { | |
| 64 | dispose(); | |
| 58 | 65 | resolve(args); |
| 59 | 66 | cb?.(...args); |
| 60 | }) | |
| 61 | ), | |
| 62 | () => dispose?.(), | |
| 67 | }); | |
| 68 | disposeError = this.on(channel, (...args) => { | |
| 69 | dispose(); | |
| 70 | reject(args); | |
| 71 | cb?.(...args); | |
| 72 | }); | |
| 73 | }), | |
| 74 | () => dispose(), | |
| 63 | 75 | ); |
| 64 | 76 | } |
| 65 | 77 | /** listen for any event */ |
| ... | ... | @@ -71,7 +83,17 @@ export class Events<Map extends Events.Map> { |
| 71 | 83 | } |
| 72 | 84 | /** emit an event on the specified channel */ |
| 73 | 85 | emit<C extends keyof Map>(channel: C, ...args: Map[C]): void { |
| 74 | this.#channels[channel]?.forEach((cb) => cb(...args)); | |
| 86 | const handlers = this.#channels[channel]; | |
| 87 | if (handlers) handlers.forEach((cb) => cb(...args)); | |
| 88 | else if (channel === "error") { | |
| 89 | this.#global?.forEach((cb) => cb(channel, args)); | |
| 90 | throw args.length === 0 | |
| 91 | ? new Error("Error event emitted") | |
| 92 | : args.length === 1 | |
| 93 | ? args[0] | |
| 94 | : new AggregateError(args); | |
| 95 | } | |
| 96 | this.#global?.forEach((cb) => cb(channel, args)); | |
| 75 | 97 | } |
| 76 | 98 | } |
| 77 | 99 |
lib/Lru.ts+6-4| ... | ... | @@ -2,10 +2,12 @@ |
| 2 | 2 | * Least-recently-used cache. |
| 3 | 3 | * This module is intended to be imported via the main class. |
| 4 | 4 | * |
| 5 | * import { Lru } from '@clo/lib/Lru'; | |
| 6 | * // Extra types are in a `declare namespace` | |
| 7 | * const opts: Lru.Options = { capacity: 2 }; | |
| 8 | * const lru = new Lru(opts); | |
| 5 | * ```ts | |
| 6 | * import { Lru } from '@clo/lib/Lru'; | |
| 7 | * // Extra types are in a `declare namespace` | |
| 8 | * const opts: Lru.Options = { capacity: 2 }; | |
| 9 | * const lru = new Lru(opts); | |
| 10 | * ``` | |
| 9 | 11 | * |
| 10 | 12 | * @module |
| 11 | 13 | */ |
lib/assert.ts+4-9| ... | ... | @@ -3,6 +3,7 @@ |
| 3 | 3 | * intended to be imported by symbol. |
| 4 | 4 | * @module |
| 5 | 5 | */ |
| 6 | /* node:coverage disable */ | |
| 6 | 7 | |
| 7 | 8 | /** assert that `condition` is truthy. */ |
| 8 | 9 | export function ASSERT(condition: unknown, msg?: Message): asserts condition { |
| ... | ... | @@ -21,14 +22,6 @@ export function UNWRAP<T>(nullable: T | null | undefined, msg?: Message): T { |
| 21 | 22 | return nullable; |
| 22 | 23 | } |
| 23 | 24 | |
| 24 | /** assert that `number` is not NaN. */ | |
| 25 | export function FINITE(number: number, msg?: Message): number { | |
| 26 | if (Number.isNaN(number)) { | |
| 27 | throw new Error(`Unexpected NaN${msg ? `: ${messageToString(msg)}` : ""}`); | |
| 28 | } | |
| 29 | return number; | |
| 30 | } | |
| 31 | ||
| 32 | 25 | /** assert that a `default` case in a switch is never reached. */ |
| 33 | 26 | export function unreachableCase(value: never): never { |
| 34 | 27 | throw new Error(`Unexpected value in switch: ${inspect(value as unknown)}`); |
| ... | ... | @@ -44,8 +37,10 @@ function messageToString(msg: Message) { |
| 44 | 37 | |
| 45 | 38 | function inspect(object: unknown) { |
| 46 | 39 | try { |
| 47 | return require("node:util").inspect(object); | |
| 40 | return node.builtin("util")?.inspect(object) ?? typeof object; | |
| 48 | 41 | } catch { |
| 49 | 42 | return typeof object; |
| 50 | 43 | } |
| 51 | 44 | } |
| 45 | ||
| 46 | import * as node from "./node.ts"; |
lib/async.ts+5-3| ... | ... | @@ -99,7 +99,7 @@ export class Watch<T> { |
| 99 | 99 | |
| 100 | 100 | until(condition: (v: T) => boolean): Promise<T> { |
| 101 | 101 | if (condition(this.#value)) return Promise.resolve(this.#value); |
| 102 | return new Promise((resolve) => { | |
| 102 | return new Promise<T>((resolve) => { | |
| 103 | 103 | function check(next: T) { |
| 104 | 104 | if (condition(next)) resolve(next), release(); |
| 105 | 105 | } |
| ... | ... | @@ -220,12 +220,14 @@ export class PromiseAggregator { |
| 220 | 220 | } |
| 221 | 221 | |
| 222 | 222 | /** a promise that can be cancelled. */ |
| 223 | export type Cancelable<T> = Promise<T> & Disposable & { cancel: VoidFunction }; | |
| 223 | export type Cancelable<T> = Promise<T> & Disposable & { | |
| 224 | cancel: (reason?: unknown) => void; | |
| 225 | }; | |
| 224 | 226 | |
| 225 | 227 | /** make an existing promise cancelable. */ |
| 226 | 228 | export function makeCancelable<T>( |
| 227 | 229 | promise: Promise<T>, |
| 228 | cancel: ts.VoidFunction, | |
| 230 | cancel: (reason?: unknown) => void, | |
| 229 | 231 | ): Cancelable<T> { |
| 230 | 232 | const p = promise as Cancelable<T>; |
| 231 | 233 | p[Symbol.dispose] = cancel; |
lib/mime.ts+5| ... | ... | @@ -1,3 +1,8 @@ |
| 1 | /** | |
| 2 | * a small mime type library. | |
| 3 | * @module | |
| 4 | */ | |
| 5 | ||
| 1 | 6 | /** |
| 2 | 7 | * retrieves the 'Content-Type' header to use given a file extension. |
| 3 | 8 | * accepts: |
lib/node.ts+1| ... | ... | @@ -48,6 +48,7 @@ interface Builtins { |
| 48 | 48 | sep: string; |
| 49 | 49 | }; |
| 50 | 50 | "util": { |
| 51 | inspect(object: unknown): string; | |
| 51 | 52 | formatWithOptions( |
| 52 | 53 | options: { colors?: boolean }, |
| 53 | 54 | ...args: unknown[] |
lib/readme.changes.md+45-9| ... | ... | @@ -4,20 +4,57 @@ |
| 4 | 4 | |
| 5 | 5 | ### breaking |
| 6 | 6 | |
| 7 | - promote `log/progress.ts` to top level `progress.ts` | |
| 8 | - rework Log dispatching | |
| 7 | - `log` dispatching reworked | |
| 9 | 8 | - delete `log/headless.ts` by moving it into `log` |
| 10 | 9 | - headless scopes now emit `log.Message` objects instead of ANSI text, |
| 11 | 10 | templating is done in the consumer of the headless logging scope. |
| 11 | - rename `HeadlessWidgetEnv.wait` to `delay`, matching `async.delay` | |
| 12 | - `progress` had everything besides `start` and `Node` heavily altered | |
| 13 | - promote to top level namespace `lib/progress.ts` | |
| 14 | - `Node.estimate` -> `Node.total` | |
| 15 | - rename `.null` to `.nullNode` | |
| 16 | - `.SortNode` -> `.ReadOnlyNode` | |
| 17 | - `.getUnicodeBar` -> `.formatUnicodeBar` | |
| 18 | - rework headless rendering story, rename `.Headless` -> `.Root` | |
| 19 | - `.rootFromWidgetHost` to source from a widget host | |
| 20 | - remove `.internals`, use `.Root` | |
| 21 | - deleted `assert.FINITE` | |
| 12 | 22 | |
| 13 | 23 | ### features |
| 14 | 24 | |
| 15 | - add `log.tee` (and `log.Scope.tee`) | |
| 16 | - `log` in Node.js will inject into `console.*` to prevent interweaving logs | |
| 17 | with widget output text. this injection is enabled regardless of if widgets | |
| 18 | are actually running, but do not otherwise change their behavior. | |
| 19 | - `log` scopes render differently in the terminal now. | |
| 20 | - `log` in the browser will call the correct log level function in the browser. | |
| 25 | - `progress` has expanded documentation and use cases. | |
| 26 | - create headless nodes with `Root` (subclass of `Events`) | |
| 27 | - streaming over the wire via `encodeEventStream`, `encodeEventStream`, | |
| 28 | `encodeByteStream`, `encodeByteStream`. these APIs produce JSON and Binary | |
| 29 | `ReadableStream`s. with work to make `log` better supported in the browser, | |
| 30 | progress streams are fully consumable from within a web browser. | |
| 31 | - `.attach` to display a decoded root | |
| 32 | - expose `.formatAnsi` for external use | |
| 33 | - `log` slowly moves towards maturity | |
| 34 | - add `.tee` (and `log.Scope.tee`) | |
| 35 | - in Node.js, `log` will inject into `console.*` to prevent interweaving logs | |
| 36 | with widget output text. this injection is enabled regardless of if widgets | |
| 37 | are actually running, but do not otherwise change their behavior. | |
| 38 | - `log` scopes render differently in the terminal now | |
| 39 | - browser support by calling the correct `console` api to signal warnings and | |
| 40 | errors correctly. | |
| 41 | - fix assertion failure when creating a widget in the same frame a log message | |
| 42 | is written. | |
| 43 | - new library `Events.ts` | |
| 44 | - new library `bytes.ts` (subject to being moved as it is one function) | |
| 45 | - new library `stream.ts` | |
| 46 | - new library `testing.ts` | |
| 47 | - `string` | |
| 48 | - add `encodeUtf8` and `decodeUtf8` | |
| 49 | - add `formatByteSize` and `formatBinaryByteSize` and `formatDurationLetters`, | |
| 50 | all subject to change as `progress` is used more. | |
| 51 | - `async` | |
| 52 | - add `.Cancelable` type, constructed with `.makeCancelable` | |
| 53 | - add `.delay` | |
| 54 | - `ts` | |
| 55 | - add `EmptyObject` | |
| 56 | - add `Json`, `ToJson`, and `JsonValue` | |
| 57 | - add `mixin` | |
| 21 | 58 | |
| 22 | 59 | ## v2 |
| 23 | 60 | |
| ... | ... | @@ -33,7 +70,6 @@ |
| 33 | 70 | ### features |
| 34 | 71 | |
| 35 | 72 | - top level readme now explains general usage patterns. more docs overall. |
| 36 | - new library `render` | |
| 37 | 73 | - new library `async.ts` |
| 38 | 74 | - new library `Lru.ts` |
| 39 | 75 | - new library `mime.ts` |
lib/render.ts+1-1| ... | ... | @@ -413,7 +413,7 @@ export function clsx(mix: ClassName): string { |
| 413 | 413 | } else if (typeof mix === "object") { |
| 414 | 414 | if (Array.isArray(mix)) { |
| 415 | 415 | for (k = 0; k < mix.length; k++) { |
| 416 | if (mix[k] && (y = clsx(mix[k]))) { | |
| 416 | if (mix[k] && (y = clsx(mix[k]!))) { | |
| 417 | 417 | str && (str += " "); |
| 418 | 418 | str += y; |
| 419 | 419 | } |
lib/string.test.ts+16| ... | ... | @@ -29,6 +29,22 @@ test("escapeShellArgument", () => { |
| 29 | 29 | assert.equal(string.escapeShellArgument("'string"), '"\'string"'); |
| 30 | 30 | assert.equal(string.escapeShellArgument("'str\"ing"), "\\''str\"ing'"); |
| 31 | 31 | }); |
| 32 | test("formatByteSize", () => { | |
| 33 | assert.equal(string.formatByteSize(0), "0B"); | |
| 34 | assert.equal(string.formatByteSize(1), "1B"); | |
| 35 | assert.equal(string.formatByteSize(1000), "1kB"); | |
| 36 | assert.equal(string.formatByteSize(-1000), "-1kB"); | |
| 37 | assert.equal(string.formatByteSize(1500), "1.5kB"); | |
| 38 | assert.equal(string.formatByteSize(1578), "1.58kB"); | |
| 39 | assert.equal(string.formatByteSize(5_100_000), "5.1MB"); | |
| 40 | assert.equal(string.formatByteSize(-7_231_692_881), "-7.23GB"); | |
| 41 | assert.equal(string.formatByteSize(-4_294_967_296), "-7.23GB"); | |
| 42 | ||
| 43 | assert.equal(string.formatByteSize(53.251), "53.25B"); | |
| 44 | assert.equal(string.formatByteSize(Infinity), "Infinity"); | |
| 45 | assert.equal(string.formatByteSize(-Infinity), "-Infinity"); | |
| 46 | assert.equal(string.formatByteSize(NaN), "NaN"); | |
| 47 | }); | |
| 32 | 48 | |
| 33 | 49 | import { test } from "node:test"; |
| 34 | 50 | import assert from "node:assert"; |
lib/string.ts+1-2| ... | ... | @@ -1,4 +1,3 @@ |
| 1 | // TODO: this function is not benchmarked | |
| 2 | 1 | export function countNewlines(str: string): number { |
| 3 | 2 | let count = 0; |
| 4 | 3 | for (let i = 0, { length } = str; i < length; i += 1) { |
| ... | ... | @@ -43,7 +42,7 @@ export function decodeUtf8(input: Uint8Array): string { |
| 43 | 42 | return td.decode(input); |
| 44 | 43 | } |
| 45 | 44 | |
| 46 | const byteUnits = "kMGTPEZYRQ"; | |
| 45 | const byteUnits = "kMGTPEZYRQ"; // kilo, mega, giga, etc | |
| 47 | 46 | export function formatByteSize(bytes: number) { |
| 48 | 47 | if (!Number.isFinite(bytes)) return bytes.toString(); |
| 49 | 48 | let prefix = ""; |
lib/string/ansi.ts+9-3| ... | ... | @@ -1,9 +1,9 @@ |
| 1 | 1 | /** |
| 2 | 2 | * ANSI escape sequences can be used to style text in a tty or perform other |
| 3 | * interactions. This file contains many constants for escape codes, as well as | |
| 3 | * interactions. this file contains many constants for escape codes, as well as | |
| 4 | 4 | * functions for ones that take an argument, such as `cursorUp`. |
| 5 | 5 | * |
| 6 | * Note that this file only produces escape sequences, and does not yet feature | |
| 6 | * note that this file only produces escape sequences, and does not yet feature | |
| 7 | 7 | * detection or fallback code. |
| 8 | 8 | * |
| 9 | 9 | * @module |
| ... | ... | @@ -203,11 +203,12 @@ export function mergeStyles(code: string): string { |
| 203 | 203 | export function debugAnsi(text: string): string { |
| 204 | 204 | // deno-fmt-ignore |
| 205 | 205 | const verbaitim: (keyof typeof self)[] = [ |
| 206 | "fgCyan", "fgBlue", | |
| 206 | 207 | "bgReset", "bgBlack", "bgRed", "bgGreen", "bgYellow", "bgBlue", "bgMagenta", |
| 207 | 208 | "bgCyan", "bgWhite", "bgBrightBlack", "bgBrightRed", "bgBrightGreen", |
| 208 | 209 | "bgBrightYellow", "bgBrightBlue", "bgBrightMagenta", "bgBrightCyan", |
| 209 | 210 | "bgBrightWhite", "fgReset", "fgBlack", "fgRed", "fgGreen", "fgYellow", |
| 210 | "fgBlue", "fgMagenta", "fgCyan", "fgWhite", "fgBrightBlack", "fgBrightRed", | |
| 211 | "fgMagenta", "fgWhite", "fgBrightBlack", "fgBrightRed", | |
| 211 | 212 | "fgBrightGreen", "fgBrightYellow", "fgBrightBlue", "fgBrightMagenta", |
| 212 | 213 | "fgBrightCyan", "fgBrightWhite", "clearToEndOfLine", "clearToStartOfLine", |
| 213 | 214 | "clearFullLine", "syncStart", "syncEnd", "bold", "dim", "resetWeight" |
| ... | ... | @@ -320,6 +321,11 @@ const zeroWidthClusterRegex = |
| 320 | 321 | const leadingNonPrintingRegex = |
| 321 | 322 | /^[\p{Default_Ignorable_Code_Point}\p{Control}\p{Format}\p{Mark}\p{Surrogate}]+/v; |
| 322 | 323 | const rgiEmojiRegex = /^\p{RGI_Emoji}$/v; |
| 324 | ||
| 325 | export function strip(text: string) { | |
| 326 | return text.replace(ansiEscapes, ""); | |
| 327 | } | |
| 328 | ||
| 323 | 329 | function segmentWidth(segment: string) { |
| 324 | 330 | if (zeroWidthClusterRegex.test(segment)) return 0; |
| 325 | 331 | if (rgiEmojiRegex.test(segment)) return 2; |
lib/subprocess/ffmpeg.ts+7-5| ... | ... | @@ -6,15 +6,17 @@ export interface SpawnOptions { |
| 6 | 6 | } |
| 7 | 7 | |
| 8 | 8 | /** |
| 9 | * Spawn an instance of `ffmpeg` and bind it's live status to the given | |
| 9 | * spawn an instance of `ffmpeg` and bind it's live status to the given | |
| 10 | 10 | * `progress.Node`. Logs are routed through the progress node, and dismissed on |
| 11 | 11 | * exit. `lib/progress.ts` will buffer and colocate the logs when running |
| 12 | 12 | * parallel jobs. |
| 13 | 13 | * |
| 14 | * await ffmpeg.spawn({ | |
| 15 | * cmd: ["-i", "hello.mov", "-c:v", "libsvtav1", "hello.mp4"], | |
| 16 | * progress: progress.start("encode hello.mov"), | |
| 17 | * }); | |
| 14 | * ```ts | |
| 15 | * await ffmpeg.spawn({ | |
| 16 | * cmd: ["-i", "hello.mov", "-c:v", "libsvtav1", "hello.mp4"], | |
| 17 | * progress: progress.start("encode hello.mov"), | |
| 18 | * }); | |
| 19 | * ``` | |
| 18 | 20 | */ |
| 19 | 21 | export async function spawn(options: SpawnOptions) { |
| 20 | 22 | const { ffmpeg = "ffmpeg", args, cwd } = options; |
lib/testing.ts+39| ... | ... | @@ -109,6 +109,45 @@ export class SyncPromise<T> implements Promise<T> { |
| 109 | 109 | } |
| 110 | 110 | } |
| 111 | 111 | |
| 112 | export class FakeTimers { | |
| 113 | time = 0; | |
| 114 | timers: Array<{ | |
| 115 | duration: number; | |
| 116 | resolve: () => void; | |
| 117 | src: stack.Frame[]; | |
| 118 | }> = []; | |
| 119 | waitTime = 0; | |
| 120 | ||
| 121 | now = () => { | |
| 122 | return this.time; | |
| 123 | }; | |
| 124 | delay: typeof async.delay = (ms) => { | |
| 125 | const src = stack.capture(2); | |
| 126 | return async.makeCancelable( | |
| 127 | new SyncPromise((resolve) => { | |
| 128 | this.timers.push({ | |
| 129 | duration: ms - this.waitTime, | |
| 130 | resolve, | |
| 131 | src, | |
| 132 | }); | |
| 133 | this.waitTime = ms; | |
| 134 | }), | |
| 135 | () => { | |
| 136 | throw new Error("TODO"); | |
| 137 | }, | |
| 138 | ); | |
| 139 | }; | |
| 140 | } | |
| 141 | ||
| 142 | export function removeUndefinedKeys(obj: unknown): unknown { | |
| 143 | if (typeof obj !== "object" || !obj || Array.isArray(obj)) return obj; | |
| 144 | return Object.fromEntries( | |
| 145 | Object.getOwnPropertyNames(obj) | |
| 146 | .map((k) => [k, (obj as any)[k]]) | |
| 147 | .filter(([, v]) => v !== undefined), | |
| 148 | ); | |
| 149 | } | |
| 150 | ||
| 112 | 151 | /** implements a log.HeadlessWidgetHost that acts as a fake screen. */ |
| 113 | 152 | export class MockScreen implements Disposable, log.HeadlessWidgetHost { |
| 114 | 153 | columns = 80; |
run.js+1-1| ... | ... | @@ -105,7 +105,7 @@ if (process.argv[1].startsWith(import.meta.filename.slice(0, -".js".length))) { |
| 105 | 105 | ); |
| 106 | 106 | }, |
| 107 | 107 | ); |
| 108 | } else { | |
| 108 | } else if (!found.match(/\.test\.[tj]sx?$/)) { | |
| 109 | 109 | log.warn( |
| 110 | 110 | hot.load("path").relative(".", found), |
| 111 | 111 | 'does not export a "main" function', |
tsconfig.json+3-3| ... | ... | @@ -5,7 +5,7 @@ |
| 5 | 5 | "incremental": true, |
| 6 | 6 | "jsx": "react-jsxdev", |
| 7 | 7 | "jsxImportSource": "#jsx", |
| 8 | "lib": ["esnext", "dom", "dom.iterable"], | |
| 8 | "lib": ["esnext", "es2024", "dom", "dom.iterable"], | |
| 9 | 9 | "module": "nodenext", |
| 10 | 10 | "noEmit": true, |
| 11 | 11 | "outDir": ".clover/ts", |
| ... | ... | @@ -14,9 +14,9 @@ |
| 14 | 14 | "skipLibCheck": true, |
| 15 | 15 | "strict": true, |
| 16 | 16 | "verbatimModuleSyntax": true, |
| 17 | "target": "es2024", | |
| 17 | "target": "esnext", | |
| 18 | 18 | "noUncheckedIndexedAccess": true, |
| 19 | 19 | "resolveJsonModule": true |
| 20 | 20 | }, |
| 21 | "include": ["framework/**/*", "src/**/*", "lib/**/*", "framework/global.d.ts"] | |
| 21 | "include": ["lib/**/*"] | |
| 22 | 22 | } |