@orkestrel/console 0.0.6 → 0.0.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -84,6 +84,32 @@ function columnsOf(target) {
84
84
  return 80;
85
85
  }
86
86
  /**
87
+ * Infer whether one stream target should receive styled output. The result is a construction-time
88
+ * target fact for {@link import('./factories.js').createServerSink}; this helper is pure and never
89
+ * reads process globals itself.
90
+ *
91
+ * @remarks
92
+ * A present `FORCE_COLOR` key has first precedence: only the exact value `'0'` disables styling.
93
+ * Next, a non-empty `NO_COLOR` disables styling. Otherwise styling follows
94
+ * `target.isTTY === true`.
95
+ *
96
+ * @param target - The stream target whose terminal capability is the fallback
97
+ * @param environment - The environment record supplying `FORCE_COLOR` and `NO_COLOR`
98
+ * @returns Whether output for the target should retain styling and control sequences
99
+ *
100
+ * @example
101
+ * ```ts
102
+ * inferStyled({ write: () => true, isTTY: false }, { FORCE_COLOR: '1' }) // true
103
+ * inferStyled({ write: () => true, isTTY: true }, { NO_COLOR: '1' }) // false
104
+ * ```
105
+ */
106
+ function inferStyled(target, environment) {
107
+ if (Object.hasOwn(environment, "FORCE_COLOR")) return environment.FORCE_COLOR !== "0";
108
+ const disabled = environment.NO_COLOR;
109
+ if (disabled !== void 0 && disabled !== "") return false;
110
+ return target.isTTY === true;
111
+ }
112
+ /**
87
113
  * Decode one `process.stdout.write` / `process.stderr.write` chunk to a string — TOTAL, never
88
114
  * throws (AGENTS §14). The process write signature accepts `string | Uint8Array` plus an optional
89
115
  * encoding; the capture wrapper reuses this so intercepting a raw stream write can never crash the
@@ -277,10 +303,9 @@ var ProcessCapture = class {
277
303
  /**
278
304
  * Create the server TTY {@link ServerSinkInterface} — the C-g server output backend, the
279
305
  * env-symmetric sibling of `createBrowserSink` / core's `createConsoleSink`. `write(text, level?)`
280
- * routes by level to the process streams and is isTTY-aware: it sends ANSI straight to a terminal
281
- * (which renders it, with a leading `\r` overwriting the line natively — that is how the C-e
282
- * animations become a LIVE redraw here, with no extra code) but {@link import('@src/core').strip}s
283
- * the ANSI to clean text when the stream is piped to a file or another process.
306
+ * routes by level to the process streams and uses construction-time styled facts: it sends ANSI
307
+ * straight to a styled target (with a leading `\r` overwriting a terminal line natively) but
308
+ * {@link import('@src/core').strip}s ANSI to clean text for a plain target.
284
309
  *
285
310
  * @param options - See {@link ServerSinkOptions}
286
311
  * @returns A {@link ServerSinkInterface} — a {@link import('@src/core').SinkInterface} that also
@@ -290,11 +315,11 @@ var ProcessCapture = class {
290
315
  * - **Routes by level.** `error` / `warn` → the error stream (`process.stderr` by default), every
291
316
  * other level (and an omitted level) → the out stream (`process.stdout`) — the SAME routing as
292
317
  * core's `createConsoleSink`, so a logger's `error` reaches `stderr`.
293
- * - **isTTY-aware ANSI.** For each write, if the TARGET stream is a TTY the styled `text` is written
294
- * VERBATIM (the terminal renders the ANSI, and a leading `\r` overwrites the current line — live
295
- * animations for free); if it is NOT a TTY (a pipe / redirect to a log file), the ANSI is stripped
296
- * so the file gets clean text. The decision is per-stream and per-write, re-read live, so it stays
297
- * correct if a stream's TTY-ness ever differs between out and err.
318
+ * - **Per-target styled facts.** At construction, each target uses `options.styled` when supplied;
319
+ * otherwise {@link inferStyled} applies the injected `environment` (default `process.env`) and
320
+ * then that target's `isTTY`.
321
+ * Writes use those stored facts, so `styled` and the out target's strip decision never disagree;
322
+ * the err target keeps its own fact internally.
298
323
  * - **Width.** `columns` reflects the live `out.columns` (so it tracks a terminal resize), falling
299
324
  * back to {@link import('./constants.js').DEFAULT_COLUMNS} when the out stream is not a TTY — or a
300
325
  * fixed value when `options.columns` is supplied. Feed it to a `Reporter` / `Progress` `width`.
@@ -306,11 +331,12 @@ var ProcessCapture = class {
306
331
  *
307
332
  * @example
308
333
  * ```ts
309
- * import { createLogger, createReporter } from '@src/core'
334
+ * import { createLogger, createReporter, createStyler } from '@src/core'
310
335
  * import { createServerSink } from '@src/server'
311
336
  *
312
337
  * const sink = createServerSink()
313
- * const logger = createLogger({ name: 'app', sink })
338
+ * const styler = createStyler({ enabled: sink.styled })
339
+ * const logger = createLogger({ name: 'app', sink, styler })
314
340
  * logger.error('boom') // → process.stderr, ANSI rendered on a TTY / stripped to a pipe
315
341
  * const reporter = createReporter({ sink, width: sink.columns })
316
342
  * ```
@@ -318,12 +344,19 @@ var ProcessCapture = class {
318
344
  function createServerSink(options) {
319
345
  const out = isStreamTarget(options?.out) ? options.out : process.stdout;
320
346
  const err = isStreamTarget(options?.err) ? options.err : process.stderr;
347
+ const styled = options?.styled;
348
+ const environment = options?.environment ?? process.env;
349
+ const outStyled = styled ?? inferStyled(out, environment);
350
+ const errStyled = styled ?? inferStyled(err, environment);
321
351
  const fixed = options?.columns;
322
352
  return Object.freeze({
353
+ styled: outStyled,
323
354
  write(text, level) {
324
- const target = level === "error" || level === "warn" ? err : out;
355
+ const error = level === "error" || level === "warn";
356
+ const target = error ? err : out;
357
+ const keep = error ? errStyled : outStyled;
325
358
  const line = text.startsWith("\r") ? text : `${text}\n`;
326
- target.write(target.isTTY === true ? line : (0, _src_core.stripControls)((0, _src_core.strip)(line)));
359
+ target.write(keep ? line : (0, _src_core.stripControls)((0, _src_core.strip)(line)));
327
360
  },
328
361
  get columns() {
329
362
  return typeof fixed === "number" ? fixed : columnsOf(out);
@@ -372,6 +405,7 @@ exports.columnsOf = columnsOf;
372
405
  exports.createProcessCapture = createProcessCapture;
373
406
  exports.createServerSink = createServerSink;
374
407
  exports.decodeChunk = decodeChunk;
408
+ exports.inferStyled = inferStyled;
375
409
  exports.isBufferEncoding = isBufferEncoding;
376
410
  exports.isStreamTarget = isStreamTarget;
377
411
 
@@ -72,10 +72,9 @@ export declare function createProcessCapture(options?: ProcessCaptureOptions): P
72
72
  /**
73
73
  * Create the server TTY {@link ServerSinkInterface} — the C-g server output backend, the
74
74
  * env-symmetric sibling of `createBrowserSink` / core's `createConsoleSink`. `write(text, level?)`
75
- * routes by level to the process streams and is isTTY-aware: it sends ANSI straight to a terminal
76
- * (which renders it, with a leading `\r` overwriting the line natively — that is how the C-e
77
- * animations become a LIVE redraw here, with no extra code) but {@link import('@src/core').strip}s
78
- * the ANSI to clean text when the stream is piped to a file or another process.
75
+ * routes by level to the process streams and uses construction-time styled facts: it sends ANSI
76
+ * straight to a styled target (with a leading `\r` overwriting a terminal line natively) but
77
+ * {@link import('@src/core').strip}s ANSI to clean text for a plain target.
79
78
  *
80
79
  * @param options - See {@link ServerSinkOptions}
81
80
  * @returns A {@link ServerSinkInterface} — a {@link import('@src/core').SinkInterface} that also
@@ -85,11 +84,11 @@ export declare function createProcessCapture(options?: ProcessCaptureOptions): P
85
84
  * - **Routes by level.** `error` / `warn` → the error stream (`process.stderr` by default), every
86
85
  * other level (and an omitted level) → the out stream (`process.stdout`) — the SAME routing as
87
86
  * core's `createConsoleSink`, so a logger's `error` reaches `stderr`.
88
- * - **isTTY-aware ANSI.** For each write, if the TARGET stream is a TTY the styled `text` is written
89
- * VERBATIM (the terminal renders the ANSI, and a leading `\r` overwrites the current line — live
90
- * animations for free); if it is NOT a TTY (a pipe / redirect to a log file), the ANSI is stripped
91
- * so the file gets clean text. The decision is per-stream and per-write, re-read live, so it stays
92
- * correct if a stream's TTY-ness ever differs between out and err.
87
+ * - **Per-target styled facts.** At construction, each target uses `options.styled` when supplied;
88
+ * otherwise {@link inferStyled} applies the injected `environment` (default `process.env`) and
89
+ * then that target's `isTTY`.
90
+ * Writes use those stored facts, so `styled` and the out target's strip decision never disagree;
91
+ * the err target keeps its own fact internally.
93
92
  * - **Width.** `columns` reflects the live `out.columns` (so it tracks a terminal resize), falling
94
93
  * back to {@link import('./constants.js').DEFAULT_COLUMNS} when the out stream is not a TTY — or a
95
94
  * fixed value when `options.columns` is supplied. Feed it to a `Reporter` / `Progress` `width`.
@@ -101,11 +100,12 @@ export declare function createProcessCapture(options?: ProcessCaptureOptions): P
101
100
  *
102
101
  * @example
103
102
  * ```ts
104
- * import { createLogger, createReporter } from '@src/core'
103
+ * import { createLogger, createReporter, createStyler } from '@src/core'
105
104
  * import { createServerSink } from '@src/server'
106
105
  *
107
106
  * const sink = createServerSink()
108
- * const logger = createLogger({ name: 'app', sink })
107
+ * const styler = createStyler({ enabled: sink.styled })
108
+ * const logger = createLogger({ name: 'app', sink, styler })
109
109
  * logger.error('boom') // → process.stderr, ANSI rendered on a TTY / stripped to a pipe
110
110
  * const reporter = createReporter({ sink, width: sink.columns })
111
111
  * ```
@@ -167,6 +167,28 @@ export declare const DEFAULT_CAPTURE_LIMIT = 1000;
167
167
  */
