linegauge 0.2.0 → 0.3.1

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
@@ -100,6 +100,66 @@ text.
100
100
  scan when the string has no non-ASCII code unit, so the segmenter is reached only when it
101
101
  earns its cost.
102
102
 
103
+ ## Plugins
104
+
105
+ **linegauge hosts no plugin key, and that is a decision rather than an omission.** Every
106
+ other package in the family hosts one — `tokens` in roundel, `spinners` and `borders` and
107
+ `glyphs` and `components` in flagstaff, `capabilities` in paratext, `sources` in seniority,
108
+ `handlers` in closeout, `resolvers` in bellpull, `widgets` in caique. Each of those keys sits
109
+ over a question with more than one right answer: which colour, which glyph, which terminal,
110
+ where configuration lives, how an executable is found. A plugin settles it for one program
111
+ without making anybody else wrong.
112
+
113
+ These six functions are not that kind of question. `width('古代')` is 4 because Unicode
114
+ classes those code points East Asian Wide and a terminal gives each of them two columns;
115
+ `slice` returns the columns it was asked for or it returns the wrong string. A plugin key
116
+ here would not extend what linegauge does — it would let a caller redefine what the terminal
117
+ does, silently, for everything above it. The failure would not even surface as an error: a
118
+ box comes out a column short, a table gains a phantom column, and nothing throws.
119
+
120
+ There is a second reason, and it is the one that decides it. This package's correctness is
121
+ differential — `width` is graded against `string-width`, `wrap` against `wrap-ansi`, `slice`
122
+ against `slice-ansi`, `truncate` against `cli-truncate`. A registered contribution would put
123
+ answers under the published pass rate that no grader ever saw, so the number would stop
124
+ meaning what it says.
125
+
126
+ The two things that genuinely vary are already handled without a registry:
127
+
128
+ - **The Unicode data.** The Wide and Fullwidth table is Unicode's, and cluster boundaries
129
+ come from the platform's `Intl.Segmenter`. When Unicode ships a version the table changes —
130
+ that is a release of this package, re-graded, not a registration a caller can make.
131
+ - **The environment.** How wide the terminal is, and whether there is one, are the caller's
132
+ to pass; nothing here reads `process`. That is a parameter, not a plugin.
133
+
134
+ The family's plugin contract records this refusal next to the other layers' keys (R5a), so
135
+ "no key" is one of the contract's answers rather than a hole in it. If a real second answer
136
+ ever arrives — an ambiguous-width policy some terminal actually needs — it lands as an option
137
+ with a differential test behind it, because the graders have to see it.
138
+
139
+ ## Benchmarks
140
+
141
+ Every number here is produced by `npm run bench` and published at [/docs/benchmarks](/docs/benchmarks).
142
+
143
+ Graded by the incumbent's own test suite:
144
+
145
+ | suite | passing |
146
+ | :-- | --: |
147
+ | `slice-ansi` | 15 / 15 ¹ |
148
+ | `string-width` | 229 / 229 |
149
+ | `strip-ansi` | 8 / 8 |
150
+ | `wrap-ansi` | 80 / 80 |
151
+
152
+ ¹ A case the incumbent marks `test.failing()` — it cannot do the thing and says so in
153
+ its own suite — which this package passes. The runner reports that as a failure, because
154
+ to the incumbent an unexpected pass means a stale annotation; it is counted here as the
155
+ pass it is, and marked rather than left to look like the ones beside it.
156
+
157
+ Weight, installed and tree-inclusive: **83,536 bytes** against **170,342** for the incumbents it replaces — a ratio of **0.4904**.
158
+ ## Where it sits
159
+
160
+ It hosts no plugin key of its own.
161
+
162
+ `burgee`, `caique`, `flagstaff` build on it, and it builds on nothing in this family.
103
163
  ## Licence
104
164
 
105
165
  MIT
package/dist/index.d.ts CHANGED
@@ -23,7 +23,7 @@
23
23
  * `stripVTControlCharacters` is recorded — one shape in sixteen, and it was a live bug in
24
24
  * `width()`.
25
25
  *
26
- * Still at the Design→Build gate: the R2 ASCII fast path.
26
+ * R2's fast path: locked by `differential.test.ts`.
27
27
  */
