@astryxdesign/core 0.5.0-canary.b0a09a6 → 0.5.0-canary.b79f0db

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.
@@ -7,9 +7,21 @@
7
7
  * People paste numbers out of spreadsheets, and a spreadsheet writes them
8
8
  * grouped, signed, and decorated. This reads those back. Everything locale
9
9
  * specific — the grouping and decimal separators, the group sizes, the digit
10
- * script — is derived from Intl and Unicode rather than a table, and anything
11
- * that could mean two different numbers returns null so the field stays
12
- * visibly invalid instead of committing a guess.
10
+ * script — is derived from Intl and Unicode rather than a table, and text that
11
+ * no reading fits returns null so the field stays visibly invalid instead of
12
+ * committing a guess. Where both readings are well formed the locale's wins,
13
+ * the way utils/dateParser.ts settles an ambiguous date by locale preference.
14
+ * Grouping is well formed only when every group fits the locale's own sizes:
15
+ * the last exactly the primary size, each inner one exactly the secondary, the
16
+ * first no longer than that. So de-DE `1.234` is 1234, while `1.2345` (last
17
+ * group too long) and `1234.567` (too much ahead of it) keep the full stop as
18
+ * a decimal point. Groups of three fit every locale, so a separator repeated
19
+ * with three-digit groups is grouping in all of them and `1,234,567` reads the
20
+ * same everywhere; for a space or apostrophe, which no locale writes as a
21
+ * decimal point, one occurrence is enough, so en-US `1 234` is 1234.
22
+ *
23
+ * That paragraph is executable: numberParser.docblock.test.ts implements it as
24
+ * code, quote by quote, and fails when the parser and the words disagree.
13
25
  *
14
26
  * Which characters may separate digits at all is the one thing that is NOT
15
27
  * read off the input: SEPARATOR_CHARS below is a bounded alphabet, the same
@@ -18,6 +30,7 @@
18
30
  * SYNC: When modified, update these files to stay in sync:
19
31
  * - /packages/core/src/NumberInput/NumberInput.tsx
20
32
  * - /packages/core/src/NumberInput/numberParser.test.ts
33
+ * - /packages/core/src/NumberInput/numberParser.docblock.test.ts
21
34
  */
22
35
  import type { Locale } from '../i18n';
23
36
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"numberParser.d.ts","sourceRoot":"","sources":["../../src/NumberInput/numberParser.ts"],"names":[],"mappings":"AAEA;;;;;;;;;;;;;;;;;;;;GAoBG;AAEH,OAAO,KAAK,EAAC,MAAM,EAAC,MAAM,SAAS,CAAC;AA6OpC;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,wBAAgB,iBAAiB,CAC/B,IAAI,EAAE,MAAM,EACZ,MAAM,CAAC,EAAE,MAAM,GACd,MAAM,GAAG,IAAI,CA8Ef;AAaD;;;;GAIG;AACH,wBAAgB,oBAAoB,CAAC,KAAK,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM,GAAG,MAAM,CAI3E"}
1
+ {"version":3,"file":"numberParser.d.ts","sourceRoot":"","sources":["../../src/NumberInput/numberParser.ts"],"names":[],"mappings":"AAEA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiCG;AAEH,OAAO,KAAK,EAAC,MAAM,EAAC,MAAM,SAAS,CAAC;AAiPpC;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,wBAAgB,iBAAiB,CAC/B,IAAI,EAAE,MAAM,EACZ,MAAM,CAAC,EAAE,MAAM,GACd,MAAM,GAAG,IAAI,CA8Ef;AAaD;;;;GAIG;AACH,wBAAgB,oBAAoB,CAAC,KAAK,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM,GAAG,MAAM,CAI3E"}
@@ -9,9 +9,21 @@
9
9
  * People paste numbers out of spreadsheets, and a spreadsheet writes them
10
10
  * grouped, signed, and decorated. This reads those back. Everything locale
