grapheme-conformance 0.1.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/LICENSE +21 -0
- package/README.md +169 -0
- package/SCOREBOARD.md +27 -0
- package/VERIFY.md +58 -0
- package/dist/cli.cjs +239 -0
- package/dist/cli.d.cts +1 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +234 -0
- package/dist/index.cjs +184 -0
- package/dist/index.d.cts +78 -0
- package/dist/index.d.ts +78 -0
- package/dist/index.js +151 -0
- package/package.json +72 -0
- package/vectors/GraphemeBreakTest-15.0.0.txt +630 -0
- package/vectors/GraphemeBreakTest-15.1.0.txt +1215 -0
- package/vectors/GraphemeBreakTest-16.0.0.txt +1121 -0
- package/vectors/GraphemeBreakTest-17.0.0.txt +796 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 grapheme-conformance contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
# grapheme-conformance
|
|
2
|
+
|
|
3
|
+
**Your string splitter is probably wrong about Hindi.**
|
|
4
|
+
|
|
5
|
+
Unicode publishes `GraphemeBreakTest.txt`: a machine-readable answer key that
|
|
6
|
+
says exactly where a string may be split into user-perceived characters. This
|
|
7
|
+
package scores any JavaScript grapheme segmenter against it, tells you which
|
|
8
|
+
cases it got wrong, and gives you an exit code you can put in CI.
|
|
9
|
+
|
|
10
|
+
It does not ship another segmenter.
|
|
11
|
+
|
|
12
|
+
## The finding
|
|
13
|
+
|
|
14
|
+
`क्षि` — Devanagari, four code points, one character a reader sees. Split it
|
|
15
|
+
with a popular library and you get two clusters. `grapheme-splitter` and
|
|
16
|
+
`graphemer` both break it; `runes2` breaks it into three.
|
|
17
|
+
|
|
18
|
+
| input | splits wrongly in |
|
|
19
|
+
|---|---|
|
|
20
|
+
| `क्षि` Devanagari (`0915 094D 0937 093F`) | `grapheme-splitter` (2), `graphemer` (2), `runes2` (3) |
|
|
21
|
+
| `e` + combining acute (`0065 0301`) | `runes2` (2) |
|
|
22
|
+
| Hangul `한` (`1112 1161 11AB`) | `runes2` (2) |
|
|
23
|
+
| pirate flag (`1F3F4 200D 2620 FE0F`) | `grapheme-splitter` (2) |
|
|
24
|
+
|
|
25
|
+
Family emoji, skin-tone modifiers, regional-indicator flags, tag-sequence
|
|
26
|
+
flags, ZWJ professions and keycaps pass in every library tested. Emoji is not
|
|
27
|
+
the story; Indic script is. All seven of `graphemer`'s Unicode 15.1 failures
|
|
28
|
+
are rule GB9c, added in that release for Indic conjunct clusters.
|
|
29
|
+
|
|
30
|
+
## Scoreboard
|
|
31
|
+
|
|
32
|
+
See [SCOREBOARD.md](./SCOREBOARD.md), regenerated by `npm run scoreboard`.
|
|
33
|
+
Cases passed:
|
|
34
|
+
|
|
35
|
+
| implementation | U15.1.0 (1187) | U16.0.0 (1093) |
|
|
36
|
+
|---|---|---|
|
|
37
|
+
| `Intl.Segmenter` (ICU, Node 22) | 1186 | 1092 |
|
|
38
|
+
| `unicode-segmenter@0.17.3` | 1186 | 1092 |
|
|
39
|
+
| `graphemer@1.4.0` | 1180 | 1086 |
|
|
40
|
+
| `grapheme-splitter@1.0.4` | 1175 | 1081 |
|
|
41
|
+
| `runes2@1.1.4` | 730 | 695 |
|
|
42
|
+
|
|
43
|
+
`Intl.Segmenter`'s single failure is the input `2701 200D 2701`, a known ICU
|
|
44
|
+
deviation — and one that arrived with a later ICU rather than an earlier one.
|
|
45
|
+
Node 18.20.8 ships an ICU without it and passes every case (`1187` and `1093`);
|
|
46
|
+
Node 20 and 22 split that input and score `1186` and `1092`.
|
|
47
|
+
|
|
48
|
+
## Install
|
|
49
|
+
|
|
50
|
+
```sh
|
|
51
|
+
npm install --save-dev grapheme-conformance
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Zero runtime dependencies. Dual ESM/CJS. Node 18+.
|
|
55
|
+
|
|
56
|
+
## CLI
|
|
57
|
+
|
|
58
|
+
```sh
|
|
59
|
+
npx grapheme-conformance --module unicode-segmenter/grapheme \
|
|
60
|
+
--export splitGraphemes --version 16.0.0
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
```
|
|
64
|
+
unicode-segmenter/grapheme (splitGraphemes) GraphemeBreakTest 16.0.0
|
|
65
|
+
passed 1092/1093 99.91%
|
|
66
|
+
failed 1
|
|
67
|
+
|
|
68
|
+
line input want got rule
|
|
69
|
+
1105 2701 200D 2701 1 2 -
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
| flag | default | meaning |
|
|
73
|
+
|---|---|---|
|
|
74
|
+
| `--module` | required | module to load: a bare name, or a path relative to cwd |
|
|
75
|
+
| `--export` | `default` | the export to score |
|
|
76
|
+
| `--version` | `16.0.0` | vendored vectors: `15.0.0`, `15.1.0`, `16.0.0`, `17.0.0` |
|
|
77
|
+
| `--min` | `1.0` | minimum pass rate before exiting non-zero |
|
|
78
|
+
| `--limit` | `10` | failing cases to print |
|
|
79
|
+
|
|
80
|
+
Exits `0` when the pass rate is at least `--min`, `1` when below, `2` on a
|
|
81
|
+
usage or module-loading error. The exit code is the point: drop it into your
|
|
82
|
+
own CI. Exports returning a generator or any other iterable are accepted.
|
|
83
|
+
|
|
84
|
+
## API
|
|
85
|
+
|
|
86
|
+
```ts
|
|
87
|
+
import { parseBreakTest, score, vectors } from 'grapheme-conformance';
|
|
88
|
+
|
|
89
|
+
const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });
|
|
90
|
+
const report = score(
|
|
91
|
+
(s) => [...segmenter.segment(s)].map((part) => part.segment),
|
|
92
|
+
vectors['16.0.0'],
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
report.passed; // 1092
|
|
96
|
+
report.total; // 1093
|
|
97
|
+
report.rate; // 0.999085...
|
|
98
|
+
report.failures[0].inputHex; // '2701 200D 2701'
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
```ts
|
|
102
|
+
type Segmenter = (input: string) => string[];
|
|
103
|
+
|
|
104
|
+
interface Vector { input: string; expected: string[]; line: number }
|
|
105
|
+
|
|
106
|
+
function parseBreakTest(source: string): Vector[];
|
|
107
|
+
function score(segmenter: Segmenter, vectors: Vector[]): Report;
|
|
108
|
+
|
|
109
|
+
interface Report { passed: number; total: number; rate: number; failures: Failure[] }
|
|
110
|
+
|
|
111
|
+
interface Failure {
|
|
112
|
+
line: number; // 1-based line number in the source .txt
|
|
113
|
+
input: string;
|
|
114
|
+
inputHex: string; // 'space-separated UPPERCASE hex code points'
|
|
115
|
+
expected: string[];
|
|
116
|
+
actual: string[];
|
|
117
|
+
rule: string | null; // inferred rule id, or null
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const vectors: Record<string, Vector[]>; // keyed by Unicode version
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
`parseBreakTest` takes a string and never touches the filesystem. `score` never
|
|
124
|
+
throws: a segmenter that throws on an input yields a failure with `actual: []`.
|
|
125
|
+
`failures` is ordered by `line`, and repeated calls return deeply equal reports.
|
|
126
|
+
|
|
127
|
+
## Vectors
|
|
128
|
+
|
|
129
|
+
`15.0.0`, `15.1.0`, `16.0.0` and `17.0.0` are vendored under `vectors/` and
|
|
130
|
+
committed, fetched once from
|
|
131
|
+
[unicode-org/unicodetools](https://github.com/unicode-org/unicodetools). Nothing
|
|
132
|
+
is fetched at install, build or test time; the whole suite runs offline.
|
|
133
|
+
|
|
134
|
+
## Rule inference
|
|
135
|
+
|
|
136
|
+
Each failure carries a best-effort `rule`: `GB9c` (Indic conjunct), `GB11`
|
|
137
|
+
(pictographic ZWJ sequence), `GB12/GB13` (regional indicators), `GB6/GB7/GB8`
|
|
138
|
+
(Hangul jamo), or `null`.
|
|
139
|
+
|
|
140
|
+
**This is a convenience for reading the scoreboard, not a correctness claim.**
|
|
141
|
+
It inspects the input for characteristic code points; it does not implement
|
|
142
|
+
UAX #29 and does not prove which rule an implementation actually got wrong.
|
|
143
|
+
|
|
144
|
+
## A note on the numbers
|
|
145
|
+
|
|
146
|
+
The scoreboard states counts. It does not rank or grade, and a lower number is
|
|
147
|
+
not automatically a reason to switch libraries — segmenters trade conformance
|
|
148
|
+
against size and speed, and which cases matter depends on the text you handle.
|
|
149
|
+
What the numbers do show is that the disagreements are not exotic: they land on
|
|
150
|
+
ordinary Devanagari.
|
|
151
|
+
|
|
152
|
+
`Intl.Segmenter` is scored against whatever ICU the host Node ships, so its row
|
|
153
|
+
moves with the runtime, and not monotonically: Node 18.20.8 scores higher than
|
|
154
|
+
Node 22 because the `2701 200D 2701` deviation is a newer ICU behaviour. The
|
|
155
|
+
pure-JS libraries are pinned to exact versions and score identically everywhere.
|
|
156
|
+
|
|
157
|
+
## Verifying this build
|
|
158
|
+
|
|
159
|
+
See [VERIFY.md](./VERIFY.md): one command, ten integers to compare.
|
|
160
|
+
|
|
161
|
+
## Scope
|
|
162
|
+
|
|
163
|
+
Grapheme clusters only. Word and sentence breaking are out of scope —
|
|
164
|
+
`unicode-segmenter` does not implement them, which leaves too few
|
|
165
|
+
implementations to be worth a scoreboard.
|
|
166
|
+
|
|
167
|
+
## License
|
|
168
|
+
|
|
169
|
+
MIT
|
package/SCOREBOARD.md
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# Scoreboard
|
|
2
|
+
|
|
3
|
+
Cases passed against Unicode's official `GraphemeBreakTest.txt`, by Unicode
|
|
4
|
+
version. Case counts are in the column headers.
|
|
5
|
+
|
|
6
|
+
| implementation | 15.0.0 (602) | 15.1.0 (1187) | 16.0.0 (1093) | 17.0.0 (766) |
|
|
7
|
+
|---|---|---|---|---|
|
|
8
|
+
| `Intl.Segmenter` | 601/602 (99.83%) | 1186/1187 (99.92%) | 1092/1093 (99.91%) | 766/766 (100.00%) |
|
|
9
|
+
| `unicode-segmenter` | 601/602 (99.83%) | 1186/1187 (99.92%) | 1092/1093 (99.91%) | 766/766 (100.00%) |
|
|
10
|
+
| `graphemer` | 602/602 (100.00%) | 1180/1187 (99.41%) | 1086/1093 (99.36%) | 749/766 (97.78%) |
|
|
11
|
+
| `grapheme-splitter` | 597/602 (99.17%) | 1175/1187 (98.99%) | 1081/1093 (98.90%) | 746/766 (97.39%) |
|
|
12
|
+
| `runes2` | 412/602 (68.44%) | 730/1187 (61.50%) | 695/1093 (63.59%) | 501/766 (65.40%) |
|
|
13
|
+
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
Generated 2026-08-13 by `npm run scoreboard`. Versions under test:
|
|
17
|
+
|
|
18
|
+
| implementation | version |
|
|
19
|
+
|---|---|
|
|
20
|
+
| `Intl.Segmenter` | ICU, Unicode 17.0 |
|
|
21
|
+
| `unicode-segmenter` | 0.17.3 |
|
|
22
|
+
| `graphemer` | 1.4.0 |
|
|
23
|
+
| `grapheme-splitter` | 1.0.4 |
|
|
24
|
+
| `runes2` | 1.1.4 |
|
|
25
|
+
|
|
26
|
+
`Intl.Segmenter` is scored against the host ICU, so its row depends on the
|
|
27
|
+
runtime the scoreboard was generated on. The other rows do not.
|
package/VERIFY.md
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# VERIFY
|
|
2
|
+
|
|
3
|
+
Run one command:
|
|
4
|
+
|
|
5
|
+
```
|
|
6
|
+
npm run scoreboard
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
It builds the package, scores every library against every vendored copy of
|
|
10
|
+
Unicode's `GraphemeBreakTest.txt`, writes `SCOREBOARD.md`, and prints the table
|
|
11
|
+
below to stdout.
|
|
12
|
+
|
|
13
|
+
## Expected output
|
|
14
|
+
|
|
15
|
+
Compare ten integers. These two columns are the specified baseline.
|
|
16
|
+
|
|
17
|
+
| implementation | U15.1.0 (1187) | U16.0.0 (1093) |
|
|
18
|
+
|---|---|---|
|
|
19
|
+
| `Intl.Segmenter` | 1186 | 1092 |
|
|
20
|
+
| `unicode-segmenter` | 1186 | 1092 |
|
|
21
|
+
| `graphemer` | 1180 | 1086 |
|
|
22
|
+
| `grapheme-splitter` | 1175 | 1081 |
|
|
23
|
+
| `runes2` | 730 | 695 |
|
|
24
|
+
|
|
25
|
+
The printed table shows each cell as `passed/total (rate%)`; the integer to
|
|
26
|
+
check is the one before the slash. If all ten match, the build is correct.
|
|
27
|
+
|
|
28
|
+
## One caveat, and it is the only one
|
|
29
|
+
|
|
30
|
+
`Intl.Segmenter` is not a fixed library. It is scored against whatever ICU the
|
|
31
|
+
host Node ships, so its row moves with the runtime. Measured across the CI
|
|
32
|
+
matrix:
|
|
33
|
+
|
|
34
|
+
| runtime | 15.1.0 | 16.0.0 | `2701 200D 2701` |
|
|
35
|
+
|---|---|---|---|
|
|
36
|
+
| Node 22.22.2 (ICU 78.2) | 1186 | 1092 | split, the known deviation |
|
|
37
|
+
| Node 20.x | 1186 | 1092 | split |
|
|
38
|
+
| Node 18.20.8 | 1187 | 1093 | not split |
|
|
39
|
+
|
|
40
|
+
Run `npm run scoreboard` on Node 22 or 20 to get the two baseline numbers. On
|
|
41
|
+
Node 18 the `Intl.Segmenter` row reads `1187` and `1093` instead: that ICU
|
|
42
|
+
predates the deviation and passes every case. The other four rows are pinned to
|
|
43
|
+
exact versions and hold identically on every Node.
|
|
44
|
+
|
|
45
|
+
The baseline was verified on Node 22.22.2 (ICU 78.2, Unicode 17.0).
|
|
46
|
+
|
|
47
|
+
## Everything else
|
|
48
|
+
|
|
49
|
+
```
|
|
50
|
+
npm test # 104 assertions, offline, including all ten integers above
|
|
51
|
+
npm run typecheck
|
|
52
|
+
npm run build
|
|
53
|
+
npm run smoke # loads the ESM and CJS entry points and scores Intl.Segmenter
|
|
54
|
+
npm run scoreboard:check # asserts SCOREBOARD.md is byte-identical to a fresh run
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
No network access is required by any of these. The vectors are committed under
|
|
58
|
+
`vectors/` and are never fetched.
|
package/dist/cli.cjs
ADDED
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
|
|
4
|
+
// node_modules/tsup/assets/cjs_shims.js
|
|
5
|
+
var getImportMetaUrl = () => typeof document === "undefined" ? new URL(`file:${__filename}`).href : document.currentScript && document.currentScript.src || new URL("main.js", document.baseURI).href;
|
|
6
|
+
var importMetaUrl = /* @__PURE__ */ getImportMetaUrl();
|
|
7
|
+
|
|
8
|
+
// src/cli.ts
|
|
9
|
+
var import_node_module = require("module");
|
|
10
|
+
var import_node_path2 = require("path");
|
|
11
|
+
var import_node_url2 = require("url");
|
|
12
|
+
|
|
13
|
+
// src/rules.ts
|
|
14
|
+
var VIRAMA = /[्্੍્୍்్್്]/;
|
|
15
|
+
var INDIC_SCRIPT = /[\p{Script=Devanagari}\p{Script=Bengali}\p{Script=Gurmukhi}\p{Script=Gujarati}\p{Script=Oriya}\p{Script=Tamil}\p{Script=Telugu}\p{Script=Kannada}\p{Script=Malayalam}]/u;
|
|
16
|
+
var LETTER = /\p{L}/u;
|
|
17
|
+
var MARK = /\p{M}/u;
|
|
18
|
+
var EXT_PICT = /\p{Extended_Pictographic}/u;
|
|
19
|
+
var ZWJ = "\u200D";
|
|
20
|
+
function isRegionalIndicator(cp) {
|
|
21
|
+
const c = cp.codePointAt(0);
|
|
22
|
+
return c >= 127462 && c <= 127487;
|
|
23
|
+
}
|
|
24
|
+
function isHangulJamo(cp) {
|
|
25
|
+
const c = cp.codePointAt(0);
|
|
26
|
+
return c >= 4352 && c <= 4607 || c >= 43360 && c <= 43391 || c >= 55216 && c <= 55295;
|
|
27
|
+
}
|
|
28
|
+
function hasIndicConjunct(cps) {
|
|
29
|
+
for (let i = 0; i < cps.length - 1; i++) {
|
|
30
|
+
if (!VIRAMA.test(cps[i])) continue;
|
|
31
|
+
let j = i + 1;
|
|
32
|
+
while (j < cps.length && (cps[j] === ZWJ || MARK.test(cps[j]))) j++;
|
|
33
|
+
if (j < cps.length && LETTER.test(cps[j]) && INDIC_SCRIPT.test(cps[j])) {
|
|
34
|
+
return true;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
function hasPictographicZwj(cps) {
|
|
40
|
+
for (let i = 1; i < cps.length - 1; i++) {
|
|
41
|
+
if (cps[i] === ZWJ && EXT_PICT.test(cps[i - 1]) && EXT_PICT.test(cps[i + 1])) {
|
|
42
|
+
return true;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
function inferRule(input) {
|
|
48
|
+
const cps = [...input];
|
|
49
|
+
if (hasIndicConjunct(cps)) return "GB9c";
|
|
50
|
+
if (hasPictographicZwj(cps)) return "GB11";
|
|
51
|
+
if (cps.filter(isRegionalIndicator).length >= 2) return "GB12/GB13";
|
|
52
|
+
if (cps.some(isHangulJamo)) return "GB6/GB7/GB8";
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// src/score.ts
|
|
57
|
+
function toHex(input) {
|
|
58
|
+
return [...input].map((cp) => cp.codePointAt(0).toString(16).toUpperCase().padStart(4, "0")).join(" ");
|
|
59
|
+
}
|
|
60
|
+
function sameClusters(a, b) {
|
|
61
|
+
if (a.length !== b.length) return false;
|
|
62
|
+
for (let i = 0; i < a.length; i++) {
|
|
63
|
+
if (a[i] !== b[i]) return false;
|
|
64
|
+
}
|
|
65
|
+
return true;
|
|
66
|
+
}
|
|
67
|
+
function score(segmenter, vectors2) {
|
|
68
|
+
const failures = [];
|
|
69
|
+
let passed = 0;
|
|
70
|
+
for (const vector of vectors2) {
|
|
71
|
+
let actual;
|
|
72
|
+
try {
|
|
73
|
+
const result = segmenter(vector.input);
|
|
74
|
+
actual = Array.isArray(result) && result.every((c) => typeof c === "string") ? [...result] : [];
|
|
75
|
+
} catch {
|
|
76
|
+
actual = [];
|
|
77
|
+
}
|
|
78
|
+
if (sameClusters(actual, vector.expected)) {
|
|
79
|
+
passed++;
|
|
80
|
+
} else {
|
|
81
|
+
failures.push({
|
|
82
|
+
line: vector.line,
|
|
83
|
+
input: vector.input,
|
|
84
|
+
inputHex: toHex(vector.input),
|
|
85
|
+
expected: [...vector.expected],
|
|
86
|
+
actual,
|
|
87
|
+
rule: inferRule(vector.input)
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
const total = vectors2.length;
|
|
92
|
+
return { passed, total, rate: total === 0 ? 1 : passed / total, failures };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// src/vectors.ts
|
|
96
|
+
var import_node_fs = require("fs");
|
|
97
|
+
var import_node_path = require("path");
|
|
98
|
+
var import_node_url = require("url");
|
|
99
|
+
|
|
100
|
+
// src/parse.ts
|
|
101
|
+
var BREAK = "\xF7";
|
|
102
|
+
var NO_BREAK = "\xD7";
|
|
103
|
+
function parseBreakTest(source) {
|
|
104
|
+
const vectors2 = [];
|
|
105
|
+
const lines = source.split(/\r\n|\r|\n/);
|
|
106
|
+
for (let i = 0; i < lines.length; i++) {
|
|
107
|
+
const hash = lines[i].indexOf("#");
|
|
108
|
+
const body = (hash === -1 ? lines[i] : lines[i].slice(0, hash)).trim();
|
|
109
|
+
if (body === "") continue;
|
|
110
|
+
const tokens = body.split(/\s+/);
|
|
111
|
+
if (tokens[0] !== BREAK) continue;
|
|
112
|
+
const expected = [];
|
|
113
|
+
let cluster = "";
|
|
114
|
+
let malformed = false;
|
|
115
|
+
for (const token of tokens) {
|
|
116
|
+
if (token === BREAK) {
|
|
117
|
+
if (cluster !== "") expected.push(cluster);
|
|
118
|
+
cluster = "";
|
|
119
|
+
} else if (token === NO_BREAK) {
|
|
120
|
+
continue;
|
|
121
|
+
} else if (/^[0-9A-Fa-f]+$/.test(token)) {
|
|
122
|
+
cluster += String.fromCodePoint(parseInt(token, 16));
|
|
123
|
+
} else {
|
|
124
|
+
malformed = true;
|
|
125
|
+
break;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
if (malformed) continue;
|
|
129
|
+
if (cluster !== "") expected.push(cluster);
|
|
130
|
+
if (expected.length === 0) continue;
|
|
131
|
+
vectors2.push({ input: expected.join(""), expected, line: i + 1 });
|
|
132
|
+
}
|
|
133
|
+
return vectors2;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// src/vectors.ts
|
|
137
|
+
var VERSIONS = ["15.0.0", "15.1.0", "16.0.0", "17.0.0"];
|
|
138
|
+
function vectorsDir() {
|
|
139
|
+
let dir = (0, import_node_path.dirname)((0, import_node_url.fileURLToPath)(importMetaUrl));
|
|
140
|
+
for (let i = 0; i < 4; i++) {
|
|
141
|
+
const candidate = (0, import_node_path.join)(dir, "vectors");
|
|
142
|
+
if ((0, import_node_fs.existsSync)((0, import_node_path.join)(candidate, "GraphemeBreakTest-15.1.0.txt"))) return candidate;
|
|
143
|
+
const parent = (0, import_node_path.resolve)(dir, "..");
|
|
144
|
+
if (parent === dir) break;
|
|
145
|
+
dir = parent;
|
|
146
|
+
}
|
|
147
|
+
throw new Error(
|
|
148
|
+
"grapheme-conformance: vendored vectors/ directory not found next to the package"
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
function load() {
|
|
152
|
+
const dir = vectorsDir();
|
|
153
|
+
const out = /* @__PURE__ */ Object.create(null);
|
|
154
|
+
for (const version of VERSIONS) {
|
|
155
|
+
const source = (0, import_node_fs.readFileSync)((0, import_node_path.join)(dir, `GraphemeBreakTest-${version}.txt`), "utf8");
|
|
156
|
+
out[version] = parseBreakTest(source);
|
|
157
|
+
}
|
|
158
|
+
return out;
|
|
159
|
+
}
|
|
160
|
+
var vectors = load();
|
|
161
|
+
|
|
162
|
+
// src/cli.ts
|
|
163
|
+
var USAGE = `Usage: grapheme-conformance --module <specifier> [options]
|
|
164
|
+
--module <specifier> module to load (bare name, or path relative to cwd)
|
|
165
|
+
--export <name> export to score (default: default)
|
|
166
|
+
--version <x.y.z> vendored vectors: ${Object.keys(vectors).join(", ")}
|
|
167
|
+
--min <0..1> minimum pass rate (default: 1.0)
|
|
168
|
+
--limit <n> failing cases to print (default: 10)
|
|
169
|
+
Exits non-zero when the pass rate is below --min.`;
|
|
170
|
+
function parseArgs(argv) {
|
|
171
|
+
const out = {};
|
|
172
|
+
for (let i = 0; i < argv.length; i++) {
|
|
173
|
+
const arg = argv[i];
|
|
174
|
+
if (arg === "-h" || arg === "--help") return { help: "true" };
|
|
175
|
+
if (!arg.startsWith("--")) continue;
|
|
176
|
+
const eq = arg.indexOf("=");
|
|
177
|
+
if (eq !== -1) out[arg.slice(2, eq)] = arg.slice(eq + 1);
|
|
178
|
+
else out[arg.slice(2)] = argv[++i] ?? "";
|
|
179
|
+
}
|
|
180
|
+
return out;
|
|
181
|
+
}
|
|
182
|
+
function fail(message) {
|
|
183
|
+
console.error(message);
|
|
184
|
+
process.exit(2);
|
|
185
|
+
}
|
|
186
|
+
async function load2(specifier, exportName) {
|
|
187
|
+
const relative = specifier.startsWith(".") || (0, import_node_path2.isAbsolute)(specifier);
|
|
188
|
+
const target = relative ? (0, import_node_url2.pathToFileURL)((0, import_node_path2.resolve)(process.cwd(), specifier)).href : specifier;
|
|
189
|
+
let module2;
|
|
190
|
+
try {
|
|
191
|
+
module2 = await import(target);
|
|
192
|
+
} catch {
|
|
193
|
+
const from = (0, import_node_module.createRequire)((0, import_node_path2.resolve)(process.cwd(), "noop.js"));
|
|
194
|
+
module2 = await import((0, import_node_url2.pathToFileURL)(from.resolve(specifier)).href);
|
|
195
|
+
}
|
|
196
|
+
const direct = module2[exportName];
|
|
197
|
+
const nested = module2.default?.[exportName];
|
|
198
|
+
const picked = typeof direct === "function" ? direct : nested;
|
|
199
|
+
if (typeof picked !== "function") fail(`No callable export '${exportName}' in '${specifier}'.`);
|
|
200
|
+
return (input) => {
|
|
201
|
+
const result = picked(input);
|
|
202
|
+
if (Array.isArray(result)) return result;
|
|
203
|
+
if (result && typeof result[Symbol.iterator] === "function") {
|
|
204
|
+
return [...result];
|
|
205
|
+
}
|
|
206
|
+
throw new TypeError("segmenter did not return an array of clusters");
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
async function main() {
|
|
210
|
+
const args = parseArgs(process.argv.slice(2));
|
|
211
|
+
if (args.help || !args.module) {
|
|
212
|
+
console.log(USAGE);
|
|
213
|
+
process.exit(args.module ? 0 : 2);
|
|
214
|
+
}
|
|
215
|
+
const version = args.version ?? "16.0.0";
|
|
216
|
+
const cases = vectors[version];
|
|
217
|
+
if (!cases) fail(`Unknown version '${version}'. Have: ${Object.keys(vectors).join(", ")}`);
|
|
218
|
+
const min = args.min === void 0 ? 1 : Number(args.min);
|
|
219
|
+
if (!Number.isFinite(min) || min < 0 || min > 1) fail("--min must be between 0 and 1.");
|
|
220
|
+
const limit = args.limit === void 0 ? 10 : Number(args.limit);
|
|
221
|
+
const exportName = args.export ?? "default";
|
|
222
|
+
const report = score(await load2(args.module, exportName), cases);
|
|
223
|
+
console.log(`${args.module} (${exportName}) GraphemeBreakTest ${version}`);
|
|
224
|
+
console.log(` passed ${report.passed}/${report.total} ${(report.rate * 100).toFixed(2)}%`);
|
|
225
|
+
console.log(` failed ${report.failures.length}`);
|
|
226
|
+
if (report.failures.length > 0) {
|
|
227
|
+
console.log(`
|
|
228
|
+
${"line".padEnd(6)}${"input".padEnd(34)}${"want".padEnd(6)}${"got".padEnd(6)}rule`);
|
|
229
|
+
for (const f of report.failures.slice(0, Math.max(0, limit))) {
|
|
230
|
+
const hex = f.inputHex.length > 32 ? `${f.inputHex.slice(0, 29)}...` : f.inputHex;
|
|
231
|
+
console.log(
|
|
232
|
+
` ${String(f.line).padEnd(6)}${hex.padEnd(34)}${String(f.expected.length).padEnd(6)}${String(f.actual.length).padEnd(6)}${f.rule ?? "-"}`
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
if (report.failures.length > limit) console.log(` ... and ${report.failures.length - limit} more`);
|
|
236
|
+
}
|
|
237
|
+
process.exit(report.rate < min ? 1 : 0);
|
|
238
|
+
}
|
|
239
|
+
main().catch((error) => fail(error instanceof Error ? error.message : String(error)));
|
package/dist/cli.d.cts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
package/dist/cli.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
#!/usr/bin/env node
|