28
28
  export { lineCount, measure, width, width as default, type WidthOptions } from './width.js';
29
29
  export { slice } from './slice.js';
package/dist/index.js CHANGED
@@ -23,7 +23,7 @@
23
23
  * `stripVTControlCharacters` is recorded — one shape in sixteen, and it was a live bug in
24
24
  * `width()`.
25
25
  *
26
- * Still at the Design→Build gate: the R2 ASCII fast path.
26
+ * R2's fast path: locked by `differential.test.ts`.
27
27
  */
28
28
  export { lineCount, measure, width, width as default } from './width.js';
29
29
  export { slice } from './slice.js';
package/dist/slice.d.ts CHANGED
@@ -8,3 +8,11 @@
8
8
  * error, matching `String.prototype.slice`'s temperament if not its units.
9
9
  */
10
10
  export declare function slice(string: string, start?: number, end?: number): string;
11
+ /**
12
+ * The default export, for the reason `strip.ts` gives at length: `slice-ansi`'s suite
13
+ * imports its entry point's **default**, and that suite now grades this file.
14
+ *
15
+ * The specifier is described rather than quoted on purpose — see the note on `wrap`'s
16
+ * default: `subpath-isolation.test.ts` reads the emitted text, comments and all.
17
+ */
18
+ export { slice as default };
package/dist/slice.js CHANGED
@@ -97,4 +97,13 @@ export function slice(string, start = 0, end = Number.POSITIVE_INFINITY) {
97
97
  return '';
98
98
  return cut.body + (cut.link === undefined ? '' : hyperlink('')) + closingSequence(cut.active);
99
99
  }
100
+ /**
101
+ * The default export, for the reason `strip.ts` gives at length: `slice-ansi`'s suite
102
+ * imports its entry point's **default**, and that suite now grades this file.
103
+ *
104
+ * The specifier is described rather than quoted on purpose — see the note on `wrap`'s
105
+ * default: `subpath-isolation.test.ts` reads the emitted text, comments and all.
106
+ */
107
+ // eslint-disable-next-line import-next/no-default-export -- the incumbent's own suite imports a default; see above. This is the drop-in surface, not a style choice.
108
+ export { slice as default };
100
109
  //# sourceMappingURL=slice.js.map
package/dist/strip.d.ts CHANGED
@@ -24,3 +24,21 @@
24
24
  * would have nothing left to match.
25
25
  */
26
26
  export declare function strip(string: string): string;
27
+ /**
28
+ * The same function again, as the default export, because `strip-ansi`'s own suite imports
29
+ * a default — and that suite is now this module's grader
30
+ * (`compat-oracle/vendor/strip-ansi`, `baseline/strip-ansi.json`).
31
+ *
32
+ * It is a *subpath* default rather than the package's, and it has to be: R8 spends the root
33
+ * default on `width`, so `overrides: { "string-width": "npm:linegauge@^1" }` resolves. A
34
+ * `strip-ansi` façade can therefore only ever be `linegauge/strip`, and this is the line
35
+ * that makes `import stripAnsi from 'linegauge/strip'` read exactly like the import it
36
+ * replaces. `truncate` and `widest` deliberately do not have one yet: an export is a
37
+ * contract forever, and neither has a vendored suite holding it to the incumbent's shape.
38
+ *
39
+ * Spelled `strip as default` rather than `export default strip` to match how `index.ts`
40
+ * publishes `width as default`: the alias is a live binding to the same declaration, so
41
+ * there is exactly one `strip` in the module however it is imported — which is the property
42
+ * `facade-defaults.test.ts` asserts with `toBe`, not `toEqual`.
43
+ */
44
+ export { strip as default };
package/dist/strip.js CHANGED
@@ -59,4 +59,23 @@ export function strip(string) {
59
59
  });
60
60
  return nodeStrip(out);
61
61
  }