168
168
  export declare const DEFAULT_COLUMNS = 80;
169
169
 
170
+ /**
171
+ * Infer whether one stream target should receive styled output. The result is a construction-time
172
+ * target fact for {@link import('./factories.js').createServerSink}; this helper is pure and never
173
+ * reads process globals itself.
174
+ *
175
+ * @remarks
176
+ * A present `FORCE_COLOR` key has first precedence: only the exact value `'0'` disables styling.
177
+ * Next, a non-empty `NO_COLOR` disables styling. Otherwise styling follows
178
+ * `target.isTTY === true`.
179
+ *
180
+ * @param target - The stream target whose terminal capability is the fallback
181
+ * @param environment - The environment record supplying `FORCE_COLOR` and `NO_COLOR`
182
+ * @returns Whether output for the target should retain styling and control sequences
183
+ *
184
+ * @example
185
+ * ```ts
186
+ * inferStyled({ write: () => true, isTTY: false }, { FORCE_COLOR: '1' }) // true
187
+ * inferStyled({ write: () => true, isTTY: true }, { NO_COLOR: '1' }) // false
188
+ * ```
189
+ */
190
+ export declare function inferStyled(target: StreamTargetInterface, environment: Readonly<Record<string, string | undefined>>): boolean;
191
+
170
192
  /**
171
193
  * Whether `encoding` is a {@link BufferEncoding} accepted by `Buffer.prototype.toString` — a total
172
194
  * guard used by {@link decodeChunk} to honor a process-write `encoding` argument only when it is a
@@ -354,13 +376,17 @@ export declare interface ProcessCaptureOptions {
354
376
  * A {@link SinkInterface} that also exposes the target terminal's {@link columns} width — the shape
355
377
  * {@link import('./factories.js').createServerSink} returns. It is a drop-in {@link SinkInterface}
356
378
  * (so a `Logger` / `Reporter` / `Spinner` / `Progress` takes it as `sink`) whose extra `columns`
357
- * getter lets a consumer size a `Reporter`'s layout to the live terminal.
379
+ * getter lets a consumer size a `Reporter`'s layout to the live terminal. Its `styled` fact lets
380
+ * the same consumer enable or disable its styler for the out target.
358
381
  *
359
382
  * @remarks
360
- * `columns` is a getter, re-read on every access so it reflects the CURRENT terminal width (a
361
- * resize is observed) unless a fixed `options.columns` was supplied, in which case it is constant.
383
+ * - `styled` is the `out` target's construction-time fact. The sink handles `err` through its own
384
+ * independently inferred fact because the two targets can differ.
385
+ * - `columns` is a getter, re-read on every access — so it reflects the CURRENT terminal width (a
386
+ * resize is observed) unless a fixed `options.columns` was supplied, in which case it is constant.
362
387
  */
363
388
  export declare interface ServerSinkInterface extends SinkInterface {
389
+ readonly styled: boolean;
364
390
  readonly columns: number;
365
391
  }
366
392
 
