@stackline/wcwidth 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/lib/width.js ADDED
@@ -0,0 +1,91 @@
1
+ 'use strict'
2
+
3
+ const {
4
+ EMOJI,
5
+ EMOJI_PRESENTATION,
6
+ EXTENDED_PICTOGRAPHIC,
7
+ UNICODE_VERSION,
8
+ WIDE,
9
+ ZERO_WIDTH
10
+ } = require('./unicode-tables')
11
+ const { splitGraphemeCodePoints } = require('./graphemes')
12
+ const { inRanges } = require('./ranges')
13
+
14
+ const TEXT_VARIATION_SELECTOR = 0xfe0e
15
+ const EMOJI_VARIATION_SELECTOR = 0xfe0f
16
+ const ZERO_WIDTH_JOINER = 0x200d
17
+ const COMBINING_ENCLOSING_KEYCAP = 0x20e3
18
+
19
+ function measure(value, options) {
20
+ if (typeof value !== 'string') return codePointWidth(value, options)
21
+
22
+ const asciiWidth = printableAsciiWidth(value)
23
+ if (asciiWidth !== -1) return asciiWidth
24
+
25
+ let total = 0
26
+ for (const cluster of splitGraphemeCodePoints(value)) {
27
+ const width = graphemeWidth(cluster, options)
28
+ if (width < 0) return -1
29
+ total += width
30
+ }
31
+ return total
32
+ }
33
+
34
+ function printableAsciiWidth(value) {
35
+ for (let index = 0; index < value.length; index += 1) {
36
+ const codeUnit = value.charCodeAt(index)
37
+ if (codeUnit < 0x20 || codeUnit > 0x7e) return -1
38
+ }
39
+ return value.length
40
+ }
41
+
42
+ function graphemeWidth(points, options) {
43
+ if (isEmojiCluster(points)) return 2
44
+
45
+ let total = 0
46
+ for (const point of points) {
47
+ const width = codePointWidth(point, options)
48
+ if (width < 0) return -1
49
+ total += width
50
+ }
51
+ return total
52
+ }
53
+
54
+ function codePointWidth(point, options) {
55
+ if (point === 0) return options.nul
56
+ if (point < 32 || (point >= 0x7f && point < 0xa0)) return options.control
57
+ if (inRanges(point, ZERO_WIDTH)) return 0
58
+ if (inRanges(point, WIDE)) return 2
59
+ return 1
60
+ }
61
+
62
+ function isEmojiCluster(points) {
63
+ if (points.includes(TEXT_VARIATION_SELECTOR) && !points.includes(EMOJI_VARIATION_SELECTOR)) {
64
+ return false
65
+ }
66
+
67
+ const hasEmoji = points.some((point) => inRanges(point, EMOJI))
68
+ if (hasEmoji && points.includes(EMOJI_VARIATION_SELECTOR)) return true
69
+ if (points.includes(COMBINING_ENCLOSING_KEYCAP) && points.some(isKeycapBase)) return true
70
+ if (points.some((point) => inRanges(point, EMOJI_PRESENTATION))) return true
71
+
72
+ if (points.includes(ZERO_WIDTH_JOINER)) {
73
+ let pictographs = 0
74
+ for (const point of points) {
75
+ if (inRanges(point, EXTENDED_PICTOGRAPHIC)) pictographs += 1
76
+ }
77
+ if (pictographs >= 2) return true
78
+ }
79
+
80
+ return false
81
+ }
82
+
83
+ function isKeycapBase(point) {
84
+ return point === 0x23 || point === 0x2a || (point >= 0x30 && point <= 0x39)
85
+ }
86
+
87
+ module.exports = {
88
+ UNICODE_VERSION,
89
+ codePointWidth,
90
+ measure
91
+ }
package/package.json ADDED
@@ -0,0 +1,149 @@
1
+ {
2
+ "name": "@stackline/wcwidth",
3
+ "version": "1.0.0",
4
+ "description": "Terminal column widths with modern Unicode grapheme support and wcwidth compatibility",
5
+ "keywords": [
6
+ "terminal",
7
+ "unicode",
8
+ "width",
9
+ "wcwidth",
10
+ "grapheme",
11
+ "emoji"
12
+ ],
13
+ "license": "MIT",
14
+ "type": "commonjs",
15
+ "sideEffects": false,
16
+ "main": "./index.js",
17
+ "module": "./dist/index.mjs",
18
+ "types": "./index.d.ts",
19
+ "typings": "./index.d.ts",
20
+ "exports": {
21
+ ".": {
22
+ "types": {
23
+ "import": "./index.d.mts",
24
+ "require": "./index.d.cts",
25
+ "default": "./index.d.ts"
26
+ },
27
+ "browser": {
28
+ "import": "./dist/index.mjs",
29
+ "require": "./index.js",
30
+ "default": "./index.js"
31
+ },
32
+ "import": "./dist/index.mjs",
33
+ "require": "./index.js",
34
+ "default": "./index.js"
35
+ },
36
+ "./index.js": {
37
+ "types": {
38
+ "import": "./index.d.mts",
39
+ "require": "./index.d.cts",
40
+ "default": "./index.d.ts"
41
+ },
42
+ "import": "./dist/index.mjs",
43
+ "require": "./index.js",
44
+ "default": "./index.js"
45
+ },
46
+ "./combining": {
47
+ "types": "./combining.d.ts",
48
+ "require": "./combining.js",
49
+ "default": "./combining.js"
50
+ },
51
+ "./combining.js": {
52
+ "types": "./combining.d.ts",
53
+ "require": "./combining.js",
54
+ "default": "./combining.js"
55
+ },
56
+ "./package.json": "./package.json"
57
+ },
58
+ "files": [
59
+ "index.js",
60
+ "index.mjs",
61
+ "index.d.ts",
62
+ "index.d.cts",
63
+ "index.d.mts",
64
+ "combining.js",
65
+ "combining.d.ts",
66
+ "dist",
67
+ "lib",
68
+ "tools",
69
+ "examples",
70
+ "scripts/test-package.mjs",
71
+ "CHANGELOG.md",
72
+ "COMPATIBILITY.md",
73
+ "COMPATIBILITY_CONTRACT.md",
74
+ "CONTRIBUTING.md",
75
+ "LICENSE",
76
+ "MIGRATION.md",
77
+ "NOTICE",
78
+ "PUBLISHING.md",
79
+ "README.md",
80
+ "SECURITY.md",
81
+ "THIRD_PARTY_LICENSES.md",
82
+ "VERIFICATION.md",
83
+ "unicode-sources.json"
84
+ ],
85
+ "scripts": {
86
+ "clean": "node scripts/clean.mjs",
87
+ "build": "node scripts/build.mjs",
88
+ "lint": "eslint index.js index.mjs lib scripts test examples docs-site/*.js docs-site/*.mjs",
89
+ "docs:prepare": "node docs-site/prepare.mjs",
90
+ "docs:check": "node docs-site/check.mjs",
91
+ "unicode:generate": "node tools/generate-unicode-tables.mjs",
92
+ "unicode:check": "node tools/generate-unicode-tables.mjs --check",
93
+ "test:source": "npm run test:core && npm run test:release-helpers && npm run test:browser && npm run test:types",
94
+ "test:core": "node --test test/*.test.js",
95
+ "test:release-helpers": "node --test test/registry-verification.test.cjs",
96
+ "test:browser": "node scripts/check-browser.mjs",
97
+ "test:types": "tsc -p test/types/modern/tsconfig.json && node node_modules/typescript-3-9/bin/tsc -p test/types/legacy/tsconfig.json --typeRoots ./test/types/no-global-types",
98
+ "test": "node scripts/test-package.mjs",
99
+ "test:coverage": "c8 --all --include=index.js --include=lib/*.js --exclude=lib/unicode-tables.js --check-coverage --lines 95 --functions 95 --branches 90 node --test test/*.test.js",
100
+ "test:smoke": "node scripts/smoke-install.mjs",
101
+ "test:closure": "node scripts/check-production-closure.mjs",
102
+ "check:package": "publint --strict && attw --pack .",
103
+ "check:licenses": "node scripts/check-licenses.mjs",
104
+ "check:sbom": "node scripts/check-sbom.mjs",
105
+ "check:release": "node scripts/check-release.mjs",
106
+ "evidence:assemble": "node scripts/assemble-release-assets.mjs",
107
+ "evidence:check": "node scripts/check-release-assets.mjs",
108
+ "pack:check": "npm pack --dry-run",
109
+ "audit:dependencies": "npm audit --omit=dev --audit-level=low",
110
+ "audit:all": "npm audit --audit-level=low",
111
+ "audit:signatures": "npm audit signatures",
112
+ "verify": "npm run clean && npm run lint && npm run build && npm run docs:prepare && npm run docs:check && npm run unicode:check && npm test && npm run test:coverage && npm run test:smoke && npm run test:closure && npm run check:package && npm run check:licenses && npm run check:sbom && npm run check:release && npm run pack:check && npm run audit:dependencies && npm run audit:all && npm run audit:signatures",
113
+ "artifact:prepare": "node scripts/create-artifact.mjs",
114
+ "prepack": "npm run build"
115
+ },
116
+ "engines": {
117
+ "node": ">=18.0.0"
118
+ },
119
+ "publishConfig": {
120
+ "access": "public",
121
+ "provenance": true
122
+ },
123
+ "dependencies": {},
124
+ "devDependencies": {
125
+ "@arethetypeswrong/cli": "0.18.5",
126
+ "@eslint/js": "10.0.1",
127
+ "@rollup/plugin-node-resolve": "16.0.3",
128
+ "c8": "12.0.0",
129
+ "esbuild": "0.28.2",
130
+ "eslint": "10.9.1",
131
+ "publint": "0.3.24",
132
+ "rollup": "4.63.1",
133
+ "typescript": "7.0.2",
134
+ "typescript-3-9": "npm:typescript@3.9.10",
135
+ "wcwidth-upstream": "npm:wcwidth@1.0.1"
136
+ },
137
+ "author": "Stackline maintainers",
138
+ "contributors": [
139
+ "Jun Woong (original wcwidth JavaScript port)"
140
+ ],
141
+ "homepage": "https://alexandro.net/docs/vanilla/wcwidth/",
142
+ "repository": {
143
+ "type": "git",
144
+ "url": "git+https://github.com/alexandroit/stackline-wcwidth.git"
145
+ },
146
+ "bugs": {
147
+ "url": "https://github.com/alexandroit/stackline-wcwidth/issues"
148
+ }
149
+ }
@@ -0,0 +1,38 @@
1
+ import assert from 'node:assert/strict'
2
+ import { spawnSync } from 'node:child_process'
3
+ import { existsSync } from 'node:fs'
4
+ import { createRequire } from 'node:module'
5
+
6
+ const root = new URL('../', import.meta.url)
7
+
8
+ if (existsSync(new URL('../test/api.test.js', import.meta.url))) {
9
+ if (!existsSync(new URL('../dist/index.mjs', import.meta.url))) runNpm(['run', 'build'])
10
+ runNpm(['run', 'test:source'])
11
+ } else {
12
+ const require = createRequire(import.meta.url)
13
+ const wcwidth = require('../index.js')
14
+ const esm = await import(new URL('../dist/index.mjs', import.meta.url))
15
+
16
+ assert.equal(typeof wcwidth, 'function')
17
+ assert.deepEqual(Object.keys(wcwidth), ['config'])
18
+ assert.equal(wcwidth('A字🤦🏼‍♂️e\u0301'), 6)
19
+ assert.equal(wcwidth.config({ control: -1 })('a\nb'), -1)
20
+ assert.equal(esm.default('🇨🇦'), 2)
21
+ assert.equal(esm.config({ nul: 3 })('\0'), 3)
22
+ assert.equal(esm.unicodeVersion, '17.0.0')
23
+ process.stdout.write('Installed package CJS and ESM smoke tests passed.\n')
24
+ }
25
+
26
+ function runNpm(arguments_) {
27
+ const command = process.env.npm_execpath || 'npm'
28
+ const invocation = process.env.npm_execpath
29
+ ? [command, ...arguments_]
30
+ : arguments_
31
+ const result = spawnSync(process.env.npm_execpath ? process.execPath : command, invocation, {
32
+ cwd: root,
33
+ encoding: 'utf8',
34
+ stdio: 'inherit'
35
+ })
36
+ if (result.error) throw result.error
37
+ assert.equal(result.status, 0, `npm ${arguments_.join(' ')} failed`)
38
+ }
@@ -0,0 +1,234 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { createHash } from 'node:crypto'
4
+ import { access, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
5
+ import { tmpdir } from 'node:os'
6
+ import { dirname, join } from 'node:path'
7
+ import { fileURLToPath } from 'node:url'
8
+
9
+ const root = dirname(dirname(fileURLToPath(import.meta.url)))
10
+ const manifestPath = join(root, 'unicode-sources.json')
11
+ const outputPath = join(root, 'lib', 'unicode-tables.js')
12
+ const graphemeTestPath = join(root, 'test', 'fixtures', 'GraphemeBreakTest.txt')
13
+ const emojiTestPath = join(root, 'test', 'fixtures', 'emoji-test.txt')
14
+ const check = process.argv.includes('--check')
15
+
16
+ const manifest = JSON.parse(await readFile(manifestPath, 'utf8'))
17
+ const scratch = await mkdtemp(join(tmpdir(), 'stackline-wcwidth-unicode-'))
18
+
19
+ try {
20
+ const contents = {}
21
+ for (const [name, source] of Object.entries(manifest.sources)) {
22
+ const response = await fetch(source.url)
23
+ if (!response.ok) {
24
+ throw new Error(`Unable to download ${source.url}: HTTP ${response.status}`)
25
+ }
26
+
27
+ const bytes = Buffer.from(await response.arrayBuffer())
28
+ const actual = createHash('sha256').update(bytes).digest('hex')
29
+ if (actual !== source.sha256) {
30
+ throw new Error(`${name} checksum mismatch: expected ${source.sha256}, got ${actual}`)
31
+ }
32
+
33
+ await writeFile(join(scratch, name), bytes)
34
+ contents[name] = bytes.toString('utf8')
35
+ }
36
+
37
+ const generated = generateTables(manifest.version, contents)
38
+ const graphemeTests = contents['GraphemeBreakTest.txt']
39
+ const emojiTests = contents['emoji-test.txt']
40
+ const sourceTreeIncludesTests = await exists(dirname(graphemeTestPath))
41
+
42
+ if (check) {
43
+ const current = await readFile(outputPath, 'utf8')
44
+ if (current !== generated) {
45
+ throw new Error('lib/unicode-tables.js is not reproducible from unicode-sources.json')
46
+ }
47
+ if (sourceTreeIncludesTests) {
48
+ const currentGraphemeTests = await readFile(graphemeTestPath, 'utf8')
49
+ if (currentGraphemeTests !== graphemeTests) {
50
+ throw new Error('GraphemeBreakTest.txt does not match its pinned Unicode source')
51
+ }
52
+ const currentEmojiTests = await readFile(emojiTestPath, 'utf8')
53
+ if (currentEmojiTests !== emojiTests) {
54
+ throw new Error('emoji-test.txt does not match its pinned Unicode source')
55
+ }
56
+ }
57
+ process.stdout.write(`Unicode ${manifest.version} tables are current.\n`)
58
+ } else {
59
+ await writeFile(outputPath, generated)
60
+ if (sourceTreeIncludesTests) {
61
+ await writeFile(graphemeTestPath, graphemeTests)
62
+ await writeFile(emojiTestPath, emojiTests)
63
+ }
64
+ process.stdout.write(`Generated ${outputPath} from Unicode ${manifest.version}.\n`)
65
+ }
66
+ } finally {
67
+ await rm(scratch, { recursive: true, force: true })
68
+ }
69
+
70
+ async function exists(pathname) {
71
+ try {
72
+ await access(pathname)
73
+ return true
74
+ } catch (error) {
75
+ if (error && error.code === 'ENOENT') return false
76
+ throw error
77
+ }
78
+ }
79
+
80
+ function generateTables(version, files) {
81
+ const categories = parseUnicodeData(files['UnicodeData.txt'])
82
+ const zeroWidth = mergeRanges([
83
+ ...categories.Mn,
84
+ ...categories.Me,
85
+ ...categories.Cf,
86
+ [0x1160, 0x11ff],
87
+ [0xd7b0, 0xd7c6],
88
+ [0xd7cb, 0xd7fb]
89
+ ].flatMap((range) => subtractCodePoint(range, 0x00ad)))
90
+
91
+ const eastAsian = parseProperties(files['EastAsianWidth.txt'])
92
+ const wide = mergeRanges([...(eastAsian.W || []), ...(eastAsian.F || [])])
93
+
94
+ const grapheme = parseProperties(files['GraphemeBreakProperty.txt'])
95
+ const emoji = parseProperties(files['emoji-data.txt'])
96
+ const derived = parseProperties(files['DerivedCoreProperties.txt'], true)
97
+
98
+ const tables = {
99
+ ZERO_WIDTH: zeroWidth,
100
+ WIDE: wide,
101
+ EMOJI: emoji.Emoji || [],
102
+ EMOJI_PRESENTATION: emoji.Emoji_Presentation || [],
103
+ EXTENDED_PICTOGRAPHIC: emoji.Extended_Pictographic || [],
104
+ GCB_CR: grapheme.CR || [],
105
+ GCB_LF: grapheme.LF || [],
106
+ GCB_CONTROL: grapheme.Control || [],
107
+ GCB_EXTEND: grapheme.Extend || [],
108
+ GCB_ZWJ: grapheme.ZWJ || [],
109
+ GCB_REGIONAL_INDICATOR: grapheme.Regional_Indicator || [],
110
+ GCB_PREPEND: grapheme.Prepend || [],
111
+ GCB_SPACING_MARK: grapheme.SpacingMark || [],
112
+ GCB_L: grapheme.L || [],
113
+ GCB_V: grapheme.V || [],
114
+ GCB_T: grapheme.T || [],
115
+ GCB_LV: grapheme.LV || [],
116
+ GCB_LVT: grapheme.LVT || [],
117
+ INCB_CONSONANT: derived['InCB=Consonant'] || [],
118
+ INCB_EXTEND: derived['InCB=Extend'] || [],
119
+ INCB_LINKER: derived['InCB=Linker'] || []
120
+ }
121
+
122
+ const lines = [
123
+ "'use strict'",
124
+ '',
125
+ '// Generated by tools/generate-unicode-tables.mjs. Do not edit by hand.',
126
+ `const UNICODE_VERSION = ${JSON.stringify(version)}`,
127
+ ''
128
+ ]
129
+
130
+ for (const [name, ranges] of Object.entries(tables)) {
131
+ lines.push(`const ${name} = ${formatRanges(ranges)}`)
132
+ }
133
+
134
+ lines.push(
135
+ '',
136
+ 'module.exports = {',
137
+ ' UNICODE_VERSION,',
138
+ ...Object.keys(tables).map((name) => ` ${name},`),
139
+ '}',
140
+ ''
141
+ )
142
+
143
+ return lines.join('\n')
144
+ }
145
+
146
+ function parseUnicodeData(text) {
147
+ const wanted = { Mn: [], Me: [], Cf: [] }
148
+ let pending = null
149
+
150
+ for (const line of text.split(/\r?\n/)) {
151
+ if (!line) continue
152
+ const fields = line.split(';')
153
+ const point = Number.parseInt(fields[0], 16)
154
+ const name = fields[1]
155
+ const category = fields[2]
156
+
157
+ if (name.endsWith(', First>')) {
158
+ pending = { point, category }
159
+ continue
160
+ }
161
+ if (name.endsWith(', Last>')) {
162
+ if (!pending || pending.category !== category) {
163
+ throw new Error(`Malformed UnicodeData range ending at ${fields[0]}`)
164
+ }
165
+ if (wanted[category]) wanted[category].push([pending.point, point])
166
+ pending = null
167
+ continue
168
+ }
169
+ if (wanted[category]) wanted[category].push([point, point])
170
+ }
171
+
172
+ if (pending) throw new Error('Unclosed UnicodeData range')
173
+ return wanted
174
+ }
175
+
176
+ function parseProperties(text, compound = false) {
177
+ const properties = {}
178
+
179
+ for (const original of text.split(/\r?\n/)) {
180
+ const line = original.replace(/#.*/, '').trim()
181
+ if (!line || line.startsWith('@missing:')) continue
182
+ const fields = line.split(';').map((field) => field.trim())
183
+ if (fields.length < 2) continue
184
+
185
+ const property = compound && fields[1] === 'InCB'
186
+ ? `${fields[1]}=${fields[2]}`
187
+ : fields[1]
188
+ if (!property) continue
189
+
190
+ const [first, last = first] = fields[0].split('..')
191
+ const range = [Number.parseInt(first, 16), Number.parseInt(last, 16)]
192
+ ;(properties[property] ||= []).push(range)
193
+ }
194
+
195
+ for (const [name, ranges] of Object.entries(properties)) {
196
+ properties[name] = mergeRanges(ranges)
197
+ }
198
+ return properties
199
+ }
200
+
201
+ function subtractCodePoint([start, end], point) {
202
+ if (point < start || point > end) return [[start, end]]
203
+ const result = []
204
+ if (start < point) result.push([start, point - 1])
205
+ if (point < end) result.push([point + 1, end])
206
+ return result
207
+ }
208
+
209
+ function mergeRanges(ranges) {
210
+ const sorted = ranges
211
+ .map(([start, end]) => [start, end])
212
+ .sort((a, b) => a[0] - b[0] || a[1] - b[1])
213
+ const merged = []
214
+
215
+ for (const range of sorted) {
216
+ const previous = merged.at(-1)
217
+ if (previous && range[0] <= previous[1] + 1) {
218
+ previous[1] = Math.max(previous[1], range[1])
219
+ } else {
220
+ merged.push(range)
221
+ }
222
+ }
223
+ return merged
224
+ }
225
+
226
+ function formatRanges(ranges) {
227
+ if (ranges.length === 0) return '[]\n'
228
+ const values = ranges.map(([start, end]) => `[0x${start.toString(16)},0x${end.toString(16)}]`)
229
+ const rows = []
230
+ for (let index = 0; index < values.length; index += 5) {
231
+ rows.push(` ${values.slice(index, index + 5).join(',')}`)
232
+ }
233
+ return `[\n${rows.join(',\n')}\n]\n`
234
+ }
@@ -0,0 +1,33 @@
1
+ {
2
+ "version": "17.0.0",
3
+ "sources": {
4
+ "UnicodeData.txt": {
5
+ "url": "https://www.unicode.org/Public/17.0.0/ucd/UnicodeData.txt",
6
+ "sha256": "2e1efc1dcb59c575eedf5ccae60f95229f706ee6d031835247d843c11d96470c"
7
+ },
8
+ "EastAsianWidth.txt": {
9
+ "url": "https://www.unicode.org/Public/17.0.0/ucd/EastAsianWidth.txt",
10
+ "sha256": "ea7ce50f3444a050333448dffef1cadd9325af55cbb764b4a2280faf52170a33"
11
+ },
12
+ "GraphemeBreakProperty.txt": {
13
+ "url": "https://www.unicode.org/Public/17.0.0/ucd/auxiliary/GraphemeBreakProperty.txt",
14
+ "sha256": "d6b51d1d2ae5c33b451b7ed994b48f1f4dc62b2272a5831e7fd418514a6bae89"
15
+ },
16
+ "emoji-data.txt": {
17
+ "url": "https://www.unicode.org/Public/17.0.0/ucd/emoji/emoji-data.txt",
18
+ "sha256": "2cb2bb9455cda83e8481541ecf5b6dfda66a3bb89efa3fa7c5297eccf607b72b"
19
+ },
20
+ "DerivedCoreProperties.txt": {
21
+ "url": "https://www.unicode.org/Public/17.0.0/ucd/DerivedCoreProperties.txt",
22
+ "sha256": "24c7fed1195c482faaefd5c1e7eb821c5ee1fb6de07ecdbaa64b56a99da22c08"
23
+ },
24
+ "GraphemeBreakTest.txt": {
25
+ "url": "https://www.unicode.org/Public/17.0.0/ucd/auxiliary/GraphemeBreakTest.txt",
26
+ "sha256": "e2d134d2c52919bace503ebb6a551c1855fe1a1faec18478c78fff254a1793ec"
27
+ },
28
+ "emoji-test.txt": {
29
+ "url": "https://www.unicode.org/Public/17.0.0/emoji/emoji-test.txt",
30
+ "sha256": "1d8a944f88d7952f7ef7c5167fef3c67995bcae24543949710231b03a201acda"
31
+ }
32
+ }
33
+ }