11
11
  * specific — the grouping and decimal separators, the group sizes, the digit
12
- * script — is derived from Intl and Unicode rather than a table, and anything
13
- * that could mean two different numbers returns null so the field stays
14
- * visibly invalid instead of committing a guess.
12
+ * script — is derived from Intl and Unicode rather than a table, and text that
13
+ * no reading fits returns null so the field stays visibly invalid instead of
14
+ * committing a guess. Where both readings are well formed the locale's wins,
15
+ * the way utils/dateParser.ts settles an ambiguous date by locale preference.
16
+ * Grouping is well formed only when every group fits the locale's own sizes:
17
+ * the last exactly the primary size, each inner one exactly the secondary, the
18
+ * first no longer than that. So de-DE `1.234` is 1234, while `1.2345` (last
19
+ * group too long) and `1234.567` (too much ahead of it) keep the full stop as
20
+ * a decimal point. Groups of three fit every locale, so a separator repeated
21
+ * with three-digit groups is grouping in all of them and `1,234,567` reads the
22
+ * same everywhere; for a space or apostrophe, which no locale writes as a
23
+ * decimal point, one occurrence is enough, so en-US `1 234` is 1234.
24
+ *
25
+ * That paragraph is executable: numberParser.docblock.test.ts implements it as
26
+ * code, quote by quote, and fails when the parser and the words disagree.
15
27
  *
16
28
  * Which characters may separate digits at all is the one thing that is NOT
17
29
  * read off the input: SEPARATOR_CHARS below is a bounded alphabet, the same
@@ -20,6 +32,7 @@
20
32
  * SYNC: When modified, update these files to stay in sync:
21
33
  * - /packages/core/src/NumberInput/NumberInput.tsx
22
34
  * - /packages/core/src/NumberInput/numberParser.test.ts
35
+ * - /packages/core/src/NumberInput/numberParser.docblock.test.ts
23
36
  */
24
37
 
25
38
  const symbolsByLocale = new Map();
@@ -64,15 +77,19 @@ const INVISIBLES = /[\u200B-\u200D\u200E\u200F\u061C\u2066-\u2069\uFEFF]/g;
64
77
  const MINUS_SIGNS = '\u2212\u2012\u2013-';
65
78
 
66
79
  /**
67
- * Every character a locale writes between digits: comma, full stop, space
68
- * (the whole space family folds to it under NFKC), the Swiss apostrophes, the
69
- * Catalan middle dot, and the Arabic decimal and thousands separators.
80
+ * Every character a locale writes between digits. Sweeping every language ICU
81
+ * resolves, in every region, yields seven: comma, full stop, apostrophe,
82
+ * space (the whole space family folds to it under NFKC), the Arabic decimal
83
+ * and thousands separators, and U+060C — which only nqo writes, and which
84
+ * this alphabet leaves out. U+2019 is here in its place: no locale writes it,
85
+ * but the corpus pins it as the Swiss spelling.
70
86
  *
71
87
  * The alphabet is bounded on purpose. Read the candidates off the input
72
88
  * instead and any repeated character is grouping, so a hyphenated ID commits
73
- * as a number.
89
+ * as a number. It bounds characters and not shapes: a full stop is a real
90
+ * separator, so `192.168.100.200` is four well-formed groups.
74
91
  */
75
- const SEPARATOR_CHARS = ",.' \u2019\u00B7\u066B\u066C";
92
+ const SEPARATOR_CHARS = ",.' \u2019\u066B\u066C";
76
93
  const NUMBER_BODY = new RegExp(`^[0-9${SEPARATOR_CHARS}]+$`);
77
94
  /** No grouping and at most one full stop: the format `Number()` reads. */
78
95
  const MACHINE_NUMBER = /^(?:\d+\.?\d*|\.\d+)$/;