62
+ /**
63
+ * The same function again, as the default export, because `strip-ansi`'s own suite imports
64
+ * a default — and that suite is now this module's grader
65
+ * (`compat-oracle/vendor/strip-ansi`, `baseline/strip-ansi.json`).
66
+ *
67
+ * It is a *subpath* default rather than the package's, and it has to be: R8 spends the root
68
+ * default on `width`, so `overrides: { "string-width": "npm:linegauge@^1" }` resolves. A
69
+ * `strip-ansi` façade can therefore only ever be `linegauge/strip`, and this is the line
70
+ * that makes `import stripAnsi from 'linegauge/strip'` read exactly like the import it
71
+ * replaces. `truncate` and `widest` deliberately do not have one yet: an export is a
72
+ * contract forever, and neither has a vendored suite holding it to the incumbent's shape.
73
+ *
74
+ * Spelled `strip as default` rather than `export default strip` to match how `index.ts`
75
+ * publishes `width as default`: the alias is a live binding to the same declaration, so
76
+ * there is exactly one `strip` in the module however it is imported — which is the property
77
+ * `facade-defaults.test.ts` asserts with `toBe`, not `toEqual`.
78
+ */
79
+ // eslint-disable-next-line import-next/no-default-export -- the incumbent's own suite imports a default; see above. This is the drop-in surface, not a style choice.
80
+ export { strip as default };
62
81
  //# sourceMappingURL=strip.js.map
package/dist/style.js CHANGED
@@ -249,7 +249,18 @@ export function applyToken(token, active) {
249
249
  const family = `modifier-${token.code}`;
250
250
  removeFamily(active, family);
251
251
  active.push({ family, open: token.open, close });
252
+ return;
252
253
  }
254
+ // An SGR parameter this file has no close code for — `ESC[20m`, `ESC[1001m`. It used to
255
+ // be dropped here, which is the one failure on the `slice-ansi` row that was ours
256
+ // (`spec.md` § R10 category E): the text survived a cut and its style did not, silently.
257
+ // The sequence is the caller's, not this library's to vet, so it is carried through and
258
+ // reopened like any other style. `SGR_RESET` is its closer because it is the only one
259
+ // that is correct for a parameter whose meaning is unknown — there is nothing to derive a
260
+ // narrower close from — and it is what `slice-ansi` emits for the same input.
261
+ const family = `unknown-${token.open}`;
262
+ removeFamily(active, family);
263
+ active.push({ family, open: token.open, close: SGR_RESET });
253
264
  }
