Public: anyone can read this; the crew can change it.
src/text/clip.ts: one pure `clip(text, max)` that folds whitespace and cuts at a code point, never leaving a lone surrogate #6634
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/6627-src-text-clip-ts-one-pure-clip-text-max → main 2 commits fork 2 threads · 2 unresolved
2 files
changed +50
−0
src/text/clip.ts added +9
| @@ -0,0 +1,9 @@ | ||
| 1 | /** Fold whitespace runs to one space, trim, and cut to at most `max` code points, ending in "…" when cut. */ | |
| 2 | export function clip(text: string, max: number): string { | |
| 3 | const flat = text.replace(/\s+/g, " ").trim(); | |
| 4 | const limit = Math.max(0, Math.floor(max) || 0); | |
| 5 | const points = Array.from(flat); | |
| 6 | if (points.length <= limit) return flat; | |
| 7 | if (limit === 0) return ""; | |
This max-below-1 branch is untested; add clip("abc", 0) and clip("abc", -3) expecting "". |
||
| 8 | return `${points.slice(0, limit - 1).join("")}…`; | |
| 9 | } |
test/text-clip.test.ts added +41
| @@ -0,0 +1,41 @@ | ||
| 1 | import { expect, test } from "bun:test"; | |
| 2 | import { clip } from "../src/text/clip"; | |
| 3 | ||
| 4 | const count = (s: string) => Array.from(s).length; | |
| 5 | ||
| 6 | test("ASCII at, under and over the limit", () => { | |
| 7 | expect(clip("abcde", 5)).toBe("abcde"); | |
| 8 | expect(clip("abc", 5)).toBe("abc"); | |
| 9 | expect(clip("abcdef", 5)).toBe("abcd…"); | |
| 10 | }); | |
| 11 | ||
| 12 | test("whitespace folds to one space and trims", () => { | |
| 13 | expect(clip(" a \n\t b c ", 20)).toBe("a b c"); | |
| 14 | }); | |
| 15 | ||
| 16 | test("an emoji straddling the cut is dropped whole", () => { | |
| 17 | const out = clip("abc😀def", 5); | |
| 18 | expect(out).toBe("abc😀…"); | |
| 19 | const cut = clip("ab😀😀cd", 4); | |
| 20 | expect(cut).toBe("ab😀…"); | |
| 21 | expect(count(cut)).toBeLessThanOrEqual(4); | |
| 22 | expect(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/.test(clip("a😀😀😀", 3))).toBe(false); | |
| 23 | expect(clip("a😀😀😀", 3)).toBe("a😀…"); | |
| 24 | }); | |
| 25 | ||
| 26 | test("max of 1 gives the ellipsis, or the text if one point", () => { | |
| 27 | expect(clip("abc", 1)).toBe("…"); | |
| 28 | expect(clip("😀😀", 1)).toBe("…"); | |
| 29 | expect(clip("a", 1)).toBe("a"); | |
| 30 | }); | |
| 31 | ||
| 32 | test("a max below 1 or not a number gives an empty string", () => { | |
| 33 | expect(clip("abc", 0)).toBe(""); | |
| 34 | expect(clip("abc", -3)).toBe(""); | |
| 35 | expect(clip("", 0)).toBe(""); | |
| 36 | expect(clip("abc", NaN)).toBe(""); | |
| 37 | }); | |
| 38 | ||
| 39 | test("a lone surrogate already in the input passes through untouched", () => { | |
| 40 | expect(clip("a\uD83Db", 5)).toBe("a\uD83Db"); | |
| 41 | }); |
new above
offline
Math.floor(NaN) is NaN, and Math.max(0, NaN) is NaN, so a NaN max falls through to "…". Use
Math.max(0, Math.floor(max) || 0)and test it.