@@ -167,7 +184,7 @@ function readUnder(core, group, decimal, primaryGroupSize, secondaryGroupSize, m
167
184
  * beats the decimal-point reading.
168
185
  */
169
186
  function isGroupOnlySeparator(separator) {
170
- return /^[\s'\u2019\u00B7]$/.test(separator);
187
+ return /^[\s'\u2019]$/.test(separator);
171
188
  }
172
189
  function toPlainNumber(core, locale) {
173
190
  if (!NUMBER_BODY.test(core)) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astryxdesign/core",
3
- "version": "0.5.0-canary.b0a09a6",
3
+ "version": "0.5.0-canary.b79f0db",
4
4
  "displayName": "Astryx Core",
5
5
  "description": "The component library. Accessible, themeable React components with built-in spacing, dark mode, and StyleX styling.",
6
6
  "author": "Meta Open Source",
@@ -661,7 +661,7 @@
661
661
  "react-dom": ">=19.0.0"
662
662
  },
663
663
  "devDependencies": {
664
- "@astryxdesign/cli": "0.5.0-canary.b0a09a6",
664
+ "@astryxdesign/cli": "0.5.0-canary.b79f0db",
665
665
  "@babel/cli": "^8.0.4",
666
666
  "@babel/core": "^7.29.7",
667
667
  "@babel/preset-react": "^8.0.1",
@@ -0,0 +1,234 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file numberParser.docblock.test.ts
5
+ * @input The rule paragraph in numberParser.ts, and the parser itself
6
+ * @output Nothing; fails when the two disagree
7
+ * @position Test-only; grades the prose in numberParser.ts against the code
8
+ *
9
+ * The file docblock of numberParser.ts states the rule the parser follows.
10
+ * A sentence like that rots silently — the code moves and the words stay —
11
+ * and the next person to change this file reads the words. So the paragraph
12
+ * is executable here: `predict` implements it, clause by clause, and every
13
+ * input is graded prose-against-parser.
14
+ *
15
+ * The one discipline that makes the number mean anything: `predict` may use
16
+ * ONLY what the paragraph says. Every branch below quotes the clause it
17
+ * implements, and those quotes are asserted verbatim against the source, so
18
+ * a paragraph that loses a clause fails here instead of drifting. If the
19
+ * parser grows a rule the words do not mention, this test goes red — that is
20
+ * the point, not a defect.
21
+ *
22
+ * SYNC: When modified, update these files to stay in sync:
23
+ * - /packages/core/src/NumberInput/numberParser.ts
24
+ */
25
+
26
+ import {describe, it, expect} from 'vitest';
27
+ import {parseLocaleNumber} from './numberParser';
28
+ import SOURCE from './numberParser.ts?raw';
29
+
30
+ /** The docblock as running prose: comment markers and line breaks removed. */
31
+ const PROSE = SOURCE.replace(/^\s*\*\s?/gm, '').replace(/\s+/g, ' ');
32
+
33
+ /** Every clause `predict` is allowed to know, quoted from the docblock. */
34
+ const CLAUSES = {
35
+ 'defer to the alphabet': 'SEPARATOR_CHARS below is a bounded alphabet',
36
+ 'fold the space family': 'the whole space family folds to it under NFKC',
37
+ 'refuse what no reading fits': 'text that no reading fits returns null',
38
+ 'prefer the locale': 'Where both readings are well formed the locale',
39
+ 'size every group':
40
+ "Grouping is well formed only when every group fits the locale's own " +
41
+ 'sizes: the last exactly the primary size, each inner one exactly the ' +
42
+ 'secondary, the first no longer than that.',
43
+ 'fall back to the decimal point': 'keep the full stop as a decimal point',
44
+ 'read three-digit groups anywhere':
45
+ 'Groups of three fit every locale, so a separator repeated with ' +
46
+ 'three-digit groups is grouping in all of them',
47
+ 'need only one group-only occurrence':
48
+ 'for a space or apostrophe, which no locale writes as a decimal point, ' +
49
+ 'one occurrence is enough',
50
+ } as const;
51
+
52
+ /** The bounded alphabet the docblock defers to, read out of the source. */
53
+ const ALPHABET: ReadonlySet<string> = new Set(
54
+ JSON.parse(
55
+ `"${/const SEPARATOR_CHARS = "([^"]*)"/.exec(SOURCE)?.[1] ?? ''}"`,
56
+ ),
57
+ );
58
+
59
+ const symbolsCache = new Map<string, ReturnType<typeof readSymbols>>();
60
+ function readSymbols(locale: string) {
61
+ const parts = new Intl.NumberFormat(locale).formatToParts(12345.6);
62
+ const sizes = new Intl.NumberFormat(locale)
63
+ .formatToParts(11111111111)
64
+ .filter(p => p.type === 'integer')
65
+ .map(p => p.value.length);
66
+ const grouped = sizes.length > 1;
67
+ return {
68
+ group: (parts.find(p => p.type === 'group')?.value ?? ',').normalize(
69
+ 'NFKC',
70
+ ),
71
+ decimal: parts.find(p => p.type === 'decimal')?.value ?? '.',
72
+ primary: grouped ? sizes[sizes.length - 1] : 3,
73
+ secondary: grouped ? sizes[sizes.length - 2] : 3,
74
+ };
75
+ }
76
+ function symbolsOf(locale: string) {
77
+ let s = symbolsCache.get(locale);
78
+ if (s === undefined) {
79
+ s = readSymbols(locale);
80
+ symbolsCache.set(locale, s);
81
+ }
82
+ return s;
83
+ }
84
+
85
+ /** CLAUSES['size every group'], with the sizes it is handed. */
86
+ function groupsFit(chunks: string[], primary: number, secondary: number) {
87
+ return (
88
+ chunks[chunks.length - 1].length === primary &&
89
+ chunks[0].length <= secondary &&
90
+ chunks.slice(1, -1).every(chunk => chunk.length === secondary)
91
+ );
92
+ }
93
+
94
+ /** CLAUSES['need only one group-only occurrence']: "a space or apostrophe". */
95
+ const isSpaceOrApostrophe = (char: string) => /^[\s'\u2019]$/.test(char);
96
+
97
+ const SKIPPED = Symbol('outside the paragraph');
98
+
99
+ /**
100
+ * The paragraph, as code. Digits with one separator character repeated
101
+ * between them is the shape it describes; anything else is not this
102
+ * paragraph's subject and is skipped rather than guessed at.
103
+ */
104
+ function predict(input: string, locale: string): number | null | symbol {
105
+ // CLAUSES['fold the space family'].
106
+ const text = input.normalize('NFKC');
107
+ if (!/^\d/.test(text) || !/\d$/.test(text)) {
108
+ return SKIPPED;
109
+ }
110
+ const separators = [...new Set(text.replace(/\d/g, ''))];
111
+ if (separators.length !== 1) {
112
+ return SKIPPED;
113
+ }
114
+ const [separator] = separators;
115
+
116
+ // CLAUSES['defer to the alphabet'].
117
+ if (!ALPHABET.has(separator)) {
118
+ return null;
119
+ }
120
+
121
+ const chunks = text.split(separator);
122
+ const {group, decimal, primary, secondary} = symbolsOf(locale);
123
+
124
+ // CLAUSES['prefer the locale'] + CLAUSES['size every group']: of the two
125
+ // locale readings, grouping wins wherever it is well formed.
126
+ if (separator === group && groupsFit(chunks, primary, secondary)) {
127
+ return Number(chunks.join(''));
128
+ }
129
+ // The locale's other reading. A decimal point appears once.
130
+ if (separator === decimal && chunks.length === 2) {
131
+ return Number(`${chunks[0]}.${chunks[1]}`);
132
+ }
133
+ // CLAUSES['fall back to the decimal point'], for a full stop the locale
134
+ // could read neither way.
135
+ if (separator === '.' && chunks.length === 2) {
136
+ return Number(text);
137
+ }
138
+ // CLAUSES['read three-digit groups anywhere'], and CLAUSES['need only one
139
+ // group-only occurrence'] for how many occurrences it takes.
140
+ const enough = isSpaceOrApostrophe(separator) ? 1 : 2;
141
+ if (chunks.length - 1 >= enough && groupsFit(chunks, 3, 3)) {
142
+ return Number(chunks.join(''));
143
+ }
144
+ // CLAUSES['refuse what no reading fits'].
145
+ return null;
146
+ }
147
+
148
+ const LOCALES = ['en-US', 'de-DE', 'fr-FR', 'de-CH', 'en-IN', 'ar-SA'];
149
+
150
+ /** The shapes the paragraph names, plus the seams around each clause. */
151
+ const NAMED: [string, string][] = [
152
+ ['1.234', 'de-DE'],
153
+ ['1.2345', 'de-DE'],
154
+ ['1234.567', 'de-DE'],
155
+ ['12345.678', 'de-DE'],
156
+ ['12.345', 'de-DE'],
157
+ ['1.23', 'de-DE'],
158
+ ['1,234,567', 'en-US'],
159
+ ['1,234,567', 'de-DE'],
160
+ ['1.234.567', 'en-US'],
161
+ ['1234.567.890', 'de-DE'],
162
+ ['1 234', 'en-US'],
163
+ ['1\u00A0234', 'en-US'],
164
+ ['1 234 567', 'en-US'],
165
+ ['1234 567', 'fr-FR'],
166
+ ["1'234", 'de-CH'],
167
+ ["1'234", 'en-US'],
168
+ ['1\u2019234', 'de-CH'],
169
+ ['1,234', 'en-IN'],
170
+ ['12,34,567', 'en-IN'],
171
+ ['123,45,678', 'en-IN'],
172
+ ['1,2,345', 'en-IN'],
173
+ ['1\u066C234', 'ar-SA'],
174
+ ['1\u066B5', 'ar-SA'],
175
+ ['1\u00B7234\u00B7567', 'en-US'],
176
+ ['123-456-789', 'en-US'],
177
+ ];
178
+
179
+ /** Every one-, two- and three-separator shape over short digit runs. */
180
+ function* generated() {
181
+ const runs = ['1', '12', '123', '1234', '12345'];
182
+ for (const locale of LOCALES) {
183
+ for (const separator of ALPHABET) {
184
+ for (const a of runs) {
185
+ for (const b of runs) {
186
+ yield [`${a}${separator}${b}`, locale] as [string, string];
187
+ for (const c of runs) {
188
+ yield [`${a}${separator}${b}${separator}${c}`, locale] as [
189
+ string,
190
+ string,
191
+ ];
192
+ for (const d of runs) {
193
+ yield [
194
+ `${a}${separator}${b}${separator}${c}${separator}${d}`,
195
+ locale,
196
+ ] as [string, string];
197
+ }
198
+ }
199
+ }
200
+ }
201
+ }
202
+ }
203
+ }
204
+
205
+ describe('the numberParser docblock', () => {
206
+ it.each(Object.entries(CLAUSES))('still says: %s', (_name, quote) => {
207
+ expect(PROSE).toContain(quote);
208
+ });
209
+
210
+ it.each(NAMED)('reads %j in %s the way the parser does', (input, locale) => {
211
+ expect(predict(input, locale)).toBe(parseLocaleNumber(input, locale));
212
+ });
213
+
214
+ it('reads every generated shape the way the parser does', () => {
215
+ const disagreements: string[] = [];
216
+ let checked = 0;
217
+ for (const [input, locale] of generated()) {
218
+ const said = predict(input, locale);
219
+ if (said === SKIPPED) {
220
+ continue;
221
+ }
222
+ checked++;
223
+ const did = parseLocaleNumber(input, locale);
224
+ if (!Object.is(said, did)) {
225
+ disagreements.push(
226
+ `${JSON.stringify(input)} in ${locale}: docblock ${String(said)}, parser ${String(did)}`,
227
+ );
228
+ }
229
+ }
230
+ // The sweep is the evidence; a shrunken one silently proves nothing.
231
+ expect(checked).toBeGreaterThan(30000);
232
+ expect(disagreements.slice(0, 10)).toEqual([]);
233
+ });
234
+ });
@@ -6,6 +6,7 @@ import {formatEditableNumber, parseLocaleNumber} from './numberParser';
6
6
  const NBSP = '\u00A0';
7
7
  const NARROW_NBSP = '\u202F';
8
8
  const APOSTROPHE = '\u2019';
9
+ const MIDDLE_DOT = '\u00B7';
9
10
 
10
11
  /**
11
12
  * The contract, enumerated. A row is `[input, locale, expected]`, and `null`
@@ -53,6 +54,9 @@ const CORPUS: [string, string, number | null][] = [
53
54
  ['1.5e3', 'de-DE', 1500],
54
55
  ['1.234', 'de-DE', 1234],
55
56
  ['1.2345', 'de-DE', 1.2345],
57
+ // The same text in the other comma-decimal locale: fr-FR groups with a
58
+ // space, so a full stop has no grouping reading there and stays the point.
59
+ ['1.234', 'fr-FR', 1.234],
56
60
 
57
61
  // Decimals and signs.
58
62
  ['1,234.56', 'en-US', 1234.56],
@@ -142,6 +146,11 @@ const CORPUS: [string, string, number | null][] = [
142
146
  ['800-555-1212', 'en-US', null],
143
147
  ['2024-01-15', 'en-US', null],
144
148
 
149
+ // U+00B7 was in the alphabet as the Catalan middle dot. Catalan groups with
150
+ // a full stop, and no locale ICU resolves writes it between digits at all.
151
+ [`1${MIDDLE_DOT}234${MIDDLE_DOT}567`, 'en-US', null],
152
+ [`1${MIDDLE_DOT}234${MIDDLE_DOT}567`, 'ca-ES', null],
153
+
145
154
  // The field has no percent semantics, so a percent sign is not dropped:
146
155
  // committing 45 for `45%` is off by a factor of a hundred.
147
156
  ['45%', 'en-US', null],
@@ -174,6 +183,9 @@ describe('formatEditableNumber', () => {
174
183
  [1.5, 'en-US', '1.5'],
175
184
  [1.5, 'de-DE', '1,5'],
176
185
  [-1234.56, 'fr-FR', '-1234,56'],
186
+ // Only the decimal separator is localized. The digits stay as `String`
187
+ // writes them, which is what the field shows at rest when no `formatValue`
188
+ // is given, so localizing them here would flip the script on focus.
177
189
  [3.5, 'ar-SA', '3٫5'],
178
190
  // No grouping: the text has to be editable, and a separator the person did
179
191
  // not type is one they have to delete.
@@ -9,9 +9,21 @@
9
9
  * People paste numbers out of spreadsheets, and a spreadsheet writes them
10
10
  * grouped, signed, and decorated. This reads those back. Everything locale
11
11
  * specific — the grouping and decimal separators, the group sizes, the digit
12
- * script — is derived from Intl and Unicode rather than a table, and anything
13
- * that could mean two different numbers returns null so the field stays
14
- * visibly invalid instead of committing a guess.
12
+ * script — is derived from Intl and Unicode rather than a table, and text that
13
+ * no reading fits returns null so the field stays visibly invalid instead of
14
+ * committing a guess. Where both readings are well formed the locale's wins,
15
+ * the way utils/dateParser.ts settles an ambiguous date by locale preference.
16
+ * Grouping is well formed only when every group fits the locale's own sizes:
17
+ * the last exactly the primary size, each inner one exactly the secondary, the
18
+ * first no longer than that. So de-DE `1.234` is 1234, while `1.2345` (last
19
+ * group too long) and `1234.567` (too much ahead of it) keep the full stop as
20
+ * a decimal point. Groups of three fit every locale, so a separator repeated
21
+ * with three-digit groups is grouping in all of them and `1,234,567` reads the
22
+ * same everywhere; for a space or apostrophe, which no locale writes as a
23
+ * decimal point, one occurrence is enough, so en-US `1 234` is 1234.
24
+ *
25
+ * That paragraph is executable: numberParser.docblock.test.ts implements it as
26
+ * code, quote by quote, and fails when the parser and the words disagree.
15
27
  *
16
28
  * Which characters may separate digits at all is the one thing that is NOT
17
29
  * read off the input: SEPARATOR_CHARS below is a bounded alphabet, the same
@@ -20,6 +32,7 @@
20
32
  * SYNC: When modified, update these files to stay in sync:
21
33
  * - /packages/core/src/NumberInput/NumberInput.tsx
22
34
  * - /packages/core/src/NumberInput/numberParser.test.ts
35
+ * - /packages/core/src/NumberInput/numberParser.docblock.test.ts
23
36
  */
24
37
 
25
38
  import type {Locale} from '../i18n';
@@ -81,15 +94,19 @@ const INVISIBLES = /[\u200B-\u200D\u200E\u200F\u061C\u2066-\u2069\uFEFF]/g;
81
94
  const MINUS_SIGNS = '\u2212\u2012\u2013-';
82
95
 
83
96
  /**
84
- * Every character a locale writes between digits: comma, full stop, space
85
- * (the whole space family folds to it under NFKC), the Swiss apostrophes, the
86
- * Catalan middle dot, and the Arabic decimal and thousands separators.
97
+ * Every character a locale writes between digits. Sweeping every language ICU
98
+ * resolves, in every region, yields seven: comma, full stop, apostrophe,
99
+ * space (the whole space family folds to it under NFKC), the Arabic decimal
100
+ * and thousands separators, and U+060C — which only nqo writes, and which
101
+ * this alphabet leaves out. U+2019 is here in its place: no locale writes it,
102
+ * but the corpus pins it as the Swiss spelling.
87
103
  *
88
104
  * The alphabet is bounded on purpose. Read the candidates off the input
89
105
  * instead and any repeated character is grouping, so a hyphenated ID commits
90
- * as a number.
106
+ * as a number. It bounds characters and not shapes: a full stop is a real
107
+ * separator, so `192.168.100.200` is four well-formed groups.
91
108
  */
92
- const SEPARATOR_CHARS = ",.' \u2019\u00B7\u066B\u066C";
109
+ const SEPARATOR_CHARS = ",.' \u2019\u066B\u066C";
93
110
  const NUMBER_BODY = new RegExp(`^[0-9${SEPARATOR_CHARS}]+$`);
94
111
  /** No grouping and at most one full stop: the format `Number()` reads. */
95
112
  const MACHINE_NUMBER = /^(?:\d+\.?\d*|\.\d+)$/;
@@ -198,7 +215,7 @@ function readUnder(
198
215
  * beats the decimal-point reading.
199
216
  */
200
217
  function isGroupOnlySeparator(separator: string): boolean {
201
- return /^[\s'\u2019\u00B7]$/.test(separator);
218
+ return /^[\s'\u2019]$/.test(separator);
202
219
  }
203
220
 
204
221
  function toPlainNumber(
@@ -0,0 +1,14 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file Ambient module declaration for Vite's `?raw` imports.
5
+ *
6
+ * numberParser.docblock.test.ts reads its own subject's source as text, to
7
+ * check the prose in it against the parser's behaviour. Vite serves that with
8
+ * the `?raw` query; without this declaration TypeScript fails with TS2307.
9
+ */
10
+
11
+ declare module '*?raw' {
12
+ const content: string;
13
+ export default content;
14
+ }