@@ -372,6 +398,9 @@ export declare interface ServerSinkInterface extends SinkInterface {
372
398
  * - `out` — the stream `info` / `debug` (and an omitted level) are written to; defaults to
373
399
  * `process.stdout`. Any {@link StreamTargetInterface} is accepted, so a test injects a fake.
374
400
  * - `err` — the stream `error` / `warn` are written to; defaults to `process.stderr`.
401
+ * - `styled` — an explicit styling decision for both targets. When omitted, each target infers its
402
+ * own fact from `FORCE_COLOR`, `NO_COLOR`, and `isTTY` at construction.
403
+ * - `environment` — the environment used for inference; defaults to `process.env`.
375
404
  * - `columns` — an explicit width override for {@link ServerSinkInterface.columns}. When omitted,
376
405
  * the sink reads the live `out.columns` (so it tracks a terminal resize), falling back to
377
406
  * {@link import('./constants.js').DEFAULT_COLUMNS} when the out stream is not a TTY.
@@ -379,6 +408,8 @@ export declare interface ServerSinkInterface extends SinkInterface {
379
408
  export declare interface ServerSinkOptions {
380
409
  readonly out?: StreamTargetInterface;
381
410
  readonly err?: StreamTargetInterface;
411
+ readonly styled?: boolean;
412
+ readonly environment?: Readonly<Record<string, string | undefined>>;
382
413
  readonly columns?: number;
383
414
  }
384
415
 
@@ -423,9 +454,9 @@ export declare type StreamLevel = 'stdout' | 'stderr';
423
454
  * backpressure boolean (`false` when the kernel buffer is full). A `process` stream returns it;
424
455
  * a fake may return `void` (read as truthy / no backpressure).
425
456
  * - `isTTY` — present and `true` on a real terminal, absent / `false` when the stream is piped to a
426
- * file or another process. The sink reads it to decide whether to keep ANSI (a terminal renders
427
- * it, and a leading `\r` overwrites natively) or {@link import('@src/core').strip} it to clean
428
- * text (a log file should not carry escape codes).
457
+ * file or another process. When no explicit styling override exists, the sink reads it at
458
+ * construction to decide whether to keep ANSI or {@link import('@src/core').strip} it to clean
459
+ * text.
429
460
  * - `columns` — the terminal width in character cells when the stream is a TTY, `undefined`
430
461
  * otherwise; the sink surfaces it as {@link ServerSinkInterface.columns} so a consumer can feed a
431
462
  * `Reporter` / `Progress` its render width.
@@ -72,10 +72,9 @@ export declare function createProcessCapture(options?: ProcessCaptureOptions): P
72
72
  /**
73
73
  * Create the server TTY {@link ServerSinkInterface} — the C-g server output backend, the
74
74
  * env-symmetric sibling of `createBrowserSink` / core's `createConsoleSink`. `write(text, level?)`
75
- * routes by level to the process streams and is isTTY-aware: it sends ANSI straight to a terminal
76
- * (which renders it, with a leading `\r` overwriting the line natively — that is how the C-e
77
- * animations become a LIVE redraw here, with no extra code) but {@link import('@src/core').strip}s
78
- * the ANSI to clean text when the stream is piped to a file or another process.
75
+ * routes by level to the process streams and uses construction-time styled facts: it sends ANSI
76
+ * straight to a styled target (with a leading `\r` overwriting a terminal line natively) but
77
+ * {@link import('@src/core').strip}s ANSI to clean text for a plain target.
79
78
  *
80
79
  * @param options - See {@link ServerSinkOptions}
81
80
  * @returns A {@link ServerSinkInterface} — a {@link import('@src/core').SinkInterface} that also
@@ -85,11 +84,11 @@ export declare function createProcessCapture(options?: ProcessCaptureOptions): P
85
84
  * - **Routes by level.** `error` / `warn` → the error stream (`process.stderr` by default), every
86
85
  * other level (and an omitted level) → the out stream (`process.stdout`) — the SAME routing as
87
86
  * core's `createConsoleSink`, so a logger's `error` reaches `stderr`.
88
- * - **isTTY-aware ANSI.** For each write, if the TARGET stream is a TTY the styled `text` is written
89
- * VERBATIM (the terminal renders the ANSI, and a leading `\r` overwrites the current line — live
90
- * animations for free); if it is NOT a TTY (a pipe / redirect to a log file), the ANSI is stripped
91
- * so the file gets clean text. The decision is per-stream and per-write, re-read live, so it stays
92
- * correct if a stream's TTY-ness ever differs between out and err.
87
+ * - **Per-target styled facts.** At construction, each target uses `options.styled` when supplied;
88
+ * otherwise {@link inferStyled} applies the injected `environment` (default `process.env`) and
89
+ * then that target's `isTTY`.
90
+ * Writes use those stored facts, so `styled` and the out target's strip decision never disagree;
91
+ * the err target keeps its own fact internally.
93
92
  * - **Width.** `columns` reflects the live `out.columns` (so it tracks a terminal resize), falling
94
93
  * back to {@link import('./constants.js').DEFAULT_COLUMNS} when the out stream is not a TTY — or a
95
94
  * fixed value when `options.columns` is supplied. Feed it to a `Reporter` / `Progress` `width`.
@@ -101,11 +100,12 @@ export declare function createProcessCapture(options?: ProcessCaptureOptions): P
101
100
  *
102
101
  * @example
103
102
  * ```ts
104
- * import { createLogger, createReporter } from '@src/core'
103
+ * import { createLogger, createReporter, createStyler } from '@src/core'
105
104
  * import { createServerSink } from '@src/server'
106
105
  *
107
106
  * const sink = createServerSink()
108
- * const logger = createLogger({ name: 'app', sink })
107
+ * const styler = createStyler({ enabled: sink.styled })
108
+ * const logger = createLogger({ name: 'app', sink, styler })
109
109
  * logger.error('boom') // → process.stderr, ANSI rendered on a TTY / stripped to a pipe
110
110
  * const reporter = createReporter({ sink, width: sink.columns })
111
111
  * ```
@@ -167,6 +167,28 @@ export declare const DEFAULT_CAPTURE_LIMIT = 1000;
167
167
  */
168
168
  export declare const DEFAULT_COLUMNS = 80;
169
169
 
170
+ /**
171
+ * Infer whether one stream target should receive styled output. The result is a construction-time
172
+ * target fact for {@link import('./factories.js').createServerSink}; this helper is pure and never
173
+ * reads process globals itself.
174
+ *
175
+ * @remarks
176
+ * A present `FORCE_COLOR` key has first precedence: only the exact value `'0'` disables styling.
177
+ * Next, a non-empty `NO_COLOR` disables styling. Otherwise styling follows
178
+ * `target.isTTY === true`.
179
+ *
180
+ * @param target - The stream target whose terminal capability is the fallback
181
+ * @param environment - The environment record supplying `FORCE_COLOR` and `NO_COLOR`
182
+ * @returns Whether output for the target should retain styling and control sequences
183
+ *
184
+ * @example
185
+ * ```ts
186
+ * inferStyled({ write: () => true, isTTY: false }, { FORCE_COLOR: '1' }) // true
187
+ * inferStyled({ write: () => true, isTTY: true }, { NO_COLOR: '1' }) // false
188
+ * ```
189
+ */
190
+ export declare function inferStyled(target: StreamTargetInterface, environment: Readonly<Record<string, string | undefined>>): boolean;
191
+
170
192
  /**
171
193
  * Whether `encoding` is a {@link BufferEncoding} accepted by `Buffer.prototype.toString` — a total
172
194
  * guard used by {@link decodeChunk} to honor a process-write `encoding` argument only when it is a
@@ -354,13 +376,17 @@ export declare interface ProcessCaptureOptions {
354
376
  * A {@link SinkInterface} that also exposes the target terminal's {@link columns} width — the shape
355
377
  * {@link import('./factories.js').createServerSink} returns. It is a drop-in {@link SinkInterface}
356
378
  * (so a `Logger` / `Reporter` / `Spinner` / `Progress` takes it as `sink`) whose extra `columns`
357
- * getter lets a consumer size a `Reporter`'s layout to the live terminal.
379
+ * getter lets a consumer size a `Reporter`'s layout to the live terminal. Its `styled` fact lets
380
+ * the same consumer enable or disable its styler for the out target.
358
381
  *
359
382
  * @remarks
360
- * `columns` is a getter, re-read on every access so it reflects the CURRENT terminal width (a
361
- * resize is observed) unless a fixed `options.columns` was supplied, in which case it is constant.
383
+ * - `styled` is the `out` target's construction-time fact. The sink handles `err` through its own
384
+ * independently inferred fact because the two targets can differ.
385
+ * - `columns` is a getter, re-read on every access — so it reflects the CURRENT terminal width (a
386
+ * resize is observed) unless a fixed `options.columns` was supplied, in which case it is constant.
362
387
  */
363
388
  export declare interface ServerSinkInterface extends SinkInterface {
389
+ readonly styled: boolean;
364
390
  readonly columns: number;
365
391
  }
366
392
 
@@ -372,6 +398,9 @@ export declare interface ServerSinkInterface extends SinkInterface {
372
398
  * - `out` — the stream `info` / `debug` (and an omitted level) are written to; defaults to
373
399
  * `process.stdout`. Any {@link StreamTargetInterface} is accepted, so a test injects a fake.
374
400
  * - `err` — the stream `error` / `warn` are written to; defaults to `process.stderr`.
401
+ * - `styled` — an explicit styling decision for both targets. When omitted, each target infers its
402
+ * own fact from `FORCE_COLOR`, `NO_COLOR`, and `isTTY` at construction.
403
+ * - `environment` — the environment used for inference; defaults to `process.env`.
375
404
  * - `columns` — an explicit width override for {@link ServerSinkInterface.columns}. When omitted,
376
405
  * the sink reads the live `out.columns` (so it tracks a terminal resize), falling back to
377
406
  * {@link import('./constants.js').DEFAULT_COLUMNS} when the out stream is not a TTY.
@@ -379,6 +408,8 @@ export declare interface ServerSinkInterface extends SinkInterface {
379
408
  export declare interface ServerSinkOptions {
380
409
  readonly out?: StreamTargetInterface;
381
410
  readonly err?: StreamTargetInterface;
411
+ readonly styled?: boolean;
412
+ readonly environment?: Readonly<Record<string, string | undefined>>;
382
413
  readonly columns?: number;
383
414
  }
384
415
 
@@ -423,9 +454,9 @@ export declare type StreamLevel = 'stdout' | 'stderr';
423
454
  * backpressure boolean (`false` when the kernel buffer is full). A `process` stream returns it;
424
455
  * a fake may return `void` (read as truthy / no backpressure).
425
456
  * - `isTTY` — present and `true` on a real terminal, absent / `false` when the stream is piped to a
426
- * file or another process. The sink reads it to decide whether to keep ANSI (a terminal renders
427
- * it, and a leading `\r` overwrites natively) or {@link import('@src/core').strip} it to clean
428
- * text (a log file should not carry escape codes).
457
+ * file or another process. When no explicit styling override exists, the sink reads it at
458
+ * construction to decide whether to keep ANSI or {@link import('@src/core').strip} it to clean
459
+ * text.
429
460
  * - `columns` — the terminal width in character cells when the stream is a TTY, `undefined`
430
461
  * otherwise; the sink surfaces it as {@link ServerSinkInterface.columns} so a consumer can feed a
431
462
  * `Reporter` / `Progress` its render width.
@@ -83,6 +83,32 @@ function columnsOf(target) {
83
83
  return 80;
84
84
  }
85
85
  /**
86
+ * Infer whether one stream target should receive styled output. The result is a construction-time
87
+ * target fact for {@link import('./factories.js').createServerSink}; this helper is pure and never
88
+ * reads process globals itself.
89
+ *
90
+ * @remarks
91
+ * A present `FORCE_COLOR` key has first precedence: only the exact value `'0'` disables styling.
92
+ * Next, a non-empty `NO_COLOR` disables styling. Otherwise styling follows
93
+ * `target.isTTY === true`.
94
+ *
95
+ * @param target - The stream target whose terminal capability is the fallback
96
+ * @param environment - The environment record supplying `FORCE_COLOR` and `NO_COLOR`
97
+ * @returns Whether output for the target should retain styling and control sequences
98
+ *
99
+ * @example
100
+ * ```ts
101
+ * inferStyled({ write: () => true, isTTY: false }, { FORCE_COLOR: '1' }) // true
102
+ * inferStyled({ write: () => true, isTTY: true }, { NO_COLOR: '1' }) // false
103
+ * ```
104
+ */
105
+ function inferStyled(target, environment) {
106
+ if (Object.hasOwn(environment, "FORCE_COLOR")) return environment.FORCE_COLOR !== "0";
107
+ const disabled = environment.NO_COLOR;
108
+ if (disabled !== void 0 && disabled !== "") return false;
109
+ return target.isTTY === true;
110
+ }
111
+ /**
86
112
  * Decode one `process.stdout.write` / `process.stderr.write` chunk to a string — TOTAL, never
87
113
  * throws (AGENTS §14). The process write signature accepts `string | Uint8Array` plus an optional
88
114
  * encoding; the capture wrapper reuses this so intercepting a raw stream write can never crash the
@@ -276,10 +302,9 @@ var ProcessCapture = class {
276
302
  /**
277
303
  * Create the server TTY {@link ServerSinkInterface} — the C-g server output backend, the
278
304
  * env-symmetric sibling of `createBrowserSink` / core's `createConsoleSink`. `write(text, level?)`
279
- * routes by level to the process streams and is isTTY-aware: it sends ANSI straight to a terminal
280
- * (which renders it, with a leading `\r` overwriting the line natively — that is how the C-e
281
- * animations become a LIVE redraw here, with no extra code) but {@link import('@src/core').strip}s
282
- * the ANSI to clean text when the stream is piped to a file or another process.
305
+ * routes by level to the process streams and uses construction-time styled facts: it sends ANSI
306
+ * straight to a styled target (with a leading `\r` overwriting a terminal line natively) but
307
+ * {@link import('@src/core').strip}s ANSI to clean text for a plain target.
283
308
  *
284
309
  * @param options - See {@link ServerSinkOptions}
285
310
  * @returns A {@link ServerSinkInterface} — a {@link import('@src/core').SinkInterface} that also
@@ -289,11 +314,11 @@ var ProcessCapture = class {
289
314
  * - **Routes by level.** `error` / `warn` → the error stream (`process.stderr` by default), every
290
315
  * other level (and an omitted level) → the out stream (`process.stdout`) — the SAME routing as
291
316
  * core's `createConsoleSink`, so a logger's `error` reaches `stderr`.
292
- * - **isTTY-aware ANSI.** For each write, if the TARGET stream is a TTY the styled `text` is written
293
- * VERBATIM (the terminal renders the ANSI, and a leading `\r` overwrites the current line — live
294
- * animations for free); if it is NOT a TTY (a pipe / redirect to a log file), the ANSI is stripped
295
- * so the file gets clean text. The decision is per-stream and per-write, re-read live, so it stays
296
- * correct if a stream's TTY-ness ever differs between out and err.
317
+ * - **Per-target styled facts.** At construction, each target uses `options.styled` when supplied;
318
+ * otherwise {@link inferStyled} applies the injected `environment` (default `process.env`) and
319
+ * then that target's `isTTY`.
320
+ * Writes use those stored facts, so `styled` and the out target's strip decision never disagree;
321
+ * the err target keeps its own fact internally.
297
322
  * - **Width.** `columns` reflects the live `out.columns` (so it tracks a terminal resize), falling
298
323
  * back to {@link import('./constants.js').DEFAULT_COLUMNS} when the out stream is not a TTY — or a
299
324
  * fixed value when `options.columns` is supplied. Feed it to a `Reporter` / `Progress` `width`.
@@ -305,11 +330,12 @@ var ProcessCapture = class {
305
330
  *
306
331
  * @example
307
332
  * ```ts
308
- * import { createLogger, createReporter } from '@src/core'
333
+ * import { createLogger, createReporter, createStyler } from '@src/core'
309
334
  * import { createServerSink } from '@src/server'
310
335
  *
311
336
  * const sink = createServerSink()
312
- * const logger = createLogger({ name: 'app', sink })
337
+ * const styler = createStyler({ enabled: sink.styled })
338
+ * const logger = createLogger({ name: 'app', sink, styler })
313
339
  * logger.error('boom') // → process.stderr, ANSI rendered on a TTY / stripped to a pipe
314
340
  * const reporter = createReporter({ sink, width: sink.columns })
315
341
  * ```
@@ -317,12 +343,19 @@ var ProcessCapture = class {
317
343
  function createServerSink(options) {
318
344
  const out = isStreamTarget(options?.out) ? options.out : process.stdout;
319
345
  const err = isStreamTarget(options?.err) ? options.err : process.stderr;
346
+ const styled = options?.styled;
347
+ const environment = options?.environment ?? process.env;
348
+ const outStyled = styled ?? inferStyled(out, environment);
349
+ const errStyled = styled ?? inferStyled(err, environment);
320
350
  const fixed = options?.columns;
321
351
  return Object.freeze({
352
+ styled: outStyled,
322
353
  write(text, level) {
323
- const target = level === "error" || level === "warn" ? err : out;
354
+ const error = level === "error" || level === "warn";
355
+ const target = error ? err : out;
356
+ const keep = error ? errStyled : outStyled;
324
357
  const line = text.startsWith("\r") ? text : `${text}\n`;
325
- target.write(target.isTTY === true ? line : stripControls(strip(line)));
358
+ target.write(keep ? line : stripControls(strip(line)));
326
359
  },
327
360
  get columns() {
328
361
  return typeof fixed === "number" ? fixed : columnsOf(out);
@@ -361,6 +394,6 @@ function createProcessCapture(options) {
361
394
  return new ProcessCapture(options);
362
395
  }
363
396
  //#endregion
364
- export { DEFAULT_CAPTURE_LEVELS, DEFAULT_CAPTURE_LIMIT, DEFAULT_COLUMNS, ProcessCapture, STREAM_LEVELS, STREAM_LEVEL_MAP, columnsOf, createProcessCapture, createServerSink, decodeChunk, isBufferEncoding, isStreamTarget };
397
+ export { DEFAULT_CAPTURE_LEVELS, DEFAULT_CAPTURE_LIMIT, DEFAULT_COLUMNS, ProcessCapture, STREAM_LEVELS, STREAM_LEVEL_MAP, columnsOf, createProcessCapture, createServerSink, decodeChunk, inferStyled, isBufferEncoding, isStreamTarget };
365
398
 
366
399
  //# sourceMappingURL=index.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@orkestrel/console",
3
- "version": "0.0.6",
3
+ "version": "0.0.8",
4
4
  "description": "A typed console/terminal output toolkit for the @orkestrel line — logging, spinners, progress, and capture. Part of the @orkestrel line.",
5
5
  "keywords": [
6
6
  "ansi",
@@ -87,14 +87,14 @@
87
87
  "prepublishOnly": "npm run format:check && npm run lint:check && npm run check && npm run build && npm test"
88
88
  },
89
89
  "dependencies": {
90
- "@orkestrel/contract": "^0.0.11",
91
- "@orkestrel/emitter": "^0.0.6"
90
+ "@orkestrel/contract": "^0.0.12",
91
+ "@orkestrel/emitter": "^0.0.7"
92
92
  },
93
93
  "devDependencies": {
94
94
  "@microsoft/api-extractor": "^7.58.12",
95
95
  "@orkestrel/guide": "^0.0.11",
96
- "@orkestrel/scaffold": "^0.0.30",
97
- "@orkestrel/test": "^0.0.1",
96
+ "@orkestrel/scaffold": "^0.0.38",
97
+ "@orkestrel/test": "^0.0.6",
98
98
  "@types/node": "^26.1.2",
99
99
  "@vitest/browser-playwright": "^4.1.10",
100
100
  "oxfmt": "^0.61.0",
@@ -1 +0,0 @@
1
- {"version":3,"file":"index.js","names":[],"sources":["../../../src/browser/constants.ts","../../../src/browser/helpers.ts","../../../src/browser/factories.ts"],"sourcesContent":["import type { Color } from '@src/core'\nimport { ATTRIBUTE_CODES, BACKGROUND_CODES, ESC, FOREGROUND_CODES } from '@src/core'\n\n// The SGR → CSS translation DATA the browser sink maps ANSI runs through (the C-f branch).\n// The core `src/core/console` is the source of truth for the SGR NUMBERS (which code is which\n// color / attribute); this module owns only the BROWSER-side mapping — a named-color → hex\n// palette and each SGR number → its CSS declaration. Each number→CSS row is written as a\n// COMPUTED key over core's code maps with its value read from the palette, so neither the\n// number↔name mapping nor a hex value is re-hardcoded here; `tests/src/browser/helpers.test.ts`\n// walks core's `COLORS` and fails if a row is missing. This file holds DATA only — a derivation\n// written as a callback would be module function syntax in a data-kind file (AGENTS §5).\n// The SGR-scan pattern is built from core's `ESC` so no control-character literal appears in\n// source. UPPER_SNAKE, deeply `Object.freeze`d, every member exported (AGENTS §5).\n\n/**\n * Each named {@link Color}'s hex value — the 16 standard terminal colors a browser DevTools\n * console renders the SAME {@link Color} names as. The source of truth for the BROWSER color\n * axis: the ANSI renderer maps a `Color` name to an SGR number, and this maps the same name to\n * the CSS color the `%c` sink paints with, so a browser shows the same 16 colors a terminal does.\n *\n * @remarks\n * The conventional VGA/xterm 16-color palette (the base 8 plus their bright variants); `default`\n * is intentionally absent (it leaves the console's own ink and emits no CSS). Deeply frozen.\n */\nexport const COLOR_HEX: Readonly<Record<Exclude<Color, 'default'>, string>> = Object.freeze({\n\tblack: '#000000',\n\tred: '#cd0000',\n\tgreen: '#00cd00',\n\tyellow: '#cdcd00',\n\tblue: '#0000ee',\n\tmagenta: '#cd00cd',\n\tcyan: '#00cdcd',\n\twhite: '#e5e5e5',\n\tbrightBlack: '#7f7f7f',\n\tbrightRed: '#ff0000',\n\tbrightGreen: '#00ff00',\n\tbrightYellow: '#ffff00',\n\tbrightBlue: '#5c5cff',\n\tbrightMagenta: '#ff00ff',\n\tbrightCyan: '#00ffff',\n\tbrightWhite: '#ffffff',\n})\n\n/**\n * Each text-{@link Attribute}'s SGR \"on\" number → its equivalent CSS declaration — the browser\n * counterpart to the terminal's SGR text effects (`bold` 1 → `font-weight:bold`, `dim` 2 →\n * `opacity:0.6`, `italic` 3 → `font-style:italic`, `underline` 4 → `text-decoration:underline`,\n * `inverse` 7 → best-effort, `strikethrough` 9 → `text-decoration:line-through`). Keyed by the SGR\n * NUMBER (derived from core's {@link ATTRIBUTE_CODES}) so the sink looks a parameter up directly\n * while scanning a run.\n *\n * @remarks\n * `inverse` (SGR 7) has no faithful single-declaration CSS equivalent (it swaps the fore/back inks,\n * which depends on the live colors); it maps to a best-effort `filter:invert(100%)` — documented as\n * approximate, never silently dropped. Deeply frozen.\n */\nexport const ATTRIBUTE_CSS: Readonly<Record<number, string>> = Object.freeze({\n\t[ATTRIBUTE_CODES.bold]: 'font-weight:bold',\n\t[ATTRIBUTE_CODES.dim]: 'opacity:0.6',\n\t[ATTRIBUTE_CODES.italic]: 'font-style:italic',\n\t[ATTRIBUTE_CODES.underline]: 'text-decoration:underline',\n\t[ATTRIBUTE_CODES.inverse]: 'filter:invert(100%)',\n\t[ATTRIBUTE_CODES.strikethrough]: 'text-decoration:line-through',\n})\n\n/**\n * Each SGR FOREGROUND parameter (30–37 / 90–97) → its `color:<hex>` CSS. The sink reads this while\n * scanning a run to translate a foreground code to CSS.\n *\n * @remarks\n * Every key is core's {@link FOREGROUND_CODES} entry and every value reads {@link COLOR_HEX}, so\n * neither the number↔name mapping nor the palette is duplicated here — this is a table of\n * references, not of literals. `tests/src/browser/helpers.test.ts` walks core's {@link COLORS} and\n * asserts this record covers every one of them, so a color added to core fails there rather than\n * going silently untranslated. Deeply frozen.\n */\nexport const FOREGROUND_CSS: Readonly<Record<number, string>> = Object.freeze({\n\t[FOREGROUND_CODES.black]: `color:${COLOR_HEX.black}`,\n\t[FOREGROUND_CODES.red]: `color:${COLOR_HEX.red}`,\n\t[FOREGROUND_CODES.green]: `color:${COLOR_HEX.green}`,\n\t[FOREGROUND_CODES.yellow]: `color:${COLOR_HEX.yellow}`,\n\t[FOREGROUND_CODES.blue]: `color:${COLOR_HEX.blue}`,\n\t[FOREGROUND_CODES.magenta]: `color:${COLOR_HEX.magenta}`,\n\t[FOREGROUND_CODES.cyan]: `color:${COLOR_HEX.cyan}`,\n\t[FOREGROUND_CODES.white]: `color:${COLOR_HEX.white}`,\n\t[FOREGROUND_CODES.brightBlack]: `color:${COLOR_HEX.brightBlack}`,\n\t[FOREGROUND_CODES.brightRed]: `color:${COLOR_HEX.brightRed}`,\n\t[FOREGROUND_CODES.brightGreen]: `color:${COLOR_HEX.brightGreen}`,\n\t[FOREGROUND_CODES.brightYellow]: `color:${COLOR_HEX.brightYellow}`,\n\t[FOREGROUND_CODES.brightBlue]: `color:${COLOR_HEX.brightBlue}`,\n\t[FOREGROUND_CODES.brightMagenta]: `color:${COLOR_HEX.brightMagenta}`,\n\t[FOREGROUND_CODES.brightCyan]: `color:${COLOR_HEX.brightCyan}`,\n\t[FOREGROUND_CODES.brightWhite]: `color:${COLOR_HEX.brightWhite}`,\n})\n\n/**\n * Each SGR BACKGROUND parameter (40–47 / 100–107) → its `background:<hex>` CSS. The sink reads this\n * while scanning a run to translate a background code to CSS.\n *\n * @remarks\n * Built the same way as {@link FOREGROUND_CSS} — keys from core's {@link BACKGROUND_CODES}, values\n * from {@link COLOR_HEX} — and covered by the same exhaustive walk over core's {@link COLORS} in\n * `tests/src/browser/helpers.test.ts`. Deeply frozen.\n */\nexport const BACKGROUND_CSS: Readonly<Record<number, string>> = Object.freeze({\n\t[BACKGROUND_CODES.black]: `background:${COLOR_HEX.black}`,\n\t[BACKGROUND_CODES.red]: `background:${COLOR_HEX.red}`,\n\t[BACKGROUND_CODES.green]: `background:${COLOR_HEX.green}`,\n\t[BACKGROUND_CODES.yellow]: `background:${COLOR_HEX.yellow}`,\n\t[BACKGROUND_CODES.blue]: `background:${COLOR_HEX.blue}`,\n\t[BACKGROUND_CODES.magenta]: `background:${COLOR_HEX.magenta}`,\n\t[BACKGROUND_CODES.cyan]: `background:${COLOR_HEX.cyan}`,\n\t[BACKGROUND_CODES.white]: `background:${COLOR_HEX.white}`,\n\t[BACKGROUND_CODES.brightBlack]: `background:${COLOR_HEX.brightBlack}`,\n\t[BACKGROUND_CODES.brightRed]: `background:${COLOR_HEX.brightRed}`,\n\t[BACKGROUND_CODES.brightGreen]: `background:${COLOR_HEX.brightGreen}`,\n\t[BACKGROUND_CODES.brightYellow]: `background:${COLOR_HEX.brightYellow}`,\n\t[BACKGROUND_CODES.brightBlue]: `background:${COLOR_HEX.brightBlue}`,\n\t[BACKGROUND_CODES.brightMagenta]: `background:${COLOR_HEX.brightMagenta}`,\n\t[BACKGROUND_CODES.brightCyan]: `background:${COLOR_HEX.brightCyan}`,\n\t[BACKGROUND_CODES.brightWhite]: `background:${COLOR_HEX.brightWhite}`,\n})\n\n/**\n * The browser console directive that switches the active style — one `%c` prefixes every styled run\n * in the {@link import('./types.js').ConsoleOutput} format string, consuming the next entry of the\n * parallel CSS array. The single source of truth for the directive token.\n */\nexport const DIRECTIVE = '%c'\n\n/**\n * Matches one SGR sequence (`ESC[ <params> m`) and CAPTURES its `;`-separated numeric parameters —\n * the subset of ANSI {@link import('@src/core').strip} cares about that carries STYLE (color /\n * attribute / reset), as opposed to cursor / erase / OSC sequences. Global, so the scanner walks\n * every SGR run in a string; built from core's {@link ESC} so no control-character literal appears\n * in source (the codebase idiom). The capture group is the parameter list (`''` for a bare `ESC[m`,\n * which the spec treats as a reset).\n *\n * @remarks\n * A global `RegExp` carries a mutable `lastIndex`; a scan builds a FRESH `RegExp` from this one's\n * `source` + `flags` rather than reuse this instance, so concurrent scans never collide. This is the\n * canonical definition, not a shared scanner.\n */\nexport const SGR_PATTERN = new RegExp(`${ESC}\\\\[([0-9;]*)m`, 'g')\n","import type { ConsoleOutput, StyleAccumulator } from './types.js'\nimport { RESET_CODE } from '@src/core'\nimport {\n\tATTRIBUTE_CSS,\n\tBACKGROUND_CSS,\n\tDIRECTIVE,\n\tFOREGROUND_CSS,\n\tSGR_PATTERN,\n} from './constants.js'\n\n// The pure, browser-only translation behind the `%c` console sink (the C-f branch). The core\n// styler / Logger / Reporter emit ANSI-styled STRINGS; a DevTools console can't render ANSI but\n// can style via `console.log('%ctext', 'css')`, so `ansiToConsole` parses the SGR runs in the\n// incoming text and re-emits them as a `%c`-ready format string + parallel CSS array — the\n// translation happens at the OUTPUT boundary, leaving the core unchanged. Pure + total + `%`-safe.\n// `ansiToConsole` carries immutable style snapshots while its local arrays assemble the final\n// `%c` output; only the standalone, reusable `escapePercent` / `parseParameters` utilities are\n// exported alongside it.\n\n/**\n * Translate an ANSI-styled string into a browser `console.log`-ready {@link ConsoleOutput} — a\n * `%c`-segmented format string and the parallel array of CSS declarations, so a DevTools console\n * renders the SAME styling a terminal would (the C-f sink calls `console[method](format, ...styles)`).\n *\n * @remarks\n * - **SGR runs → `%c` segments.** The text is scanned for SGR sequences ({@link SGR_PATTERN} —\n * `ESC[…m`); each delimits a run. A run carrying VISIBLE text emits one `%c` directive plus that\n * text into `format` and the run's accumulated CSS into `styles`, so the browser switches style at\n * each `%c`. Foreground / background / attribute codes accumulate; the reset code (`0`, or a bare\n * `ESC[m`) clears the accumulated style back to none. A later color of the same channel REPLACES\n * the earlier one; an attribute is added once. Non-SGR escapes (cursor / erase / OSC) are not style\n * and are left in the text verbatim.\n * - **`%`-safe.** Every LITERAL `%` in the text is doubled to `%%` so the console never treats it as\n * a directive — only the `%c`s this function inserts are real directives. So `format`'s real `%c`\n * count always equals `styles.length`, and `console.log(format, ...styles)` lines up exactly.\n * - **Plain text short-circuits.** A string with NO SGR sequence yields `{ format: <escaped text>,\n * styles: [] }` — no `%c`, no styles (the text is still `%`-escaped).\n * - **Pure + total.** Same input → same output; it never throws on any string (adversarial escapes,\n * lone `ESC`, unterminated sequences all fall through as literal text).\n *\n * @param text - Any string, ANSI-styled or plain\n * @returns The `%c` format string + parallel CSS array ({@link ConsoleOutput})\n *\n * @example\n * ```ts\n * ansiToConsole('\\x1b[31mred\\x1b[0m') // { format: '%cred', styles: ['color:#cd0000'] }\n * ansiToConsole('plain') // { format: 'plain', styles: [] }\n * ansiToConsole('50%') // { format: '50%%', styles: [] }\n * ```\n */\nexport function ansiToConsole(text: string): ConsoleOutput {\n\tconst scanner = new RegExp(SGR_PATTERN.source, SGR_PATTERN.flags)\n\t// The accumulated active style across a run — a separate foreground / background declaration\n\t// (each channel REPLACEABLE) plus an ordered, de-duplicated list of attribute declarations. An\n\t// SGR reset empties all three. Serialized to a `;`-joined CSS string per emitted run.\n\tlet active: StyleAccumulator = Object.freeze({\n\t\tforeground: '',\n\t\tbackground: '',\n\t\tattributes: Object.freeze([]),\n\t})\n\tconst segments: string[] = []\n\tconst styles: string[] = []\n\tlet cursor = 0\n\tlet pending = ''\n\tlet match: RegExpExecArray | null = scanner.exec(text)\n\tif (match === null) return { format: escapePercent(text), styles: [] }\n\n\t// A null match is the final text boundary, so every visible run passes through one flush path.\n\twhile (true) {\n\t\tconst boundary = match === null ? text.length : match.index\n\t\tpending += escapePercent(text.slice(cursor, boundary))\n\t\tif (pending !== '') {\n\t\t\tsegments.push(`${DIRECTIVE}${pending}`)\n\t\t\tconst declarations = [...active.attributes]\n\t\t\tif (active.foreground !== '') declarations.push(active.foreground)\n\t\t\tif (active.background !== '') declarations.push(active.background)\n\t\t\tstyles.push(declarations.join(';'))\n\t\t\tpending = ''\n\t\t}\n\t\tif (match === null) break\n\n\t\t// Apply one SGR sequence by replacing the readonly accumulator. A reset clears every channel;\n\t\t// colors replace their channel; attributes accumulate once; unknown extensions are ignored.\n\t\tfor (const code of parseParameters(match[1] ?? '')) {\n\t\t\tif (code === RESET_CODE) {\n\t\t\t\tactive = Object.freeze({\n\t\t\t\t\tforeground: '',\n\t\t\t\t\tbackground: '',\n\t\t\t\t\tattributes: Object.freeze([]),\n\t\t\t\t})\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tconst foreground = FOREGROUND_CSS[code]\n\t\t\tif (foreground !== undefined) {\n\t\t\t\tactive = Object.freeze({ ...active, foreground })\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tconst background = BACKGROUND_CSS[code]\n\t\t\tif (background !== undefined) {\n\t\t\t\tactive = Object.freeze({ ...active, background })\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tconst attribute = ATTRIBUTE_CSS[code]\n\t\t\tif (attribute !== undefined && !active.attributes.includes(attribute)) {\n\t\t\t\tactive = Object.freeze({\n\t\t\t\t\t...active,\n\t\t\t\t\tattributes: Object.freeze([...active.attributes, attribute]),\n\t\t\t\t})\n\t\t\t}\n\t\t}\n\t\tcursor = match.index + match[0].length\n\t\tmatch = scanner.exec(text)\n\t}\n\treturn { format: segments.join(''), styles }\n}\n\n/**\n * Double every literal `%` in `text` to `%%` — the `%`-escape that keeps a browser console from\n * reading a stray `%` (e.g. in `50%` or `%s`) as a format directive. The single escape the\n * {@link ansiToConsole} translation applies to every text segment before assembling the format\n * string (so only the `%c`s it inserts are real directives).\n *\n * @param text - A literal text segment (no inserted directives)\n * @returns `text` with each `%` doubled\n *\n * @example\n * ```ts\n * escapePercent('100% done') // '100%% done'\n * ```\n */\nexport function escapePercent(text: string): string {\n\treturn text.replace(/%/g, '%%')\n}\n\n/**\n * Parse an SGR parameter list (the `;`-separated numeric string captured by {@link SGR_PATTERN})\n * into its numeric codes — `'1;31'` → `[1, 31]`. An EMPTY list (a bare `ESC[m`) yields `[0]`, since\n * the SGR spec treats a parameterless sequence as a reset; an empty field within a list (`'1;;4'`)\n * likewise counts as a `0` reset, matching the spec.\n *\n * @param parameters - The raw `;`-separated parameter string (the regex capture)\n * @returns The parsed SGR codes (a parameterless / empty field becoming `0`)\n *\n * @example\n * ```ts\n * parseParameters('1;31') // [1, 31]\n * parseParameters('') // [0]\n * ```\n */\nexport function parseParameters(parameters: string): readonly number[] {\n\tif (parameters === '') return [RESET_CODE]\n\treturn parameters.split(';').map((field) => (field === '' ? RESET_CODE : Number(field)))\n}\n","import type { LogLevel, SinkInterface } from '@src/core'\nimport { ansiToConsole } from './helpers.js'\n\n// The browser `%c` console sink (the C-f branch) — the platform-bound backend that satisfies core's\n// `SinkInterface` in a browser DevTools console. The core styler / Logger / Reporter emit ANSI-styled\n// STRINGS; a DevTools console can't render ANSI but CAN style via `console.log('%ctext', 'css')`, so\n// this sink translates the incoming ANSI runs into a `%c` call at the OUTPUT boundary (the env-split\n// rule: core owns the contract + universal logic, the browser provides the platform backend). A thin\n// stateless adapter, so a frozen-object factory — like core's `createConsoleSink` — not a class\n// (AGENTS §5). `SinkInterface` / `LogLevel` are IMPORTED from `@src/core`, never redeclared.\n\n/**\n * Create the browser `%c` {@link SinkInterface} — the C-f browser output backend. `write(text, level?)`\n * translates the ANSI-styled `text` into a browser `console` call (`console[method](format, ...styles)`)\n * via {@link ansiToConsole}, so a DevTools console renders the SAME styling a terminal does. Drop it in\n * as a logger / reporter / spinner sink (`createLogger({ sink: createBrowserSink() })`) to retarget the\n * core output to the browser console with no change to the core.\n *\n * @returns A browser `%c` {@link SinkInterface}\n *\n * @remarks\n * - **ANSI → `%c` at the sink.** The core produces ANSI strings; this sink parses the SGR runs and\n * re-emits them as a `console.log`-ready `%c` format string + parallel CSS array ({@link ansiToConsole}\n * — pure, total, and `%`-safe), so the styling survives the trip to a console that can't render ANSI.\n * - **Routes by level.** `error` → `console.error`, `warn` → `console.warn`, every other level (and an\n * omitted level) → `console.log` — the SAME routing as core's `createConsoleSink`, so a logger's level\n * reaches the matching DevTools stream.\n * - **Animation degrade (locked).** A browser console cannot overwrite a line, so a `text` beginning with\n * a carriage return `\\r` (a spinner / progress redraw) has the leading `\\r` STRIPPED and is written as a\n * fresh, non-overwriting line — the locked browser degrade. Only a LEADING `\\r` is stripped; an interior\n * one is left to the console.\n * - **Snapshotted — no capture loop.** It captures `console.log` / `console.warn` / `console.error` AT\n * CREATION and writes through those references, so a later `Capture` that PATCHES `console.*` can never\n * feed this sink's output back into itself (the no-capture-loop principle, AGENTS / the core sink's\n * precedent). Create the sink (or the logger) BEFORE installing a capture.\n *\n * @example\n * ```ts\n * import { createLogger } from '@src/core'\n * import { createBrowserSink } from '@src/browser'\n *\n * const logger = createLogger({ name: 'app', sink: createBrowserSink() })\n * logger.error('boom') // → console.error('%c…', 'color:#cd0000;…') in DevTools\n * ```\n */\nexport function createBrowserSink(): SinkInterface {\n\t// Snapshot the three console writers NOW — bound to their `console` receiver — so a later patch of\n\t// `console.*` (by Capture) can never reach this sink's output (no capture loop), exactly as core's\n\t// `createConsoleSink` does.\n\tconst log = console.log.bind(console)\n\tconst warn = console.warn.bind(console)\n\tconst error = console.error.bind(console)\n\treturn {\n\t\twrite(text: string, level?: LogLevel): void {\n\t\t\t// Degrade the animation redraw first: a leading `\\r` can't overwrite a line in a browser\n\t\t\t// console, so drop it and write a fresh, non-overwriting line (the locked decision).\n\t\t\tconst line = text.startsWith('\\r') ? text.slice(1) : text\n\t\t\tconst { format, styles } = ansiToConsole(line)\n\t\t\tif (level === 'error') {\n\t\t\t\terror(format, ...styles)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif (level === 'warn') {\n\t\t\t\twarn(format, ...styles)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tlog(format, ...styles)\n\t\t},\n\t}\n}\n"],"mappings":"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"}