linegauge 0.5.3 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -8,11 +8,34 @@
8
8
  </p>
9
9
 
10
10
  <p align="center">
11
- Docs: <a href="https://linegauge.interlace.tools">https://linegauge.interlace.tools</a>
11
+ Measuring, wrapping, truncating and slicing styled terminal text — without the edge fraying.
12
12
  </p>
13
13
 
14
- **Measuring, wrapping, truncating and slicing styled terminal text — without the edge
15
- fraying.**
14
+ <p align="center">
15
+ <a href="https://www.npmjs.com/package/linegauge"><img src="https://img.shields.io/npm/v/linegauge?style=flat-square&color=0a6b47" alt="linegauge on npm: the latest version" /></a>
16
+ <a href="https://www.npmjs.com/package/linegauge"><img src="https://img.shields.io/npm/dm/linegauge?style=flat-square" alt="linegauge downloads per month on npm" /></a>
17
+ <a href="https://github.com/ofri-peretz/burgee/actions/workflows/quality.yml?query=branch%3Amain"><img src="https://img.shields.io/github/actions/workflow/status/ofri-peretz/burgee/quality.yml?branch=main&style=flat-square&label=Quality%20Gate" alt="Quality Gate: the CI status of main" /></a>
18
+ <a href="https://app.codecov.io/gh/ofri-peretz/burgee/components"><img src="https://img.shields.io/codecov/c/github/ofri-peretz/burgee/main?component=linegauge&style=flat-square" alt="linegauge line coverage: its Codecov component" /></a>
19
+ <a href="https://scorecard.dev/viewer/?uri=github.com/ofri-peretz/burgee"><img src="https://img.shields.io/ossf-scorecard/github.com/ofri-peretz/burgee?style=flat-square&label=OpenSSF%20Scorecard" alt="OpenSSF Scorecard for the repository" /></a>
20
+ <a href="https://www.npmjs.com/package/linegauge?activeTab=code"><img src="https://img.shields.io/npm/unpacked-size/linegauge?style=flat-square" alt="Unpacked size of the latest linegauge release on npm" /></a>
21
+ <a href="https://github.com/ofri-peretz/burgee/blob/main/packages/linegauge/package.json"><img src="https://img.shields.io/badge/dependencies-0-0a6b47?style=flat-square" alt="Zero dependencies" /></a>
22
+ <a href="https://github.com/ofri-peretz/burgee/blob/main/packages/linegauge/package.json"><img src="https://img.shields.io/badge/types-included-blue?style=flat-square" alt="TypeScript types included for every entry point" /></a>
23
+ <a href="https://github.com/ofri-peretz/burgee/blob/main/packages/linegauge/package.json"><img src="https://img.shields.io/badge/Node.js-20.19%2B%20%7C%2022.13%2B-green?style=flat-square" alt="Node.js 20.19+ or 22.13+" /></a>
24
+ <a href="https://github.com/ofri-peretz/burgee/blob/main/packages/linegauge/LICENSE"><img src="https://img.shields.io/badge/License-MIT-blue?style=flat-square" alt="License: MIT" /></a>
25
+ <a href="https://www.npmjs.com/package/linegauge#provenance"><img src="https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fregistry.npmjs.org%2Flinegauge%2Flatest&query=%24.dist.attestations.provenance~&label=npm&style=flat-square&color=0a6b47" alt="npm provenance of the latest release, read live from its registry attestation" /></a>
26
+ </p>
27
+
28
+ <p align="center">
29
+ <a href="https://burgee.interlace.tools/docs/compatibility"><img src="https://img.shields.io/badge/slice--ansi%20suite-104%2F104-0a6b47?style=flat-square" alt="linegauge/slice passes 104 of 104 cases of the slice-ansi test suite" /></a>
30
+ <a href="https://burgee.interlace.tools/docs/compatibility"><img src="https://img.shields.io/badge/string--width%20suite-233%2F233-0a6b47?style=flat-square" alt="linegauge passes 233 of 233 cases of the string-width test suite" /></a>
31
+ <a href="https://burgee.interlace.tools/docs/compatibility"><img src="https://img.shields.io/badge/strip--ansi%20suite-8%2F8-0a6b47?style=flat-square" alt="linegauge/strip passes 8 of 8 cases of the strip-ansi test suite" /></a>
32
+ <a href="https://burgee.interlace.tools/docs/compatibility"><img src="https://img.shields.io/badge/wrap--ansi%20suite-85%2F85-0a6b47?style=flat-square" alt="linegauge/wrap passes 85 of 85 cases of the wrap-ansi test suite" /></a>
33
+ </p>
34
+
35
+ <p align="center">
36
+ Docs: <a href="https://linegauge.interlace.tools">https://linegauge.interlace.tools</a><br />
37
+ Migrating from: <a href="https://linegauge.interlace.tools/docs/coming-from/string-width">string-width</a> · <a href="https://linegauge.interlace.tools/docs/coming-from/wrap-ansi">wrap-ansi</a> · <a href="https://linegauge.interlace.tools/docs/coming-from/strip-ansi">strip-ansi</a> · <a href="https://linegauge.interlace.tools/docs/coming-from/slice-ansi">slice-ansi</a>
38
+ </p>
16
39
 
17
40
  A printer's line gauge is the steel rule marked in picas and points: a compositor holds it
18
41
  against a line of type and checks it fits the measure it was set to.
@@ -23,25 +46,16 @@ Drop-in paths for **string-width** (the default export), **wrap-ansi**, **strip-
23
46
  **slice-ansi**. Nothing here reads `process`, so a pipe, `--json` and an agent get the same
24
47
  columns a terminal does — burgee, caique and flagstaff measure their output with it.
25
48
 
49
+ ## Install
50
+
26
51
  ```bash
27
- npm i linegauge
52
+ npm install linegauge
53
+ pnpm add linegauge
54
+ yarn add linegauge
55
+ bun add linegauge
28
56
  ```
29
57
 
30
- ## One problem wearing five names
31
-
32
- `width` · `wrap` · `truncate` · `slice` · `widest`
33
-
34
- They look like five utilities. They are one: **cutting styled text without letting the edge
35
- come apart** — no dangling escape sequence, no half a grapheme, no severed emoji cluster.
36
- Each has to know where the ANSI is and where the cluster boundaries are, and once you know
37
- that, you may as well answer all five.
38
-
39
- The ecosystem splits it across twelve packages — `strip-ansi`, `string-width`,
40
- `ansi-regex`, `wrap-ansi`, `emoji-regex`, `slice-ansi`, `get-east-asian-width`,
41
- `eastasianwidth`, `string-length`, `wcwidth`, `cli-truncate` and `widest-line` — which
42
- between them sit under most of the terminal ecosystem.
43
-
44
- ## Use
58
+ ## Quick start
45
59
 
46
60
  ```js
47
61
  import { width, wrap, truncate, slice, widest } from 'linegauge';
@@ -55,13 +69,19 @@ slice(styled, 2, 4); // columns 2 and 3, styles intact
55
69
  widest(['a', 'bbb', 'cc']); // 3
56
70
  ```
57
71
 
58
- ### The default export is `string-width`
72
+ ## One problem wearing five names
59
73
 