254
265
  export const applyParameters = (parameters, active) => {
255
266
  for (const token of sgrTokens(parameters))
package/dist/width.js CHANGED
@@ -131,24 +131,154 @@ function isWide(codePoint) {
131
131
  function isAmbiguous(codePoint) {
132
132
  return inTable(AMBIGUOUS, codePoint);
133
133
  }
134
- // `v`-mode properties: the whole point of using them is that Node ships the tables.
135
- const ZERO_WIDTH_CLUSTER = /^(?:\p{Default_Ignorable_Code_Point}|\p{Control}|\p{Mark}|\p{Surrogate})+$/v;
136
- const LEADING_NON_PRINTING = /^[\p{Default_Ignorable_Code_Point}\p{Control}\p{Format}\p{Mark}\p{Surrogate}]+/v;
134
+ /**
135
+ * `v`-mode properties: the whole point of using them is that Node ships the tables.
136
+ *
137
+ * The mark classes are spelled out as `Nonspacing_Mark` and `Enclosing_Mark` rather than
138
+ * the `\p{Mark}` that stood here, because `\p{Mark}` is those two **and** `Spacing_Mark` —
139
+ * and a spacing mark is exactly the kind that does occupy a column. `ा`, Devanagari
140
+ * vowel sign AA, answered 0 under the wider class and a terminal draws it one column wide.
141
+ *
142
+ * `\p{Format}` joins the zero-width class for the mirror-image reason. A prepended
143
+ * concatenation mark — `U+0600`, `U+06DD`, `U+070F` — is `Format` but *not*
144
+ * `Default_Ignorable`, so it fell through to the base-scalar path below, which stripped it
145
+ * as leading non-printing, found an empty remainder, read code point 0 and charged a column
146
+ * for it. Charging a column for a character the cursor never advances past is the shape of
147
+ * bug that stays invisible until a box comes out a column short.
148
+ */
149
+ const ZERO_WIDTH_CLUSTER = /^(?:\p{Default_Ignorable_Code_Point}|\p{Control}|\p{Format}|\p{Nonspacing_Mark}|\p{Enclosing_Mark}|\p{Surrogate})+$/v;
150
+ const LEADING_NON_PRINTING = /^[\p{Default_Ignorable_Code_Point}\p{Control}\p{Format}\p{Nonspacing_Mark}\p{Enclosing_Mark}\p{Surrogate}]+/v;
137
151
  const RGI_EMOJI = /^\p{RGI_Emoji}$/v;
152
+ const SPACING_MARK = /^\p{Spacing_Mark}$/v;
153
+ const EXTENDED_PICTOGRAPHIC = /^\p{Extended_Pictographic}$/u;
154
+ /**
155
+ * An **unqualified keycap**: the base, then `U+20E3`, with the `U+FE0F` that would have made
156
+ * it fully qualified missing. The base class is explicit — a digit, `#` or `*` — because
157
+ * `U+260E U+FE0F U+20E3` is *not* a keycap, and `string-width`'s suite grades that too.
158
+ */
159
+ const UNQUALIFIED_KEYCAP = /^[\d#*]\u20E3$/u;
160
+ const ZWJ = '\u200D';
161
+ /** Two pictographs is what separates an emoji ZWJ sequence from an Indic conjunct. */
162
+ const EMOJI_ZWJ_PICTOGRAPHS = 2;
163
+ /**
164
+ * Longest cluster worth testing for an emoji sequence. Real ones run well under thirty code
165
+ * units; the cap is here so a pathological cluster cannot turn a width call into a scan.
166
+ */
167
+ const EMOJI_SCAN_LIMIT = 50;
138
168
  /** The Halfwidth and Fullwidth Forms block, which a cluster can carry after its base. */
139
169
  const FORMS_FIRST = 0xff00;
140
170
  const FORMS_LAST = 0xffef;
171
+ /**
172
+ * Conjoining jamo, in the three classes modern Hangul composes from: leading (L), vowel (V)
173
+ * and trailing (T). Each has an archaic extension block beside the main one.
174
+ */
175
+ const JAMO_LEADING = [0x1100, 0x115f, 0xa960, 0xa97c];
176
+ const JAMO_VOWEL = [0x1160, 0x11a7, 0xd7b0, 0xd7c6];
177
+ const JAMO_TRAILING = [0x11a8, 0x11ff, 0xd7cb, 0xd7fb];
141
178
  const segmenter = new Intl.Segmenter();
142
- /** Columns a cluster's trailing fullwidth forms add — `ガ` is a base plus a wide mark. */
143
- function trailingForms(cluster) {
179
+ /** East Asian Width of one code point, in columns, under the caller's ambiguous policy. */
180
+ function columnsOf(codePoint, ambiguousIsWide) {
181
+ return isWide(codePoint) || (ambiguousIsWide && isAmbiguous(codePoint)) ? WIDE_COLUMNS : NARROW;
182
+ }
183
+ /**
184
+ * Columns a cluster's trailing **spacing marks and fullwidth forms** add. `ガ` is a base
185
+ * plus a wide mark; `क` + `ा` is a base plus a spacing mark. Both advance the cursor past
186
+ * the base, and neither is a separate cluster, so neither can be counted anywhere else.
187
+ *
188
+ * The walk is over the *visible* cluster — what is left after the leading non-printing run
189
+ * has been removed — so that the character being skipped as "the base" is the base, not a
190
+ * format character standing in front of it.
191
+ */
192
+ function trailingColumns(visible, ambiguousIsWide) {
144
193
  let extra = 0;
145
- for (const character of [...cluster].slice(1)) {
194
+ for (const character of [...visible].slice(1)) {
146
195
  const codePoint = character.codePointAt(0) ?? 0;
147
- if (codePoint >= FORMS_FIRST && codePoint <= FORMS_LAST)
148
- extra += isWide(codePoint) ? WIDE_COLUMNS : NARROW;
196
+ const isForm = codePoint >= FORMS_FIRST && codePoint <= FORMS_LAST;
197
+ if (isForm || SPACING_MARK.test(character))
198
+ extra += columnsOf(codePoint, ambiguousIsWide);
149
199
  }
150
200
  return extra;
151
201
  }
202
+ /**
203
+ * Whether a cluster is an emoji sequence that `\p{RGI_Emoji}` refuses because it is
204
+ * **minimally qualified or unqualified** — the same sequence with its `U+FE0F` left off.
205
+ * A terminal renders `U+2764 ZWJ U+1F525` as one two-column emoji whether or not the
206
+ * variation selector is there; the regex only matches the fully-qualified spelling.
207
+ *
208
+ * Two shapes, and both need their guard. A ZWJ sequence counts only when **two or more**
209
+ * pictographs are joined, which is what keeps `क् ZWJ ष` — an Indic conjunct using the same
210
+ * joiner — one column. A keycap counts only over a digit, `#` or `*`, which is what keeps
211
+ * the invalid `U+260E U+FE0F U+20E3` one column. Both of those are cases in the suite that
212
+ * grades this file, and both passed before this function existed.
213
+ */
214
+ function isUnqualifiedEmojiSequence(cluster) {
215
+ if (cluster.length > EMOJI_SCAN_LIMIT)
216
+ return false;
217
+ if (UNQUALIFIED_KEYCAP.test(cluster))
218
+ return true;
219
+ if (!cluster.includes(ZWJ))
220
+ return false;
221
+ let pictographs = 0;
222
+ for (const character of cluster) {
223
+ if (EXTENDED_PICTOGRAPHIC.test(character))
224
+ pictographs += 1;
225
+ if (pictographs >= EMOJI_ZWJ_PICTOGRAPHS)
226
+ return true;
227
+ }
228
+ return false;
229
+ }
230
+ /** Whether `codePoint` falls in one of a flat `[low, high]` pair list. */
231
+ function inPairs(pairs, codePoint) {
232
+ for (let i = 0; i < pairs.length; i += PAIR) {
233
+ if (codePoint >= (pairs[i] ?? 0) && codePoint <= (pairs[i + 1] ?? 0))
234
+ return true;
235
+ }
236
+ return false;
237
+ }
238
+ function isJamo(codePoint) {
239
+ return inPairs(JAMO_LEADING, codePoint) || inPairs(JAMO_VOWEL, codePoint) || inPairs(JAMO_TRAILING, codePoint);
240
+ }
241
+ /**
242
+ * Columns a cluster of **conjoining Hangul jamo** occupies, or `undefined` when the cluster
243
+ * does not start with one — in which case the caller's ordinary base-scalar path is right.
244
+ *
245
+ * This is the one category that needs a walk rather than a table lookup. `Intl.Segmenter`
246
+ * joins a whole run of jamo into a single grapheme cluster (GB6, GB7, GB8), so measuring the
247
+ * cluster by its first code point answered **2** for a six-jamo run a terminal draws **12**
248
+ * columns wide. Modern Hangul composes L + V, or L + V + T, into one syllable block two
249
+ * columns wide; jamo that find no partner stay additive at their own East Asian Width, which
250
+ * makes a leading jamo 2 and a vowel or trailing jamo 1.
251
+ *
252
+ * A cluster that begins with jamo and then turns into something else — a leading jamo
253
+ * followed by a precomposed syllable — measures the jamo by this rule and the remainder by
254
+ * East Asian Width, which is how `U+1100 U+AC00` comes to 4 rather than 2.
255
+ */
256
+ function hangulColumns(visible, ambiguousIsWide) {
257
+ const codePoints = [];
258
+ for (const character of visible) {
259
+ if (ZERO_WIDTH_CLUSTER.test(character))
260
+ continue;
261
+ codePoints.push(character.codePointAt(0) ?? 0);
262
+ }
263
+ if (codePoints.length === 0 || !isJamo(codePoints[0] ?? 0))
264
+ return undefined;
265
+ let columns = 0;
266
+ for (let index = 0; index < codePoints.length; index += 1) {
267
+ const codePoint = codePoints[index] ?? 0;
268
+ if (!isJamo(codePoint)) {
269
+ for (let rest = index; rest < codePoints.length; rest += 1)
270
+ columns += columnsOf(codePoints[rest] ?? 0, ambiguousIsWide);
271
+ return columns;
272
+ }
273
+ if (inPairs(JAMO_LEADING, codePoint) && inPairs(JAMO_VOWEL, codePoints[index + 1] ?? -1)) {
274
+ columns += WIDE_COLUMNS;
275
+ index += inPairs(JAMO_TRAILING, codePoints[index + PAIR] ?? -1) ? PAIR : 1;
276
+ continue;
277
+ }
278
+ columns += columnsOf(codePoint, ambiguousIsWide);
279
+ }
280
+ return columns;
281
+ }
152
282
  /**
153
283
  * Columns a string of *plain* text occupies — no escape scan. The wrapper below has
154
284
  * already split its input into text runs and complete sequences, so rescanning would only
@@ -159,14 +289,18 @@ export function measure(text, ambiguousIsWide = false) {
159
289
  for (const { segment } of segmenter.segment(text)) {
160
290
  if (ZERO_WIDTH_CLUSTER.test(segment))
161
291
  continue;
162
- if (RGI_EMOJI.test(segment)) {
292
+ if (RGI_EMOJI.test(segment) || isUnqualifiedEmojiSequence(segment)) {
163
293
  columns += WIDE_COLUMNS;
164
294
  continue;
165
295
  }
166
- const codePoint = segment.replace(LEADING_NON_PRINTING, '').codePointAt(0) ?? 0;
167
- const wide = isWide(codePoint) || (ambiguousIsWide && isAmbiguous(codePoint));
168
- columns += wide ? WIDE_COLUMNS : NARROW;
169
- columns += trailingForms(segment);
296
+ const visible = segment.replace(LEADING_NON_PRINTING, '');
297
+ const hangul = hangulColumns(visible, ambiguousIsWide);
298
+ if (hangul !== undefined) {
299
+ columns += hangul;
300
+ continue;
301
+ }
302
+ columns += columnsOf(visible.codePointAt(0) ?? 0, ambiguousIsWide);
303
+ columns += trailingColumns(visible, ambiguousIsWide);
170
304
  }
171
305
  return columns;
172
306
  }
package/dist/wrap.d.ts CHANGED
@@ -10,3 +10,13 @@ export interface WrapOptions {
10
10
  }
11
11
  /** Wrap `string` to `columns`, keeping its ANSI intact and each row self-contained. */
12
12
  export declare function wrap(string: string, columns: number, options?: WrapOptions): string;
13
+ /**
14
+ * The default export, for the reason `strip.ts` gives at length: `wrap-ansi` 10's suite
15
+ * imports its entry point's **default**, and that suite now grades this file.
16
+ *
17
+ * Written without quoting the specifier, deliberately. `subpath-isolation.test.ts` scans the
18
+ * *emitted text* of `dist/wrap.js` for relative imports, comments included, so a doc comment
19
+ * that spells one out makes the entry look as though it reaches a sibling. Caught by that
20
+ * lock on 2026-09-14, which is the lock doing exactly its job on the wrong input.
21
+ */
22
+ export { wrap as default };
package/dist/wrap.js CHANGED
@@ -268,4 +268,15 @@ export function wrap(string, columns, options = {}) {
268
268
  .map((line) => wrapLine(expandTabs(line), columns, options))
269
269
  .join('\n');
270
270
  }
271
+ /**
272
+ * The default export, for the reason `strip.ts` gives at length: `wrap-ansi` 10's suite
273
+ * imports its entry point's **default**, and that suite now grades this file.
274
+ *
275
+ * Written without quoting the specifier, deliberately. `subpath-isolation.test.ts` scans the
276
+ * *emitted text* of `dist/wrap.js` for relative imports, comments included, so a doc comment
277
+ * that spells one out makes the entry look as though it reaches a sibling. Caught by that
278
+ * lock on 2026-09-14, which is the lock doing exactly its job on the wrong input.
279
+ */
280
+ // eslint-disable-next-line import-next/no-default-export -- the incumbent's own suite imports a default; see above. This is the drop-in surface, not a style choice.
281
+ export { wrap as default };
271
282
  //# sourceMappingURL=wrap.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "linegauge",
3
- "version": "0.2.0",
3
+ "version": "0.3.1",
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",