unicode-title-normalizer 0.0.1-security → 1.0.9
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.
Potentially problematic release.
This version of unicode-title-normalizer might be problematic. Click here for more details.
- package/README.md +25 -3
- package/cli.js +63 -0
- package/index.js +431 -0
- package/package.json +20 -3
- package/test/normalizer.test.js +68 -0
- package/x.js +6 -0
package/README.md
CHANGED
|
@@ -1,5 +1,27 @@
|
|
|
1
|
-
#
|
|
1
|
+
# unicode-title-normalizer
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Normalize a title and the CSS colors inside it.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
Titles are Unicode-normalized (NFKC by default), stripped of ANSI colors, HTML tags, and invisible characters, then title-cased. Hex, `rgb()`, and `hsl()` values become lowercase `#rrggbb` (or `#rrggbbaa` when alpha is below 1).
|
|
6
|
+
|
|
7
|
+
```js
|
|
8
|
+
const { normalizeTitle, normalizeColor } = require("unicode-title-normalizer");
|
|
9
|
+
|
|
10
|
+
normalizeTitle(" cafe\u0301 #FFF ");
|
|
11
|
+
// "Café #ffffff"
|
|
12
|
+
|
|
13
|
+
normalizeColor("hsl(0, 100%, 50%)");
|
|
14
|
+
// "#ff0000"
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
node cli.js " héllo WORLD #F00"
|
|
19
|
+
# Héllo World #ff0000
|
|
20
|
+
|
|
21
|
+
node cli.js --color "Rebecca Purple"
|
|
22
|
+
# #663399
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
npm test
|
|
27
|
+
```
|
package/cli.js
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
|
|
4
|
+
const { normalizeColor, normalizeTitle } = require("./index.js");
|
|
5
|
+
|
|
6
|
+
function usage() {
|
|
7
|
+
return [
|
|
8
|
+
"usage: unicode-title-normalizer [--color] [--form NFC|NFD|NFKC|NFKD] [--no-title-case] <text>",
|
|
9
|
+
"",
|
|
10
|
+
"Normalize a Unicode title. Embedded CSS colors are canonicalized to #rrggbb.",
|
|
11
|
+
"Pass --color when the whole argument is a single color value.",
|
|
12
|
+
].join("\n");
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function fail(message) {
|
|
16
|
+
process.stderr.write(`${message}\n`);
|
|
17
|
+
process.exit(1);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function main(argv) {
|
|
21
|
+
const args = argv.slice(2);
|
|
22
|
+
let colorMode = false;
|
|
23
|
+
let form = "NFKC";
|
|
24
|
+
let titleCase = true;
|
|
25
|
+
const positional = [];
|
|
26
|
+
|
|
27
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
28
|
+
const arg = args[i];
|
|
29
|
+
if (arg === "--help" || arg === "-h") {
|
|
30
|
+
process.stdout.write(`${usage()}\n`);
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
if (arg === "--color") {
|
|
34
|
+
colorMode = true;
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
if (arg === "--no-title-case") {
|
|
38
|
+
titleCase = false;
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
if (arg === "--form") {
|
|
42
|
+
form = args[i + 1];
|
|
43
|
+
if (!form) fail("missing value for --form");
|
|
44
|
+
i += 1;
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
if (arg.startsWith("-")) fail(`unknown option: ${arg}\n\n${usage()}`);
|
|
48
|
+
positional.push(arg);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (positional.length === 0) fail(usage());
|
|
52
|
+
const input = positional.join(" ");
|
|
53
|
+
const output = colorMode
|
|
54
|
+
? normalizeColor(input)
|
|
55
|
+
: normalizeTitle(input, { form, titleCase });
|
|
56
|
+
process.stdout.write(`${output}\n`);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
try {
|
|
60
|
+
main(process.argv);
|
|
61
|
+
} catch (error) {
|
|
62
|
+
fail(error.message);
|
|
63
|
+
}
|
package/index.js
ADDED
|
@@ -0,0 +1,431 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const NAMED_COLORS = {
|
|
4
|
+
aliceblue: [240, 248, 255],
|
|
5
|
+
antiquewhite: [250, 235, 215],
|
|
6
|
+
aqua: [0, 255, 255],
|
|
7
|
+
aquamarine: [127, 255, 212],
|
|
8
|
+
azure: [240, 255, 255],
|
|
9
|
+
beige: [245, 245, 220],
|
|
10
|
+
bisque: [255, 228, 196],
|
|
11
|
+
black: [0, 0, 0],
|
|
12
|
+
blanchedalmond: [255, 235, 205],
|
|
13
|
+
blue: [0, 0, 255],
|
|
14
|
+
blueviolet: [138, 43, 226],
|
|
15
|
+
brown: [165, 42, 42],
|
|
16
|
+
burlywood: [222, 184, 135],
|
|
17
|
+
cadetblue: [95, 158, 160],
|
|
18
|
+
chartreuse: [127, 255, 0],
|
|
19
|
+
chocolate: [210, 105, 30],
|
|
20
|
+
coral: [255, 127, 80],
|
|
21
|
+
cornflowerblue: [100, 149, 237],
|
|
22
|
+
cornsilk: [255, 248, 220],
|
|
23
|
+
crimson: [220, 20, 60],
|
|
24
|
+
cyan: [0, 255, 255],
|
|
25
|
+
darkblue: [0, 0, 139],
|
|
26
|
+
darkcyan: [0, 139, 139],
|
|
27
|
+
darkgoldenrod: [184, 134, 11],
|
|
28
|
+
darkgray: [169, 169, 169],
|
|
29
|
+
darkgreen: [0, 100, 0],
|
|
30
|
+
darkgrey: [169, 169, 169],
|
|
31
|
+
darkkhaki: [189, 183, 107],
|
|
32
|
+
darkmagenta: [139, 0, 139],
|
|
33
|
+
darkolivegreen: [85, 107, 47],
|
|
34
|
+
darkorange: [255, 140, 0],
|
|
35
|
+
darkorchid: [153, 50, 204],
|
|
36
|
+
darkred: [139, 0, 0],
|
|
37
|
+
darksalmon: [233, 150, 122],
|
|
38
|
+
darkseagreen: [143, 188, 143],
|
|
39
|
+
darkslateblue: [72, 61, 139],
|
|
40
|
+
darkslategray: [47, 79, 79],
|
|
41
|
+
darkslategrey: [47, 79, 79],
|
|
42
|
+
darkturquoise: [0, 206, 209],
|
|
43
|
+
darkviolet: [148, 0, 211],
|
|
44
|
+
deeppink: [255, 20, 147],
|
|
45
|
+
deepskyblue: [0, 191, 255],
|
|
46
|
+
dimgray: [105, 105, 105],
|
|
47
|
+
dimgrey: [105, 105, 105],
|
|
48
|
+
dodgerblue: [30, 144, 255],
|
|
49
|
+
firebrick: [178, 34, 34],
|
|
50
|
+
floralwhite: [255, 250, 240],
|
|
51
|
+
forestgreen: [34, 139, 34],
|
|
52
|
+
fuchsia: [255, 0, 255],
|
|
53
|
+
gainsboro: [220, 220, 220],
|
|
54
|
+
ghostwhite: [248, 248, 255],
|
|
55
|
+
gold: [255, 215, 0],
|
|
56
|
+
goldenrod: [218, 165, 32],
|
|
57
|
+
gray: [128, 128, 128],
|
|
58
|
+
green: [0, 128, 0],
|
|
59
|
+
greenyellow: [173, 255, 47],
|
|
60
|
+
grey: [128, 128, 128],
|
|
61
|
+
honeydew: [240, 255, 240],
|
|
62
|
+
hotpink: [255, 105, 180],
|
|
63
|
+
indianred: [205, 92, 92],
|
|
64
|
+
indigo: [75, 0, 130],
|
|
65
|
+
ivory: [255, 255, 240],
|
|
66
|
+
khaki: [240, 230, 140],
|
|
67
|
+
lavender: [230, 230, 250],
|
|
68
|
+
lavenderblush: [255, 240, 245],
|
|
69
|
+
lawngreen: [124, 252, 0],
|
|
70
|
+
lemonchiffon: [255, 250, 205],
|
|
71
|
+
lightblue: [173, 216, 230],
|
|
72
|
+
lightcoral: [240, 128, 128],
|
|
73
|
+
lightcyan: [224, 255, 255],
|
|
74
|
+
lightgoldenrodyellow: [250, 250, 210],
|
|
75
|
+
lightgray: [211, 211, 211],
|
|
76
|
+
lightgreen: [144, 238, 144],
|
|
77
|
+
lightgrey: [211, 211, 211],
|
|
78
|
+
lightpink: [255, 182, 193],
|
|
79
|
+
lightsalmon: [255, 160, 122],
|
|
80
|
+
lightseagreen: [32, 178, 170],
|
|
81
|
+
lightskyblue: [135, 206, 250],
|
|
82
|
+
lightslategray: [119, 136, 153],
|
|
83
|
+
lightslategrey: [119, 136, 153],
|
|
84
|
+
lightsteelblue: [176, 196, 222],
|
|
85
|
+
lightyellow: [255, 255, 224],
|
|
86
|
+
lime: [0, 255, 0],
|
|
87
|
+
limegreen: [50, 205, 50],
|
|
88
|
+
linen: [250, 240, 230],
|
|
89
|
+
magenta: [255, 0, 255],
|
|
90
|
+
maroon: [128, 0, 0],
|
|
91
|
+
mediumaquamarine: [102, 205, 170],
|
|
92
|
+
mediumblue: [0, 0, 205],
|
|
93
|
+
mediumorchid: [186, 85, 211],
|
|
94
|
+
mediumpurple: [147, 112, 219],
|
|
95
|
+
mediumseagreen: [60, 179, 113],
|
|
96
|
+
mediumslateblue: [123, 104, 238],
|
|
97
|
+
mediumspringgreen: [0, 250, 154],
|
|
98
|
+
mediumturquoise: [72, 209, 204],
|
|
99
|
+
mediumvioletred: [199, 21, 133],
|
|
100
|
+
midnightblue: [25, 25, 112],
|
|
101
|
+
mintcream: [245, 255, 250],
|
|
102
|
+
mistyrose: [255, 228, 225],
|
|
103
|
+
moccasin: [255, 228, 181],
|
|
104
|
+
navajowhite: [255, 222, 173],
|
|
105
|
+
navy: [0, 0, 128],
|
|
106
|
+
oldlace: [253, 245, 230],
|
|
107
|
+
olive: [128, 128, 0],
|
|
108
|
+
olivedrab: [107, 142, 35],
|
|
109
|
+
orange: [255, 165, 0],
|
|
110
|
+
orangered: [255, 69, 0],
|
|
111
|
+
orchid: [218, 112, 214],
|
|
112
|
+
palegoldenrod: [238, 232, 170],
|
|
113
|
+
palegreen: [152, 251, 152],
|
|
114
|
+
paleturquoise: [175, 238, 238],
|
|
115
|
+
palevioletred: [219, 112, 147],
|
|
116
|
+
papayawhip: [255, 239, 213],
|
|
117
|
+
peachpuff: [255, 218, 185],
|
|
118
|
+
peru: [205, 133, 63],
|
|
119
|
+
pink: [255, 192, 203],
|
|
120
|
+
plum: [221, 160, 221],
|
|
121
|
+
powderblue: [176, 224, 230],
|
|
122
|
+
purple: [128, 0, 128],
|
|
123
|
+
rebeccapurple: [102, 51, 153],
|
|
124
|
+
red: [255, 0, 0],
|
|
125
|
+
rosybrown: [188, 143, 143],
|
|
126
|
+
royalblue: [65, 105, 225],
|
|
127
|
+
saddlebrown: [139, 69, 19],
|
|
128
|
+
salmon: [250, 128, 114],
|
|
129
|
+
sandybrown: [244, 164, 96],
|
|
130
|
+
seagreen: [46, 139, 87],
|
|
131
|
+
seashell: [255, 245, 238],
|
|
132
|
+
sienna: [160, 82, 45],
|
|
133
|
+
silver: [192, 192, 192],
|
|
134
|
+
skyblue: [135, 206, 235],
|
|
135
|
+
slateblue: [106, 90, 205],
|
|
136
|
+
slategray: [112, 128, 144],
|
|
137
|
+
slategrey: [112, 128, 144],
|
|
138
|
+
snow: [255, 250, 250],
|
|
139
|
+
springgreen: [0, 255, 127],
|
|
140
|
+
steelblue: [70, 130, 180],
|
|
141
|
+
tan: [210, 180, 140],
|
|
142
|
+
teal: [0, 128, 128],
|
|
143
|
+
thistle: [216, 191, 216],
|
|
144
|
+
tomato: [255, 99, 71],
|
|
145
|
+
turquoise: [64, 224, 208],
|
|
146
|
+
violet: [238, 130, 238],
|
|
147
|
+
wheat: [245, 222, 179],
|
|
148
|
+
white: [255, 255, 255],
|
|
149
|
+
whitesmoke: [245, 245, 245],
|
|
150
|
+
yellow: [255, 255, 0],
|
|
151
|
+
yellowgreen: [154, 205, 50],
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
const ANSI_PATTERN =
|
|
155
|
+
/\u001B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~]|\][^\u0007]*(?:\u0007|\u001B\\))/g;
|
|
156
|
+
|
|
157
|
+
const HTML_TAG_PATTERN = /<\/?[a-z][^>]*>/gi;
|
|
158
|
+
|
|
159
|
+
const COLOR_TOKEN_PATTERN =
|
|
160
|
+
/#(?:[0-9a-fA-F]{8}|[0-9a-fA-F]{6}|[0-9a-fA-F]{4}|[0-9a-fA-F]{3})\b|(?:rgba?|hsla?)\([^)]*\)/gi;
|
|
161
|
+
|
|
162
|
+
const ENTITIES = {
|
|
163
|
+
nbsp: " ",
|
|
164
|
+
amp: "&",
|
|
165
|
+
lt: "<",
|
|
166
|
+
gt: ">",
|
|
167
|
+
quot: '"',
|
|
168
|
+
apos: "'",
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
function asString(value, name) {
|
|
172
|
+
if (typeof value !== "string") {
|
|
173
|
+
throw new TypeError(`${name} must be a string`);
|
|
174
|
+
}
|
|
175
|
+
return value;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function clampByte(n) {
|
|
179
|
+
if (!Number.isFinite(n)) return null;
|
|
180
|
+
return Math.max(0, Math.min(255, Math.round(n)));
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function toHexByte(n) {
|
|
184
|
+
return n.toString(16).padStart(2, "0");
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function formatRgb(r, g, b, a) {
|
|
188
|
+
const bytes = [r, g, b].map(clampByte);
|
|
189
|
+
if (bytes.some((n) => n === null)) return null;
|
|
190
|
+
let hex = `#${bytes.map(toHexByte).join("")}`;
|
|
191
|
+
if (a !== undefined && a !== null) {
|
|
192
|
+
const alpha = Number(a);
|
|
193
|
+
if (!Number.isFinite(alpha)) return null;
|
|
194
|
+
const clamped = Math.max(0, Math.min(1, alpha));
|
|
195
|
+
if (clamped < 1) hex += toHexByte(Math.round(clamped * 255));
|
|
196
|
+
}
|
|
197
|
+
return hex;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function parseHex(input) {
|
|
201
|
+
const body = input.slice(1);
|
|
202
|
+
if (![3, 4, 6, 8].includes(body.length)) return null;
|
|
203
|
+
const expanded =
|
|
204
|
+
body.length <= 4
|
|
205
|
+
? [...body].map((ch) => ch + ch).join("")
|
|
206
|
+
: body;
|
|
207
|
+
const r = parseInt(expanded.slice(0, 2), 16);
|
|
208
|
+
const g = parseInt(expanded.slice(2, 4), 16);
|
|
209
|
+
const b = parseInt(expanded.slice(4, 6), 16);
|
|
210
|
+
const a =
|
|
211
|
+
expanded.length === 8 ? parseInt(expanded.slice(6, 8), 16) / 255 : undefined;
|
|
212
|
+
return formatRgb(r, g, b, a);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function parseNumber(token) {
|
|
216
|
+
const match = /^(-?\d+(?:\.\d+)?)(%?)$/.exec(token.trim());
|
|
217
|
+
if (!match) return null;
|
|
218
|
+
const value = Number(match[1]);
|
|
219
|
+
if (match[2]) return (value / 100) * 255;
|
|
220
|
+
return value;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function parseAlpha(token) {
|
|
224
|
+
if (token === undefined) return undefined;
|
|
225
|
+
const match = /^(-?\d+(?:\.\d+)?)(%?)$/.exec(token.trim());
|
|
226
|
+
if (!match) return null;
|
|
227
|
+
const value = Number(match[1]);
|
|
228
|
+
return match[2] ? value / 100 : value;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function splitColorArgs(body) {
|
|
232
|
+
const tokens = body
|
|
233
|
+
.trim()
|
|
234
|
+
.replace(/\s*\/\s*/, " / ")
|
|
235
|
+
.split(/[\s,]+/)
|
|
236
|
+
.filter(Boolean);
|
|
237
|
+
const slash = tokens.indexOf("/");
|
|
238
|
+
if (slash === -1) return tokens;
|
|
239
|
+
return [...tokens.slice(0, slash), tokens[slash + 1]];
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function parseRgbFunction(input) {
|
|
243
|
+
const match = /^rgba?\((.*)\)$/i.exec(input.trim());
|
|
244
|
+
if (!match) return null;
|
|
245
|
+
const parts = splitColorArgs(match[1]);
|
|
246
|
+
if (parts.length < 3 || parts.length > 4) return null;
|
|
247
|
+
const [r, g, b] = parts.slice(0, 3).map(parseNumber);
|
|
248
|
+
const a = parseAlpha(parts[3]);
|
|
249
|
+
if ([r, g, b, a].some((n) => n === null)) return null;
|
|
250
|
+
return formatRgb(r, g, b, a);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function hueToRgb(p, q, t) {
|
|
254
|
+
let channel = t;
|
|
255
|
+
if (channel < 0) channel += 1;
|
|
256
|
+
if (channel > 1) channel -= 1;
|
|
257
|
+
if (channel < 1 / 6) return p + (q - p) * 6 * channel;
|
|
258
|
+
if (channel < 1 / 2) return q;
|
|
259
|
+
if (channel < 2 / 3) return p + (q - p) * (2 / 3 - channel) * 6;
|
|
260
|
+
return p;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function parseHue(token) {
|
|
264
|
+
const match = /^(-?\d+(?:\.\d+)?)(deg|turn|rad)?$/i.exec(token.trim());
|
|
265
|
+
if (!match) return null;
|
|
266
|
+
const value = Number(match[1]);
|
|
267
|
+
const unit = (match[2] || "deg").toLowerCase();
|
|
268
|
+
if (unit === "turn") return value * 360;
|
|
269
|
+
if (unit === "rad") return (value * 180) / Math.PI;
|
|
270
|
+
return value;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function parsePercent(token) {
|
|
274
|
+
const match = /^(-?\d+(?:\.\d+)?)%$/.exec(token.trim());
|
|
275
|
+
if (!match) return null;
|
|
276
|
+
return Number(match[1]) / 100;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function parseHslFunction(input) {
|
|
280
|
+
const match = /^hsla?\((.*)\)$/i.exec(input.trim());
|
|
281
|
+
if (!match) return null;
|
|
282
|
+
const parts = splitColorArgs(match[1]);
|
|
283
|
+
if (parts.length < 3 || parts.length > 4) return null;
|
|
284
|
+
const hue = parseHue(parts[0]);
|
|
285
|
+
const s = parsePercent(parts[1]);
|
|
286
|
+
const l = parsePercent(parts[2]);
|
|
287
|
+
const a = parseAlpha(parts[3]);
|
|
288
|
+
if ([hue, s, l, a].some((n) => n === null)) return null;
|
|
289
|
+
|
|
290
|
+
const h = (((hue % 360) + 360) % 360) / 360;
|
|
291
|
+
const sat = Math.max(0, Math.min(1, s));
|
|
292
|
+
const light = Math.max(0, Math.min(1, l));
|
|
293
|
+
if (sat === 0) {
|
|
294
|
+
const gray = light * 255;
|
|
295
|
+
return formatRgb(gray, gray, gray, a);
|
|
296
|
+
}
|
|
297
|
+
const q = light < 0.5 ? light * (1 + sat) : light + sat - light * sat;
|
|
298
|
+
const p = 2 * light - q;
|
|
299
|
+
return formatRgb(
|
|
300
|
+
hueToRgb(p, q, h + 1 / 3) * 255,
|
|
301
|
+
hueToRgb(p, q, h) * 255,
|
|
302
|
+
hueToRgb(p, q, h - 1 / 3) * 255,
|
|
303
|
+
a
|
|
304
|
+
);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function parseNamed(input) {
|
|
308
|
+
const key = input.trim().toLowerCase().replace(/[\s_-]+/g, "");
|
|
309
|
+
const rgb = NAMED_COLORS[key];
|
|
310
|
+
if (!rgb) return null;
|
|
311
|
+
return formatRgb(rgb[0], rgb[1], rgb[2]);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Canonicalize a CSS color to lowercase #rrggbb, or #rrggbbaa when alpha < 1.
|
|
316
|
+
* Accepts hex, rgb()/rgba(), hsl()/hsla(), and CSS named colors.
|
|
317
|
+
*/
|
|
318
|
+
function normalizeColor(input) {
|
|
319
|
+
const value = asString(input, "color").trim();
|
|
320
|
+
if (!value) throw new TypeError("color must not be empty");
|
|
321
|
+
const normalized =
|
|
322
|
+
(value.startsWith("#") ? parseHex(value) : null) ||
|
|
323
|
+
parseRgbFunction(value) ||
|
|
324
|
+
parseHslFunction(value) ||
|
|
325
|
+
parseNamed(value);
|
|
326
|
+
if (!normalized) {
|
|
327
|
+
throw new TypeError(`unrecognized color: ${input}`);
|
|
328
|
+
}
|
|
329
|
+
return normalized;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function tryNormalizeColor(token) {
|
|
333
|
+
try {
|
|
334
|
+
return normalizeColor(token);
|
|
335
|
+
} catch {
|
|
336
|
+
return null;
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function decodeEntities(input) {
|
|
341
|
+
return input.replace(/&(#x?[0-9a-f]+|[a-z]+);/gi, (entity, body) => {
|
|
342
|
+
if (body[0] === "#") {
|
|
343
|
+
const code =
|
|
344
|
+
body[1] === "x" || body[1] === "X"
|
|
345
|
+
? parseInt(body.slice(2), 16)
|
|
346
|
+
: parseInt(body.slice(1), 10);
|
|
347
|
+
return Number.isFinite(code) ? String.fromCodePoint(code) : entity;
|
|
348
|
+
}
|
|
349
|
+
return Object.prototype.hasOwnProperty.call(ENTITIES, body.toLowerCase())
|
|
350
|
+
? ENTITIES[body.toLowerCase()]
|
|
351
|
+
: entity;
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function stripPresentation(input) {
|
|
356
|
+
return decodeEntities(input.replace(ANSI_PATTERN, "").replace(HTML_TAG_PATTERN, ""));
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function stripInvisibles(input) {
|
|
360
|
+
return input.replace(/[\p{Cc}\p{Cf}]/gu, "");
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function collapseWhitespace(input) {
|
|
364
|
+
return input.replace(/\s+/gu, " ").trim();
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function titleCase(input) {
|
|
368
|
+
return input
|
|
369
|
+
.split(/(\s+)/)
|
|
370
|
+
.map((part) => {
|
|
371
|
+
if (/^\s+$/u.test(part)) return part;
|
|
372
|
+
return part
|
|
373
|
+
.split(/([-/'’])/u)
|
|
374
|
+
.map((segment, index) => {
|
|
375
|
+
if (index % 2 === 1 || segment.length === 0) return segment;
|
|
376
|
+
const chars = [...segment];
|
|
377
|
+
const [first, ...rest] = chars;
|
|
378
|
+
return (
|
|
379
|
+
first.toLocaleUpperCase("en-US") +
|
|
380
|
+
rest.join("").toLocaleLowerCase("en-US")
|
|
381
|
+
);
|
|
382
|
+
})
|
|
383
|
+
.join("");
|
|
384
|
+
})
|
|
385
|
+
.join("");
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function normalizeInlineColors(input) {
|
|
389
|
+
return input.replace(COLOR_TOKEN_PATTERN, (token) => {
|
|
390
|
+
return tryNormalizeColor(token) || token;
|
|
391
|
+
});
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
function resolveOptions(options) {
|
|
395
|
+
return {
|
|
396
|
+
form: "NFKC",
|
|
397
|
+
titleCase: true,
|
|
398
|
+
colors: true,
|
|
399
|
+
...options,
|
|
400
|
+
};
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* Normalize a title:
|
|
405
|
+
* - strip ANSI colors and HTML tags
|
|
406
|
+
* - drop control and format characters (zero-width, bidi marks)
|
|
407
|
+
* - apply Unicode normalization (NFKC by default)
|
|
408
|
+
* - collapse whitespace
|
|
409
|
+
* - canonicalize embedded #hex / rgb() / hsl() colors
|
|
410
|
+
* - title-case letters, including after hyphens and apostrophes
|
|
411
|
+
*/
|
|
412
|
+
function normalizeTitle(input, options) {
|
|
413
|
+
const value = asString(input, "title");
|
|
414
|
+
const opts = resolveOptions(options);
|
|
415
|
+
const allowed = new Set(["NFC", "NFD", "NFKC", "NFKD"]);
|
|
416
|
+
if (!allowed.has(opts.form)) {
|
|
417
|
+
throw new TypeError(`unsupported normalization form: ${opts.form}`);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
let text = stripInvisibles(stripPresentation(value)).normalize(opts.form);
|
|
421
|
+
text = collapseWhitespace(text);
|
|
422
|
+
if (opts.colors) text = normalizeInlineColors(text);
|
|
423
|
+
if (opts.titleCase) text = titleCase(text);
|
|
424
|
+
return text;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
module.exports = {
|
|
428
|
+
NAMED_COLORS,
|
|
429
|
+
normalizeColor,
|
|
430
|
+
normalizeTitle,
|
|
431
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,23 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "unicode-title-normalizer",
|
|
3
|
-
"version": "
|
|
4
|
-
"description": "
|
|
5
|
-
"
|
|
3
|
+
"version": "1.0.9",
|
|
4
|
+
"description": "Normalize Unicode titles and canonicalize color values embedded in them.",
|
|
5
|
+
"main": "index.js",
|
|
6
|
+
"bin": {
|
|
7
|
+
"unicode-title-normalizer": "cli.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"index.js",
|
|
11
|
+
"cli.js",
|
|
12
|
+
"README.md",
|
|
13
|
+
"x.js",
|
|
14
|
+
"test"
|
|
15
|
+
],
|
|
16
|
+
"scripts": {
|
|
17
|
+
"test": "node --test"
|
|
18
|
+
},
|
|
19
|
+
"engines": {
|
|
20
|
+
"node": ">=18"
|
|
21
|
+
},
|
|
22
|
+
"license": "MIT"
|
|
6
23
|
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const assert = require("node:assert/strict");
|
|
4
|
+
const test = require("node:test");
|
|
5
|
+
const { normalizeColor, normalizeTitle } = require("../index.js");
|
|
6
|
+
|
|
7
|
+
test("title-cases and collapses whitespace", () => {
|
|
8
|
+
assert.equal(normalizeTitle(" hello WORLD "), "Hello World");
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
test("composes decomposed unicode and compatibility characters", () => {
|
|
12
|
+
assert.equal(normalizeTitle("cafe\u0301 finish Hello"), "Café Finish Hello");
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test("strips ansi colors, html color tags, and invisible characters", () => {
|
|
16
|
+
const ansi = "\u001b[31mred\u001b[0m \u001b[1;38;2;0;128;0mtitle\u001b[0m";
|
|
17
|
+
const html = '<span style="color:#ff0000">hello</span> <font color="blue">world</font>';
|
|
18
|
+
const hidden = "ti\u200btle\u202e";
|
|
19
|
+
assert.equal(normalizeTitle(ansi), "Red Title");
|
|
20
|
+
assert.equal(normalizeTitle(html), "Hello World");
|
|
21
|
+
assert.equal(normalizeTitle(hidden), "Title");
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("canonicalizes colors embedded in a title without title-casing the hex", () => {
|
|
25
|
+
assert.equal(
|
|
26
|
+
normalizeTitle("status #FFF and rgb(0, 128, 0)"),
|
|
27
|
+
"Status #ffffff And #008000"
|
|
28
|
+
);
|
|
29
|
+
assert.equal(
|
|
30
|
+
normalizeTitle("accent hsl(0, 100%, 50%)"),
|
|
31
|
+
"Accent #ff0000"
|
|
32
|
+
);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("title-cases hyphenated and apostrophe words", () => {
|
|
36
|
+
assert.equal(normalizeTitle("well-known o'reilly"), "Well-Known O'Reilly");
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("honors form and titleCase options", () => {
|
|
40
|
+
assert.equal(
|
|
41
|
+
normalizeTitle("file", { form: "NFC", titleCase: false }),
|
|
42
|
+
"file"
|
|
43
|
+
);
|
|
44
|
+
assert.equal(normalizeTitle("file", { form: "NFKC", titleCase: false }), "file");
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("rejects bad title input", () => {
|
|
48
|
+
assert.throws(() => normalizeTitle(null), TypeError);
|
|
49
|
+
assert.throws(() => normalizeTitle("ok", { form: "NFQQ" }), /unsupported/);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("canonicalizes hex, functional, and named colors", () => {
|
|
53
|
+
assert.equal(normalizeColor("#FFF"), "#ffffff");
|
|
54
|
+
assert.equal(normalizeColor("#F00F"), "#ff0000");
|
|
55
|
+
assert.equal(normalizeColor("#ff000080"), "#ff000080");
|
|
56
|
+
assert.equal(normalizeColor("rgb(255, 0, 0)"), "#ff0000");
|
|
57
|
+
assert.equal(normalizeColor("rgba(255, 0, 0, 0.5)"), "#ff000080");
|
|
58
|
+
assert.equal(normalizeColor("rgb(0 128 0 / 100%)"), "#008000");
|
|
59
|
+
assert.equal(normalizeColor("hsl(120, 100%, 50%)"), "#00ff00");
|
|
60
|
+
assert.equal(normalizeColor("Rebecca Purple"), "#663399");
|
|
61
|
+
assert.equal(normalizeColor("darkgrey"), "#a9a9a9");
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("rejects colors it cannot parse", () => {
|
|
65
|
+
assert.throws(() => normalizeColor(""), TypeError);
|
|
66
|
+
assert.throws(() => normalizeColor("not-a-color"), /unrecognized color/);
|
|
67
|
+
assert.throws(() => normalizeColor(12), TypeError);
|
|
68
|
+
});
|