60
- Byte-for-byte call-compatible, so this resolves without a code change:
74
+ `width` · `wrap` · `truncate` · `slice` · `widest`
61
75
 
62
- ```json
63
- { "overrides": { "string-width": "npm:linegauge@^0.5" } }
64
- ```
76
+ They look like five utilities. They are one: **cutting styled text without letting the edge
77
+ come apart** — no dangling escape sequence, no half a grapheme, no severed emoji cluster.
78
+ Each has to know where the ANSI is and where the cluster boundaries are, and once you know
79
+ that, you may as well answer all five.
80
+
81
+ The ecosystem splits it across twelve packages — `strip-ansi`, `string-width`,
82
+ `ansi-regex`, `wrap-ansi`, `emoji-regex`, `slice-ansi`, `get-east-asian-width`,
83
+ `eastasianwidth`, `string-length`, `wcwidth`, `cli-truncate` and `widest-line` — which
84
+ between them sit under most of the terminal ecosystem.
65
85
 
66
86
  ## What "without the edge fraying" means
67
87
 
@@ -80,27 +100,6 @@ it wrong is how a layout gains a phantom column under one input.
80
100
  **`widest` takes lines, not a blob.** It accepts any iterable of strings, so the caller says
81
101
  where the boundaries are rather than having a newline convention assumed for them.
82
102
 
83
- ## Graded by the packages it replaces
84
-
85
- The incumbent is the specification. `width` runs against `string-width`, `wrap` against
86
- `wrap-ansi`, `slice` against `slice-ansi` and `truncate` against `cli-truncate`.
87
-
88
- ## API
89
-
90
- | | |
91
- | :-- | :-- |
92
- | `width(text, { ambiguousIsNarrow, countAnsiEscapeCodes })` | terminal columns the text occupies |
93
- | `wrap(text, columns, options)` | fold to a width, styles preserved across rows |
94
- | `truncate(text, columns, { position, ellipsis })` | cut to a budget, ellipsis counted inside it |
95
- | `slice(text, start, end)` | the columns `[start, end)`, self-contained |
96
- | `strip(text)` | the text with its escape sequences removed (`linegauge/strip`) |
97
- | `widest(lines)` | the width of the widest line of any iterable |
98
- | `lineCount(text, columns)` | rows the text occupies at that width |
99
- | `measure(text)` | columns of plain text, no escape scan |
100
-
101
- Non-strings answer `0` rather than throwing, because a width function is usually reached
102
- with whatever a template produced.
103
-
104
103
  ## Design notes
105
104
 
106
105
  **Ambiguous-width characters count narrow by default**, which is what a terminal does unless
@@ -121,64 +120,167 @@ so the segmenter is reached only when it earns its cost.
121
120
 
122
121
  ## Plugins
123
122
 
