# linegauge/cli-truncate > Every export of linegauge/cli-truncate, with its signature and doc comment: cliTruncate, plus 1 type. Source: https://linegauge.interlace.tools/docs/api/cli-truncate ```ts import cliTruncate from 'linegauge/cli-truncate'; import type { Options } from 'linegauge/cli-truncate'; ``` ## Functions ### default The default export, declared as `cliTruncate`. Truncate `text` to `columns` terminal columns, as cli-truncate does. ```ts function cliTruncate(text: string, columns: number, options?: Options): string; ``` | Parameter | Type | | :-- | :-- | | `text` | `string` | | `columns` | `number` | | `options` (optional) | `Options` | **Returns** `string` ## Interfaces ### Options cli-truncate's options. ```ts interface Options { /** Which end loses characters. Default `'end'`. */ readonly position?: 'start' | 'middle' | 'end'; /** A space between the text and the mark. Default `false`. */ readonly space?: boolean; /** Cut at a space within three columns of the break, when there is one. Default `false`. */ readonly preferTruncationOnSpace?: boolean; /** The mark that says something was removed. Default `'…'`. */ readonly truncationCharacter?: string; } ``` --- # linegauge > Every export of linegauge, with its signature and doc comment: lineCount, measure, width, width, strip, widest and 3 more, plus 4 types. Source: https://linegauge.interlace.tools/docs/api linegauge — how much of a terminal line a string occupies, and how to fold it. ```ts import width from 'linegauge'; import { lineCount, measure, width, … } from 'linegauge'; ``` ## Functions ### default The default export, declared as `width`. How many terminal columns `input` occupies once its escape sequences are removed. ```ts function width(input: string, options?: WidthOptions): number; ``` | Parameter | Type | | :-- | :-- | | `input` | `string` | | `options` (optional) | `WidthOptions` | **Returns** `number` ### lineCount Lines a string occupies in a terminal `columns` wide — the measurement the frame loop actually asks for. An empty line still occupies one. ```ts function lineCount(text: string, columns: number): number; ``` | Parameter | Type | | :-- | :-- | | `text` | `string` | | `columns` | `number` | **Returns** `number` ### measure Columns a string of *plain* text occupies — no escape scan. The wrapper below has already split its input into text runs and complete sequences, so rescanning would only give a malformed sequence a second chance to be mistaken for one. ```ts function measure(text: string, ambiguousIsWide?: boolean): number; ``` | Parameter | Type | | :-- | :-- | | `text` | `string` | | `ambiguousIsWide` (optional) | `boolean` | **Returns** `number` ### width How many terminal columns `input` occupies once its escape sequences are removed. ```ts function width(input: string, options?: WidthOptions): number; ``` | Parameter | Type | | :-- | :-- | | `input` | `string` | | `options` (optional) | `WidthOptions` | **Returns** `number` ## Interfaces ### Options What `width` accepts beyond the string. Graded against `string-width`'s own suite, so the names and the defaults are its names and its defaults, not ours. ```ts interface WidthOptions { /** * Treat East Asian Ambiguous characters as one column. **Default `true`** — the incumbent's * default, and right for a Latin terminal: `±`, `×`, `÷`, the box-drawing set, Greek and * Cyrillic all occupy one column there. */ ambiguousIsNarrow?: boolean; /** * Count escape sequences as the characters they are made of instead of removing them. */ countAnsiEscapeCodes?: boolean; } ``` ### WidthOptions What `width` accepts beyond the string. Graded against `string-width`'s own suite, so the names and the defaults are its names and its defaults, not ours. ```ts interface WidthOptions { /** * Treat East Asian Ambiguous characters as one column. **Default `true`** — the incumbent's * default, and right for a Latin terminal: `±`, `×`, `÷`, the box-drawing set, Greek and * Cyrillic all occupy one column there. */ ambiguousIsNarrow?: boolean; /** * Count escape sequences as the characters they are made of instead of removing them. */ countAnsiEscapeCodes?: boolean; } ``` ## Re-exported Documented on the page of the entry point that declares them. | Export | Kind | Documented in | | :-- | :-- | :-- | | `slice` | function | [`linegauge/slice`](/docs/api/slice#slice) | | `strip` | function | [`linegauge/strip`](/docs/api/strip#strip) | | `truncate` | function | [`linegauge/truncate`](/docs/api/truncate#truncate) | | `widest` | function | [`linegauge/widest`](/docs/api/widest#widest) | | `wrap` | function | [`linegauge/wrap`](/docs/api/wrap#wrap) | | `TruncateOptions` | interface | [`linegauge/truncate`](/docs/api/truncate#truncateoptions) | | `WrapOptions` | interface | [`linegauge/wrap`](/docs/api/wrap#wrapoptions) | --- # linegauge/plugin > Every export of linegauge/plugin, with its signature and doc comment: setOverrides, overrides, problems, validate, register, CONTRACT and 1 more, plus 3 types. Source: https://linegauge.interlace.tools/docs/api/plugin ```ts import { setOverrides, overrides, problems, … } from 'linegauge/plugin'; ``` ## Functions ### overrides Every override currently registered, by plugin name — what `linegauge check` prints. ```ts function overrides(): ReadonlyMap>; ``` **Returns** `ReadonlyMap>` ### problems Everything wrong with a plugin document, in the family's vocabulary. Empty means it registers. ```ts function problems(plugin: unknown): Problem[]; ``` | Parameter | Type | | :-- | :-- | | `plugin` | `unknown` | **Returns** `Problem[]` ### register Register a plugin's width overrides. Later registrations win over earlier ones, and over the built-in tables — which is the point: the built-in answer is right for most terminals and the user is the authority on theirs. ```ts function register(plugin: unknown): void; ``` | Parameter | Type | | :-- | :-- | | `plugin` | `unknown` | **Returns** `void` ### setOverrides Later registrations win, which is the point: the user is the authority on their terminal. ```ts function setOverrides(name: string, widths: Record): void; ``` | Parameter | Type | | :-- | :-- | | `name` | `string` | | `widths` | `Record` | **Returns** `void` ### validate Throws on the first problem, in the family's vocabulary. ```ts function validate(plugin: unknown): asserts plugin is Plugin; ``` | Parameter | Type | | :-- | :-- | | `plugin` | `unknown` | **Returns** `asserts plugin is Plugin` ## Classes ### PluginError A refused plugin says what is wrong and what to do about it — the family's one vocabulary. ```ts class PluginError extends Error { readonly code: PluginErrorCode; readonly fix: string; constructor(code: PluginErrorCode, message: string, fix: string); } ``` ## Constants ### CONTRACT The plugin contract version. One number for the family — the same `1` flagstaff, caique, closeout and paratext declare, written out rather than imported for the reason above. ```ts const CONTRACT = 1; ``` ## Interfaces ### Plugin The keys linegauge reads. Declared structurally: any object with these fields is a plugin here, whatever else it carries. ```ts interface Plugin { name: string; contract?: number; /** Width overrides by name. The key is the author's label; see {@link validate}. */ widths?: Record; } ``` ### WidthOverride One plugin-supplied width override: the column count a set of code-point ranges occupies. ```ts interface WidthOverride { /** Inclusive `[low, high]` code-point pairs. */ ranges: readonly (readonly [number, number])[]; /** 0 for a zero-width mark, 1 narrow, 2 wide. */ columns: number; /** Which terminal or font disagrees, and how it was measured. Required; see the file comment. */ why: string; } ``` ## Types ### PluginErrorCode Two codes, both already in the family's vocabulary (`scripts/plugin-error-vocabulary-lock.test.ts`). ```ts type PluginErrorCode = 'E_PLUGIN_SCHEMA' | 'E_PLUGIN_CONTRACT' | 'E_NO_CONTRIBUTION'; ``` --- # linegauge/slice > Every export of linegauge/slice, with its signature and doc comment: slice, slice. Source: https://linegauge.interlace.tools/docs/api/slice ```ts import slice from 'linegauge/slice'; import { slice } from 'linegauge/slice'; ``` ## Functions ### default The default export, declared as `slice`. `[start, end)` in display columns. A negative or reversed range is empty rather than an error, matching `String.prototype.slice`'s temperament if not its units. ```ts function slice(string: string, start?: number, end?: number): string; ``` | Parameter | Type | | :-- | :-- | | `string` | `string` | | `start` (optional) | `number` | | `end` (optional) | `number` | **Returns** `string` ### slice `[start, end)` in display columns. A negative or reversed range is empty rather than an error, matching `String.prototype.slice`'s temperament if not its units. ```ts function slice(string: string, start?: number, end?: number): string; ``` | Parameter | Type | | :-- | :-- | | `string` | `string` | | `start` (optional) | `number` | | `end` (optional) | `number` | **Returns** `string` --- # linegauge/strip > Every export of linegauge/strip, with its signature and doc comment: strip, strip. Source: https://linegauge.interlace.tools/docs/api/strip ```ts import strip from 'linegauge/strip'; import { strip } from 'linegauge/strip'; ``` ## Functions ### default The default export, declared as `strip`. Everything a terminal would print, with the escape sequences removed: CSI (SGR and the cursor and erase forms), OSC including `OSC 8` hyperlinks under all three terminators, the C1 introducers, and the single-character escapes. A lone `ESC` with nothing that parses after it is text and is kept — the same answer `strip-ansi` gives, because it is the same pattern. A string with neither `ESC` nor `0x9B` is returned as it is, without a replace — strip-ansi's own fast path, including what it leaves alone: a C1 OSC (`0x9D`) with no `ESC` or `0x9B` beside it is kept by the incumbent, and so it is kept here. ```ts function strip(string: string): string; ``` | Parameter | Type | | :-- | :-- | | `string` | `string` | **Returns** `string` ### strip Everything a terminal would print, with the escape sequences removed: CSI (SGR and the cursor and erase forms), OSC including `OSC 8` hyperlinks under all three terminators, the C1 introducers, and the single-character escapes. A lone `ESC` with nothing that parses after it is text and is kept — the same answer `strip-ansi` gives, because it is the same pattern. A string with neither `ESC` nor `0x9B` is returned as it is, without a replace — strip-ansi's own fast path, including what it leaves alone: a C1 OSC (`0x9D`) with no `ESC` or `0x9B` beside it is kept by the incumbent, and so it is kept here. ```ts function strip(string: string): string; ``` | Parameter | Type | | :-- | :-- | | `string` | `string` | **Returns** `string` --- # linegauge/truncate > Every export of linegauge/truncate, with its signature and doc comment: truncate, plus 1 type. Source: https://linegauge.interlace.tools/docs/api/truncate ```ts import { truncate } from 'linegauge/truncate'; ``` ## Functions ### truncate ```ts function truncate(string: string, columns: number, options?: TruncateOptions): string; ``` | Parameter | Type | | :-- | :-- | | `string` | `string` | | `columns` | `number` | | `options` (optional) | `TruncateOptions` | **Returns** `string` ## Interfaces ### TruncateOptions ```ts interface TruncateOptions { /** Which end loses characters. Default `'end'`. */ position?: 'start' | 'middle' | 'end'; /** The mark that says something was removed. Measured, not assumed. Default `'\u2026'`. */ ellipsis?: string; } ``` --- # linegauge/widest-line > Every export of linegauge/widest-line, with its signature and doc comment: widestLine. Source: https://linegauge.interlace.tools/docs/api/widest-line ```ts import widestLine from 'linegauge/widest-line'; ``` ## Functions ### default The default export, declared as `widestLine`. The width of the widest line in `string`, in terminal columns. ```ts function widestLine(string: string): number; ``` | Parameter | Type | | :-- | :-- | | `string` | `string` | **Returns** `number` --- # linegauge/widest > Every export of linegauge/widest, with its signature and doc comment: widest. Source: https://linegauge.interlace.tools/docs/api/widest ```ts import { widest } from 'linegauge/widest'; ``` ## Functions ### widest ```ts function widest(lines: Iterable): number; ``` | Parameter | Type | | :-- | :-- | | `lines` | `Iterable` | **Returns** `number` --- # linegauge/wrap > Every export of linegauge/wrap, with its signature and doc comment: visibleWidth, wrap, wrap, plus 2 types. Source: https://linegauge.interlace.tools/docs/api/wrap ```ts import wrap from 'linegauge/wrap'; import { visibleWidth, wrap } from 'linegauge/wrap'; ``` ## Functions ### default The default export, declared as `wrap`. Wrap `string` to `columns`, keeping its ANSI intact and each row self-contained. ```ts function wrap(string: string, columns: number, options?: WrapOptions): string; ``` | Parameter | Type | | :-- | :-- | | `string` | `string` | | `columns` | `number` | | `options` (optional) | `WrapOptions` | **Returns** `string` ### visibleWidth The visible width of a string, escape sequences ignored. ```ts function visibleWidth(string: string): number; ``` | Parameter | Type | | :-- | :-- | | `string` | `string` | **Returns** `number` ### wrap Wrap `string` to `columns`, keeping its ANSI intact and each row self-contained. ```ts function wrap(string: string, columns: number, options?: WrapOptions): string; ``` | Parameter | Type | | :-- | :-- | | `string` | `string` | | `columns` | `number` | | `options` (optional) | `WrapOptions` | **Returns** `string` ## Interfaces ### Options ```ts interface WrapOptions { /** Trim leading and trailing whitespace from each row. Default true. */ trim?: boolean; /** Break a word longer than `columns` rather than let it overflow. Default false. */ hard?: boolean; /** Break on any character rather than at word boundaries. Default true. */ wordWrap?: boolean; } ``` ### WrapOptions ```ts interface WrapOptions { /** Trim leading and trailing whitespace from each row. Default true. */ trim?: boolean; /** Break a word longer than `columns` rather than let it overflow. Default false. */ hard?: boolean; /** Break on any character rather than at word boundaries. Default true. */ wordWrap?: boolean; } ``` --- # Changelog > Every release of linegauge, newest first, from its CHANGELOG.md — what changed and the pull request it came from. Source: https://linegauge.interlace.tools/docs/changelog ## 1.2.1 ### Patch Changes - [#1483](https://github.com/ofri-peretz/burgee/pull/1483) [`3e69fb2`](https://github.com/ofri-peretz/burgee/commit/3e69fb2d8d068210347ac71d2886d6a4309859d1) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - The package manifest names its author and publishes with `publishConfig` `{ "access": "public", "provenance": true }`. burgee's `homepage` is its landing page, https://burgee.interlace.tools. oxmutate gains `keywords`, so npm search finds it, and a `sideEffects` list (its CLI, its worker and its Vitest runner run on load), so a bundler can drop the rest. No code changes. ## 1.2.0 ### Minor Changes - [#1411](https://github.com/ofri-peretz/burgee/pull/1411) [`e500294`](https://github.com/ofri-peretz/burgee/commit/e500294af9e3810f108039d637aef7162affb166) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - `linegauge/slice` follows slice-ansi 10.0.0 (106 / 106, level with its control): a cut counts the columns `width` counts, so CRLF and every other cluster that draws nothing take no column, and a lone regional indicator takes one, where slice-ansi 9 gave each at least one position and the indicator two. `linegauge/cli-truncate` follows cli-truncate 6.2.0 (17 / 17): `preferTruncationOnSpace` finds its space by terminal column, so a style or a wide or zero-width character before the break no longer moves it, and the text after the mark loses its leading spaces without losing its trailing ones or its closing style. `linegauge/strip` follows ansi-regex 6.5.0, which strip-ansi 7.2.0 now resolves: a CSI's final byte is never a digit, and a private-parameter CSI such as the device attributes reply is stripped whole. ## 1.1.5 ### Patch Changes - [#1258](https://github.com/ofri-peretz/burgee/pull/1258) [`e3b3a41`](https://github.com/ofri-peretz/burgee/commit/e3b3a41745ecdc853d741c457306c566541271b6) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - `check` reads a `.json` plugin file as data. It handed every file to `import()`, and Node refuses a `.json` module without an import attribute (ERR_IMPORT_ATTRIBUTE_MISSING), so a plugin written as JSON got Node's message and no verdict, from the host's own `check` and through `burgee check`, which runs it. - [#1266](https://github.com/ofri-peretz/burgee/pull/1266) [`02e6f9f`](https://github.com/ofri-peretz/burgee/commit/02e6f9fcd779d892e7097659a6bd4c2f0e5b5a04) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - The family plugin schema (`schema.json`, identical in every plugin host) now describes `formats`, burgee's output-format key. This package reads nothing new, and a plugin carrying `formats` registers here as before. - [#1252](https://github.com/ofri-peretz/burgee/pull/1252) [`efc994e`](https://github.com/ofri-peretz/burgee/commit/efc994ea20dae1f31990a7afef9dfa3c34fee596) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - The README names the Node builtin the package builds on, and says what the package adds over it: `Intl.Segmenter` and `util.stripVTControlCharacters` (linegauge), `process.loadEnvFile` and `util.parseEnv` (seniority), `node:child_process` (bellpull), `process.on(signal)` (closeout). The README's opening names the package and its incumbents only, and every benchmark figure in it links to where it is measured. ## 1.1.4 ### Patch Changes - [#1186](https://github.com/ofri-peretz/burgee/pull/1186) [`af24328`](https://github.com/ofri-peretz/burgee/commit/af243288e27cbffbcffac18af7112172e46a003b) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - Published declarations now keep each doc comment's first paragraph, its code examples and its `@param`, `@returns`, `@throws`, `@example`, `@deprecated`, `@default` and `@see` tags, and drop the longer design notes below them, so the tarballs are smaller (burgee by 5.7% gzipped, bellpull and closeout by about a fifth). Types are unchanged, and hover text in an editor keeps its summary. `burgee`'s README also names `burgee/program-schema.json`, the JSON Schema every `--schema` document validates against. ## 1.1.3 ### Patch Changes - [#1160](https://github.com/ofri-peretz/burgee/pull/1160) [`0b381a0`](https://github.com/ofri-peretz/burgee/commit/0b381a0d394092edf2ea21a9143a10111aa72fe4) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - Export `./package.json`. `require('/package.json')` threw `ERR_PACKAGE_PATH_NOT_EXPORTED`, which broke tools that read a dependency's manifest that way: bundlers, license scanners and version checks. ## 1.1.2 ### Patch Changes - [#1130](https://github.com/ofri-peretz/burgee/pull/1130) [`c05c86e`](https://github.com/ofri-peretz/burgee/commit/c05c86e2caa0fc3a89f97f7fe9c37bd7b876bfaf) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - Every drop-in subpath now type-checks under `moduleResolution: node10`, and under `module: commonjs` with no resolution set, which is TypeScript's default there. `import … from 'burgee/commander'` used to fail with TS2307 in such a project, although `require()` of the same specifier worked. Each package now declares `types` and a `typesVersions` map, derived from `exports`, with one key per typed subpath pointing at the file `exports` already names. `exports` is unchanged, so `node16`, `nodenext` and `bundler` read exactly what they read before. `controlroom/ink`'s declarations no longer name the untyped `react-reconciler`, which was TS7016 for consumers with `skipLibCheck: false`. ## 1.1.1 ### Patch Changes - [#1045](https://github.com/ofri-peretz/burgee/pull/1045) [`c4fd9a3`](https://github.com/ofri-peretz/burgee/commit/c4fd9a3cb10bc36bef8c468365addfc6deb572cf) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - The family plugin schema (`schema.json`, identical in every plugin host) now describes `locales`, burgee's locale-pack key. A plugin carrying `locales` validates against the shared schema as it does against `burgee/locale-pack-schema.json`, which lists every message id. This package reads nothing new, and a plugin carrying `locales` registers here as before. ## 1.1.0 ### Minor Changes - [#1007](https://github.com/ofri-peretz/burgee/pull/1007) [`bfe4ab8`](https://github.com/ofri-peretz/burgee/commit/bfe4ab8b13ff04293345a69007cad9e0cb81c678) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - Two new drop-in subpaths, each graded by its incumbent's own suite and level with it. `linegauge/cli-truncate` is cli-truncate 6 (12 of 12): its default export and `position`, `space`, `preferTruncationOnSpace` and `truncationCharacter`, over linegauge's own `slice` and `width`. `linegauge/widest-line` is widest-line 6 (1 of 1) over `widest`. `truncate` and `widest` are unchanged. `burgee migrate` now rewrites `cli-truncate` and `widest-line`. ### Patch Changes - [#1011](https://github.com/ofri-peretz/burgee/pull/1011) [`1968eb4`](https://github.com/ofri-peretz/burgee/commit/1968eb46feec57bb047a9c4a4288e0cf4442f4b2) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - The README quick start runs as written. On npm it was broken for flagstaff (`rt` undefined) and linegauge (`styled` undefined), controlroom's imported flagstaff without installing it, and paratext's printed nothing. No code changed; `readme-quick-start-lock` now runs every package's quick start. - [#1022](https://github.com/ofri-peretz/burgee/pull/1022) [`ecb0a00`](https://github.com/ofri-peretz/burgee/commit/ecb0a00ca68e9a3b3584aefabb9dcf97e3ff967a) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - The tarball no longer carries declaration files that no `exports` entry reaches. TypeScript could never open them, because a deep import is refused by `exports`. Every entry's types are unchanged. The ten packages drop 71 files and 118,312 bytes between them, burgee 35 files and 76,504 bytes, which takes its tarball from 251,814 to 225,278 bytes gzipped. ## 1.0.8 ### Patch Changes - [#938](https://github.com/ofri-peretz/burgee/pull/938) [`9babb6d`](https://github.com/ofri-peretz/burgee/commit/9babb6da1ae7783c7e8367f89e3da9dffc85ffdb) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - npm description and README: the description says what the package does, then its dependency fact in one of two wordings ("Zero dependencies." or "No dependency outside the burgee family."), then its drop-in paths. The README's family section states how the family is built — the leaves, one-way dependencies, nothing from outside the family, trusted publishing with provenance — and its table column is "Migrates from". No API, compatibility grade or benchmark changes. ## 1.0.7 ### Patch Changes - [#917](https://github.com/ofri-peretz/burgee/pull/917) [`735f0f6`](https://github.com/ofri-peretz/burgee/commit/735f0f68048f2d0f5e4effad151ed5132b480bb8) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - README: the opening paragraph says what the package does first. The drop-in path follows it as one line, naming the incumbent and `npx burgee migrate`, and every compatibility row and benchmark is unchanged. ## 1.0.6 ### Patch Changes - [#897](https://github.com/ofri-peretz/burgee/pull/897) [`f29f159`](https://github.com/ofri-peretz/burgee/commit/f29f159ca0c9746f58d8ba84eec9be72b1140093) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - controlroom has its own docs site at https://controlroom.interlace.tools: its README's Docs line, its `homepage`, and every family table now link there. ## 1.0.5 ### Patch Changes - [#848](https://github.com/ofri-peretz/burgee/pull/848) [`6ae533e`](https://github.com/ofri-peretz/burgee/commit/6ae533e4610c32bad557f5d74a24d0c9a0f07f7e) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - The family `schema.json` describes what five values look like. `contract` is `1`, and burgee refuses a plugin that declares none. A caique widget's `static(spec)` gets `{ kind, message, ...sample.done }`. A seniority `rank` sits between the built-in layers at flag 0, environment 10, config file 20, `package.json` 30 and default 40. A burgee hook's `filter` is `{ command: RegExp }`, now with `command` required, so the schema and the host refuse the same filters. - [#857](https://github.com/ofri-peretz/burgee/pull/857) [`d80b2d0`](https://github.com/ofri-peretz/burgee/commit/d80b2d0f4a535400c6825b42ac2d8e1be2827f4e) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - The family `schema.json` closes a bellpull resolver's `when` with `additionalProperties: false`, so the schema and bellpull's own validation refuse the same unknown keys. ## 1.0.4 ### Patch Changes - [#816](https://github.com/ofri-peretz/burgee/pull/816) [`5230016`](https://github.com/ofri-peretz/burgee/commit/52300169a98ece41b3544e5316833464e63a7c83) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - controlroom hosts plugins (R10): `keymaps` and `panes` register through `controlroom/plugin`'s `register()` against the family schema, a screen takes either by name, and `controlroom check ` reports what a plugin contributes. The family schema every host ships gains the `keymaps` and `panes` definitions. ## 1.0.3 ### Patch Changes - [#781](https://github.com/ofri-peretz/burgee/pull/781) [`df87199`](https://github.com/ofri-peretz/burgee/commit/df87199c8484ffaeae523dc55131f33eca485f5f) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - Remove 20 unreachable `??` fallbacks from `width` and `wrap`. Each one guarded a value that is always present: an in-range table index, the code point of a non-empty cluster, the first element of `String.prototype.split`, or a named group of a regex that declares it. No output changes: the compat grades are unchanged at 233/85/8/104. The coverage gate is now a plain 100% on lines, functions, statements and branches, with no per-file exception. ## 1.0.2 ### Patch Changes - [#777](https://github.com/ofri-peretz/burgee/pull/777) [`1857ff6`](https://github.com/ofri-peretz/burgee/commit/1857ff65ca0e1b0b5686480c1a37dc8834bf688b) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - `measure` answers a one-unit string without building a grapheme segmenter. A prompt frame's glyphs (`│`, `●`, `◆`) arrive at `wrap` as words of their own, and segmenting each one was most of what `caique/clack` spent measuring. In B5, `caique/clack` ÷ @clack/prompts went from 1.45× on CI to 1.09× locally. ## 1.0.1 ### Patch Changes - [#763](https://github.com/ofri-peretz/burgee/pull/763) [`6c2e9c5`](https://github.com/ofri-peretz/burgee/commit/6c2e9c5cee5d9962c0d75d84a766c76b76760f7f) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - Faster at every entry point, with the same answers: - `strip` is strip-ansi's own grammar in one pass, with its fast path for a string with no escape. - `slice` segments lazily and stops at `end`. - `width` and `wrap` measure each cluster once, and skip the emoji regexes for Latin, CJK and box-drawing clusters. B5, ours ÷ incumbent: `slice` 8.7× → 0.76×, `width` 1.34× → 0.70×, `wrap` 1.82× → 0.73×, `strip` 4.3× → 1.0×. `flagstaff`'s log-update, boxen and cli-table3 drop to 0.39–0.72× on the back of it. `width` and `strip` now also agree with string-width and strip-ansi on the edges the old two-pass strip answered differently: a C1 OSC, and `ESC [` before a byte that ends no sequence. Bundles are lighter too: `linegauge` 6,448 → 5,653 B (0.94× string-width) and `linegauge/strip` 950 → 272 B (0.60× strip-ansi). ## 1.0.0 ### Major Changes - [#685](https://github.com/ofri-peretz/burgee/pull/685) [`6ef8227`](https://github.com/ofri-peretz/burgee/commit/6ef822762f5ad19564215945d7e76aa329614585) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - linegauge 1.0.0. No API changes from 0.5: this release makes a promise. Every published entry point is now under semver and can change incompatibly only in a new major: `linegauge`, `linegauge/wrap`, `linegauge/slice`, `linegauge/truncate`, `linegauge/widest`, `linegauge/strip`, `linegauge/plugin`, the `schema.json` plugin contract and the `linegauge` bin. Each drop-in path is graded at 100% by its incumbent's own test suite, vendored at the release tag and run unmodified: string-width 8.3.0 by 233 of 233 cases (`linegauge`), wrap-ansi 10.0.2 by 85 of 85 (`linegauge/wrap`), strip-ansi 7.2.0 by 8 of 8 (`linegauge/strip`) and slice-ansi 9.0.1 by 104 of 104 (`linegauge/slice`). Grading the newest string-width and wrap-ansi before release found two defects, fixed here: `width()` could hang on a run of combining joiners, and `wrap()` normalized escape sequences. Out of scope: `truncate` and `widest` are linegauge's own API and are not graded against an incumbent's suite, and earlier majors of the four incumbents are not claimed. ### Minor Changes - [#655](https://github.com/ofri-peretz/burgee/pull/655) [`4319563`](https://github.com/ofri-peretz/burgee/commit/4319563b902c9d968786196f495cf47e7d4d9d39) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - linegauge: `linegauge/slice` is graded against slice-ansi 9.0.1's own suite (104 cases, up from 15 at 7.1.2) and passes all 104; East Asian Width is Unicode 17. - `slice` rounds **inward** at a wide character, as slice-ansi does: a cluster the range only half covers is left out instead of returned whole. Before, `slice('あいう', 0, 3)` returned `あい` (four columns), and `truncate('あいう', 4)` returned `あい…` (five columns, over its budget). Both now stay inside the columns asked for. - `slice` reads the escapes slice-ansi 9 reads: `OSC 8` links ended by `ESC \` or `U+009C`, the C1 `OSC` introducer, `DCS`, `SOS`, `PM` and `APC` strings, a lone `ST`, and truncated or malformed `CSI`. A malformed `CSI` ends at the first byte that cannot belong to it, so the text after it is kept. - An escape inside a grapheme cluster (`e`, a style, then a combining mark) no longer splits the cluster. - Hyperlinks don't nest: a second open replaces the first, and the first is closed with its own introducer and terminator. A link around no visible text is removed. A close just past the end of the range is kept as written, and an opener with no text after it is dropped. - Where a cut lands, `slice` gives every cluster at least one position (so CRLF and zero-width characters can start or end a range) and a lone regional indicator two, as slice-ansi does. `width` is unchanged and still answers as string-width does. - The Wide/Fullwidth table is generated from get-east-asian-width 1.7.0 (Unicode 17), like the Ambiguous table next to it. The hand-written table was missing 1,147 code points, which `width` measured as one column where string-width measures two. burgee: `burgee migrate` serves slice-ansi 9 (its compatibility row moved from 7.1.2 to 9.0.1). slice-ansi 7 is still graded, at 14 of 15, but no longer claimed, so a project on slice-ansi 7 is left on it. ### Patch Changes - [#717](https://github.com/ofri-peretz/burgee/pull/717) [`7c77cd2`](https://github.com/ofri-peretz/burgee/commit/7c77cd25c8fbeea4199c38c135e3a8937907f849) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - Code no test could reach is gone, and nothing a caller can observe changes. - `slice()`: the fallbacks for a character or code point read at an index that is always inside the string, and the check for a visible token the segmenter could not have skipped, are removed. The hyperlink bookkeeping no longer tracks "no position yet" before the slice starts — nothing has been emitted then, so taking an empty link back out removes nothing — and an empty link at the cut is taken out once, at the end, rather than twice. - The SGR reader shared by `slice`, `wrap` and `truncate` drops the same kind of fallback, and a bounds check that a malformed-colour check after it already covered. - `linegauge check`: an unused helper and an unreachable `?` in the report are removed. The published entries that cut styled text are a little lighter; `linegauge`'s unpacked size goes from 102,495 to 102,004 bytes. - [#685](https://github.com/ofri-peretz/burgee/pull/685) [`6ef8227`](https://github.com/ofri-peretz/burgee/commit/6ef822762f5ad19564215945d7e76aa329614585) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - Security: a string of combining grapheme joiners could hang `width()`. Deciding whether a cluster occupies no column used the regex `^(?:DI|Control|Format|Mn|Me|Surrogate)+$`, and `U+034F` COMBINING GRAPHEME JOINER belongs to two of those classes, so a run of them followed by one visible character backtracked exponentially: 26 joiners took 2.4 seconds and 1,000 did not finish in ten minutes. Any caller measuring untrusted text, and everything that measures through `width()` (`wrap`, `slice`, `truncate`, `widest`, `lineCount`), could be stalled by a few dozen invisible characters. The check is now a loop over code points, linear in the input: 1,000 joiners measure in under a millisecond and 3,000,000 in about 200 ms, with the answer unchanged on every input the regex finished on. Found by string-width 8.3.0's suite, which added these cases. - [#685](https://github.com/ofri-peretz/burgee/pull/685) [`6ef8227`](https://github.com/ofri-peretz/burgee/commit/6ef822762f5ad19564215945d7e76aa329614585) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - Fixed: `wrap()` normalized the whole string to NFC, escape sequences included. A combining mark right after a sequence composed with the sequence's last character, so `ESC[31m` followed by `U+0301` became `ESC[31ḿ`. That is no longer an SGR: the colour was lost and its bytes were wrapped as visible text. OSC payloads such as window titles and hyperlink targets were rewritten as well. Only the text between sequences is normalized now, as wrap-ansi 10.0.2 does. `flagstaff/log-update` and `flagstaff/boxen` wrap through it. - [#674](https://github.com/ofri-peretz/burgee/pull/674) [`e9f45d8`](https://github.com/ofri-peretz/burgee/commit/e9f45d85d9db5b2e3dcaa1e43f292a1281a6952a) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - README: family header, badges, install, migrating, the family table. Every package README now opens the same way — lockup, tagline, one badge row in one order (npm version, downloads, Quality Gate, the package's own coverage, OpenSSF Scorecard, unpacked size, dependencies, types, Node, licence, npm provenance), a row of compatibility badges read from the graded baseline — and carries the same sections in the same order: Install for npm, pnpm, yarn and bun, Quick start, Migrating as a before/after diff, Compatibility, Benchmarks, For agents, API, and a generated table of the nine packages. Links are absolute, so they work on npm as well as GitHub. ## 0.5.4 ### Patch Changes - [#604](https://github.com/ofri-peretz/burgee/pull/604) [`0e7b1e8`](https://github.com/ofri-peretz/burgee/commit/0e7b1e88a7021350cf689f109c7728f799be1589) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - Each README links to its migration guides under the docs link: "Migrating from: chalk", "ora · log-update · boxen · cli-table3", and so on. That puts a path from the npm page to the guide for the library you are replacing. No code changes. ## 0.5.3 ### Patch Changes - [#588](https://github.com/ofri-peretz/burgee/pull/588) [`073037a`](https://github.com/ofri-peretz/burgee/commit/073037ab38b13490ab119c84122a46c7c605be14) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - READMEs and package descriptions now match what each drop-in path is graded at. bellpull names `bellpull/node-which` as the drop-in for npm `which` (5 / 5) and no longer lists execa as a drop-in. paratext's ansi-escapes row is 4 / 4, with the CSI half implemented. seniority's rc row is 1 / 1, dotenv's `config()` defaults to `process.env`, and lilconfig is 77 / 77. linegauge documents `ambiguousIsNarrow` and `strip` as shipped. No code changes. ## 0.5.2 ### Patch Changes - [#474](https://github.com/ofri-peretz/burgee/pull/474) [`1955419`](https://github.com/ofri-peretz/burgee/commit/19554194342b55f8893161f894a8c2a4df1b0f21) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - burgee plugins can hook two more stages. `parse` runs before the command is resolved: it receives argv and may return a replacement, which is how an alias plugin maps `d` to `deploy`. `shutdown` runs once as the program exits, whether the command succeeded or failed. The family `schema.json` shipped in every package now describes both stages. - [#519](https://github.com/ofri-peretz/burgee/pull/519) [`77ff1cb`](https://github.com/ofri-peretz/burgee/commit/77ff1cb8e595d32bb8bddf441ae21fc61e6f247d) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - The drop-ins now export their incumbents' type names, so a TypeScript program migrates by its import alone: `roundel/chalk` gains chalk's `Color`, `ForegroundColor`, `BackgroundColor`, `Modifiers` and `Options`; `flagstaff/ora` gains `Spinner`, `PrefixTextGenerator` and `SuffixTextGenerator`; `flagstaff/boxen` gains `Options`, `CustomBorderStyle` and `Boxes`; `flagstaff/log-update`, `linegauge`, `linegauge/wrap` and `closeout/exit-hook` gain `Options`; `burgee/yargs/parser` gains `Arguments`, `Options` and `Configuration`. Types only — no runtime bytes. ## 0.5.1 ### Patch Changes - [#508](https://github.com/ofri-peretz/burgee/pull/508) [`1aae1e2`](https://github.com/ofri-peretz/burgee/commit/1aae1e2186ce88421067df5317795773419e53d0) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - ` --help` and `--version` answer instead of crashing. The bin took its first argument as the plugin file to import, so `roundel --help` failed with `Cannot find module '…/--help'` and exit 1. `-h`/`--help` now print usage and exit 0, `-V`/`--version` print the version and exit 0, and any other flag where the plugin file belongs is a usage error, exit 2. ## 0.5.0 ### Minor Changes - [#507](https://github.com/ofri-peretz/burgee/pull/507) [`b8e97dc`](https://github.com/ofri-peretz/burgee/commit/b8e97dcb64772e413f0b6f9e17e063c73314d242) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - Runs on Node 20 and 22, not just 24+: `engines.node` is now `^20.19.0 || >=22.13.0`. Those are the first releases where `require(esm)` loads without a warning, so the CommonJS `require()` path keeps working. Every package's test suite runs on exactly 20.19.0 and 22.13.0, on Linux, macOS and Windows. caique's prompts no longer call `Promise.withResolvers`, which Node 20 doesn't have. ### Patch Changes - [#505](https://github.com/ofri-peretz/burgee/pull/505) [`9800b43`](https://github.com/ofri-peretz/burgee/commit/9800b43d9c74a49dfb66d04a40fd0d1c48892e20) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - Docs: the Benchmarks section's weight ceiling is re-measured against a fresh install of each incumbent's latest release (cosmiconfig 10.0.1, slice-ansi 9.0.1, which 7.0.0, dotenv 18.0.3, …) instead of the copies hoisted in this workspace, and names incumbents that were measured but left out of the ceiling as exactly that. ## 0.4.4 ### Patch Changes - [#494](https://github.com/ofri-peretz/burgee/pull/494) [`f7f6d4b`](https://github.com/ofri-peretz/burgee/commit/f7f6d4b8e8f9d9c7010bd4c81fda4b4d106fc9f0) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - Each package's `homepage` and README docs link now point at its own documentation site, `https://.interlace.tools`, instead of a page on burgee's site. The old `burgee.interlace.tools/docs/packages/` URLs answer with a 301 to the new host, so nothing already linked breaks. closeout's README override example also resolves to the current release again (`npm:closeout@^0.4`; the 0.4.0 release left it at `^0.3`). ## 0.4.3 ### Patch Changes - [#480](https://github.com/ofri-peretz/burgee/pull/480) [`2dc573f`](https://github.com/ofri-peretz/burgee/commit/2dc573f884e7a4cc46829cd8f2c949a17f07710c) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - README corrections: paratext shows terminal-link at its measured 8 / 10 (was the stale 0 / 10 floor); linegauge's and closeout's `npm:` override examples resolve to the current release instead of 0.2 / 0.1. ## 0.4.2 ### Patch Changes - [#465](https://github.com/ofri-peretz/burgee/pull/465) [`acf98f3`](https://github.com/ofri-peretz/burgee/commit/acf98f3e612c6d79e6c2b78a847abcd06a063cbc) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - Each README now opens with the incumbent it replaces and the agent surface it serves (`--json`, an agent event, or a static projection), so npm shows both above the fold. README text only; no code changed. ## 0.4.1 ### Patch Changes - [#454](https://github.com/ofri-peretz/burgee/pull/454) [`b4584e7`](https://github.com/ofri-peretz/burgee/commit/b4584e719bc0064b294aab5ea6da1c11f698f0e9) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - Every package's npm `homepage` now points at its page on the docs site, `https://burgee.interlace.tools/docs/packages/`, and each README links it under the header. The keywords add what people and models search for: `burgee` gains `cli-framework`, `argument-parser`, `subcommands`, `json-schema`, `mcp-server`, `model-context-protocol`, `ai-agent`, `llm`, `shell-completion`, `typescript`, `zero-dependency`, `commander-alternative` and `yargs-alternative`; the other eight gain `agent`, `ai-agent`, `non-tty`, `json` and `zero-dependency` where the package does that — `zero-dependency` only on the six that install nothing at all. `burgee`'s README gains a short FAQ (commander alternative, agent use, MCP, dependencies) and states the compatibility counts the oracle holds — 1,360 / 1,360 of commander's tests and 804 / 804 of yargs' — where it had said 1,215 and 1,185. `caique`'s README no longer calls a released package pre-release. - [#442](https://github.com/ofri-peretz/burgee/pull/442) [`bdaf364`](https://github.com/ofri-peretz/burgee/commit/bdaf364f81564c1700cf1adec18f927afe6c60c9) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - Every package now lists `plugin`, `plugins` and `extensible` in its npm keywords, because every package takes plugins through one shared contract. A plugin is a plain object, validated against the `schema.json` that ships in every package, and checked with the package's own `check` command. Each package reads its own key and ignores the rest, so one object can extend any subset of the family. The [plugins page](https://github.com/ofri-peretz/burgee/blob/main/apps/docs/content/docs/plugins.mdx) has a nine-layer example that every package's `check` accepts in CI. - [#435](https://github.com/ofri-peretz/burgee/pull/435) [`7888524`](https://github.com/ofri-peretz/burgee/commit/78885245eb292cd4a40541fe09382a198c9c45cf) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - `schema.json` now describes every plugin host in the family. The one schema each package ships as its plugin contract used to cover only four hosts: roundel's `tokens`, flagstaff's `glyphs`, `spinners`, `borders` and `components`, paratext's `capabilities`, and linegauge's `widths`. Five hosts validated their keys in their own code, but the file an author (or a model) writes against said nothing about them. It now describes all of them: - bellpull `resolvers`, including the absolute-path rule on `paths` - caique `widgets` - closeout `handlers`, including the phases a plugin may use - seniority `sources`, including the rank bounds - burgee `commands`, `hooks` and `enforce` Where the schema can express a rule, it gives the same verdict as the host's own validator, and a test holds the two together. Function-valued fields (`static`, `run`, `read`, `handler`) are described and required, but not typed, because JSON Schema can't say "function". **flagstaff** now validates a plugin against only its own keys, not the whole family schema. It no longer refuses a plugin over another host's key, which lets one plugin object contribute to several hosts. Its entry points are also 4.7–5.9 KB lighter for it. - [#445](https://github.com/ofri-peretz/burgee/pull/445) [`dac303e`](https://github.com/ofri-peretz/burgee/commit/dac303e944e889ac4175ac38c94e4ca0f0ca5358) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - Every package now declares `sideEffects` truthfully, so bundlers can drop what you don't import. Six packages declared nothing, so no bundler could drop any of their modules. A named import from the root now bundles to the same bytes as the same import from its subpath: | import | before | after | | :------------------------------------ | ------: | ------: | | `import { explain } from 'seniority'` | 2,939 B | 1,067 B | | `import { decide } from 'caique'` | 1,235 B | 734 B | | `import { strip } from 'linegauge'` | 1,102 B | 940 B | | `import { once } from 'closeout'` | 353 B | 235 B | flagstaff and roundel used to declare `false`, but each ships a `check` command whose file runs when loaded. Each now lists that file, which is the true statement. paratext also lists the two modules that register its built-in capabilities when they load. ## 0.4.0 ### Minor Changes - [#421](https://github.com/ofri-peretz/burgee/pull/421) [`db3c59e`](https://github.com/ofri-peretz/burgee/commit/db3c59e3dcd373c7e6e4a057715adb173766523f) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - linegauge hosts plugins — `widths`, and it is the ninth of nine. `scripts/extension-surface-lock.test.ts` has carried `linegauge: { plugin: false }` since it was written, and the row was empty honestly: a width function is not obviously extensible, and an extension point invented to fill a table is worse than a gap that says so. What makes `widths` real is that the package already admits the problem. `width.ts` says ambiguous-width characters are _"counted narrow, which is what a terminal does unless it has been told it is rendering an East Asian locale"_ — and that covers only the ambiguity Unicode sanctions. A Nerd Font putting a two-column icon in the Private Use Area, a code point added by a Unicode release newer than the table compiled into this build, a font drawing U+2500 wide: each is a real, local disagreement with the built-in answer, and until now a user had no way to settle it short of patching the package. ```js export default { name: "nerd-font", widths: { icons: { ranges: [[0xe000, 0xf8ff]], columns: 2, why: "Nerd Font patches two-column icons into the PUA; measured in WezTerm", }, }, }; ``` Three fields of plain data, so a plugin can arrive as JSON, be diffed, be generated and be printed without running its author's code (R7). **`why` is required**, which no other `$def` in the family does: a width table with no provenance cannot be audited when it turns out to be wrong, and _wrong_ is the normal outcome for ambiguous width. A later registration wins over an earlier one and over the built-in tables, which is the point — the built-in answer is right for most terminals and the user is the authority on theirs. An override applies **before** the zero-width and emoji rules, or it would be decorative. A program with no plugin pays one `length === 0` per cluster, and the ASCII fast path never reaches it. The family schema gains `widthRange`, `widthOverride` and `widths`, and because it is one byte-identical file across every host, **every host that validates against it grows by about 1.3 KB** — five flagstaff budgets and two paratext ones moved for a definition only linegauge reads. That trade is the design's and is recorded as D-108 rather than absorbed. - [#421](https://github.com/ofri-peretz/burgee/pull/421) [`db3c59e`](https://github.com/ofri-peretz/burgee/commit/db3c59e3dcd373c7e6e4a057715adb173766523f) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - Every plugin host has a `check` command. ```bash npx linegauge check ./my-widths.mjs npx burgee check ./my-plugin.mjs --json ``` PRINCIPLES 7 asks three things of an extension surface: the plugin is data validated against one published schema, there is a **`check` command that shows it every way it can be seen**, and the bar is measured. The first was built in all nine hosts; the second existed in `flagstaff` alone. So an author writing a plugin for any other host found out what it did by shipping it into a program — and a surface nobody can check is a surface nobody outside this repository can write against. Each command validates, registers, and shows what the host does with the plugin, in the host's own terms: linegauge measures each code point **before and after** the override, paratext shows a capability's `encode` **and** its `fallback`, roundel each token and what it replaced, caique each widget's static projection rendered with its own sample. burgee's returns a **document** rather than printing one, so `burgee check --json` is the form an agent that just wrote a plugin reads. They share one contract with the author, held identically across all nine: - a readable report, contribution by contribution, with **`ok` as the last line**; - a refusal with a code from the family's vocabulary and a `fix`, exit 1; - **`E_NO_CONTRIBUTION`** for a plugin that contributes nothing to this host — the schema allows unknown keys so one object registers everywhere, which makes a misspelled key silent, and this is how that typo tells on itself; - exit 2 with no file. Each host also gains an eval case measuring the one-turn claim, proved to discriminate before it was committed: green against a correct plugin, red against the same plugin with one field broken. ### Patch Changes - [#430](https://github.com/ofri-peretz/burgee/pull/430) [`4d1b2b3`](https://github.com/ofri-peretz/burgee/commit/4d1b2b399cff354864d1e2e843a19fde80ef1f30) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - `check` now reports every refusal with its code and its fix, wherever it was raised. Some plugin files register themselves on import: they call `register()` at the top of the module and export the result. Until now, when such a file was refused, the error was thrown inside `check`'s `import()`, before the only `try` that turns a `PluginError` into `E_PLUGIN_SCHEMA: …` plus a `fix:` line. The author got the bare message on stderr, with no code and no fix. Now the whole of `check` runs inside that one handler, so every refusal comes out the same way on every host. ## 0.3.3 ### Patch Changes - [#400](https://github.com/ofri-peretz/burgee/pull/400) [`5b224e5`](https://github.com/ofri-peretz/burgee/commit/5b224e58db17a72c225d7ea8e14109d6d93f3968) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - Thirty fewer bundled bytes per entry point, at no change in behaviour. The five Unicode property classes are built on first use and were cached in one object keyed by name. A minifier renames a module-level binding to a single character and cannot touch a property name, so each `classes['zeroWidth']` survived minification at full length. Five `let` bindings hold the same five regexes: `linegauge` 6,307 → 6,277 bundled bytes, `linegauge/wrap` 11,278 → 11,248, `linegauge/slice` 8,938 → 8,908. ## 0.3.2 ### Patch Changes - [#386](https://github.com/ofri-peretz/burgee/pull/386) [`5a85175`](https://github.com/ofri-peretz/burgee/commit/5a85175da66df5e797446eaada1c3492cc8b8fff) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - The Unicode segmenter is built on first use rather than at import, and both packages now strip comments from what they publish. `new Intl.Segmenter()` loads ICU's grapheme-break data. Two were constructed at module scope, and almost nothing paid for them: every caller takes the ASCII fast path first, so a run of printable ASCII — a help screen, a flag name, a path — never reaches `segment()`. `segmenter` is now a function; the two call sites become `segmenter()`. `linegauge` and `seniority` were also the two published packages whose build never ran `strip-comments` at all. Unpacked: linegauge 83,538 → 56,148 and seniority 193,682 → 139,793. Together these take `import 'burgee'` from 56.87 ms to 44.39 ms, medians of seven. - [#389](https://github.com/ofri-peretz/burgee/pull/389) [`88a6996`](https://github.com/ofri-peretz/burgee/commit/88a699645f986c6e5dcead465e3f36238f0ae77d) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - The five Unicode property classes are built on first use, not at import. A `\p{…}` class under the `v` flag is built when V8 **compiles the literal**, not when the literal is evaluated — so a module carrying five of them pays for all five at import even if nothing calls them. Wrapping the literals in functions does not help; only constructing from a source string does. Measured on Node 24: `width.js` imports in **5.95 ms against 15.30**, `linegauge` in **11.60 against 19.00**, and `import 'burgee'` in **21.69 against 33.26**. A caller that measures a non-ASCII cluster pays the ~10 ms once, on first call. string-width 229/229, wrap-ansi 80/80, slice-ansi 15/15 and strip-ansi 8/8 are unchanged. ## 0.3.1 ### Patch Changes - [#373](https://github.com/ofri-peretz/burgee/pull/373) [`a1f1d40`](https://github.com/ofri-peretz/burgee/commit/a1f1d40b7d1e7244f3a180943b641bb3b491d256) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - Stage 2's artifact is now `spec.md`, the name Anthropic's AI-Native SDLC playbook gives it, so the source comments and README sections that cite a package's own design document point at `spec.md` rather than `design.md`. No behaviour changes. The published tarballs do move, by two bytes per surviving reference — `design.md` is nine characters and `spec.md` is seven — so the four packages carrying a weight band were re-measured against it: linegauge 83,538 to 83,536; paratext 66,343 to 66,341; closeout 84,455 to 84,453; bellpull 86,113 to 86,107. ## 0.3.0 ### Minor Changes - [#303](https://github.com/ofri-peretz/burgee/pull/303) [`fc640dd`](https://github.com/ofri-peretz/burgee/commit/fc640ddec11255c12d1e0948c5b8e99cc3f3263b) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - Give `linegauge/strip`, `linegauge/wrap` and `linegauge/slice` a default export, each the same function object as the subpath's named export. The three packages they replace — `strip-ansi`, `wrap-ansi`, `slice-ansi` — all publish a single function as their default, so `import stripAnsi from 'linegauge/strip'` now reads exactly like the import it replaces. The root default is untouched and still `width`: that one is spent on the `string-width` override recipe and cannot move. This is what unblocked grading those three suites. Their tests open with `import x from './index.js'`, and without a default the generated shim does not fail a case, it fails to link — measured at `# tests 0 / # pass 0 / # fail 2` on eight cases the implementation already satisfied. All three now grade: `strip-ansi` 8 / 8, `wrap-ansi` 80 / 80, `slice-ansi` 13 / 15. ### Patch Changes - [#332](https://github.com/ofri-peretz/burgee/pull/332) [`3ea38c3`](https://github.com/ofri-peretz/burgee/commit/3ea38c363b9f0cee9f1c1c2dae4037b047e98328) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - Lock the two claims `linegauge` was making with nothing behind them: that its ASCII fast path agrees with the path it short-circuits, and that `require('linegauge')` works. `width()` has had two implementations of one answer since `slice` landed — `asciiColumns` returns `s.length` for printable ASCII, everything else walks `Intl.Segmenter`. The design has promised a `differential.test.ts` locking the two together since 2026-09-09, `index.ts` still said the fast path was unbuilt, and no test compared them. Every existing case either used an input the fast path rejects or one where both paths are trivially right, so widening the fast path's range by a byte passed the entire suite. A wrong fast path is not a slow program, it is a silently wrong measurement: a box a column short, a help column that stops lining up, and nothing thrown. `differential.test.ts` now asserts `width(s) === measure(strip(s))` over the intent's six-row grapheme table, 24 boundary fixtures and 2 000 inputs from a recorded seed, and carries the grapheme table itself as assertions — code units against cluster count against columns. Proven red before green: widening the range to `0x7F` fails 2 cases and dropping its floor to `0x00`, which lets `ESC` onto the fast path, fails 8. `shape.test.ts` covers R12. Nothing in the tree had ever called `require` on this package, and the override recipe it is built for — `overrides: { "string-width": "npm:linegauge@^1" }` — lands it inside CommonJS trees that have required `string-width` since 2015. Every published entry is now required from CommonJS, which proves both halves of R12 at once: Node refuses a graph containing a top-level `await` with `ERR_REQUIRE_ASYNC_MODULE`, so a `require` that returns the namespace is also the no-top-level-await check. Proven red by appending a top-level `await` to `widest.ts`: 3 of 8 cases fail. No behaviour change. 913 tests, up from 867. - [#316](https://github.com/ofri-peretz/burgee/pull/316) [`c8acb28`](https://github.com/ofri-peretz/burgee/commit/c8acb2848714a1cbe3e26a2f9895d7498fb4f096) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - `width` and the style stack now answer what `string-width` and `slice-ansi` answer on twenty-nine cases they did not. The `string-width` row goes 201 / 229 → **229 / 229**; the `slice-ansi` row goes 13 / 15 → **15 / 15**. `wrap-ansi` holds at 80 / 80 and `strip-ansi` at 8 / 8 across the change. Four defects in `width`, not twenty-eight cases. **Hangul conjoining jamo** are additive inside a grapheme cluster: `Intl.Segmenter` joins a run of them into one cluster, and measuring that cluster by its first code point answered 2 where a terminal draws 12. Modern Hangul composes L + V (+ T) into one two-column syllable and leaves unmatched jamo at their own East Asian Width. **Spacing combining marks** occupy a column — the zero-width class matched `\p{Mark}`, which is the spacing marks as well as the non-spacing ones, so Devanagari vowel sign AA measured 0. **Prepended concatenation marks** (`U+0600`, `U+06DD`, `U+070F`) are `Format` but not `Default_Ignorable`, so they missed the zero-width class and were charged a column each — the worst shape of the bug, because a character the cursor never advances past is invisible until a box comes out short. And **minimally-qualified emoji sequences** — the same ZWJ sequence or keycap without its `U+FE0F` — are still two columns in every terminal, but `\p{RGI_Emoji}` matches only the fully-qualified spelling. One defect in the style stack, which `slice`, `wrap` and `truncate` share. An SGR parameter with no entry in the close-code table — `ESC[20m`, `ESC[1001m` — was **dropped** at a cut, so the text survived and its styling did not, silently. It is now carried through and reopened like any other style, closed with `ESC[0m`. The sequence is the caller's, not this library's to vet. Measured cost, stated rather than absorbed: the minified bundle grows 939–1 040 bytes per entry that measures or cuts — `linegauge` itself from 5 241 to 6 180 bytes, 18%. `strip` is unchanged. `packages/linegauge/ceilings.json` carries the before, the delta and the after, and a new `weight.test.ts` ratchets every subpath's `dist/` closure so the next growth cannot be silent. That file also records, rather than hides, that R9's weight ceiling is **not met**: one entry of six is under the bar the design names. - [#339](https://github.com/ofri-peretz/burgee/pull/339) [`f295630`](https://github.com/ofri-peretz/burgee/commit/f2956301d5f9dcbcac0b001b00ebaf0315891fac) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - `schema.json` constrains token names, because it was promising something no host honours. `tokens` was described as any name to a `#rrggbb` colour. `roundel`'s `validate()` accepts ten semantic names — `error`, `warn`, `ok`, `hint`, `muted`, `command`, `flag`, `value`, `heading`, `ground` — and throws on everything else. So a plugin author doing exactly what their own `E_PLUGIN_SCHEMA` error tells them, comparing their object against `roundel/schema.json`, got a green from the schema and `"accent" is not a token` from `register()`. Measured 2026-09-16 with `{ accent: '#336699' }`. The schema now carries `propertyNames.enum`, and `scripts/plugin-contract-lock.test.ts` pins the enum and the runtime set to each other from both sides, so neither can grow a name the other does not know. Every host ships a byte-identical copy of this file (`plugin-schema-lock.test.ts` asserts it), which is why nine packages are listed. Only the key `roundel` owns is constrained: describing `widgets`, `handlers`, `sources`, `resolvers` or `commands` in a file all eight hosts share is what made _flagstaff_ start validating caique's key last time (`PluginError: plugin.widgets.later: expected object, got boolean`), and those stay in `plugin-schema-lock`'s `UNDESCRIBED` list with that reason. `linegauge` is in the list for a different change: `ceilings.json`'s R9 block now records the bar as D1's tree-inclusive ceiling — 83,538 against 170,342, a ratio of 0.4904 — and keeps the superseded `get-east-asian-width` bar beside it with the count of entries that cleared it. ## 0.2.0 ### Minor Changes - [#186](https://github.com/ofri-peretz/burgee/pull/186) [`3d744d9`](https://github.com/ofri-peretz/burgee/commit/3d744d99d92cdc7fc675ad105e4e4b9c6eebca5f) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - `slice`, `truncate` and `widest`, over the style stack extracted from `wrap`. Cutting a styled string in display columns never splits a grapheme cluster, never loses a combining mark, and closes and reopens whatever styles the cut ran through. `truncate` measures the ellipsis and keeps it inside the budget. Published as `linegauge/slice`, `linegauge/truncate` and `linegauge/widest`, each isolated from the others. - [#210](https://github.com/ofri-peretz/burgee/pull/210) [`59d910c`](https://github.com/ofri-peretz/burgee/commit/59d910c1da7bca519bd1c2d5ca43b6c57e260621) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - `strip` (R3), and a width bug it found. `util.stripVTControlCharacters` leaves the colon form of an extended colour — `ESC[38:2::255:0:0m`, how every truecolor library writes one — in the output as text, so `width()` answered 15 for a three-column string. Measured across sixteen sequence shapes: Node is exact on fifteen and wrong on that one. Published as `linegauge/strip`. ### Patch Changes - [#213](https://github.com/ofri-peretz/burgee/pull/213) [`ecedfa2`](https://github.com/ofri-peretz/burgee/commit/ecedfa2ed0c7aaed23d77c4d02d7c94156a78ce9) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - `width` takes `ambiguousIsNarrow`. East Asian Ambiguous characters — `±`, `×`, `÷`, the box-drawing set, Greek and Cyrillic — are one column in a Latin terminal and two in a CJK one, and nothing can detect which a terminal is doing, so it is the caller's decision. Default `true`, matching the incumbent. The 179-range table is generated from Unicode rather than transcribed, with a `--check` that fails on drift. Graded: `string-width` 198 / 229 → **201 / 229**. - [#224](https://github.com/ofri-peretz/burgee/pull/224) [`ea58e63`](https://github.com/ofri-peretz/burgee/commit/ea58e637ee0ee6cdcc655478f6bef54d854f6c0f) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - docs: the README describes the package that shipped It opened with "**Not yet released.** This version reserves the name", under a heading reading "What it will be", while `linegauge@0.1.0` was live on npm with a graded string-width row on the public compatibility page. Anyone who installed it was told the package does nothing. Rewritten for someone installing it today: what the five functions are, why they are one package rather than twelve, and what "without the edge fraying" actually guarantees — a cluster is atomic, a style that was open gets closed, and the ellipsis is counted inside the budget rather than added on top. `strip` and the ASCII fast path are named as still at the gate, because they are. - [#205](https://github.com/ofri-peretz/burgee/pull/205) [`93114d3`](https://github.com/ofri-peretz/burgee/commit/93114d33b5ac3f9a0ab3b48506255897af40133b) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - `width` keeps the contract `string-width` has always kept. A non-string measures `0` instead of throwing — a width function is usually reached with whatever a template produced, which is why the incumbent answers rather than making every caller guard — and a new `countAnsiEscapeCodes` option counts escape sequences as the characters they are made of. Graded: `string-width` 194 / 229 → **198 / 229**. ## 0.1.0 ### Minor Changes - [#182](https://github.com/ofri-peretz/burgee/pull/182) [`28a838f`](https://github.com/ofri-peretz/burgee/commit/28a838f0c1314fb79594d9d8bd8e02de785ea80a) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - `linegauge` is real: `width` and `wrap` move out of `flagstaff` into the foundation package that was reserved for them (F1, the move only). The default export is `width`, call-compatible with `string-width`'s default. `flagstaff` imports them and deletes both files; its 227 tests pass unchanged, and B4's bundled bytes are identical to the byte — the code went to a different file, not away. ### Patch Changes - [#193](https://github.com/ofri-peretz/burgee/pull/193) [`8586f58`](https://github.com/ofri-peretz/burgee/commit/8586f58542e7896675c0b6fa8815278f8d22d4a3) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - Name the tool the package is named after. The npm description opened with "the width of material a saw removes in a cut" — that is a _kerf_, a different tool from a different trade. A line gauge is the printer's steel rule marked in picas and points, which is what a package that measures typeset width actually does. The description is the first line a reader sees on npm, so it may as well be the one that explains the name. No behaviour change. --- # Coming from slice-ansi > A slice-ansi alternative with a drop-in path: import sliceAnsi from linegauge/slice, graded 106 / 106 by slice-ansi 10's own test suite — then zero dependencies, grapheme clusters never halved, and one style stack shared with wrap. Source: https://linegauge.interlace.tools/docs/coming-from/slice-ansi **linegauge** is a **slice-ansi alternative** you adopt by changing one import. `linegauge/slice`'s default export is `slice`, call-compatible with slice-ansi's default, and slice-ansi's own test suite is the grade. ## Migrate from slice-ansi in one import ```diff - import sliceAnsi from 'slice-ansi'; + import sliceAnsi from 'linegauge/slice'; ``` The call is slice-ansi's — `sliceAnsi(string, start, end)`, in display columns, `end` optional. The same function is also the named export `slice`, from `linegauge/slice` or from `linegauge`. It also answers the questions slice-ansi's neighbours do, one subpath each: | Incumbent | linegauge path | | :-- | :-- | | `string-width` | `linegauge` (the default export) | | `strip-ansi` | `linegauge/strip` | | `wrap-ansi` | `linegauge/wrap` | | `slice-ansi` | `linegauge/slice` | ## Is linegauge compatible with slice-ansi? Graded, not claimed. slice-ansi's own suite, vendored at 10.0.0 and unmodified apart from the import specifier, runs against `linegauge/slice` beside a control that runs it against the real slice-ansi: | | passing | rate | | :-- | --: | --: | | `linegauge/slice` | 106 / 106 | 100.0% | | slice-ansi itself (control) | 106 / 106 | 100.0% | From [Compatibility](https://burgee.interlace.tools/docs/compatibility), which `npm run compat:page` generates from the oracle's last run; that page is the authority. Where a cut lands, `slice` counts the columns `width` counts, as slice-ansi 10 does: it takes each cluster's width from string-width, so CRLF and every other cluster that draws nothing take no column, and a lone regional indicator (`🇦`) takes one. slice-ansi 9 gave every cluster at least one position and a lone indicator two, and `slice` followed it until 10. slice-ansi 7's suite is graded too, at 14 / 15, and 7 is not claimed. The one it fails is the case where 7 and 9 contradict each other: 7 wants a wide character that runs one column past the end of the range included, and 9 wants it left out. linegauge leaves it out. The same page grades `linegauge` against string-width (233 / 233), `linegauge/strip` against strip-ansi (8 / 8) and `linegauge/wrap` against wrap-ansi (85 / 85). ## What you gain over slice-ansi slice-ansi 10.0.0 depends on `ansi-styles` and `string-width`, which brings `strip-ansi`, `ansi-regex` and `get-east-asian-width`. `linegauge/slice` depends on nothing, and it is pure: nothing in linegauge reads `process`, so the same input slices the same way on a pipe, under `--json` and in a test. - **A cluster is never halved.** Columns come from the same `width` that passes string-width's suite 233 / 233, and cluster boundaries from the platform's own `Intl.Segmenter`. A cluster that straddles either edge is left out whole, so a slice is never wider than the columns asked for and never returns half a family emoji or one letter of a flag. - **One style stack, not two.** slice-ansi and wrap-ansi each carry their own copy of open/close/reopen, and they disagree at the edges. `linegauge/slice` and `linegauge/wrap` share one, so slicing and wrapping the same styled string agree. - **The family measures with it.** burgee, caique and flagstaff build on linegauge, so what they write for a pipe or an agent is cut the same way. ## When to switch from slice-ansi - Your output carries terminal hyperlinks, and a slice of it has to keep them. - Your dependency tree carries slice-ansi beside string-width, strip-ansi or wrap-ansi, and you would rather it carried one package with no dependencies. The API and the design notes are on [linegauge](/docs); the width path is on [Coming from string-width](/docs/coming-from/string-width). --- # Coming from string-width > A string-width alternative with zero dependencies: linegauge's default export is string-width, graded 233 / 233 by its own suite — plus wrap-ansi, strip-ansi and slice-ansi paths, grapheme-correct over Intl.Segmenter and pure, so a pipe and a terminal get the same columns. Source: https://linegauge.interlace.tools/docs/coming-from/string-width **linegauge** is a **string-width alternative** you adopt by changing one import. Its default export is `width`, call-compatible with string-width's default, and string-width's own test suite is the grade. ## Migrate from string-width in one import ```diff - import stringWidth from 'string-width'; + import stringWidth from 'linegauge'; ``` It also answers the questions string-width's neighbours do, one subpath each: | Incumbent | linegauge path | | :-- | :-- | | `string-width` | `linegauge` (the default export) | | `strip-ansi` | `linegauge/strip` | | `wrap-ansi` | `linegauge/wrap` | | `slice-ansi` | `linegauge/slice` | Because the default export is call-compatible, an `overrides` entry can move a transitive string-width onto linegauge without a code change; the form is on the [linegauge](/docs) page. ## Is linegauge compatible with string-width? Graded, not claimed. Each incumbent's own suite, vendored and unmodified apart from the import specifier, runs against its linegauge path beside a control that runs it against the real package: | | linegauge | control | | :-- | --: | --: | | `string-width` → `linegauge` | 233 / 233 | 233 / 233 | | `strip-ansi` → `linegauge/strip` | 8 / 8 | 8 / 8 | | `wrap-ansi` → `linegauge/wrap` | 85 / 85 | 85 / 85 | | `slice-ansi` → `linegauge/slice` | 106 / 106 | 106 / 106 | From [Compatibility](https://burgee.interlace.tools/docs/compatibility), which `npm run compat:page` generates from the oracle's last run; that page is the authority. string-width's options carry over under their own names and defaults. Ambiguous-width characters count narrow unless you pass `ambiguousIsNarrow: false`, which counts them two columns wide for a terminal rendering with a CJK font; `countAnsiEscapeCodes: true` measures escape sequences instead of removing them. ## What you gain over string-width linegauge has no output mode of its own, and that is its agent surface: it is pure. Nothing in it reads `process`, so how wide the terminal is — and whether there is one — is the caller's to pass, and the same input gives the same columns on a pipe, under `--json` and in a test. - **The edge never frays.** A cut that would land inside a grapheme drops the whole cluster; a style open at a cut is re-emitted and closed, so a slice pasted anywhere neither loses its colour nor leaks it. - **The ellipsis is inside the budget.** `truncate(text, 10)` occupies ten columns or fewer, never eleven — the property a table column depends on. - **Grapheme-correct over the platform.** Cluster boundaries come from the platform's own `Intl.Segmenter`. - **The family measures with it.** burgee, caique and flagstaff build on linegauge, so what they write for a pipe or an agent is measured the same way a terminal draws it. ## When to switch from string-width - Your dependency tree carries several of string-width, strip-ansi, wrap-ansi and slice-ansi, and you would rather it carried one package. - A table or box in your CLI gains a phantom column under emoji, CJK or styled input. The API and the design notes are on [linegauge](/docs). --- # Coming from strip-ansi > A strip-ansi alternative with a drop-in path: import stripAnsi from linegauge/strip, graded 8 / 8 by strip-ansi's own test suite — then no ansi-regex underneath, and one escape scanner shared with width, wrap and slice, so what strip removes is exactly what they count as zero columns. Source: https://linegauge.interlace.tools/docs/coming-from/strip-ansi **linegauge** is a **strip-ansi alternative** you adopt by changing one import. `linegauge/strip`'s default export is `strip`, call-compatible with strip-ansi's default, and strip-ansi's own test suite is the grade. ## Migrate from strip-ansi in one import ```diff - import stripAnsi from 'strip-ansi'; + import stripAnsi from 'linegauge/strip'; ``` It is a subpath rather than the package root because the root's default export is `width`, the string-width drop-in. The same function is also the named export `strip`, from `linegauge/strip` or from `linegauge`. It also answers the questions strip-ansi's neighbours do, one subpath each: | Incumbent | linegauge path | | :-- | :-- | | `string-width` | `linegauge` (the default export) | | `strip-ansi` | `linegauge/strip` | | `wrap-ansi` | `linegauge/wrap` | | `slice-ansi` | `linegauge/slice` | ## Is linegauge compatible with strip-ansi? Graded, not claimed. strip-ansi's own suite, vendored at 7.2.0 and unmodified apart from the import specifier, runs against `linegauge/strip` beside a control that runs it against the real strip-ansi: | | passing | rate | | :-- | --: | --: | | `linegauge/strip` | 8 / 8 | 100.0% | | strip-ansi itself (control) | 8 / 8 | 100.0% | From [Compatibility](https://burgee.interlace.tools/docs/compatibility), which `npm run compat:page` generates from the oracle's last run; that page is the authority. The suite is small, and every case in it is a shape a stripper has to get right: `OSC 8` hyperlinks, the 8-bit CSI introducer and a bare BEL terminator. What it removes, beyond the suite: CSI, OSC including `OSC 8` under both terminators, DCS, the charset selections and the single-character escapes. A lone `ESC` with nothing that parses after it is text and is kept, the same answer strip-ansi gives. The same page grades `linegauge` against string-width (233 / 233), `linegauge/wrap` against wrap-ansi (85 / 85) and `linegauge/slice` against slice-ansi (106 / 106). ## What you gain over strip-ansi strip-ansi 7.2.0 is one scan with one dependency, `ansi-regex`. `linegauge/strip` has none, and it is pure: it reads nothing from `process`, so the same input strips the same way on a pipe, under `--json` and in a test. - **One scanner for the whole family.** `width` measures what `strip` leaves, and `wrap` and `slice` find escapes with the same scanner `strip` is built on, so a string measured, wrapped, sliced and stripped never disagrees with itself about where the escapes were. - **The family strips with it.** burgee, caique and flagstaff build on linegauge, so what they write for a pipe or an agent is stripped the same way. ## When to switch from strip-ansi - Your dependency tree carries strip-ansi beside string-width, wrap-ansi or slice-ansi, and you would rather it carried one package with no dependencies. - You were about to replace strip-ansi with Node's `util.stripVTControlCharacters`. Measured on Node 24 over sixteen sequence shapes, it agrees with strip-ansi on fifteen; on the sub-parameter form of a truecolor SGR (`ESC[38:2::255:0:0m`, ITU T.416) it stops at the first `:` and leaves the rest in the output as text. `linegauge/strip` removes it whole, as strip-ansi does. The API and the design notes are on [linegauge](/docs); the width path is on [Coming from string-width](/docs/coming-from/string-width). --- # Coming from wrap-ansi > A wrap-ansi alternative with a drop-in path: import wrapAnsi from linegauge/wrap, graded 85 / 85 by wrap-ansi's own test suite — then zero dependencies, one style stack shared with slice, and the same grapheme-correct width string-width is graded on, so a wrapped row measures the way a terminal draws it. Source: https://linegauge.interlace.tools/docs/coming-from/wrap-ansi **linegauge** is a **wrap-ansi alternative** you adopt by changing one import. `linegauge/wrap` is a port of wrap-ansi 10, its default export is call-compatible with wrap-ansi's default, and wrap-ansi's own test suite is the grade. ## Migrate from wrap-ansi in one import ```diff - import wrapAnsi from 'wrap-ansi'; + import wrapAnsi from 'linegauge/wrap'; ``` The call is wrap-ansi's — `wrapAnsi(string, columns, options)` — with its three options, `hard`, `trim` and `wordWrap`, at wrap-ansi's defaults. The options type is exported under wrap-ansi's name, so `import { type Options } from 'wrap-ansi'` moves with the same edit. The same function is also the named export `wrap`, from `linegauge/wrap` or from `linegauge`. It also answers the questions wrap-ansi's neighbours do, one subpath each: | Incumbent | linegauge path | | :-- | :-- | | `string-width` | `linegauge` (the default export) | | `strip-ansi` | `linegauge/strip` | | `wrap-ansi` | `linegauge/wrap` | | `slice-ansi` | `linegauge/slice` | ## Is linegauge compatible with wrap-ansi? Graded, not claimed. wrap-ansi's own suite, vendored at 10.0.2 and unmodified apart from the import specifier, runs against `linegauge/wrap` beside a control that runs it against the real wrap-ansi: | | passing | rate | | :-- | --: | --: | | `linegauge/wrap` | 85 / 85 | 100.0% | | wrap-ansi itself (control) | 85 / 85 | 100.0% | From [Compatibility](https://burgee.interlace.tools/docs/compatibility), which `npm run compat:page` generates from the oracle's last run; that page is the authority. No case in that suite separates `linegauge/wrap` from wrap-ansi 10, and the port is also graded differentially against the real package in linegauge's own tests. What the port keeps from wrap-ansi 10: a style or `OSC 8` hyperlink open at a break is closed at the end of the row and reopened on the next, so every row stands on its own — flagstaff's log-update front-end drops leading rows with no ANSI state tracking because of it. The same page grades `linegauge` against string-width (233 / 233), `linegauge/strip` against strip-ansi (8 / 8) and `linegauge/slice` against slice-ansi (106 / 106). ## What you gain over wrap-ansi wrap-ansi 10.0.2 depends on `ansi-styles` and `string-width`. `linegauge/wrap` depends on nothing, and it is pure: nothing in linegauge reads `process`, so the width to wrap at — and whether there is a terminal at all — is the caller's to pass, and the same input wraps the same way on a pipe, under `--json` and in a test. - **One style stack, not two.** wrap-ansi and slice-ansi each carry their own copy of open/close/reopen, and they disagree at the edges. `linegauge/wrap` and `linegauge/slice` share one, so wrapping and slicing the same styled string agree. - **Measured by the graded width.** Rows are measured with the same `width` that passes string-width's suite 233 / 233, with cluster boundaries from the platform's own `Intl.Segmenter`, so a family emoji or a CJK run is never counted differently by the wrapper than by the code that lays out the box around it. - **The family measures with it.** burgee, caique and flagstaff build on linegauge, so what they write for a pipe or an agent is wrapped the same way. ## When to switch from wrap-ansi - Your dependency tree carries wrap-ansi beside string-width, strip-ansi or slice-ansi, and you would rather it carried one package with no dependencies. - You wrap styled text and then slice or truncate it, and the two disagree about where a colour starts or stops. The API and the design notes are on [linegauge](/docs); the width path is on [Coming from string-width](/docs/coming-from/string-width). --- # Compatibility > How linegauge's four drop-ins are graded — each incumbent's own test suite, unedited — the current grades, and the differences that remain. Source: https://linegauge.interlace.tools/docs/drop-ins `linegauge` (its default export), `linegauge/wrap`, `linegauge/strip` and `linegauge/slice` are graded, not described as compatible. Each is run against its incumbent's **own test suite**, by [compat-oracle](https://github.com/ofri-peretz/burgee/blob/main/packages/compat-oracle/README.md), in CI. ✓ yes · ◐ partial (what is missing is said) · ✗ no · — does not apply. Every cell links to its evidence: our test or grade, or the incumbent’s source at the version compat-oracle grades. ### Compatibility | Capability | **linegauge** | string-width | wrap-ansi | strip-ansi | slice-ansi | | :-- | :-- | :-- | :-- | :-- | :-- | | **Passes string-width's own test suite** — linegauge's default export is graded by string-width 8.3.0's own tests, unedited, so changing the import keeps string-width's answers. | [✓ 233 / 233 of its own tests](https://github.com/ofri-peretz/burgee/blob/main/packages/compat-oracle/baseline/string-width.json) | [✓ its own suite, the control run](https://github.com/ofri-peretz/burgee/blob/main/packages/compat-oracle/vendor/string-width/test.js) | [— a different API](https://github.com/ofri-peretz/burgee/blob/main/packages/compat-oracle/vendor/wrap-ansi/test.js) | [— a different API](https://github.com/ofri-peretz/burgee/blob/main/packages/compat-oracle/vendor/strip-ansi/test.js) | [— a different API](https://github.com/ofri-peretz/burgee/blob/main/packages/compat-oracle/vendor/slice-ansi/test.js) | | **Passes wrap-ansi's own test suite** — `linegauge/wrap` is graded by wrap-ansi 10.0.2's own tests, unedited. | [✓ 85 / 85 of its own tests](https://github.com/ofri-peretz/burgee/blob/main/packages/compat-oracle/baseline/wrap-ansi.json) | [— a different API](https://github.com/ofri-peretz/burgee/blob/main/packages/compat-oracle/vendor/string-width/test.js) | [✓ its own suite, the control run](https://github.com/ofri-peretz/burgee/blob/main/packages/compat-oracle/vendor/wrap-ansi/test.js) | [— a different API](https://github.com/ofri-peretz/burgee/blob/main/packages/compat-oracle/vendor/strip-ansi/test.js) | [— a different API](https://github.com/ofri-peretz/burgee/blob/main/packages/compat-oracle/vendor/slice-ansi/test.js) | | **Passes strip-ansi's own test suite** — `linegauge/strip` is graded by strip-ansi 7.2.0's own tests, unedited. | [✓ 8 / 8 of its own tests](https://github.com/ofri-peretz/burgee/blob/main/packages/compat-oracle/baseline/strip-ansi.json) | [— a different API](https://github.com/ofri-peretz/burgee/blob/main/packages/compat-oracle/vendor/string-width/test.js) | [— a different API](https://github.com/ofri-peretz/burgee/blob/main/packages/compat-oracle/vendor/wrap-ansi/test.js) | [✓ its own suite, the control run](https://github.com/ofri-peretz/burgee/blob/main/packages/compat-oracle/vendor/strip-ansi/test.js) | [— a different API](https://github.com/ofri-peretz/burgee/blob/main/packages/compat-oracle/vendor/slice-ansi/test.js) | | **Passes slice-ansi's own test suite** — `linegauge/slice` is graded by slice-ansi 9.0.1's own tests, unedited. | [✓ 106 / 106 of its own tests](https://github.com/ofri-peretz/burgee/blob/main/packages/compat-oracle/baseline/slice-ansi.json) | [— a different API](https://github.com/ofri-peretz/burgee/blob/main/packages/compat-oracle/vendor/string-width/test.js) | [— a different API](https://github.com/ofri-peretz/burgee/blob/main/packages/compat-oracle/vendor/wrap-ansi/test.js) | [— a different API](https://github.com/ofri-peretz/burgee/blob/main/packages/compat-oracle/vendor/strip-ansi/test.js) | [✓ its own suite, the control run](https://github.com/ofri-peretz/burgee/blob/main/packages/compat-oracle/vendor/slice-ansi/test.js) | The counts are compat-oracle's baselines, the pass count each drop-in is held to. The family's [compatibility page](https://burgee.interlace.tools/docs/compatibility) is generated from the oracle's last run and is the authority for the current figures, beside every other drop-in in the family. | incumbent | graded version | drop-in | cases | | :-- | :-- | :-- | --: | | string-width | 8.3.0 | `import stringWidth from 'linegauge'` | 233 / 233 | | wrap-ansi | 10.0.2 | `import wrapAnsi from 'linegauge/wrap'` | 85 / 85 | | strip-ansi | 7.2.0 | `import stripAnsi from 'linegauge/strip'` | 8 / 8 | | slice-ansi | 10.0.0 | `import sliceAnsi from 'linegauge/slice'` | 106 / 106 | ## How a suite is graded 1. The incumbent's repository is cloned at the release tag of the graded version and its test file copied into `packages/compat-oracle/vendor/`. No incumbent ships its tests to npm, so a tarball could not be used. Each copy's `PROVENANCE` file names the tag, the commit and the command that reproduces it. 2. The only edit is the import that reaches the library: it is rewritten to a shim generated per run. Assertions, fixtures and helpers are upstream's, byte for byte. 3. A **control run** points the shim at the real incumbent first. That proves the harness before it grades anything of ours, and the control's total is what every rate is measured against. 4. The **target run** points the same shim at linegauge's path. ## Beyond the suites Each path is also graded **differentially** in linegauge's own tests, against the real incumbent installed beside it: `wrap.test.ts` compares `wrap` with wrap-ansi on thirty named inputs under six option sets and on two hundred generated ones, `slice.test.ts` compares every `[start, end)` of its corpus with slice-ansi, and `strip.test.ts` compares sixteen sequence shapes with strip-ansi. `truncate`, which has no drop-in path, is compared case by case with cli-truncate. ## Known differences - **The ellipsis of `truncate` is never styled.** cli-truncate paints it in the text's colour at two of its three positions and not the third; `truncate` puts it after the closing sequence at all three. There is no `linegauge/cli-truncate`, so no import moves onto this by accident ([Slicing and truncating](/docs/guides/cutting)). - **A width plugin changes every answer.** With nothing registered — the default — every path gives Unicode's answer, which is what the suites grade. Registering a `widths` plugin is how you ask for a different one ([Width plugins](/docs/guides/plugins)). ## Types and CommonJS Each path's default export is the incumbent's default, and its options type is exported under the incumbent's name — `Options` from `linegauge` and from `linegauge/wrap` — so a TypeScript program migrates by its import alone. `require('linegauge')` returns the namespace, whose `default` is `width`, and an `overrides` entry can move a transitive string-width onto it: ```json { "overrides": { "string-width": "npm:linegauge@^0.5" } } ``` --- # FAQ > Short answers about linegauge — terminal width, emoji, CJK, ambiguous characters, Nerd Fonts, non-strings, CommonJS and overrides — each with where to read more. Source: https://linegauge.interlace.tools/docs/faq ## How do I get the terminal's width? From the terminal, not from linegauge: `process.stdout.columns` when `process.stdout.isTTY`, and a width of your choosing otherwise. linegauge never reads `process`, so the same call gives the same answer in a terminal, a pipe and a test. [Wrapping help text](/docs/recipes/wrapping-help) shows the pattern. ## Why is an emoji two columns and not one per code point? Because a terminal draws it in two. A family emoji is four people joined by three zero-width joiners — seven code points, one grapheme cluster — and `width` counts clusters ([Measuring](/docs/guides/measuring)). ## My table's CJK column is off by one or two. Measure with `width`, not `.length` or `padEnd`, which count code units: `古` is one code unit and two columns. [Aligning columns](/docs/recipes/aligning-columns) pads by columns. ## Some characters are one column in my terminal and two in my colleague's. Those are East Asian **ambiguous** characters — `±`, `×`, Greek, Cyrillic, box drawing — which a CJK font draws wide. `width` counts them narrow unless you pass `{ ambiguousIsNarrow: false }`. No terminal reports which it does, so it is the caller's choice ([Measuring](/docs/guides/measuring#options)). ## My Nerd Font icons measure one column and draw two. They are in the Private Use Area, which Unicode leaves to the font. Register a [width plugin](/docs/guides/plugins) that says so, with a `why`, and every measurement after that agrees with your font. ## What does `width(undefined)` do? Returns `0`, as string-width does, rather than throwing. A width call is usually reached with whatever a template produced. ## Can I use it from CommonJS? Yes, on Node 22.13+: the package is ESM with a `default` condition, so `require('linegauge')` loads it through `require(esm)`, and `.default` is `width`. Every entry point is checked to load that way. ## Can I replace a string-width I do not import myself? Yes, with an npm `overrides` entry, because the root default export is string-width's call. The other three are subpaths, which an override cannot point at ([Incremental migration](/docs/recipes/incremental-migration)). ## Where is truncation from the left, or in the middle? `truncate(text, columns, { position: 'start' })` and `{ position: 'middle' }`. The ellipsis is inside the budget in all three positions ([Slicing and truncating](/docs/guides/cutting)). ## Is `linegauge/wrap` exactly wrap-ansi? It passes all 85 cases of wrap-ansi 10.0.2's own suite, and agrees with wrap-ansi on two hundred generated inputs under six option sets in linegauge's own tests. The differences that remain across all four paths are on [Compatibility](/docs/drop-ins#known-differences). --- # Getting started > Install linegauge, measure a string in terminal columns, and wrap, truncate and slice styled text without cutting a grapheme cluster or leaking a colour. Source: https://linegauge.interlace.tools/docs/getting-started linegauge answers one question five ways: how many terminal columns a piece of styled text occupies, and where it can be cut. `width` measures, `wrap` folds, `truncate` shortens, `slice` cuts out a column range, and `widest` measures many lines at once. Each one knows where the escape sequences are and where the grapheme clusters begin and end, so none of them splits an emoji or leaves a colour open. ## Install ```bash npm install linegauge ``` It is ESM with a `default` condition, so `require('linegauge')` also works from CommonJS on Node 22.13+. It depends on nothing ([`package-shape-lock.test.ts`](https://github.com/ofri-peretz/burgee/blob/main/scripts/package-shape-lock.test.ts) holds it at zero). ## A first program ```js title="measure.mjs" import { slice, truncate, widest, width, wrap } from 'linegauge'; const red = (s) => `\u001B[31m${s}\u001B[39m`; console.log(width('hello')); console.log(width('古池や')); console.log(width('👨‍👩‍👧‍👦')); console.log(width(red('hello'))); console.log(JSON.stringify(wrap('the quick brown fox jumps', 10))); console.log(truncate('the quick brown fox', 10)); console.log(JSON.stringify(slice(red('hello world'), 0, 5))); console.log(widest(['a', '古池や', 'bb'])); ``` ```text title="node measure.mjs" 5 6 2 5 "the quick\nbrown fox\njumps" the quick… "\u001b[31mhello\u001b[39m" 6 ``` Line by line: ASCII is one column a character; each of the three CJK characters is two; the family emoji is four people and three joiners, one grapheme cluster, two columns; the colour codes take no columns at all. `wrap` breaks between words at ten columns. `truncate` spends one of its ten columns on the ellipsis, so the result is ten wide, not eleven. `slice` takes columns 0 to 5 of a red string and closes the red it reopened. `widest` is the width of the widest line. Every output block on this site is checked: `tests/examples.test.ts` writes each titled file, runs the command in the block's title, and compares. ## The five answers | function | answers | guide | | :-- | :-- | :-- | | `width(text, options)` | the columns the text occupies | [Measuring](/docs/guides/measuring) | | `wrap(text, columns, options)` | the text folded to a width, styles carried across rows | [Wrapping](/docs/guides/wrapping) | | `truncate(text, columns, options)` | the text shortened, ellipsis inside the budget | [Slicing and truncating](/docs/guides/cutting) | | `slice(text, start, end)` | the columns `[start, end)`, self-contained | [Slicing and truncating](/docs/guides/cutting) | | `widest(lines)` | the width of the widest line of any iterable | [Measuring](/docs/guides/measuring#many-lines-widest) | `strip(text)` removes the escape sequences, and is the fifth drop-in's ([Stripping escapes](/docs/guides/stripping)). ## Pure, on purpose Nothing in linegauge reads `process`. It does not know how wide your terminal is or whether there is one, so the width to wrap or truncate at is always yours to pass, and the same input gives the same answer on a terminal, in a pipe, under `--json` and in a test. That is why burgee, caique and flagstaff measure their own output with it. ## Where next - [Guides](/docs/guides/measuring): measuring, wrapping, slicing and truncating, stripping, and width plugins. - [Why linegauge](/docs/why-linegauge): what it does that string-width, wrap-ansi, strip-ansi and slice-ansi do not, cell by cell, with the evidence. - [Coming from string-width](/docs/coming-from/string-width) and the other three: change one import. - [API reference](/docs/api): every export of every entry point. --- # Slicing and truncating > slice() cuts a column range that closes its own styles and never splits a grapheme cluster; truncate() shortens to a budget with the ellipsis counted inside it, from the end, the start or the middle. Source: https://linegauge.interlace.tools/docs/guides/cutting Both cut styled text at a column, and both keep the same three promises: a cut never lands inside a grapheme cluster, a style open at the cut is reopened and closed so the result stands on its own, and the result is never wider than you asked for. ## `slice(text, start, end)` The columns `[start, end)` of `text`. It is the default export of `linegauge/slice`, which slice-ansi's own suite grades, and the named export `slice` from both paths. ```js title="slice.mjs" import { slice } from 'linegauge'; const red = (s) => `\u001B[31m${s}\u001B[39m`; console.log(JSON.stringify(slice(`plain ${red('red')} plain`, 4, 8))); console.log(JSON.stringify(slice('a👨‍👩‍👧b', 1, 3))); console.log(JSON.stringify(slice('a👨‍👩‍👧b', 1, 2))); console.log(JSON.stringify(slice('a古b', 0, 2))); console.log(JSON.stringify(slice('abc', 2, 1))); ``` ```text title="node slice.mjs" "n \u001b[31mre\u001b[39m" "👨‍👩‍👧" "" "a" "" ``` - **The style comes back closed.** Columns 4 to 8 start in plain text and end inside the red; the red is opened where it was and closed at the cut, so the fragment printed anywhere leaves no colour behind it. - **A cluster is atomic.** The family emoji is one cluster two columns wide. `[1, 3)` covers it and returns it whole; `[1, 2)` covers half of it and returns nothing, because half an emoji is not a narrower emoji. - **Rounded inward.** A wide character the range only half covers is dropped, as slice-ansi drops it, so a slice is never wider than `end - start`. - **A reversed or empty range is empty**, not an error. ## `truncate(text, columns, options)` `text` shortened to at most `columns` columns, **with the ellipsis counted inside the budget**. That is the property a table column depends on, and the one a hand-rolled truncator gets wrong: `truncate(s, 10)` is ten columns or fewer, never ten plus an ellipsis. ```js title="truncate.mjs" import { truncate } from 'linegauge'; const path = 'packages/linegauge/src/truncate.ts'; const red = (s) => `\u001B[31m${s}\u001B[39m`; console.log(truncate(path, 20)); console.log(truncate(path, 20, { position: 'start' })); console.log(truncate(path, 20, { position: 'middle' })); console.log(truncate(path, 20, { ellipsis: '...' })); console.log(JSON.stringify(truncate(red('a red sentence'), 8))); console.log(JSON.stringify(truncate('hello', 1))); console.log(JSON.stringify(truncate('hello', 1, { ellipsis: '...' }))); ``` ```text title="node truncate.mjs" packages/linegauge/… …uge/src/truncate.ts packages/l…uncate.ts packages/linegaug... "\u001b[31ma red s\u001b[39m…" "…" "" ``` | option | default | meaning | | :-- | :-- | :-- | | `position` | `'end'` | Which part is removed: `'end'`, `'start'` or `'middle'`. In the middle, an odd budget gives its extra column to the left. | | `ellipsis` | `'…'` | The mark that says something was removed. It is **measured**, not assumed to be one column: `'...'` spends three. | - Text that already fits comes back untouched. - When only the ellipsis fits, you get the ellipsis; when not even that fits, you get `''` rather than a cut-up ellipsis. - The ellipsis is never painted in the caller's colour: it goes after the closing sequence, so a red sentence does not end in a red `…`. cli-truncate styles it at two of its three positions and not the third; `truncate.test.ts` asserts that disagreement, which is why this one does not follow it. `truncate` is checked case by case against cli-truncate in `truncate.test.ts`, but it is not a drop-in path for it — there is no `linegauge/cli-truncate`. ## Positions, not columns, at the edges `slice` counts **positions**, as slice-ansi does: every cluster is at least one, so a CRLF is a position of its own and a lone regional indicator is two. That keeps `linegauge/slice` identical to slice-ansi. `truncate` measures what it keeps with `width` instead, so it stays inside its budget even where positions and columns differ. --- # Measuring > width() counts terminal columns by grapheme cluster and East Asian Width, with string-width's two options; lineCount, measure and widest answer the neighbouring questions. Source: https://linegauge.interlace.tools/docs/guides/measuring `width(text)` is the number of terminal columns `text` occupies once drawn. It is the default export of `linegauge`, and string-width's own suite grades it ([Compatibility](/docs/drop-ins)). ## How a width is counted The text is split into **grapheme clusters** with the platform's `Intl.Segmenter`, and each cluster is counted once: - a cluster that draws nothing — a control character, a zero-width joiner on its own, a combining mark with no base — is **0**; - an emoji, a ZWJ sequence or a keycap is **2**, however many code points it is made of; - anything else is the **East Asian Width** of its first visible code point: **2** for wide and fullwidth, **1** otherwise, plus any spacing marks that follow it in the cluster; - escape sequences are removed first and count **0**. ```js title="widths.mjs" import { lineCount, measure, width } from 'linegauge'; console.log(width('±×÷')); console.log(width('±×÷', { ambiguousIsNarrow: false })); console.log(width('\u001B[1mbold\u001B[22m')); console.log(width('\u001B[1mbold\u001B[22m', { countAnsiEscapeCodes: true })); console.log(width('é')); console.log(width('🇯🇵')); console.log(width(undefined)); console.log(lineCount('one\n\nthe quick brown fox jumps', 10)); console.log(measure('古池や')); ``` ```text title="node widths.mjs" 3 6 4 11 1 2 0 5 6 ``` The East Asian Width tables are generated from the pinned `get-east-asian-width`, the one string-width 8.3.0 uses, and are at **Unicode 17**: U+18D80, which Unicode 17 made wide, is two columns (`width.test.ts` asserts it beside string-width). A script run with `--check` in the same test fails when the committed tables drift from the package. ## Options `width(text, { ambiguousIsNarrow, countAnsiEscapeCodes })` takes string-width's two options, under string-width's names and with its defaults: | option | default | meaning | | :-- | :-- | :-- | | `ambiguousIsNarrow` | `true` | Ambiguous-width characters (`±`, `×`, Greek, Cyrillic, box drawing) count one column. Pass `false` for a terminal drawing with a CJK font, where they are two. | | `countAnsiEscapeCodes` | `false` | Count escape sequences as characters instead of removing them, less the escape byte itself. | No terminal reports which ambiguous-width policy it uses, so that one is always the caller's decision. A terminal that disagrees with Unicode about a *particular* code point — a Nerd Font icon in the Private Use Area — is a [width plugin](/docs/guides/plugins), not an option. ## Things that are not strings `width(undefined)`, `width(42)` and `width(null)` are **0**, not a thrown `TypeError`. A width call is usually reached with whatever a template produced, and a layout that measures one missing cell as zero is better than a CLI that exits. ## Rows: `lineCount` `lineCount(text, columns)` is how many terminal rows the text occupies at that width: each line of the text, wrapped. An empty line still takes a row, which is why the example above is five — `one`, the empty line, and three rows of the sentence. ## Plain text: `measure` `measure(text)` is `width` without the escape scan, for text you already know is plain. It takes a second argument, `true`, to count ambiguous characters wide. ## Many lines: `widest` ```js title="widest.mjs" import { widest } from 'linegauge'; function* names() { yield 'a'; yield '古池や'; yield '\u001B[31mred\u001B[39m'; } console.log(widest(names())); console.log(widest(new Set(['ab', 'abcd']))); console.log(widest([])); ``` ```text title="node widest.mjs" 6 4 0 ``` `widest` takes **any iterable** — an array, a `Set`, a generator — and measures it in one pass, so a long list never has to be spread into an argument list, where it would hit the engine's argument limit (`truncate.test.ts` measures far more lines than a spread can pass). It takes lines rather than one string with newlines in it, so which newline convention splits them is your decision. --- # Width plugins > When a terminal or font disagrees with Unicode about a code point, a plugin says so as data — ranges, columns and why — and npx linegauge check validates it before it ships. Source: https://linegauge.interlace.tools/docs/guides/plugins linegauge hosts one plugin key, **`widths`**, and it answers one question: how many columns a code point occupies *on this terminal*, when that terminal disagrees with Unicode. A Nerd Font that draws Private Use icons two columns wide, a code point newer than the table in this release, a font that draws box-drawing characters wide — each is a fact about one terminal, so it is data the user supplies rather than a constant argued about upstream. ## Writing one A plugin is plain data: a `name`, and under `widths` one entry per rule, each with inclusive code-point `ranges`, a `columns` count of 0, 1 or 2, and **`why`**. ```js title="nerd-font.mjs" export default { name: 'nerd-font', widths: { icons: { ranges: [[0xe000, 0xf8ff]], columns: 2, why: 'this Nerd Font draws Private Use icons two columns wide' }, }, }; ``` `why` is required, which no other plugin key in the family asks for. A width table with no provenance cannot be audited when it turns out to be wrong, and for ambiguous width, wrong is the normal outcome. ## Registering it ```js title="branch.mjs" import { width } from 'linegauge'; import { register } from 'linegauge/plugin'; import nerdFont from './nerd-font.mjs'; const icon = '\u{E0A0}'; console.log(width(`${icon} main`)); register(nerdFont); console.log(width(`${icon} main`)); ``` ```text title="node branch.mjs" 6 7 ``` U+E0A0 is Powerline's branch glyph. Unicode calls it narrow, so `width` says six until the plugin says two columns, and every `wrap`, `slice`, `truncate` and `widest` measured after that agrees. - **Later registrations win**, over earlier ones and over the built-in table: the user is the authority on their terminal. - **Nothing is registered by default**, and the override is consulted only while something is, so what the incumbent suites grade is Unicode's answer and a program with no plugin pays nothing. - **Keys other packages read are ignored**, so one plugin file can carry roundel tokens or flagstaff spinners beside its widths. ## Refusals A plugin that would be wrong is refused at `register()`, with a code and a fix: ```js title="refused.mjs" import { register } from 'linegauge/plugin'; try { register({ name: 'no-why', widths: { icons: { ranges: [[0xe000, 0xf8ff]], columns: 2 } } }); } catch (error) { console.log(error.code); console.log(error.message); console.log(error.fix); } ``` ```text title="node refused.mjs" E_PLUGIN_SCHEMA widths.icons.why is required say which terminal or font disagrees, and how you measured it — ambiguous width is wrong often enough that the next reader needs the provenance ``` A range that runs backwards is refused rather than tolerated: it would match nothing, register cleanly and never fire, which is the failure that takes longest to find. So is a column count outside 0 to 2. ## Checking before it ships `npx linegauge check` loads the file without registering it, validates it against the family's plugin schema, and prints what each rule changes: ```text title="npx linegauge check nerd-font.mjs" nerd-font — 1 widths icons U+E000..U+F8FF built-in 1 → 2 — this Nerd Font draws Private Use icons two columns wide nerd-font: ok ``` A refusal exits 1 with the code and the fix, and running it with no file is a usage error, exit 2: ```js title="backwards.mjs" export default { name: 'backwards', widths: { icons: { ranges: [[0xf8ff, 0xe000]], columns: 2, why: 'a test' } } }; ``` ```text title="npx linegauge check backwards.mjs" exit="1" E_PLUGIN_SCHEMA: widths.icons.ranges[0] runs backwards: [63743, 57344] fix: each range is [low, high], both integers in U+0000..U+10FFFF, low first ``` ## Not a plugin: ambiguous width The ambiguous-width policy a CJK terminal needs is `width(text, { ambiguousIsNarrow: false })`, an option with tests behind it, not a plugin ([Measuring](/docs/guides/measuring#options)). --- # Stripping escapes > strip() removes every escape sequence width, wrap and slice count as zero columns — SGR in every dialect, OSC 8 hyperlinks, cursor and erase commands — with one scanner shared by all of them. Source: https://linegauge.interlace.tools/docs/guides/stripping `strip(text)` returns `text` with its escape sequences removed. It is the named export `strip` of `linegauge` and `linegauge/strip`, and the default export of `linegauge/strip`, which strip-ansi's own suite grades ([Compatibility](/docs/drop-ins)). It is a subpath rather than the root's default because the root's default is `width`, the string-width drop-in. ```js title="strip.mjs" import stripAnsi from 'linegauge/strip'; console.log(stripAnsi('\u001B[1m\u001B[31mbold red\u001B[39m\u001B[22m')); console.log(stripAnsi('\u001B[38:2::255:0:0mcolon-form truecolour\u001B[39m')); console.log(stripAnsi('\u001B]8;;https://example.com\u001B\\docs\u001B]8;;\u001B\\')); console.log(stripAnsi('\u001B[2K\u001B[1Gredrawn')); console.log(JSON.stringify(stripAnsi('a trailing escape\u001B'))); ``` ```text title="node strip.mjs" bold red colon-form truecolour docs redrawn "a trailing escape\u001b" ``` What it removes: SGR styling with semicolon or colon parameters, every other CSI command (cursor movement, erase), OSC commands including `OSC 8` hyperlinks under either terminator, DCS strings, the charset selections and the single-character escapes. An `ESC` that nothing parses after — a trailing one, above — is text and is kept, which is also strip-ansi's answer. ## One scanner `width` measures what `strip` leaves, and `wrap` and `slice` find escapes with the same scanner `strip` is built on. A string measured, wrapped, sliced and stripped therefore never disagrees with itself about where the escapes were. ## Why not Node's `stripVTControlCharacters`? `node:util` has a stripper, and on most input it gives the same answer. Before Node 24.21 it stops at the first `:` and leaves the colon form of a truecolour code behind as text — `:2::255:0:0mred` — so a three-column word measured after it comes out fifteen. `strip` removes the colon form itself before handing the rest to Node, and `strip.test.ts` asserts the colon form is stripped, and measured at its real width, on every Node the package supports. --- # Wrapping > wrap() folds styled text to a column width, closing every style and hyperlink at the end of a row and reopening it on the next — wrap-ansi's API and options, graded by its suite. Source: https://linegauge.interlace.tools/docs/guides/wrapping `wrap(text, columns, options)` folds text so no row is wider than `columns`. It is a port of wrap-ansi 10: the named export `wrap` from `linegauge` and `linegauge/wrap`, and the default export of `linegauge/wrap`, which wrap-ansi's own suite grades ([Compatibility](/docs/drop-ins)). ## Styles stay whole on every row ```js title="wrap-styles.mjs" import { wrap } from 'linegauge/wrap'; const red = (s) => `\u001B[31m${s}\u001B[39m`; const link = (url, s) => `\u001B]8;;${url}\u0007${s}\u001B]8;;\u0007`; console.log(JSON.stringify(wrap(red('hello world again'), 7))); console.log(JSON.stringify(wrap(link('https://example.com', 'a link across rows'), 8))); ``` ```text title="node wrap-styles.mjs" "\u001b[31mhello\u001b[39m\n\u001b[31mworld\u001b[39m\n\u001b[31magain\u001b[39m" "\u001b]8;;https://example.com\u0007a link\u001b]8;;\u0007\n\u001b]8;;https://example.com\u0007across\u001b]8;;\u0007\n\u001b]8;;https://example.com\u0007rows\u001b]8;;\u0007" ``` A colour open at a break is closed at the end of the row and reopened at the start of the next; so is an `OSC 8` hyperlink. Every row therefore stands on its own: a caller can print row 3 alone, drop the first two, or put a border character between rows, and no colour leaks into it or goes missing. `flagstaff/log-update` depends on this to drop the rows that have scrolled off the screen without tracking any escape state. ## Options ```js title="wrap-options.mjs" import { wrap } from 'linegauge/wrap'; console.log(JSON.stringify(wrap('supercalifragilistic', 8))); console.log(JSON.stringify(wrap('supercalifragilistic', 8, { hard: true }))); console.log(JSON.stringify(wrap(' padded ', 8))); console.log(JSON.stringify(wrap(' padded ', 8, { trim: false }))); console.log(JSON.stringify(wrap('古池や蛙飛び込む水の音', 8, { hard: true }))); ``` ```text title="node wrap-options.mjs" "supercalifragilistic" "supercal\nifragili\nstic" "padded" " \npadded \n " "古池や蛙\n飛び込む\n水の音" ``` | option | default | meaning | | :-- | :-- | :-- | | `hard` | `false` | Break a word longer than the row. Without it a long word overflows its row rather than being split. | | `trim` | `true` | Remove the whitespace at the start and end of each row. | | `wordWrap` | `true` | Break at spaces. `false` fills every row to the width and breaks wherever that lands. | CJK text has no spaces between words, so wrapping it needs `hard: true` — and a hard break lands between grapheme clusters, never inside one, measured in columns: four two-column characters to an eight-column row. The options type is exported as `WrapOptions` and, from `linegauge/wrap`, as `Options` — the name wrap-ansi gives it — so `import { type Options } from 'wrap-ansi'` moves with the same edit as the import. ## Measured with the graded width Every row is measured with the same `width` that passes string-width's suite, so a wrapped row is exactly as wide as the code laying out a box around it thinks it is. --- # linegauge > Measures, wraps, truncates and slices styled terminal text, grapheme-correct over Intl.Segmenter, with the same result on a terminal, a pipe or --json. Zero dependencies. Drop-in paths for string-width, wrap-ansi, strip-ansi, slice-ansi, cli-truncate and widest-line. Source: https://linegauge.interlace.tools/docs **linegauge** measures, wraps, truncates and slices styled terminal text without the edge fraying, grapheme-correct over the platform's own `Intl.Segmenter`. Nothing here reads `process`, so a pipe, `--json` and an agent get the same columns a terminal does. Zero dependencies. Drop-in for **string-width** (the default export), **wrap-ansi**, **strip-ansi** and **slice-ansi**: `npx burgee migrate` moves the imports, each graded by the incumbent's own suite. **Over the platform.** `Intl.Segmenter` splits graphemes and `util.stripVTControlCharacters` strips escapes; neither one measures a column. linegauge adds the width table (East Asian wide characters, emoji, zero-width marks) and, on Node before 26, strips the colon form of a colour escape (`ESC[38:2::255:0:0m`) that the builtin leaves half behind. The repository's absorption probe asks every supported Node line how much of this the builtins now do alone. A printer's line gauge is the steel rule marked in picas and points: a compositor holds it against a line of type and checks it fits the measure it was set to. ## Install ```bash npm install linegauge pnpm add linegauge yarn add linegauge bun add linegauge ``` ## Quick start ```js import { width, wrap, truncate, slice, widest } from 'linegauge'; width('古代'); // 4 — East Asian wide, two columns each width('👨‍👩‍👧‍👦'); // 2 — one cluster, not four people wrap('a long sentence that needs folding', 12); truncate('the quick brown fox', 10); // 'the quick…' const styled = '\u001B[1mbold\u001B[22m'; // the word, in bold slice(styled, 2, 4); // 'ld' still in bold — columns 2 and 3, styles intact widest(['a', 'bbb', 'cc']); // 3 ``` ## One problem wearing five names `width` · `wrap` · `truncate` · `slice` · `widest` They look like five utilities. They are one: **cutting styled text without letting the edge come apart** — no dangling escape sequence, no half a grapheme, no severed emoji cluster. Each has to know where the ANSI is and where the cluster boundaries are, and once you know that, you may as well answer all five. The ecosystem splits it across twelve packages — `strip-ansi`, `string-width`, `ansi-regex`, `wrap-ansi`, `emoji-regex`, `slice-ansi`, `get-east-asian-width`, `eastasianwidth`, `string-length`, `wcwidth`, `cli-truncate` and `widest-line` — which between them sit under most of the terminal ecosystem. ## What "without the edge fraying" means **A cluster is atomic.** A cut that would land inside a grapheme drops the whole cluster rather than half of it. Half an emoji is not a narrower emoji, it is mojibake, and a flag cut down the middle is two unrelated regional-indicator letters. **A style that was open stays open — and gets closed.** A cut re-emits the styles active at its start and closes them at its end, so the result is self-contained: paste it anywhere and it neither loses its colour nor leaks it into what follows. **The ellipsis is inside the budget, not on top of it.** `truncate(text, 10)` occupies ten columns or fewer, never eleven. That is the property a table column depends on, and getting it wrong is how a layout gains a phantom column under one input. **`widest` takes lines, not a blob.** It accepts any iterable of strings, so the caller says where the boundaries are rather than having a newline convention assumed for them. ## Design notes **Ambiguous-width characters count narrow by default**, which is what a terminal does unless it is rendering with a CJK font. `width(text, { ambiguousIsNarrow: false })` counts them two columns wide for that context — string-width's option, under its name and with its default. Nothing can detect which a terminal is doing, so it is the caller's decision. **Not a terminal emulator.** Semicolon-delimited SGR, colon-delimited extended colour and OSC 8 hyperlinks are understood. Every other complete CSI or OSC command is carried through as an opaque zero-width unit, and anything that only looks like an introducer stays plain text. **`strip` is exported**, from the root and as the `linegauge/strip` subpath whose default export is the strip-ansi drop-in. `width` measures what it leaves. **An ASCII fast path.** A string of printable ASCII is measured by a scan of its code units, so the segmenter is reached only when it earns its cost. ## Plugins linegauge hosts one key, **`widths`**, and it answers one question: how many columns a code point occupies *on this terminal*, when that terminal disagrees with Unicode. ```js import { register } from 'linegauge/plugin'; register({ name: 'nerd-font', widths: { icons: { ranges: [[0xe000, 0xf8ff]], columns: 2, why: 'this Nerd Font draws Private Use icons two columns wide' }, }, }); ``` The disagreements it exists for are real and local: a Nerd Font that put a two-column icon in the Private Use Area, a code point newer than the table compiled into this release, a font that draws box-drawing characters wide. Each is a fact about one terminal, so the honest shape for it is data the user supplies rather than a constant somebody argues about upstream. - **Plain data, no functions.** `{ ranges, columns, why }` — inclusive code-point pairs, a column count of 0, 1 or 2, and a sentence — so a plugin can arrive as JSON, be diffed, and be printed by `npx linegauge check` without running its author's code. - **`why` is required**, which no other key in the family asks for: a width table with no provenance cannot be audited when it turns out wrong, and for ambiguous width, wrong is the normal outcome. - **Later registrations win**, over earlier ones and over the built-in table — the user is the authority on their terminal. A reversed range or a column count of 3 is refused at `register()` with a code and a fix. - **Nothing is registered by default**, and the seam is installed only while something is, so what the incumbent suites grade is Unicode's answer, and a program with no plugin pays nothing. The ambiguous-width policy a CJK terminal needs is not a plugin: it is `ambiguousIsNarrow`, an option with tests behind it. ## Migrating One import per incumbent — each default export is the incumbent's: ```diff - import stringWidth from 'string-width'; + import stringWidth from 'linegauge'; ``` ```diff - import wrapAnsi from 'wrap-ansi'; + import wrapAnsi from 'linegauge/wrap'; ``` ```diff - import stripAnsi from 'strip-ansi'; + import stripAnsi from 'linegauge/strip'; ``` ```diff - import sliceAnsi from 'slice-ansi'; + import sliceAnsi from 'linegauge/slice'; ``` ```diff - import cliTruncate from 'cli-truncate'; + import cliTruncate from 'linegauge/cli-truncate'; ``` ```diff - import widestLine from 'widest-line'; + import widestLine from 'linegauge/widest-line'; ``` The default export is `string-width`, byte-for-byte call-compatible, so a transitive copy resolves without a code change too: ```json { "overrides": { "string-width": "npm:linegauge@^1" } } ``` Or let the codemod make the import change: `npx burgee migrate --dry-run` lists every import it would rewrite — only drop-ins graded level with their incumbent — and `npx burgee migrate` makes it ([Migrate](https://burgee.interlace.tools/docs/migrate)). ## Compatibility The incumbent is the specification. `width` runs against `string-width`, `wrap` against `wrap-ansi`, `slice` against `slice-ansi`, and the `cli-truncate` and `widest-line` paths against their namesakes. The six drop-in paths are graded by each incumbent's own suite, unedited, through `compat-oracle`; the grades are generated under *Benchmarks* below and published on the [compatibility page](https://burgee.interlace.tools/docs/compatibility). `linegauge/cli-truncate` passes all 17 of cli-truncate 6.2.0's cases and `linegauge/widest-line` widest-line 6.0.0's one, level with each incumbent. `truncate` and `widest` stay linegauge's own API: `truncate` counts the ellipsis inside the budget and cuts by measured width, and `widest` takes any iterable of lines. ## Benchmarks Every number here is produced by `npm run bench` and published at [burgee.interlace.tools/docs/benchmarks](https://burgee.interlace.tools/docs/benchmarks). Graded by the incumbent's own test suite: | suite | passing | | :-- | --: | | `cli-truncate` | 17 / 17 | | `slice-ansi` | 106 / 106 | | `string-width` | 233 / 233 | | `strip-ansi` | 8 / 8 | | `widest-line` | 1 / 1 | | `wrap-ansi` | 85 / 85 | Weight, installed and tree-inclusive: **107,852 bytes** against **194,329** for the incumbents it replaces — a ratio of **0.5550** ([measured](https://burgee.interlace.tools/docs/benchmarks)). ## For agents - **The same columns everywhere.** Nothing here reads `process`, so a pipe, `--json` and an agent get the columns a terminal does — which is why burgee, caique and flagstaff measure their output with it. - **Non-strings answer `0`** rather than throwing, so a width call reached with whatever a template produced never takes a program down. - **A width plugin can be checked before it ships.** `npx linegauge check ./widths.mjs` validates it against the family schema and exits 0, 1 with a code and a fix, or 2 on a usage error. - **The docs are machine-readable** at [linegauge.interlace.tools/llms.txt](https://linegauge.interlace.tools/llms.txt) and [llms-full.txt](https://linegauge.interlace.tools/llms-full.txt). ## API | | | | :-- | :-- | | `width(text, { ambiguousIsNarrow, countAnsiEscapeCodes })` | terminal columns the text occupies | | `wrap(text, columns, options)` | fold to a width, styles preserved across rows | | `truncate(text, columns, { position, ellipsis })` | cut to a budget, ellipsis counted inside it | | `slice(text, start, end)` | the columns `[start, end)`, self-contained | | `strip(text)` | the text with its escape sequences removed (`linegauge/strip`) | | `widest(lines)` | the width of the widest line of any iterable | | `lineCount(text, columns)` | rows the text occupies at that width | | `measure(text)` | columns of plain text, no escape scan | Non-strings answer `0` rather than throwing, because a width function is usually reached with whatever a template produced. Every export, with its types, is on [linegauge.interlace.tools](https://linegauge.interlace.tools/docs). ## Where it sits Plugins register under the `widths` key, against the one schema the whole family shares. `burgee`, `caique`, `controlroom`, `flagstaff` build on it, and it builds on nothing in this family. ## The family Eleven packages, one repository, one release pipeline. Each installs and works on its own, and each owns one job. Six of them — `bellpull`, `closeout`, `linegauge`, `paratext`, `roundel`, `seniority` — depend on nothing; the others depend only on packages in this table, and every dependency points one way, down the [layers](https://burgee.interlace.tools/docs/concepts/family). No package declares a dependency from outside the family; `react` and `react-reconciler` are optional peers of `controlroom`, which npm does not install; `vitest` is an optional peer of `oxmutate`, which npm does not install. [`package-shape-lock`](https://github.com/ofri-peretz/burgee/blob/main/scripts/package-shape-lock.test.ts) holds that for every manifest, and [`independence-install-lock`](https://github.com/ofri-peretz/burgee/blob/main/scripts/independence-install-lock.test.ts) installs each package alone and finds nothing but the family packages it declares. Releases are published by one workflow through npm trusted publishing: no npm token is used, and each release carries SLSA provenance, which `npm view dist.attestations` shows. [`trusted-publishing-lock`](https://github.com/ofri-peretz/burgee/blob/main/scripts/trusted-publishing-lock.test.ts) keeps both true. | Package | What it is | Migrates from | | :-- | :-- | :-- | | [burgee](https://burgee.interlace.tools/docs/packages/burgee) | The CLI framework: one declaration, every surface | commander, yargs and meow | | [roundel](https://roundel.interlace.tools/docs) | Colour: one output policy, semantic tokens, a theme | chalk, colorette, kleur, picocolors, supports-color and yoctocolors | | [flagstaff](https://flagstaff.interlace.tools/docs) | The frame loop: spinners, progress, boxes and tables | ora, log-update, boxen and cli-table3 | | [caique](https://caique.interlace.tools/docs) | Prompts that are flags first, and never hang | @inquirer/core | | **linegauge** (this package) | Measuring, wrapping, truncating and slicing styled text | string-width, wrap-ansi, strip-ansi, slice-ansi, cli-truncate and widest-line | | [paratext](https://paratext.interlace.tools/docs) | Hyperlinks, images, title, clipboard and notifications | ansi-escapes, terminal-link and term-img | | [seniority](https://seniority.interlace.tools/docs) | Configuration precedence and discovery, with provenance | cosmiconfig, lilconfig, dotenv and rc | | [closeout](https://closeout.interlace.tools/docs) | Exit handlers, terminal restore and a bounded shutdown | signal-exit, exit-hook, restore-cursor, cli-cursor and onetime | | [bellpull](https://bellpull.interlace.tools/docs) | Subprocesses, and which executable actually ran | cross-spawn and which | | [controlroom](https://controlroom.interlace.tools/docs) | Keyboard-driven terminal screens, inline or full-screen | ink | | [oxmutate](https://oxmutate.interlace.tools/docs) | Mutation testing on the oxc engine, inside your own Vitest | Stryker | Every migration guide, and the family-wide [compatibility](https://burgee.interlace.tools/docs/compatibility) and [benchmarks](https://burgee.interlace.tools/docs/benchmarks) pages, are on [burgee.interlace.tools](https://burgee.interlace.tools/docs/packages). ## Contributing Issues and pull requests are welcome at [ofri-peretz/burgee](https://github.com/ofri-peretz/burgee/issues); read [CONTRIBUTING.md](https://github.com/ofri-peretz/burgee/blob/main/CONTRIBUTING.md) first. Report a vulnerability privately, as [SECURITY.md](https://github.com/ofri-peretz/burgee/blob/main/SECURITY.md) describes — never in a public issue. ## Licence MIT © Ofri Peretz — see [LICENSE](https://github.com/ofri-peretz/burgee/blob/main/packages/linegauge/LICENSE). --- # Aligning columns > Pad text to a column count with width() and widest(), so CJK names and emoji do not push a column out of line the way padEnd does. Source: https://linegauge.interlace.tools/docs/recipes/aligning-columns `String.prototype.padEnd` counts UTF-16 code units. A CJK character is one code unit and two columns; a ZWJ emoji is several code units and two columns. Pad by `width` instead, and size the column with `widest`: ```js title="columns.mjs" import { widest, width } from 'linegauge'; const rows = [ ['name', 'city'], ['Ofri', 'Tel Aviv'], ['山田太郎', '東京'], ['Zoë 👩‍💻', 'Zürich'], ]; /** Pad to a column count, not a length: `padEnd` counts code units. */ const pad = (text, columns) => text + ' '.repeat(columns - width(text)); const first = widest(rows.map(([name]) => name)); for (const [name, city] of rows) console.log(`${pad(name, first)} | ${city}`); ``` ```text title="node columns.mjs" name | city Ofri | Tel Aviv 山田太郎 | 東京 Zoë 👩‍💻 | Zürich ``` In a terminal the bars line up. (In a browser font the CJK and emoji rows may not, because a web font does not draw them at exactly two columns — a terminal does.) The same padding is what flagstaff's tables and boxes do on top of linegauge. A styled cell pads the same way: `width` removes escape sequences before it counts, so `pad(red('Ofri'), first)` adds the same four spaces as the plain one. --- # Incremental migration > Move from string-width, wrap-ansi, strip-ansi and slice-ansi one import at a time, or move a transitive copy with npm overrides, checking each step. Source: https://linegauge.interlace.tools/docs/recipes/incremental-migration Each drop-in is graded on its own, so a program can move one import at a time and ship in between. Nothing about moving one path depends on the others. ## One import at a time ```diff - import stringWidth from 'string-width'; + import stringWidth from 'linegauge'; - import wrapAnsi from 'wrap-ansi'; + import wrapAnsi from 'linegauge/wrap'; - import stripAnsi from 'strip-ansi'; + import stripAnsi from 'linegauge/strip'; - import sliceAnsi from 'slice-ansi'; + import sliceAnsi from 'linegauge/slice'; ``` Or let the codemod do it: `npx burgee migrate --dry-run` lists every import it would rewrite — only drop-ins graded level with their incumbent — and `npx burgee migrate` rewrites them ([Migrate](https://burgee.interlace.tools/docs/migrate)). ## A copy you do not import yourself string-width sits under most of the terminal ecosystem, so your tree probably carries copies your code never imports. The root default export is call-compatible with string-width's, so an `overrides` entry moves those too, without a code change: ```json { "overrides": { "string-width": "npm:linegauge@^0.5" } } ``` Only string-width can be moved this way. The other three are subpaths, and an override replaces a whole package, so `wrap-ansi` pointed at linegauge would resolve to `width`. ## Checking each step Once the imports have moved, the dependencies can go: ```bash npm uninstall string-width wrap-ansi strip-ansi slice-ansi npm ls string-width ``` `npm ls` shows whatever still pulls a copy in transitively; that is what the override above is for. The behaviour each step keeps is the incumbent's own suite's, as [Compatibility](/docs/drop-ins) shows — to the case, not to a description. --- # Shortening paths > Fit a file path into a fixed-width column with truncate(), keeping both ends — the directory it starts in and the file it names. Source: https://linegauge.interlace.tools/docs/recipes/shortening-paths A path's two ends carry the information: where it starts and what file it names. Cut from the middle, and give the column a fixed width, and every path fits it exactly: ```js title="paths.mjs" import { truncate } from 'linegauge'; const files = ['README.md', 'packages/linegauge/src/truncate.test.ts', 'apps/docs-linegauge/content/docs/recipes/shortening-paths.mdx']; for (const file of files) console.log(`[${truncate(file, 24, { position: 'middle' })}]`); ``` ```text title="node paths.mjs" [README.md] [packages/lin…ate.test.ts] [apps/docs-li…g-paths.mdx] ``` The ellipsis is inside the 24 columns, so the bracket never moves. A path that already fits comes back as it was. For a log line where the file name matters most, cut from the start instead: `truncate(file, 24, { position: 'start' })` keeps the end. A path with a wide character in it is cut by columns, not characters, so a Japanese directory name spends two of the 24 per character and the result is still no wider than 24. --- # Wrapping help text > Wrap a paragraph to the terminal's width with an indent, and to a fixed width in a pipe or a test, so help output never runs off the right edge. Source: https://linegauge.interlace.tools/docs/recipes/wrapping-help linegauge never reads the terminal's width — the caller passes it. That makes the same code right in a terminal, in a pipe and in a test: ask the terminal when there is one, and pick a width when there is not. ```js title="help.mjs" import { wrap } from 'linegauge'; /** The terminal's width when there is one; a fixed width for a pipe, a log or a test. */ const columns = process.stdout.isTTY ? process.stdout.columns : 40; const text = 'Measures, wraps, truncates and slices styled terminal text without splitting a grapheme cluster or leaking a colour.'; const indent = ' '; console.log('Usage: gauge [options] \n'); console.log( wrap(text, columns - indent.length) .split('\n') .map((line) => indent + line) .join('\n'), ); ``` ```text title="node help.mjs" Usage: gauge [options] Measures, wraps, truncates and slices styled terminal text without splitting a grapheme cluster or leaking a colour. ``` Wrap to the width *less the indent*, then indent each row: indenting first would count the spaces as text and let `trim` remove them. Because `wrap` closes every style at the end of a row, putting the indent in front of a row never puts it inside a colour. A CLI built on [burgee](https://burgee.interlace.tools/docs) gets this for its help already: burgee measures and wraps its help with linegauge. --- # Why linegauge > linegauge against string-width, wrap-ansi, strip-ansi and slice-ansi, one capability per row, every cell linked to the test, grade or source that proves it. Source: https://linegauge.interlace.tools/docs/why-linegauge string-width, wrap-ansi, strip-ansi and slice-ansi are good at what they do, and linegauge matches them where they are right: each of its four drop-in paths is graded by the incumbent's own test suite, 100% on all four. What it adds is what four separate packages cannot share — one package with no dependencies, the pieces that live in yet more packages (truncation with the ellipsis inside the budget, the widest of many lines), and a way to tell it that your terminal disagrees with Unicode. The table below is the whole comparison. Every mark links to its evidence: a test in this repository for ours, and for theirs the source file of the exact version compat-oracle grades, or that package's own test suite. `scripts/capabilities-lock.test.ts` fails the build when a cited test no longer contains the title it is cited for, when a source no longer contains the line it is quoted for, or when a source we say lacks something has gained it. ✓ yes · ◐ partial (what is missing is said) · ✗ no · — does not apply. Every cell links to its evidence: our test or grade, or the incumbent’s source at the version compat-oracle grades. ### Measuring text | Capability | **linegauge** | string-width | wrap-ansi | strip-ansi | slice-ansi | | :-- | :-- | :-- | :-- | :-- | :-- | | **Unicode 17 East Asian Width** — A code point Unicode 17 made wide, such as U+18D80, measures two columns, so a layout built on the width does not drift on new text. | [✓](https://github.com/ofri-peretz/burgee/blob/main/packages/linegauge/src/width.test.ts) | [✓ through get-east-asian-width 1.7.0](https://cdn.jsdelivr.net/npm/string-width@8.3.0/index.js) | [✓ measures through string-width](https://cdn.jsdelivr.net/npm/wrap-ansi@10.0.2/index.js) | [— removes escape sequences; measures nothing](https://cdn.jsdelivr.net/npm/strip-ansi@7.2.0/index.js) | [✓ through string-width and get-east-asian-width, since slice-ansi 10](https://cdn.jsdelivr.net/npm/slice-ansi@10.0.0/tokenize-ansi.js) | | **A ZWJ emoji sequence is one two-column cluster** — Text is measured by grapheme cluster over `Intl.Segmenter`, so a family emoji built from four people and three joiners is two columns, not eight. | [✓](https://github.com/ofri-peretz/burgee/blob/main/packages/linegauge/src/width.test.ts) | [✓](https://cdn.jsdelivr.net/npm/string-width@8.3.0/index.js) | [✓](https://cdn.jsdelivr.net/npm/wrap-ansi@10.0.2/index.js) | [— removes escape sequences; measures nothing](https://cdn.jsdelivr.net/npm/strip-ansi@7.2.0/index.js) | [✓](https://cdn.jsdelivr.net/npm/slice-ansi@10.0.0/tokenize-ansi.js) | ### Cutting styled text | Capability | **linegauge** | string-width | wrap-ansi | strip-ansi | slice-ansi | | :-- | :-- | :-- | :-- | :-- | :-- | | **A slice closes the styles it opened** — A column range re-emits the styles active at its start and closes them at its end, so the fragment neither loses its colour nor leaks it into what follows. | [✓](https://github.com/ofri-peretz/burgee/blob/main/packages/linegauge/src/slice.test.ts) | [— measures; its one export is the width, and it does not cut](https://cdn.jsdelivr.net/npm/string-width@8.3.0/index.js) | [— folds text into rows; it takes no column range](https://cdn.jsdelivr.net/npm/wrap-ansi@10.0.2/index.js) | [— removes escape sequences; it does not cut](https://cdn.jsdelivr.net/npm/strip-ansi@7.2.0/index.js) | [✓](https://cdn.jsdelivr.net/npm/slice-ansi@10.0.0/index.js) | | **Truncation with the ellipsis inside the budget** — `truncate(text, 10)` occupies ten columns or fewer with its ellipsis counted, cutting from the end, the start or the middle, which is what a table column depends on. | [✓](https://github.com/ofri-peretz/burgee/blob/main/packages/linegauge/src/truncate.test.ts) | [✗ measures only; truncation is cli-truncate, another package](https://cdn.jsdelivr.net/npm/string-width@8.3.0/index.js) | [✗ folds text into rows; it never shortens it](https://cdn.jsdelivr.net/npm/wrap-ansi@10.0.2/index.js) | [— removes escape sequences; it does not cut](https://cdn.jsdelivr.net/npm/strip-ansi@7.2.0/index.js) | [◐ cuts a column range, but adds no ellipsis and keeps no room for one](https://cdn.jsdelivr.net/npm/slice-ansi@10.0.0/index.js) | | **The widest of many lines, from any iterable** — `widest(lines)` measures an array, a `Set` or a generator in one pass, so the lines never have to be spread into an argument list. | [✓](https://github.com/ofri-peretz/burgee/blob/main/packages/linegauge/src/truncate.test.ts) | [✗ measures one string; the widest of many is widest-line, another package](https://cdn.jsdelivr.net/npm/string-width@8.3.0/index.js) | [✗ folds one string](https://cdn.jsdelivr.net/npm/wrap-ansi@10.0.2/index.js) | [— removes escape sequences; measures nothing](https://cdn.jsdelivr.net/npm/strip-ansi@7.2.0/index.js) | [✗ cuts one string](https://cdn.jsdelivr.net/npm/slice-ansi@10.0.0/index.js) | ### Terminals that disagree with Unicode | Capability | **linegauge** | string-width | wrap-ansi | strip-ansi | slice-ansi | | :-- | :-- | :-- | :-- | :-- | :-- | | **Width overrides through a plugin, with provenance** — A plugin registers `{ ranges, columns, why }` for a font that draws Private Use icons two columns wide, and a width table without a `why` is refused. | [✓](https://github.com/ofri-peretz/burgee/blob/main/packages/linegauge/src/plugin.test.ts) | [✗ the Unicode table is fixed; only ambiguous width is an option](https://cdn.jsdelivr.net/npm/string-width@8.3.0/index.js) | [✗ measures through string-width, which takes no overrides](https://cdn.jsdelivr.net/npm/wrap-ansi@10.0.2/index.js) | [— removes escape sequences; measures nothing](https://cdn.jsdelivr.net/npm/strip-ansi@7.2.0/index.js) | [✗ the width of each code point is fixed](https://cdn.jsdelivr.net/npm/slice-ansi@10.0.0/index.js) | | **A checker for width plugins before they ship** — `npx linegauge check ./widths.mjs` validates a plugin against the family schema and exits 1 with a code and a fix when it is refused. | [✓](https://github.com/ofri-peretz/burgee/blob/main/scripts/plugin-check-lock.test.ts) | [— takes no plugins, so has nothing to check](https://cdn.jsdelivr.net/npm/string-width@8.3.0/package.json) | [— takes no plugins, so has nothing to check](https://cdn.jsdelivr.net/npm/wrap-ansi@10.0.2/package.json) | [— takes no plugins, so has nothing to check](https://cdn.jsdelivr.net/npm/strip-ansi@7.2.0/package.json) | [— takes no plugins, so has nothing to check](https://cdn.jsdelivr.net/npm/slice-ansi@10.0.0/package.json) | ### Weight | Capability | **linegauge** | string-width | wrap-ansi | strip-ansi | slice-ansi | | :-- | :-- | :-- | :-- | :-- | :-- | | **No runtime dependencies** — Measuring, wrapping, slicing, stripping and truncating install as one package that depends on nothing. | [✓](https://github.com/ofri-peretz/burgee/blob/main/scripts/package-shape-lock.test.ts) | [✗ get-east-asian-width and strip-ansi](https://cdn.jsdelivr.net/npm/string-width@8.3.0/package.json) | [✗ ansi-styles and string-width](https://cdn.jsdelivr.net/npm/wrap-ansi@10.0.2/package.json) | [✗ ansi-regex](https://cdn.jsdelivr.net/npm/strip-ansi@7.2.0/package.json) | [✗ ansi-styles and string-width](https://cdn.jsdelivr.net/npm/slice-ansi@10.0.0/package.json) | ### Compatibility | Capability | **linegauge** | string-width | wrap-ansi | strip-ansi | slice-ansi | | :-- | :-- | :-- | :-- | :-- | :-- | | **Passes string-width's own test suite** — linegauge's default export is graded by string-width 8.3.0's own tests, unedited, so changing the import keeps string-width's answers. | [✓ 233 / 233 of its own tests](https://github.com/ofri-peretz/burgee/blob/main/packages/compat-oracle/baseline/string-width.json) | [✓ its own suite, the control run](https://github.com/ofri-peretz/burgee/blob/main/packages/compat-oracle/vendor/string-width/test.js) | [— a different API](https://github.com/ofri-peretz/burgee/blob/main/packages/compat-oracle/vendor/wrap-ansi/test.js) | [— a different API](https://github.com/ofri-peretz/burgee/blob/main/packages/compat-oracle/vendor/strip-ansi/test.js) | [— a different API](https://github.com/ofri-peretz/burgee/blob/main/packages/compat-oracle/vendor/slice-ansi/test.js) | | **Passes wrap-ansi's own test suite** — `linegauge/wrap` is graded by wrap-ansi 10.0.2's own tests, unedited. | [✓ 85 / 85 of its own tests](https://github.com/ofri-peretz/burgee/blob/main/packages/compat-oracle/baseline/wrap-ansi.json) | [— a different API](https://github.com/ofri-peretz/burgee/blob/main/packages/compat-oracle/vendor/string-width/test.js) | [✓ its own suite, the control run](https://github.com/ofri-peretz/burgee/blob/main/packages/compat-oracle/vendor/wrap-ansi/test.js) | [— a different API](https://github.com/ofri-peretz/burgee/blob/main/packages/compat-oracle/vendor/strip-ansi/test.js) | [— a different API](https://github.com/ofri-peretz/burgee/blob/main/packages/compat-oracle/vendor/slice-ansi/test.js) | | **Passes strip-ansi's own test suite** — `linegauge/strip` is graded by strip-ansi 7.2.0's own tests, unedited. | [✓ 8 / 8 of its own tests](https://github.com/ofri-peretz/burgee/blob/main/packages/compat-oracle/baseline/strip-ansi.json) | [— a different API](https://github.com/ofri-peretz/burgee/blob/main/packages/compat-oracle/vendor/string-width/test.js) | [— a different API](https://github.com/ofri-peretz/burgee/blob/main/packages/compat-oracle/vendor/wrap-ansi/test.js) | [✓ its own suite, the control run](https://github.com/ofri-peretz/burgee/blob/main/packages/compat-oracle/vendor/strip-ansi/test.js) | [— a different API](https://github.com/ofri-peretz/burgee/blob/main/packages/compat-oracle/vendor/slice-ansi/test.js) | | **Passes slice-ansi's own test suite** — `linegauge/slice` is graded by slice-ansi 9.0.1's own tests, unedited. | [✓ 106 / 106 of its own tests](https://github.com/ofri-peretz/burgee/blob/main/packages/compat-oracle/baseline/slice-ansi.json) | [— a different API](https://github.com/ofri-peretz/burgee/blob/main/packages/compat-oracle/vendor/string-width/test.js) | [— a different API](https://github.com/ofri-peretz/burgee/blob/main/packages/compat-oracle/vendor/wrap-ansi/test.js) | [— a different API](https://github.com/ofri-peretz/burgee/blob/main/packages/compat-oracle/vendor/strip-ansi/test.js) | [✓ its own suite, the control run](https://github.com/ofri-peretz/burgee/blob/main/packages/compat-oracle/vendor/slice-ansi/test.js) | ## Reading it - **Parity rows are here on purpose.** All three measuring incumbents are at Unicode 17 and measure a ZWJ sequence as one cluster, and slice-ansi 10 closes the styles it opens. A reader would otherwise have to go and check; the cells say they match. - **— does not apply** is not a soft ✗. strip-ansi removes escape sequences and measures nothing, so a measuring row does not apply to it; the cell says why. - **Truncation and `widest` are not in the four incumbents** — they are cli-truncate and widest-line, two more packages. The row shows what you would install on top of the four to get them. ## What is not in the table A row goes in only when every cell of it can be proved. These were left out: - **`OSC 8` hyperlinks.** `wrap` and `slice` carry a hyperlink across a break and close it at a cut, and their differential tests include hyperlink cases — but wrap-ansi and slice-ansi do too, and no test of ours names the case on its own. - **Linear time on adversarial input.** There is no regression test for it in this repository yet, so there is no row. - **Ambiguous width.** `width(text, { ambiguousIsNarrow: false })` counts ambiguous characters wide, exactly as string-width's option does. `wrap` and `slice` do not take the option, and neither do wrap-ansi and slice-ansi, so the row would say nothing either way ([Measuring](/docs/guides/measuring#options)). - **Weight in bytes.** The per-entry figures are asserted by [`weight.test.ts`](https://github.com/ofri-peretz/burgee/blob/main/packages/linegauge/src/weight.test.ts) and published on [Benchmarks](https://burgee.interlace.tools/docs/benchmarks), measured the same way on both sides. They are a number, not a yes-or-no capability.