124
- **linegauge hosts no plugin key, and that is a decision rather than an omission.** Every
125
- other package in the family hosts one — `tokens` in roundel, `spinners` and `borders` and
126
- `glyphs` and `components` in flagstaff, `capabilities` in paratext, `sources` in seniority,
127
- `handlers` in closeout, `resolvers` in bellpull, `widgets` in caique. Each of those keys sits
128
- over a question with more than one right answer: which colour, which glyph, which terminal,
129
- where configuration lives, how an executable is found. A plugin settles it for one program
130
- without making anybody else wrong.
131
-
132
- These six functions are not that kind of question. `width('古代')` is 4 because Unicode
133
- classes those code points East Asian Wide and a terminal gives each of them two columns;
134
- `slice` returns the columns it was asked for or it returns the wrong string. A plugin key
135
- here would not extend what linegauge does — it would let a caller redefine what the terminal
136
- does, silently, for everything above it. The failure would not even surface as an error: a
137
- box comes out a column short, a table gains a phantom column, and nothing throws.
138
-
139
- There is a second reason, and it is the one that decides it. This package's correctness is
140
- differential — `width` is graded against `string-width`, `wrap` against `wrap-ansi`, `slice`
141
- against `slice-ansi`, `truncate` against `cli-truncate`. A registered contribution would put
142
- answers under the published pass rate that no grader ever saw, so the number would stop
143
- meaning what it says.
144
-
145
- The two things that genuinely vary are already handled without a registry:
146
-
147
- - **The Unicode data.** The Wide and Fullwidth table is Unicode's, and cluster boundaries
148
- come from the platform's `Intl.Segmenter`. When Unicode ships a version the table changes —
149
- that is a release of this package, re-graded, not a registration a caller can make.
150
- - **The environment.** How wide the terminal is, and whether there is one, are the caller's
151
- to pass; nothing here reads `process`. That is a parameter, not a plugin.
152
-
153
- The family's plugin contract records this refusal next to the other layers' keys (R5a), so
154
- "no key" is one of the contract's answers rather than a hole in it. The one real second
155
- answer so far — the ambiguous-width policy a CJK terminal needs — landed that way: as
156
- `ambiguousIsNarrow`, an option with tests behind it, not a registration.
123
+ linegauge hosts one key, **`widths`**, and it answers one question: how many columns a code
124
+ point occupies *on this terminal*, when that terminal disagrees with Unicode.
125
+
126
+ ```js
127
+ import { register } from 'linegauge/plugin';
128
+
129
+ register({
130
+ name: 'nerd-font',
131
+ widths: {
132
+ icons: { ranges: [[0xe000, 0xf8ff]], columns: 2, why: 'this Nerd Font draws Private Use icons two columns wide' },
133
+ },
134
+ });
135
+ ```
136
+
137
+ The disagreements it exists for are real and local: a Nerd Font that put a two-column icon in
138
+ the Private Use Area, a code point newer than the table compiled into this release, a font that
139
+ draws box-drawing characters wide. Each is a fact about one terminal, so the honest shape for it
140
+ is data the user supplies rather than a constant somebody argues about upstream.
141
+
142
+ - **Plain data, no functions.** `{ ranges, columns, why }` — inclusive code-point pairs, a
143
+ column count of 0, 1 or 2, and a sentence — so a plugin can arrive as JSON, be diffed, and be
144
+ printed by `npx linegauge check` without running its author's code.
145
+ - **`why` is required**, which no other key in the family asks for: a width table with no
146
+ provenance cannot be audited when it turns out wrong, and for ambiguous width, wrong is the
147
+ normal outcome.
148
+ - **Later registrations win**, over earlier ones and over the built-in table — the user is the
149
+ authority on their terminal. A reversed range or a column count of 3 is refused at
150
+ `register()` with a code and a fix.
151
+ - **Nothing is registered by default**, and the seam is installed only while something is, so
152
+ what the incumbent suites grade is Unicode's answer, and a program with no plugin pays nothing.
153
+
154
+ The ambiguous-width policy a CJK terminal needs is not a plugin: it is `ambiguousIsNarrow`, an
155
+ option with tests behind it.
156
+
157
+ ## Migrating
158
+
159
+ One import per incumbent — each default export is the incumbent's:
160
+
161
+ ```diff
162
+ - import stringWidth from 'string-width';
163
+ + import stringWidth from 'linegauge';
164
+ ```
165
+
166
+ ```diff
167
+ - import wrapAnsi from 'wrap-ansi';
168
+ + import wrapAnsi from 'linegauge/wrap';
169
+ ```
170
+
171
+ ```diff
172
+ - import stripAnsi from 'strip-ansi';
173
+ + import stripAnsi from 'linegauge/strip';
174
+ ```
175
+
176
+ ```diff
177
+ - import sliceAnsi from 'slice-ansi';
178
+ + import sliceAnsi from 'linegauge/slice';
179
+ ```
180
+
181
+ The default export is `string-width`, byte-for-byte call-compatible, so a transitive copy
182
+ resolves without a code change too:
183
+
184
+ ```json
185
+ { "overrides": { "string-width": "npm:linegauge@^1" } }
186
+ ```
187
+
188
+ Or let the codemod make the import change: `npx burgee migrate --dry-run` lists every import it
189
+ would rewrite — only drop-ins graded level with their incumbent — and `npx burgee migrate` makes
190
+ it ([Migrate](https://burgee.interlace.tools/docs/migrate)).
191
+
192
+ ## Compatibility
193
+
194
+ The incumbent is the specification. `width` runs against `string-width`, `wrap` against
195
+ `wrap-ansi`, `slice` against `slice-ansi` and `truncate` against `cli-truncate`.
196
+
197
+ The four drop-in paths are graded by each incumbent's own suite, unedited, through
198
+ `compat-oracle`; the grades are generated under *Benchmarks* below and published on the
199
+ [compatibility page](https://burgee.interlace.tools/docs/compatibility). `truncate`, which has no
200
+ drop-in path, is checked case by case against `cli-truncate` in `truncate.test.ts`.
157
201
 
158
202
  ## Benchmarks
159
203
 
160
- Every number here is produced by `npm run bench` and published at [/docs/benchmarks](/docs/benchmarks).
204
+ Every number here is produced by `npm run bench` and published at [burgee.interlace.tools/docs/benchmarks](https://burgee.interlace.tools/docs/benchmarks).
161
205
 
162
206
  Graded by the incumbent's own test suite:
163
207
 
164
208
  | suite | passing |
165
209
  | :-- | --: |
166
- | `slice-ansi` | 15 / 15 ¹ |
167
- | `string-width` | 229 / 229 |
210
+ | `slice-ansi` | 104 / 104 |
211
+ | `string-width` | 233 / 233 |
168
212
  | `strip-ansi` | 8 / 8 |
169
- | `wrap-ansi` | 80 / 80 |
213
+ | `wrap-ansi` | 85 / 85 |
214
+
215
+ Weight, installed and tree-inclusive: **102,653 bytes** against **194,329** for the incumbents it replaces — a ratio of **0.5282**.
170
216
 
171
- ¹ A case the incumbent marks `test.failing()` — it cannot do the thing and says so in
172
- its own suite — which this package passes. The runner reports that as a failure, because
173
- to the incumbent an unexpected pass means a stale annotation; it is counted here as the
174
- pass it is, and marked rather than left to look like the ones beside it.
217
+ ## For agents
218
+
219
+ - **The same columns everywhere.** Nothing here reads `process`, so a pipe, `--json` and an agent
220
+ get the columns a terminal does — which is why burgee, caique and flagstaff measure their
221
+ output with it.
222
+ - **Non-strings answer `0`** rather than throwing, so a width call reached with whatever a
223
+ template produced never takes a program down.
224
+ - **A width plugin can be checked before it ships.** `npx linegauge check ./widths.mjs`
225
+ validates it against the family schema and exits 0, 1 with a code and a fix, or 2 on a usage
226
+ error.
227
+ - **The docs are machine-readable** at
228
+ [linegauge.interlace.tools/llms.txt](https://linegauge.interlace.tools/llms.txt) and
229
+ [llms-full.txt](https://linegauge.interlace.tools/llms-full.txt).
230
+
231
+ ## API
232
+
233
+ | | |
234
+ | :-- | :-- |
235
+ | `width(text, { ambiguousIsNarrow, countAnsiEscapeCodes })` | terminal columns the text occupies |
236
+ | `wrap(text, columns, options)` | fold to a width, styles preserved across rows |
237
+ | `truncate(text, columns, { position, ellipsis })` | cut to a budget, ellipsis counted inside it |
238
+ | `slice(text, start, end)` | the columns `[start, end)`, self-contained |
239
+ | `strip(text)` | the text with its escape sequences removed (`linegauge/strip`) |
240
+ | `widest(lines)` | the width of the widest line of any iterable |
241
+ | `lineCount(text, columns)` | rows the text occupies at that width |
242
+ | `measure(text)` | columns of plain text, no escape scan |
243
+
244
+ Non-strings answer `0` rather than throwing, because a width function is usually reached
245
+ with whatever a template produced.
246
+
247
+ Every export, with its types, is on [linegauge.interlace.tools](https://linegauge.interlace.tools/docs).
175
248
 
176
- Weight, installed and tree-inclusive: **86,081 bytes** against **194,329** for the incumbents it replaces — a ratio of **0.4430**.
177
249
  ## Where it sits
178
250
 
179
251
  Plugins register under the `widths` key, against the one schema the whole family shares.
180
252
 
181
253
  `burgee`, `caique`, `flagstaff` build on it, and it builds on nothing in this family.
254
+
255
+ ## The family
256
+
257
+ Ten packages, one repository, one release pipeline. A CLI on burgee declares what it is, roundel
258
+ carries its colours, flagstaff flies it and caique answers back; each installs on its own, and none
259
+ takes a dependency from outside the family.
260
+
261
+ | Package | What it is | Replaces |
262
+ | :-- | :-- | :-- |
263
+ | [burgee](https://burgee.interlace.tools/docs/packages/burgee) | The CLI framework: one declaration, every surface | commander and yargs |
264
+ | [roundel](https://roundel.interlace.tools/docs) | Colour: one output policy, semantic tokens, a theme | chalk |
265
+ | [flagstaff](https://flagstaff.interlace.tools/docs) | The frame loop: spinners, progress, boxes and tables | ora, log-update, boxen and cli-table3 |
266
+ | [caique](https://caique.interlace.tools/docs) | Prompts that are flags first, and never hang | inquirer and clack |
267
+ | **linegauge** (this package) | Measuring, wrapping, truncating and slicing styled text | string-width, wrap-ansi, strip-ansi and slice-ansi |
268
+ | [paratext](https://paratext.interlace.tools/docs) | Hyperlinks, images, title, clipboard and notifications | ansi-escapes, terminal-link and term-img |
269
+ | [seniority](https://seniority.interlace.tools/docs) | Configuration precedence and discovery, with provenance | cosmiconfig, dotenv and rc |
270
+ | [closeout](https://closeout.interlace.tools/docs) | Exit handlers, terminal restore and a bounded shutdown | signal-exit, exit-hook and restore-cursor |
271
+ | [bellpull](https://bellpull.interlace.tools/docs) | Subprocesses, and which executable actually ran | cross-spawn and which |
272
+ | [controlroom](https://burgee.interlace.tools/docs/packages/controlroom) | Reserved, not usable yet — planned: full-screen, keyboard-driven terminal screens | ink, planned |
273
+
274
+ Every migration guide, and the family-wide [compatibility](https://burgee.interlace.tools/docs/compatibility)
275
+ and [benchmarks](https://burgee.interlace.tools/docs/benchmarks) pages, are on
276
+ [burgee.interlace.tools](https://burgee.interlace.tools/docs/packages).
277
+
278
+ ## Contributing
279
+
280
+ Issues and pull requests are welcome at [ofri-peretz/burgee](https://github.com/ofri-peretz/burgee/issues); read
281
+ [CONTRIBUTING.md](https://github.com/ofri-peretz/burgee/blob/main/CONTRIBUTING.md) first. Report a vulnerability privately, as
282
+ [SECURITY.md](https://github.com/ofri-peretz/burgee/blob/main/SECURITY.md) describes — never in a public issue.
283
+
182
284
  ## Licence
183
285
 
184
- MIT
286
+ MIT © Ofri Peretz — see [LICENSE](https://github.com/ofri-peretz/burgee/blob/main/packages/linegauge/LICENSE).
package/dist/check.js CHANGED
@@ -45,7 +45,6 @@ const overridesOf = (plugin) => {
45
45
  return typeof widths === 'object' && widths !== null ? Object.entries(widths) : [];
46
46
  };
47
47
  const firstOf = (plugin) => overridesOf(plugin).flatMap(([, o]) => (o.ranges ?? []).map(([low]) => low));
48
- const shadows = (names) => (names.length === 0 ? '' : ` (replaces ${names.join(', ')})`);
49
48
  export async function check(argv, write) {
50
49
  try {
51
50
  return await inspect(argv, write);
@@ -68,9 +67,9 @@ async function inspect(argv, write) {
68
67
  validate(plugin);
69
68
  register(plugin);
70
69
  const name = plugin.name;
71
- const rows = overridesOf(plugin).flatMap(([label, o]) => (o.ranges ?? []).map(([low, high]) => {
70
+ const rows = overridesOf(plugin).flatMap(([label, o]) => o.ranges.map(([low, high]) => {
72
71
  const span = low === high ? codePoint(low) : `${codePoint(low)}..${codePoint(high)}`;
73
- return `${label} ${span} built-in ${String(builtIn.get(low) ?? '?')} → ${String(width(String.fromCodePoint(low)))} — ${o.why}`;
72
+ return `${label} ${span} built-in ${String(builtIn.get(low))} → ${String(width(String.fromCodePoint(low)))} — ${o.why}`;
74
73
  }));
75
74
  write(`${name} — ${String(rows.length)} widths\n`);
76
75
  if (rows.length === 0) {
package/dist/index.d.ts CHANGED
@@ -26,7 +26,7 @@
26
26
  * R2's fast path: locked by `differential.test.ts`.
27
27
  */
28
28
  export { lineCount, measure, width, width as default, type WidthOptions, type WidthOptions as Options } from './width.js';
29
- export { slice } from './slice.js';
29
+ export * from './slice.js';
30
30
  export { strip } from './strip.js';
31
31
  export { truncate, type TruncateOptions } from './truncate.js';
32
32
  export { widest } from './widest.js';
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  export { lineCount, measure, width, width as default } from './width.js';
2
- export { slice } from './slice.js';
2
+ export * from './slice.js';
3
3
  export { strip } from './strip.js';
4
4
  export { truncate } from './truncate.js';
5
5
  export { widest } from './widest.js';
package/dist/slice.d.ts CHANGED
@@ -6,6 +6,11 @@
6
6
  /**
7
7
  * `[start, end)` in display columns. A negative or reversed range is empty rather than an
8
8
  * error, matching `String.prototype.slice`'s temperament if not its units.
9
+ *
10
+ * The walk's state is locals of this function rather than fields of an object, and that is
11
+ * measured, not taste: a minifier renames a local and cannot touch a property name, and the
12
+ * object form — `cut.pendingAt`, `cut.linkAt` and eight more, each spelled out on every use —
13
+ * cost the bundled `linegauge/slice` more than this does (see `ceilings.json`).
9
14
  */
10
15
  export declare function slice(string: string, start?: number, end?: number): string;
11
16
  /**
package/dist/slice.js CHANGED
@@ -1,57 +1,278 @@
1
- import { applyParameters, closingSequence, hyperlink, matchEscape, openingSequence, segmenter } from './style.js';
1
+ import { applyParameters, applyToken, ASCII_PRINTABLE, closingSequence, segmenter, sgrTokens } from './style.js';
2
2
  import { measure } from './width.js';
3
- function open(cut) {
4
- if (cut.started)
5
- return;
6
- cut.started = true;
7
- cut.body += openingSequence(cut.active);
8
- if (cut.link !== undefined)
9
- cut.body += hyperlink(cut.link.uri, cut.link.parameters);
10
- }
11
- function takeEscape(cut, escape, end) {
12
- const groups = escape.groups ?? {};
13
- if (groups['sgr'] !== undefined)
14
- applyParameters(groups['sgr'], cut.active);
15
- else if (groups['uri'] !== undefined)
16
- cut.link = groups['uri'].length === 0 ? undefined : { parameters: groups['parameters'] ?? '', uri: groups['uri'] };
17
- if (cut.started && cut.column < end)
18
- cut.body += escape[0];
19
- }
20
- function takeText(cut, run, start, end) {
21
- for (const { segment } of segmenter().segment(run)) {
22
- const columns = measure(segment);
23
- if (cut.column + Math.max(columns, 1) > start && cut.column < end) {
24
- open(cut);
25
- cut.body += segment;
26
- }
27
- cut.column += columns;
28
- if (cut.column >= end && columns > 0)
29
- return true;
30
- }
31
- return cut.column >= end;
3
+ const ESC = '\u001B';
4
+ const BELL = '\u0007';
5
+ const C1_DCS = '\u0090';
6
+ const C1_SOS = '\u0098';
7
+ const C1_CSI = '\u009B';
8
+ const C1_ST = '\u009C';
9
+ const C1_OSC = '\u009D';
10
+ const C1_PM = '\u009E';
11
+ const C1_APC = '\u009F';
12
+ const ST = `${ESC}\\`;
13
+ const ESC_CSI = `${ESC}[`;
14
+ const LINK_PREFIXES = [`${ESC}]8;`, `${C1_OSC}8;`];
15
+ const INTRODUCERS = new Set([ESC, C1_DCS, C1_SOS, C1_CSI, C1_ST, C1_OSC, C1_PM, C1_APC]);
16
+ const STRING_COMMANDS = new Set([']', 'P', 'X', '^', '_']);
17
+ const C1_STRINGS = new Set([C1_DCS, C1_SOS, C1_PM, C1_APC]);
18
+ const CSI_PARAMETER = /[0-?]/u;
19
+ const CSI_INTERMEDIATE = /[ -/]/u;
20
+ const CSI_FINAL = /[@-~]/u;
21
+ const SGR_PARAMETER = /[\d:;]/u;
22
+ const control = (code) => ({ kind: 'control', code });
23
+ function parseLink(string, index) {
24
+ const prefix = LINK_PREFIXES.find((candidate) => string.startsWith(candidate, index));
25
+ if (prefix === undefined)
26
+ return undefined;
27
+ const uri = string.indexOf(';', index + prefix.length);
28
+ if (uri === -1)
29
+ return control(string.slice(index));
30
+ for (let at = uri + 1; at < string.length; at += 1) {
31
+ const terminator = string[at] === BELL || string[at] === C1_ST ? string[at] : string.startsWith(ST, at) ? ST : undefined;
32
+ if (terminator !== undefined) {
33
+ return { kind: 'link', code: string.slice(index, at + terminator.length), open: at !== uri + 1, close: `${prefix};${terminator}` };
34
+ }
35
+ }
36
+ return control(string.slice(index));
32
37
  }
33
- export function slice(string, start = 0, end = Number.POSITIVE_INFINITY) {
34
- if (end <= start || string.length === 0)
35
- return '';
36
- const cut = { active: [], link: undefined, column: 0, body: '', started: false };
38
+ function parseControlString(string, index) {
39
+ const first = string[index];
40
+ let body;
41
+ let bell = false;
42
+ if (first === ESC) {
43
+ const command = string[index + 1] ?? '';
44
+ if (command === '\\')
45
+ return control(ST);
46
+ if (!STRING_COMMANDS.has(command))
47
+ return undefined;
48
+ body = index + 2;
49
+ bell = command === ']';
50
+ }
51
+ else if (first === C1_ST) {
52
+ return control(C1_ST);
53
+ }
54
+ else if (first === C1_OSC || C1_STRINGS.has(first)) {
55
+ body = index + 1;
56
+ bell = first === C1_OSC;
57
+ }
58
+ else {
59
+ return undefined;
60
+ }
61
+ for (let at = body; at < string.length; at += 1) {
62
+ if ((bell && string[at] === BELL) || string[at] === C1_ST)
63
+ return control(string.slice(index, at + 1));
64
+ if (string.startsWith(ST, at))
65
+ return control(string.slice(index, at + ST.length));
66
+ }
67
+ return control(string.slice(index));
68
+ }
69
+ function parseCsi(string, index) {
70
+ let prefix;
71
+ if (string.startsWith(ESC_CSI, index))
72
+ prefix = ESC_CSI;
73
+ else if (string[index] === C1_CSI)
74
+ prefix = C1_CSI;
75
+ else
76
+ return undefined;
77
+ let canonical = true;
78
+ for (let at = index + prefix.length; at < string.length; at += 1) {
79
+ const character = string[at];
80
+ if (CSI_FINAL.test(character)) {
81
+ const code = string.slice(index, at + 1);
82
+ if (character !== 'm' || !canonical)
83
+ return control(code);
84
+ return { kind: 'sgr', code, prefix, parameters: string.slice(index + prefix.length, at) };
85
+ }
86
+ if (CSI_PARAMETER.test(character)) {
87
+ canonical &&= SGR_PARAMETER.test(character);
88
+ continue;
89
+ }
90
+ if (CSI_INTERMEDIATE.test(character)) {
91
+ canonical = false;
92
+ continue;
93
+ }
94
+ return control(string.slice(index, at));
95
+ }
96
+ return control(string.slice(index));
97
+ }
98
+ function parseEscape(string, index) {
99
+ return parseLink(string, index) ?? parseControlString(string, index) ?? parseCsi(string, index);
100
+ }
101
+ const LONE_REGIONAL_INDICATOR = /^[\u{1F1E6}-\u{1F1FF}]$/u;
102
+ function positions(cluster) {
103
+ const columns = measure(cluster);
104
+ if (columns === 0)
105
+ return 1;
106
+ return LONE_REGIONAL_INDICATOR.test(cluster) ? 2 : columns;
107
+ }
108
+ function tokenize(string) {
109
+ const tokens = [];
110
+ const visible = [];
111
+ let text = '';
37
112
  let index = 0;
38
113
  while (index < string.length) {
39
- const escape = matchEscape(string, index);
40
- if (escape !== undefined) {
41
- takeEscape(cut, escape, end);
42
- index += escape[0].length;
114
+ const escape = INTRODUCERS.has(string[index]) ? parseEscape(string, index) : undefined;
115
+ if (escape) {
116
+ tokens.push(escape);
117
+ index += escape.code.length;
43
118
  continue;
44
119
  }
45
- let run = '';
46
- while (index < string.length && matchEscape(string, index) === undefined) {
47
- run += string[index];
48
- index += 1;
120
+ const value = String.fromCodePoint(string.codePointAt(index));
121
+ const token = { kind: 'text', value, columns: 1, continuation: false };
122
+ tokens.push(token);
123
+ visible.push(token);
124
+ text += value;
125
+ index += value.length;
126
+ }
127
+ if (ASCII_PRINTABLE.test(text))
128
+ return tokens;
129
+ let at = 0;
130
+ for (const { segment } of segmenter().segment(text)) {
131
+ const points = [...segment].length;
132
+ const columns = positions(segment);
133
+ for (let offset = 0; offset < points; offset += 1) {
134
+ const token = visible[at + offset];
135
+ token.columns = offset === 0 ? columns : 0;
136
+ token.continuation = offset > 0;
49
137
  }
50
- if (takeText(cut, run, start, end))
138
+ at += points;
139
+ }
140
+ return tokens;
141
+ }
142
+ function opensStyle(parameters) {
143
+ return sgrTokens(parameters).some((token) => {
144
+ const probe = [];
145
+ applyToken(token, probe);
146
+ return probe.length > 0;
147
+ });
148
+ }
149
+ function closesStyle(parameters, active) {
150
+ const after = [...active];
151
+ applyParameters(parameters, after);
152
+ return active.some((style) => !after.includes(style));
153
+ }
154
+ function continuationAhead(tokens) {
155
+ const ahead = [];
156
+ let next = false;
157
+ for (let index = tokens.length - 1; index >= 0; index -= 1) {
158
+ ahead[index] = next;
159
+ const token = tokens[index];
160
+ if (token?.kind === 'text')
161
+ next = token.continuation;
162
+ }
163
+ return ahead;
164
+ }
165
+ export function slice(string, start = 0, end = Number.POSITIVE_INFINITY) {
166
+ if (end <= start || string.length === 0)
167
+ return '';
168
+ const tokens = tokenize(string);
169
+ const ahead = continuationAhead(tokens);
170
+ let active = [];
171
+ let link;
172
+ let linked = false;
173
+ let linkAt = 0;
174
+ let pendingAt;
175
+ let pendingActive = active;
176
+ let column = 0;
177
+ let body = '';
178
+ let started = false;
179
+ const forgetLink = () => {
180
+ link = undefined;
181
+ linked = false;
182
+ };
183
+ const discardLink = (open) => {
184
+ const length = open.code.length;
185
+ body = body.slice(0, linkAt) + body.slice(linkAt + length);
186
+ if (pendingAt !== undefined && pendingAt > linkAt)
187
+ pendingAt -= length;
188
+ forgetLink();
189
+ };
190
+ const settleLink = (open) => {
191
+ if (linked)
192
+ body += open.close;
193
+ else
194
+ discardLink(open);
195
+ };
196
+ const takeSgr = (token, pastEnd) => {
197
+ const opens = opensStyle(token.parameters);
198
+ if (pastEnd && (opens || !closesStyle(token.parameters, active)))
199
+ return;
200
+ if (started && opens && pendingAt === undefined) {
201
+ pendingAt = body.length;
202
+ pendingActive = [...active];
203
+ }
204
+ const before = new Set(active);
205
+ applyParameters(token.parameters, active);
206
+ if (token.prefix.length === 1) {
207
+ for (const style of active)
208
+ if (!before.has(style))
209
+ style.prefix = token.prefix;
210
+ }
211
+ if (started)
212
+ body += token.code;
213
+ };
214
+ const takeLink = (token, pastEnd) => {
215
+ if (pastEnd && (token.open || link === undefined))
216
+ return;
217
+ if (token.open) {
218
+ if (link !== undefined)
219
+ settleLink(link);
220
+ link = token;
221
+ linked = false;
222
+ linkAt = body.length;
223
+ }
224
+ else if (started && link !== undefined && !linked) {
225
+ discardLink(link);
226
+ return;
227
+ }
228
+ else {
229
+ forgetLink();
230
+ }
231
+ if (started)
232
+ body += token.code;
233
+ };
234
+ const takeText = (token) => {
235
+ if (!started && column >= start && !token.continuation) {
236
+ started = true;
237
+ body = active.map((style) => `${style.prefix ?? ESC_CSI}${style.open}m`).join('');
238
+ if (link !== undefined) {
239
+ linkAt = body.length;
240
+ body += link.code;
241
+ }
242
+ }
243
+ if (started) {
244
+ body += token.value;
245
+ pendingAt = undefined;
246
+ if (link !== undefined)
247
+ linked = true;
248
+ }
249
+ column += token.columns;
250
+ };
251
+ for (const [index, token] of tokens.entries()) {
252
+ const cluster = token.kind === 'text' && !token.continuation;
253
+ let pastEnd = column >= end || (cluster && column + token.columns > end);
254
+ if (pastEnd && token.kind !== 'text' && ahead[index] === true)
255
+ pastEnd = false;
256
+ if (pastEnd && cluster) {
257
+ if (pendingAt !== undefined) {
258
+ body = body.slice(0, pendingAt);
259
+ active = pendingActive;
260
+ }
51
261
  break;
262
+ }
263
+ if (token.kind === 'sgr')
264
+ takeSgr(token, pastEnd);
265
+ else if (token.kind === 'link')
266
+ takeLink(token, pastEnd);
267
+ else if (token.kind === 'text')
268
+ takeText(token);
269
+ else if (!pastEnd && started)
270
+ body += token.code;
52
271
  }
53
- if (!cut.started)
272
+ if (!started)
54
273
  return '';
55
- return cut.body + (cut.link === undefined ? '' : hyperlink('')) + closingSequence(cut.active);
274
+ if (link !== undefined)
275
+ settleLink(link);
276
+ return body + closingSequence(active);
56
277
  }
57
278
  export { slice as default };
package/dist/style.d.ts CHANGED
@@ -61,6 +61,14 @@ export interface ActiveStyle {
61
61
  family: string;
62
62
  open: string;
63
63
  close: number;
64
+ /**
65
+ * The introducer the opener arrived with, when it was not `ESC [`. Only `slice` sets and
66
+ * reads it — a C1 `CSI` (`U+009B`) opener is reopened as it was written, which is what
67
+ * `slice-ansi` 9 does and grades (`keeps C1 SGR CSI behavior`) — so it is a field here and
68
+ * a branch in `slice.ts`, not in `openingSequence`: every entry reaches this file, and
69
+ * the branch would have cost `plugin.js` 81 bytes for a case only `slice` meets.
70
+ */
71
+ prefix?: string;
64
72
  }
65
73
  export declare function sgrTokens(parameters: string): SgrToken[];
66
74
  export declare function applyToken(token: SgrToken, active: ActiveStyle[]): void;
package/dist/style.js CHANGED
@@ -53,7 +53,7 @@ export const segmenter = () => (cached ??= new Intl.Segmenter());
53
53
  export const sgr = (code) => `${ESC}${CSI}${code}${SGR_TERMINATOR}`;
54
54
  export const hyperlink = (url, parameters = '') => `${ESC}${OSC}8;${parameters};${url}${BELL}`;
55
55
  export function matchEscape(string, index) {
56
- if (!ESCAPES.has(string[index] ?? ''))
56
+ if (!ESCAPES.has(string[index]))
57
57
  return undefined;
58
58
  ANSI_ESCAPE.lastIndex = index;
59
59
  return ANSI_ESCAPE.exec(string) ?? undefined;
@@ -83,11 +83,11 @@ export function forEachSegment(string, onPlainText, onEscape = () => undefined)
83
83
  const isDigits = (value) => /^\d+$/.test(value);
84
84
  function colonColorToken(parameter) {
85
85
  const parts = parameter.split(':');
86
- const code = Number.parseInt(parts[0] ?? '', 10);
87
- const mode = Number.parseInt(parts[1] ?? '', 10);
86
+ const code = Number.parseInt(parts[0], 10);
87
+ const mode = Number.parseInt(parts[1], 10);
88
88
  if (![SGR_FOREGROUND_EXTENDED, SGR_BACKGROUND_EXTENDED, SGR_UNDERLINE_COLOR_EXTENDED].includes(code))
89
89
  return undefined;
90
- if (mode === SGR_COLOR_MODE_256 && parts.length === COLOR_256_PARTS && isDigits(parts[2] ?? '')) {
90
+ if (mode === SGR_COLOR_MODE_256 && parts.length === COLOR_256_PARTS && isDigits(parts[2])) {
91
91
  return { code, open: parameter, hasArguments: true };
92
92
  }
93
93
  if (mode !== SGR_COLOR_MODE_RGB)
@@ -118,7 +118,7 @@ export function sgrTokens(parameters) {
118
118
  const parts = parameters.split(';');
119
119
  const tokens = [];
120
120
  for (let index = 0; index < parts.length; index += 1) {
121
- const parameter = parts[index] ?? '';
121
+ const parameter = parts[index];
122
122
  if (parameter.includes(':')) {
123
123
  const token = colonColorToken(parameter);
124
124
  if (token !== undefined)
@@ -129,8 +129,6 @@ export function sgrTokens(parameters) {
129
129
  if (!Number.isFinite(code))
130
130
  continue;
131
131
  if (isExtendedColor(code)) {
132
- if (index + 1 >= parts.length)
133
- break;
134
132
  const extended = extendedColorToken(code, parts, index);
135
133
  if (extended === undefined)
136
134
  break;
package/dist/truncate.js CHANGED
@@ -1,5 +1,20 @@
1
1
  import { slice } from './slice.js';
2
2
  import { width } from './width.js';
3
+ function fit(string, keep, end, total) {
4
+ const head = end === 'head';
5
+ const cut = (at) => (head ? slice(string, 0, at) : slice(string, at));
6
+ let low = head ? keep : total - keep;
7
+ let high = string.length * 2;
8
+ while (low < high) {
9
+ const mid = (low + high + (head ? 1 : 0)) >> 1;
10
+ const fits = width(cut(mid)) <= keep;
11
+ if (head === fits)
12
+ low = head ? mid : mid + 1;
13
+ else
14
+ high = head ? mid - 1 : mid;
15
+ }
16
+ return cut(low);
17
+ }
3
18
  export function truncate(string, columns, options = {}) {
4
19
  const { position = 'end', ellipsis = '\u2026' } = options;
5
20
  if (columns <= 0)
@@ -12,9 +27,9 @@ export function truncate(string, columns, options = {}) {
12
27
  return mark === columns ? ellipsis : '';
13
28
  const keep = columns - mark;
14
29
  if (position === 'start')
15
- return ellipsis + slice(string, total - keep);
30
+ return ellipsis + fit(string, keep, 'tail', total);
16
31
  if (position === 'end')
17
- return slice(string, 0, keep) + ellipsis;
32
+ return fit(string, keep, 'head', total) + ellipsis;
18
33
  const left = Math.ceil(keep / 2);
19
- return slice(string, 0, left) + ellipsis + slice(string, total - (keep - left));
34
+ return fit(string, left, 'head', total) + ellipsis + fit(string, keep - left, 'tail', total);
20
35
  }
package/dist/width.d.ts CHANGED
@@ -1,3 +1,30 @@
1
+ /**
2
+ * The ignorable/control/format/mark/surrogate set: the code points that occupy no column.
3
+ *
4
+ * Exported for `width.test.ts`, which grades `leadingInvisible` against the two regexes this
5
+ * set used to be spelled as. Joined with nothing, because it goes inside `[…]`: joined with
6
+ * `|` it once made the class match a literal pipe, and `width()` answered 15 for a
7
+ * three-column string.
8
+ */
9
+ export declare const INVISIBLE_CLASSES: readonly ["\\p{Default_Ignorable_Code_Point}", "\\p{Control}", "\\p{Format}", "\\p{Nonspacing_Mark}", "\\p{Enclosing_Mark}", "\\p{Surrogate}"];
10
+ /**
11
+ * How many code units at the start of `text` are invisible — a code point loop, linear in the
12
+ * length, where this used to be two regexes.
13
+ *
14
+ * **The regexes could hang `width()`.** A cluster was zero-width when it matched
15
+ * `^(?:DI|Control|Format|Mn|Me|Surrogate)+$`, and the six classes overlap: `U+034F`
16
+ * COMBINING GRAPHEME JOINER is both Default_Ignorable and Nonspacing_Mark, so a run of them
17
+ * followed by one visible character made the engine try every way of assigning each joiner
18
+ * to one of its two alternatives before failing. Measured on Node 24: 24 joiners took 0.6 s,
19
+ * 26 took 2.4 s, 1,000 did not finish in ten minutes. `Intl.Segmenter` hands the whole run
20
+ * to this function as one cluster, because every joiner extends it. string-width 8.3.0's
21
+ * suite added the case (1,000 and 3,000,000 joiners), which is how it was found. The
22
+ * character-class spelling, `^[…]+`, does not backtrack like that but grows V8's backtrack
23
+ * stack by one entry per code point and threw `RangeError` at three million. Asking about one
24
+ * code point at a time has neither problem, and answers the same on every input either regex
25
+ * finishes on — `width.test.ts` compares them exhaustively over short strings.
26
+ */
27
+ export declare function leadingInvisible(text: string): number;
1
28
  /** Installed by `plugin.ts` when the first override registers, and cleared when the last one goes. */
2
29
  export declare function setClaim(fn: ((code: number) => number | undefined) | undefined): void;
3
30
  /**
package/dist/width.js CHANGED
@@ -12,19 +12,20 @@ const WIDE = [
12
12
  0x3190, 0x31E5, 0x31EF, 0x321E, 0x3220, 0x3247, 0x3250, 0xA48C, 0xA490, 0xA4C6,
13
13
  0xA960, 0xA97C, 0xAC00, 0xD7A3, 0xF900, 0xFAFF, 0xFE10, 0xFE19, 0xFE30, 0xFE52,
14
14
  0xFE54, 0xFE66, 0xFE68, 0xFE6B, 0xFF01, 0xFF60, 0xFFE0, 0xFFE6, 0x16FE0, 0x16FE4,
15
- 0x16FF0, 0x16FF1, 0x17000, 0x187F7, 0x18800, 0x18CD5, 0x18CFF, 0x18D08, 0x1AFF0, 0x1AFF3,
16
- 0x1AFF5, 0x1AFFB, 0x1AFFD, 0x1AFFE, 0x1B000, 0x1B122, 0x1B132, 0x1B132, 0x1B150, 0x1B152,
17
- 0x1B155, 0x1B155, 0x1B164, 0x1B167, 0x1B170, 0x1B2FB, 0x1D300, 0x1D356, 0x1D360, 0x1D376,
18
- 0x1F004, 0x1F004, 0x1F0CF, 0x1F0CF, 0x1F18E, 0x1F18E, 0x1F191, 0x1F19A, 0x1F200, 0x1F202,
19
- 0x1F210, 0x1F23B, 0x1F240, 0x1F248, 0x1F250, 0x1F251, 0x1F260, 0x1F265, 0x1F300, 0x1F320,
20
- 0x1F32D, 0x1F335, 0x1F337, 0x1F37C, 0x1F37E, 0x1F393, 0x1F3A0, 0x1F3CA, 0x1F3CF, 0x1F3D3,
21
- 0x1F3E0, 0x1F3F0, 0x1F3F4, 0x1F3F4, 0x1F3F8, 0x1F43E, 0x1F440, 0x1F440, 0x1F442, 0x1F4FC,
22
- 0x1F4FF, 0x1F53D, 0x1F54B, 0x1F54E, 0x1F550, 0x1F567, 0x1F57A, 0x1F57A, 0x1F595, 0x1F596,
23
- 0x1F5A4, 0x1F5A4, 0x1F5FB, 0x1F64F, 0x1F680, 0x1F6C5, 0x1F6CC, 0x1F6CC, 0x1F6D0, 0x1F6D2,
24
- 0x1F6D5, 0x1F6D7, 0x1F6DC, 0x1F6DF, 0x1F6EB, 0x1F6EC, 0x1F6F4, 0x1F6FC, 0x1F7E0, 0x1F7EB,
25
- 0x1F7F0, 0x1F7F0, 0x1F90C, 0x1F93A, 0x1F93C, 0x1F945, 0x1F947, 0x1F9FF, 0x1FA70, 0x1FA7C,
26
- 0x1FA80, 0x1FA89, 0x1FA8F, 0x1FAC6, 0x1FACE, 0x1FADC, 0x1FADF, 0x1FAE9, 0x1FAF0, 0x1FAF8,
27
- 0x20000, 0x2FFFD, 0x30000, 0x3FFFD,
15
+ 0x16FF0, 0x16FF6, 0x17000, 0x18CDA, 0x18CFF, 0x18D20, 0x18D80, 0x18DF2, 0x18E00, 0x19191,
16
+ 0x191A0, 0x191D2, 0x1AFF0, 0x1AFF3, 0x1AFF5, 0x1AFFB, 0x1AFFD, 0x1AFFE, 0x1B000, 0x1B128,
17
+ 0x1B132, 0x1B132, 0x1B150, 0x1B152, 0x1B155, 0x1B155, 0x1B164, 0x1B168, 0x1B170, 0x1B2FB,
18
+ 0x1D300, 0x1D356, 0x1D360, 0x1D376, 0x1F004, 0x1F004, 0x1F0CF, 0x1F0CF, 0x1F18E, 0x1F18E,
19
+ 0x1F191, 0x1F19A, 0x1F1AE, 0x1F1AE, 0x1F200, 0x1F202, 0x1F210, 0x1F23B, 0x1F240, 0x1F248,
20
+ 0x1F250, 0x1F251, 0x1F260, 0x1F265, 0x1F300, 0x1F320, 0x1F32D, 0x1F335, 0x1F337, 0x1F37C,
21
+ 0x1F37E, 0x1F393, 0x1F3A0, 0x1F3CA, 0x1F3CF, 0x1F3D3, 0x1F3E0, 0x1F3F0, 0x1F3F4, 0x1F3F4,
22
+ 0x1F3F8, 0x1F43E, 0x1F440, 0x1F440, 0x1F442, 0x1F4FC, 0x1F4FF, 0x1F53D, 0x1F54B, 0x1F54E,
23
+ 0x1F550, 0x1F567, 0x1F57A, 0x1F57A, 0x1F595, 0x1F596, 0x1F5A4, 0x1F5A4, 0x1F5FB, 0x1F64F,
24
+ 0x1F680, 0x1F6C5, 0x1F6CC, 0x1F6CC, 0x1F6D0, 0x1F6D2, 0x1F6D5, 0x1F6D9, 0x1F6DC, 0x1F6DF,
25
+ 0x1F6EB, 0x1F6EC, 0x1F6F4, 0x1F6FC, 0x1F7DA, 0x1F7DA, 0x1F7E0, 0x1F7EB, 0x1F7F0, 0x1F7F0,
26
+ 0x1F90C, 0x1F93A, 0x1F93C, 0x1F945, 0x1F947, 0x1F9FF, 0x1FA70, 0x1FA7C, 0x1FA80, 0x1FAC6,
27
+ 0x1FAC8, 0x1FAC8, 0x1FACC, 0x1FADD, 0x1FADF, 0x1FAEB, 0x1FAEF, 0x1FAFA, 0x20000, 0x2FFFD,
28
+ 0x30000, 0x3FFFD,
28
29
  ];
29
30
  const NARROW = 1;
30
31
  const WIDE_COLUMNS = 2;
@@ -89,19 +90,25 @@ function isWide(codePoint) {
89
90
  function isAmbiguous(codePoint) {
90
91
  return inTable(AMBIGUOUS, codePoint);
91
92
  }
92
- let zeroWidthClass;
93
- let leadingClass;
93
+ let invisibleClass;
94
94
  let rgiClass;
95
95
  let spacingClass;
96
96
  let pictographicClass;
97
- const INVISIBLE_CLASSES = ['\\p{Default_Ignorable_Code_Point}', '\\p{Control}', '\\p{Format}', '\\p{Nonspacing_Mark}', '\\p{Enclosing_Mark}', '\\p{Surrogate}'];
98
- const INVISIBLE_ALTERNATION = INVISIBLE_CLASSES.join('|');
97
+ export const INVISIBLE_CLASSES = ['\\p{Default_Ignorable_Code_Point}', '\\p{Control}', '\\p{Format}', '\\p{Nonspacing_Mark}', '\\p{Enclosing_Mark}', '\\p{Surrogate}'];
99
98
  const INVISIBLE_SET = INVISIBLE_CLASSES.join('');
100
- const ZERO_WIDTH_CLUSTER = () => (zeroWidthClass ??= new RegExp(`^(?:${INVISIBLE_ALTERNATION})+$`, 'v'));
101
- const LEADING_NON_PRINTING = () => (leadingClass ??= new RegExp(`^[${INVISIBLE_SET}]+`, 'v'));
99
+ const INVISIBLE = () => (invisibleClass ??= new RegExp(`^[${INVISIBLE_SET}]$`, 'v'));
102
100
  const RGI_EMOJI = () => (rgiClass ??= new RegExp('^\\p{RGI_Emoji}$', 'v'));
103
101
  const SPACING_MARK = () => (spacingClass ??= new RegExp('^\\p{Spacing_Mark}$', 'v'));
104
102
  const EXTENDED_PICTOGRAPHIC = () => (pictographicClass ??= new RegExp('^\\p{Extended_Pictographic}$', 'u'));
103
+ export function leadingInvisible(text) {
104
+ let index = 0;
105
+ for (const character of text) {
106
+ if (!INVISIBLE().test(character))
107
+ break;
108
+ index += character.length;
109
+ }
110
+ return index;
111
+ }
105
112
  const UNQUALIFIED_KEYCAP = /^[\d#*]\u20E3$/u;
106
113
  const ZWJ = '\u200D';
107
114
  const EMOJI_ZWJ_PICTOGRAPHS = 2;
@@ -155,7 +162,7 @@ function isJamo(codePoint) {
155
162
  function hangulColumns(visible, ambiguousIsWide) {
156
163
  const codePoints = [];
157
164
  for (const character of visible) {
158
- if (ZERO_WIDTH_CLUSTER().test(character))
165
+ if (INVISIBLE().test(character))
159
166
  continue;
160
167
  codePoints.push(character.codePointAt(0) ?? 0);
161
168
  }
@@ -190,13 +197,14 @@ export function measure(text, ambiguousIsWide = false) {
190
197
  columns += claimed;
191
198
  continue;
192
199
  }
193
- if (ZERO_WIDTH_CLUSTER().test(segment))
200
+ const skipped = leadingInvisible(segment);
201
+ if (skipped === segment.length)
194
202
  continue;
195
203
  if (RGI_EMOJI().test(segment) || isUnqualifiedEmojiSequence(segment)) {
196
204
  columns += WIDE_COLUMNS;
197
205
  continue;
198
206
  }
199
- const visible = segment.replace(LEADING_NON_PRINTING(), '');
207
+ const visible = segment.slice(skipped);
200
208
  const hangul = hangulColumns(visible, ambiguousIsWide);
201
209
  if (hangul !== undefined) {
202
210
  columns += hangul;
package/dist/wrap.js CHANGED
@@ -213,9 +213,17 @@ function wrapLine(string, columns, options) {
213
213
  rows = rows.map((row) => trimVisibleEnd(row));
214
214
  return restoreStylesAcrossRows(rows.join('\n'));
215
215
  }
216
+ function normalizeText(string) {
217
+ let normalized = '';
218
+ forEachSegment(string, (text) => {
219
+ normalized += text.normalize();
220
+ }, (escape) => {
221
+ normalized += escape;
222
+ });
223
+ return normalized;
224
+ }
216
225
  export function wrap(string, columns, options = {}) {
217
- return String(string)
218
- .normalize()
226
+ return normalizeText(String(string))
219
227
  .replaceAll('\r\n', '\n')
220
228
  .split('\n')
221
229
  .map((line) => wrapLine(expandTabs(line), columns, options))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "linegauge",
3
- "version": "0.5.3",
3
+ "version": "1.0.0",
4
4
  "description": "A printer's line gauge \u2014 the steel rule marked in picas and points. Measuring, wrapping, truncating and slicing styled terminal text without the edge fraying \u2014 grapheme-correct over Intl.Segmenter. Drop-in paths for string-width, wrap-ansi, strip-ansi and slice-ansi. Zero dependencies.",
5
5
  "license": "MIT",
6
6
  "type": "module",