@rudra-js/attested 0.0.0 → 0.5.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 +362 -4
- package/dist/hidden.d.ts +3 -0
- package/dist/hidden.d.ts.map +1 -0
- package/dist/hidden.js +22 -0
- package/dist/hidden.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -0
- package/dist/numerals.d.ts +12 -0
- package/dist/numerals.d.ts.map +1 -0
- package/dist/numerals.js +220 -0
- package/dist/numerals.js.map +1 -0
- package/dist/phrases.d.ts +31 -0
- package/dist/phrases.d.ts.map +1 -0
- package/dist/phrases.js +199 -0
- package/dist/phrases.js.map +1 -0
- package/dist/verify.d.ts +50 -0
- package/dist/verify.d.ts.map +1 -0
- package/dist/verify.js +153 -0
- package/dist/verify.js.map +1 -0
- package/package.json +39 -3
- package/src/hidden.ts +26 -0
- package/src/index.ts +15 -0
- package/src/numerals.ts +247 -0
- package/src/phrases.ts +227 -0
- package/src/verify.ts +223 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Clive Dsouza
|
|
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
CHANGED
|
@@ -1,8 +1,366 @@
|
|
|
1
1
|
# @rudra-js/attested
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
package that already exists, so this version exists to create the name.
|
|
3
|
+
Checks model-written product copy against the facts a shop stands behind.
|
|
5
4
|
|
|
6
|
-
|
|
5
|
+
Zero dependencies, no node builtins, no I/O. It is one pure function over text
|
|
6
|
+
and facts, so it runs in Node, in an edge runtime and in a browser, and it drops
|
|
7
|
+
in after anything that writes copy — json-render, the Vercel AI SDK, a raw
|
|
8
|
+
`fetch` to a model, your own template.
|
|
7
9
|
|
|
8
|
-
|
|
10
|
+
## The guarantee
|
|
11
|
+
|
|
12
|
+
**When `quantity.supported` is true, every numeral in the text is one you
|
|
13
|
+
supplied — and a numeral the reader cannot read counts as a failure, not a
|
|
14
|
+
pass.**
|
|
15
|
+
|
|
16
|
+
That is the whole promise, and it is a promise about numerals, not about
|
|
17
|
+
sentences. It does not say the sentence around the number is true: a price you
|
|
18
|
+
supplied can still be written as a sales figure. It says nothing at all about
|
|
19
|
+
`wording.supported`, which is a denylist over an open set and carries
|
|
20
|
+
`strength: 'best-effort'` on every result for that reason.
|
|
21
|
+
|
|
22
|
+
Everything the guarantee leaves open is listed under
|
|
23
|
+
[What it cannot catch](#what-it-cannot-catch). Nothing there is a bug queued for
|
|
24
|
+
later; it is the shape of what a closed check over digits can promise.
|
|
25
|
+
|
|
26
|
+
## Install
|
|
27
|
+
|
|
28
|
+
```sh
|
|
29
|
+
npm install @rudra-js/attested
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
```ts
|
|
33
|
+
import { verify } from '@rudra-js/attested';
|
|
34
|
+
|
|
35
|
+
const result = verify('Only 2 left at $39', { values: [39, 'SKU TR-101'] });
|
|
36
|
+
|
|
37
|
+
result.supported; // false
|
|
38
|
+
result.quantity.findings;
|
|
39
|
+
// [{ layer: 'quantity', token: '2', reason: 'quantity: "2" is not a value the supplied facts carry' }]
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## The two layers
|
|
43
|
+
|
|
44
|
+
They are checked separately and reported separately, because only one of them is
|
|
45
|
+
a proof.
|
|
46
|
+
|
|
47
|
+
### Layer one — quantities. This is the provable half.
|
|
48
|
+
|
|
49
|
+
Every run of digits in the text has to be a value you supplied. Not "does this
|
|
50
|
+
read like a bad phrase" but "is this number one the shop stands behind". The set
|
|
51
|
+
of digits in a string is closed and checkable, and it does not care what
|
|
52
|
+
language the sentence is in.
|
|
53
|
+
|
|
54
|
+
```ts
|
|
55
|
+
verify('Nur noch 2 übrig', { values: [39] }).quantity.supported; // false
|
|
56
|
+
verify('4,8 von 5', { values: [4.8, 5] }).quantity.supported; // true
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
It is crude on purpose and it errs toward rejection. `"Only 2 left"` is
|
|
60
|
+
unsupported unless you supplied a fact carrying 2, even though 2 is also the
|
|
61
|
+
number of colourways. A false rejection costs a duller sentence; a false
|
|
62
|
+
acceptance costs a fabricated claim.
|
|
63
|
+
|
|
64
|
+
**What it reads.** Bare integers and decimals, both decimal separators (`4.8`
|
|
65
|
+
and `4,8`), currency symbols attached or detached, percentages, and any Unicode
|
|
66
|
+
decimal digit — Eastern Arabic `٤٫٨`, Devanagari `४.८` and mathematical `𝟰.𝟴`
|
|
67
|
+
all read as 4.8. Grouping is read in the Western (`12,345,678`), Indian
|
|
68
|
+
(`1,29,999`), space (`1 299`, including the no-break and narrow no-break spaces)
|
|
69
|
+
and Swiss (`1'299`) shapes.
|
|
70
|
+
|
|
71
|
+
**How an ambiguous mark is decided.** One dot or comma with exactly three digits
|
|
72
|
+
behind it is grouping, never a decimal point. `1,299` and `1.299` both read as
|
|
73
|
+
1299 and nothing else, which is why a rating of 4.8 cannot stand behind a
|
|
74
|
+
written `4.800` and a stock count of 12 cannot stand behind `12.000`. Where
|
|
75
|
+
grouping is impossible the decimal reading survives, so `1234,567` is still
|
|
76
|
+
1234.567. Where the two marks differ the last one is the decimal point, so
|
|
77
|
+
`1.299,50` is 1299.5.
|
|
78
|
+
|
|
79
|
+
**What it refuses to read.** Three things become findings rather than passes:
|
|
80
|
+
|
|
81
|
+
- A numeric character that is not a decimal digit — `½`, `²`, `②`, `Ⅲ`. These
|
|
82
|
+
render as numbers and carry a value the reader has no way to check.
|
|
83
|
+
- A magnitude mark glued to a run — `4.8万`, `12M`, `4.8k`. The digits may be
|
|
84
|
+
yours; the multiplier is not.
|
|
85
|
+
- A run no locale reads as a number — `24.12.2026`, `3.14.15`. These are
|
|
86
|
+
supported only by a fact written exactly the same way.
|
|
87
|
+
|
|
88
|
+
Characters that take no room on the page are dropped before any of this, so a
|
|
89
|
+
zero-width space, a soft hyphen, a variation selector, a backspace or a
|
|
90
|
+
combining overline cannot split `213` into a `2` and a `13` that happen to be
|
|
91
|
+
supported separately. That class is every format character, every
|
|
92
|
+
default-ignorable code point, every control character and every mark that hangs
|
|
93
|
+
on the character before it — a rule over the whole of Unicode rather than a list
|
|
94
|
+
someone kept up to date.
|
|
95
|
+
|
|
96
|
+
A character that does take room still separates two numbers. A tab, a line break
|
|
97
|
+
and a Devanagari matra all leave a gap the shopper can see, so `"Only 2\n3 left"`
|
|
98
|
+
is two numbers and a fact of 23 does not stand behind it.
|
|
99
|
+
|
|
100
|
+
### Layer two — wording. This is the best-effort half.
|
|
101
|
+
|
|
102
|
+
Claims with no number in them: "cheap", "bargain", "on sale", "best-selling",
|
|
103
|
+
"selling fast", "in stock", "free delivery". There is no value to check, so
|
|
104
|
+
there is nothing to invert. It stays a denylist, it stays open and
|
|
105
|
+
English-first, and every result it produces says so:
|
|
106
|
+
|
|
107
|
+
```ts
|
|
108
|
+
verify('Selling fast', { values: [] }).wording;
|
|
109
|
+
// { supported: false, strength: 'best-effort', checked: 81, findings: [...] }
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
Add your own for your own language, and name the wording you stand behind:
|
|
113
|
+
|
|
114
|
+
```ts
|
|
115
|
+
verify('Nur noch wenige', { values: [], bannedPhrases: ['nur noch'] });
|
|
116
|
+
verify('In stock, ships today', { values: [], allowedPhrases: ['in stock'] });
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
`bannedPhrases` is added to the built-in list. `allowedPhrases` works on the
|
|
120
|
+
text, not on the list: **where one of your phrases appears, a banned claim
|
|
121
|
+
sitting wholly inside it is not reported, and the same words elsewhere in the
|
|
122
|
+
copy still are.** So `['back in stock']` clears `"Back in stock"` and still
|
|
123
|
+
catches the `in stock` in `"Only 2 in stock, selling fast"`. `checked` is the
|
|
124
|
+
whole list either way, because nothing leaves it.
|
|
125
|
+
|
|
126
|
+
Without an allowance a denylist bans the truthful disclosure as hard as the
|
|
127
|
+
fabricated one, and a shop that genuinely offers free shipping cannot say so.
|
|
128
|
+
Three things are worth knowing before you write one:
|
|
129
|
+
|
|
130
|
+
- **Containment runs one way.** Allowing `in stock` does not clear
|
|
131
|
+
`back in stock`, which claims a restock on top of it.
|
|
132
|
+
- **Allowances do not compose, and they do not join.**
|
|
133
|
+
`['sold', 'out']` leaves `sold out` caught, and `['back in', 'stock']` leaves
|
|
134
|
+
both `in stock` and `back in stock` caught.
|
|
135
|
+
- **An allowance forgives whatever nests inside it, including entries added in a
|
|
136
|
+
later version.** `['in stock and ready to ship']` forgives nothing extra
|
|
137
|
+
today; the day `ready to ship` joins the list, that allowance silences it
|
|
138
|
+
wherever your sentence appears. Keep them tight.
|
|
139
|
+
- **An allowance does not cross a line break.** The denylist reads straight
|
|
140
|
+
through one, so `"Free\n\nshipping"` is still caught, but an allowance will
|
|
141
|
+
not bridge it. The model picks where the paragraph breaks fall, and
|
|
142
|
+
`"We do not offer"` above a horizontal rule, with the free shipping claim
|
|
143
|
+
below it, is two things a shopper reads apart. The two rules pull opposite
|
|
144
|
+
ways on purpose: the denylist catches more, the allowance forgives less, and
|
|
145
|
+
both err toward reporting.
|
|
146
|
+
|
|
147
|
+
It is also the way around negation. The list matches substrings, so
|
|
148
|
+
`"there is no sale on this product"` reads as a sale claim; allow `no sale` and
|
|
149
|
+
that sentence passes while a `sale` later in the same text still fails.
|
|
150
|
+
|
|
151
|
+
Matching is case-insensitive. Hyphens become spaces, accents are composed,
|
|
152
|
+
fullwidth forms are folded, characters that take no room are dropped, and a
|
|
153
|
+
handful of Cyrillic and Greek letters that render as Latin ones are folded to
|
|
154
|
+
Latin. Accents are composed before anything is dropped, so `café` keeps its `é`
|
|
155
|
+
while an overline, which composes with nothing, cannot hide `free` from its own
|
|
156
|
+
entry. A line break stays a line break: matching reads through it, and only an
|
|
157
|
+
allowance refuses to cross it. Spaces are dropped from both sides before
|
|
158
|
+
matching, so `送料 無料` still
|
|
159
|
+
reads as `送料無料`. A phrase whose first or last character is an ASCII letter or
|
|
160
|
+
digit will not match glued inside a longer word, so `cheap` misses `cheapskate`,
|
|
161
|
+
but a trailing `s` is allowed, so `discount` catches `discounts`.
|
|
162
|
+
|
|
163
|
+
## The result
|
|
164
|
+
|
|
165
|
+
```ts
|
|
166
|
+
interface VerifyResult {
|
|
167
|
+
supported: boolean; // both layers
|
|
168
|
+
quantity: LayerReport;
|
|
169
|
+
wording: LayerReport;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
interface LayerReport {
|
|
173
|
+
supported: boolean;
|
|
174
|
+
strength: 'proof' | 'best-effort';
|
|
175
|
+
checked: number; // numerals found, or phrases screened against
|
|
176
|
+
findings: Finding[]; // one per distinct token that failed
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
interface Finding {
|
|
180
|
+
layer: 'quantity' | 'wording';
|
|
181
|
+
token: string; // the numeral as the text wrote it, or the phrase
|
|
182
|
+
reason: string; // audit evidence, naming the layer and the token
|
|
183
|
+
}
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
`checked` says how much the layer actually looked at. `quantity.checked === 0`
|
|
187
|
+
means the text held no numeral and layer one proved nothing about it — which is
|
|
188
|
+
the honest reading of `"Only two left"`, and no assertion you can write on the
|
|
189
|
+
result will turn that into a catch. Layer one counts the numerals it refuses to
|
|
190
|
+
read, so `"Only ② left"` is a finding rather than a silent zero.
|
|
191
|
+
|
|
192
|
+
## Facts
|
|
193
|
+
|
|
194
|
+
```ts
|
|
195
|
+
interface Facts {
|
|
196
|
+
values: readonly (string | number | bigint)[];
|
|
197
|
+
bannedPhrases?: readonly string[];
|
|
198
|
+
allowedPhrases?: readonly string[];
|
|
199
|
+
}
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
`values` is a flat list, not a typed one. Write them however they are written in
|
|
203
|
+
your own system — `39`, `'$1,299.00'`, `'4,8'`, `'ships 13 March'` — and every
|
|
204
|
+
numeral in each one becomes a supported value.
|
|
205
|
+
|
|
206
|
+
A number is laid out in positional notation first, whatever `String` would have
|
|
207
|
+
made of it, so a fact of `1e21` stands behind
|
|
208
|
+
`'1,000,000,000,000,000,000,000'` and behind neither `1` nor `21`. A string
|
|
209
|
+
whose whole content is a number in exponent notation is laid out the same way:
|
|
210
|
+
`String`, `JSON.stringify`, a spreadsheet export and an API that writes 64-bit
|
|
211
|
+
values as strings all produce one, and none of those is you choosing the digits.
|
|
212
|
+
Any other string is read exactly as you typed it, so `'SKU AX-220e5'` still
|
|
213
|
+
mints 220 and 5.
|
|
214
|
+
|
|
215
|
+
An exponent that would lay out past a thousand digits stands behind no numeral at
|
|
216
|
+
all, and the length is weighed before any of it is written. `'1e1000'` is no
|
|
217
|
+
product fact under any reading, so it is refused at the limit rather than laid
|
|
218
|
+
out. The limit is what stops the far end: `'1e2000000000'` is twelve characters,
|
|
219
|
+
and writing its run out would cost more memory than the process has. Every finite
|
|
220
|
+
JavaScript number fits well inside the limit — the longest is `5e-324`, at 326
|
|
221
|
+
characters.
|
|
222
|
+
|
|
223
|
+
Past 2^53 a number has lost its exact digits before it ever reaches here —
|
|
224
|
+
`JSON.parse` turns `9007199254740993` into `...92` — so hand a long id over as a
|
|
225
|
+
bigint or as a string. A null or undefined field stands behind no numeral rather
|
|
226
|
+
than throwing, so one empty field does not take the whole call down. Anything
|
|
227
|
+
else is read through `String()` and mints whatever digits that produces, which
|
|
228
|
+
for a `Date` is the day, the year and every digit of the clock — so a `listedAt`
|
|
229
|
+
handed straight through is the hazard named under **What it cannot catch**, not
|
|
230
|
+
an ignored field.
|
|
231
|
+
|
|
232
|
+
Flat rather than typed (money, count, rating, date) because typing only helps if
|
|
233
|
+
the extractor can classify a token in the text, and it cannot. The `2` in
|
|
234
|
+
`"Only 2 left"` and the `2` in `"2 year warranty"` are the same two characters;
|
|
235
|
+
telling them apart means understanding the sentence, in whatever language it was
|
|
236
|
+
written, which is the problem this package exists to stop relying on. Typed
|
|
237
|
+
facts would also need an "unclassified, so skip it" branch, and that branch is a
|
|
238
|
+
hole. A flat set has no hole: every numeral is checked against every value.
|
|
239
|
+
|
|
240
|
+
The cost is real and it is stated under **What it cannot catch**. Two things
|
|
241
|
+
follow from it that are worth doing:
|
|
242
|
+
|
|
243
|
+
- **Supply only numbers you are happy to see anywhere.** A SKU of `AT-2199` in
|
|
244
|
+
`values` makes `"Was $2,199"` a supported sentence. A listing date makes its
|
|
245
|
+
day number a supported sales figure. If a number in your record is not a fact
|
|
246
|
+
about the product a shopper would read, leave it out.
|
|
247
|
+
- **`values` is a trust boundary.** On a marketplace the product title is
|
|
248
|
+
written by the seller, not by you. Putting it in `values` hands the model
|
|
249
|
+
every number the seller chose to type. Supply fields you author.
|
|
250
|
+
|
|
251
|
+
## Many fields, one product
|
|
252
|
+
|
|
253
|
+
```ts
|
|
254
|
+
import { verifyFields } from '@rudra-js/attested';
|
|
255
|
+
|
|
256
|
+
const result = verifyFields(
|
|
257
|
+
{ headline: 'Yours for $39', badge: 'Only 2 left', body: 'Built for long days' },
|
|
258
|
+
{ values: [39] },
|
|
259
|
+
);
|
|
260
|
+
|
|
261
|
+
result.supported; // false
|
|
262
|
+
result.fields; // [{ field, result }, ...] in the order the fields were given
|
|
263
|
+
result.acrossFields; // the wording layer over every field read as one
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
The facts are read once and reused. A card renders its fields next to each
|
|
267
|
+
other, and the model picks the field boundaries, so
|
|
268
|
+
`{ badge: 'Free', delivery: 'delivery on every order' }` is one sentence to a
|
|
269
|
+
shopper while neither field carries it alone. `acrossFields` reads the joined
|
|
270
|
+
text and counts toward `supported`.
|
|
271
|
+
|
|
272
|
+
## What it cannot catch
|
|
273
|
+
|
|
274
|
+
This is the honest list.
|
|
275
|
+
|
|
276
|
+
### Claims with a number in them
|
|
277
|
+
|
|
278
|
+
- **A number that is real but means something else.** This is the largest hole
|
|
279
|
+
in the provable half. A flat fact set proves the number came from you, not
|
|
280
|
+
that the sentence uses it correctly. Supply a price of 39 and `"39 sold in the
|
|
281
|
+
last hour"`, `"Save $39 today"` and `"Under $39"` all pass. Supply a rating
|
|
282
|
+
scale of 5 and `"Only 5 left"` passes. Supply a rating of 4.8 and a German
|
|
283
|
+
`"nur 4,80 €"` passes as a price, because 4.80 and 4.8 are one number.
|
|
284
|
+
- **Numbers written as words, in any script.** `"Only two left"` has no digit in
|
|
285
|
+
it, so layer one never sees it. Neither does `残り二点`, where 二 is a
|
|
286
|
+
character rather than a digit, nor Arabic `قطعتين`, where "two" is the noun's
|
|
287
|
+
dual ending and there is no numeral at all. `quantity.checked` is 0 for all
|
|
288
|
+
three, and that is the only signal you get.
|
|
289
|
+
- **Magnitudes written as words.** `4.8万` and `12M` are caught. `4,8 Millionen`,
|
|
290
|
+
`4.8 millions`, `4,8 mil`, `4.8 लाख` and `٤٫٨ مليون` are not — a word list is
|
|
291
|
+
the denylist this package exists to get away from.
|
|
292
|
+
- **The unit on the number.** `¥39.99` against a price of $39.99 passes: the
|
|
293
|
+
digits are yours and the currency symbol is not a digit. So does
|
|
294
|
+
`"39,99 Cent"`. Layer one checks the number, never what it is a number of.
|
|
295
|
+
- **A number your facts happen to contain.** A fact of `'2026-09-13'` makes
|
|
296
|
+
2026, 9 and 13 supported everywhere in the text, including as a stock count.
|
|
297
|
+
- **Where in the text a token was.** A finding names the token, not its offset.
|
|
298
|
+
|
|
299
|
+
### Claims with no number in them
|
|
300
|
+
|
|
301
|
+
- **Wording, in general.** Layer two is a denylist over an infinite set. A
|
|
302
|
+
careful rewording gets past it — `"Half of what it was"` for half price,
|
|
303
|
+
`"送料は無料"` with a particle dropped in. Entries are added as they are found,
|
|
304
|
+
and the list will never be finished.
|
|
305
|
+
- **Your language, until you give it one.** Every built-in phrase is English.
|
|
306
|
+
`"Gratis Versand"`, `"Livraison offerte"` and `"شحن مجاني"` all pass by
|
|
307
|
+
default. `bannedPhrases` is the answer, and it is your list to keep.
|
|
308
|
+
- **Negation.** The list matches substrings, so `"there is no sale on this
|
|
309
|
+
product"` is caught as a sale claim on its own. There is no rule that reads the
|
|
310
|
+
`no`; `allowedPhrases: ['no sale']` is the way out, and it is narrow by
|
|
311
|
+
design — a `sale` elsewhere in the same text still fails.
|
|
312
|
+
- **A character that folds into your allowance.** An allowance is matched
|
|
313
|
+
against normalised copy, so anything that folds to those words counts as those
|
|
314
|
+
words. Allow `no sale` and `"№ SALE"` passes, because U+2116 folds to `no`;
|
|
315
|
+
a superscript `ⁿᵒ` does the same. The shopper reads the numero sign as
|
|
316
|
+
decoration and the verifier reads it as a word. Keep allowances tight and
|
|
317
|
+
written the way a shop really writes them.
|
|
318
|
+
- **A letter glued to the end of a phrase.** `"FREE SHIPPINGᵃ see terms"` is not
|
|
319
|
+
reported. The superscript folds to an `a`, which glues to `shipping`, and the
|
|
320
|
+
word-gap rule that stops `cheap` matching `cheapskate` then treats the whole
|
|
321
|
+
thing as a longer word. It holds for every entry on the list, and a footnote
|
|
322
|
+
marker is exactly how a real shop writes one.
|
|
323
|
+
|
|
324
|
+
### Preconditions the package cannot check for you
|
|
325
|
+
|
|
326
|
+
- **The text you check has to be the text you render.** `verify` sees a string.
|
|
327
|
+
If anything downstream decodes it — an HTML sink turning `½` into ½, or
|
|
328
|
+
`2` into a 2 — then the string that was checked and the string on the page
|
|
329
|
+
are different documents, and the guarantee covers the first one.
|
|
330
|
+
- **The font has to agree with Unicode about what renders as nothing.** The
|
|
331
|
+
layer drops what Unicode marks as taking no room. Where a font gives one of
|
|
332
|
+
those a visible glyph the two disagree, and U+3164 HANGUL FILLER is the one
|
|
333
|
+
people really paste as a blank: the layer reads `"1<U+3164>3"` as 13, and a
|
|
334
|
+
font that widens it shows the shopper `1 3`.
|
|
335
|
+
- **Everything that is not a number or a phrase.** A claim about materials,
|
|
336
|
+
compatibility, a certification, a warranty condition or a person is entirely
|
|
337
|
+
out of scope.
|
|
338
|
+
|
|
339
|
+
## What it over-rejects
|
|
340
|
+
|
|
341
|
+
The guarantee errs toward rejection, and that has a bill:
|
|
342
|
+
|
|
343
|
+
- **Every incidental number has to be in `values`.** A VAT rate, a size, a care
|
|
344
|
+
temperature, a spec number, a support phone number, a founding year, the
|
|
345
|
+
denominator of a rating scale. None of them are claims, all of them are
|
|
346
|
+
digits, and copy mentioning one you did not supply is rejected.
|
|
347
|
+
- **A three-digit tail is always grouping.** `1.299 kg` reads as 1299, so a
|
|
348
|
+
genuine three-decimal number — a weight, or a price in a currency with three
|
|
349
|
+
decimal places — cannot be written with a dot or a comma. Supply it, and write
|
|
350
|
+
it, the same way.
|
|
351
|
+
- **Numeric characters that are not digits are refused outright.** `2 m²` and
|
|
352
|
+
`½ inch` fail, along with the `②` and `Ⅲ` the rule is there to stop.
|
|
353
|
+
- **A magnitude letter glued to a digit is refused.** `4K display` fails.
|
|
354
|
+
`4 K display` and `1kg` do not, because a letter followed by another letter is
|
|
355
|
+
a unit, not a multiplier.
|
|
356
|
+
- **A run no locale reads as a number needs its exact spelling.** `24.12.2026`
|
|
357
|
+
is supported by a fact of `'24.12.2026'` and by nothing else.
|
|
358
|
+
- **Scientific notation in the copy is unsupported by construction.** A fact of
|
|
359
|
+
`1e-7` is laid out as `0.0000001`, and the text `"1e-7 g"` reads as the two
|
|
360
|
+
numerals 1 and 7, so the two never meet. `"0.0000001 g"` passes. This is the
|
|
361
|
+
bill for laying facts out positionally, and a lab or bulk-goods shop that
|
|
362
|
+
prints the value the way its own data prints it pays it.
|
|
363
|
+
|
|
364
|
+
## Licence
|
|
365
|
+
|
|
366
|
+
MIT
|
package/dist/hidden.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"hidden.d.ts","sourceRoot":"","sources":["../src/hidden.ts"],"names":[],"mappings":"AAmBA,wBAAgB,cAAc,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAEnD;AAED,wBAAgB,UAAU,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAE/C"}
|
package/dist/hidden.js
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// One definition for both layers. It lived in two files, and that is why widening it
|
|
2
|
+
// for the quantity layer left the wording layer open twice running.
|
|
3
|
+
/**
|
|
4
|
+
* Characters that render as nothing: format characters, default-ignorable code points
|
|
5
|
+
* and the controls. Cf and Default_Ignorable each hold characters the other does not.
|
|
6
|
+
*/
|
|
7
|
+
const INVISIBLE = /[\p{Cf}\p{Default_Ignorable_Code_Point}\p{Cc}]/gu;
|
|
8
|
+
/** The controls that do take room. Deleting these would read `Only 2\n3 left` as 23. */
|
|
9
|
+
const SPACING = /[\t\n\v\f\r\u0085]/;
|
|
10
|
+
/**
|
|
11
|
+
* Marks that hang on the character before them instead of taking a column of their own,
|
|
12
|
+
* so `4<U+0305>9` reads as 49. Spacing marks (Mc) are not here: a Devanagari matra is
|
|
13
|
+
* a visible gap between two digits and separates them for a reader.
|
|
14
|
+
*/
|
|
15
|
+
const MARKS = /[\p{Mn}\p{Me}]/gu;
|
|
16
|
+
export function stripInvisible(text) {
|
|
17
|
+
return text.replace(INVISIBLE, (char) => (SPACING.test(char) ? char : ''));
|
|
18
|
+
}
|
|
19
|
+
export function stripMarks(text) {
|
|
20
|
+
return text.replace(MARKS, '');
|
|
21
|
+
}
|
|
22
|
+
//# sourceMappingURL=hidden.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"hidden.js","sourceRoot":"","sources":["../src/hidden.ts"],"names":[],"mappings":"AAAA,qFAAqF;AACrF,oEAAoE;AAEpE;;;GAGG;AACH,MAAM,SAAS,GAAG,kDAAkD,CAAC;AAErE,wFAAwF;AACxF,MAAM,OAAO,GAAG,oBAAoB,CAAC;AAErC;;;;GAIG;AACH,MAAM,KAAK,GAAG,kBAAkB,CAAC;AAEjC,MAAM,UAAU,cAAc,CAAC,IAAY;IACzC,OAAO,IAAI,CAAC,OAAO,CAAC,SAAS,EAAE,CAAC,IAAI,EAAE,EAAE,CAAC,CAAC,OAAO,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC;AAC7E,CAAC;AAED,MAAM,UAAU,UAAU,CAAC,IAAY;IACrC,OAAO,IAAI,CAAC,OAAO,CAAC,KAAK,EAAE,EAAE,CAAC,CAAC;AACjC,CAAC"}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAEA,OAAO,EACL,MAAM,EACN,YAAY,EACZ,KAAK,WAAW,EAChB,KAAK,KAAK,EACV,KAAK,WAAW,EAChB,KAAK,OAAO,EACZ,KAAK,KAAK,EACV,KAAK,WAAW,EAChB,KAAK,YAAY,GAClB,MAAM,aAAa,CAAC;AAErB,OAAO,EAAE,cAAc,EAAE,MAAM,cAAc,CAAC"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,8EAA8E;AAE9E,OAAO,EACL,MAAM,EACN,YAAY,GAQb,MAAM,aAAa,CAAC;AAErB,OAAO,EAAE,cAAc,EAAE,MAAM,cAAc,CAAC"}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export interface Numeral {
|
|
2
|
+
/** The run exactly as it appears in the text. */
|
|
3
|
+
token: string;
|
|
4
|
+
/** Every reading of that run, canonical. Empty when the layer cannot read the run. */
|
|
5
|
+
forms: string[];
|
|
6
|
+
/** `digits` is a run this layer reads. The other two it cannot read at all. */
|
|
7
|
+
kind: 'digits' | 'magnitude' | 'other-numeral';
|
|
8
|
+
}
|
|
9
|
+
export declare function numeralsIn(text: string): Numeral[];
|
|
10
|
+
/** Every reading of every numeral the host stands behind. */
|
|
11
|
+
export declare function supportedValues(values: readonly (string | number | bigint)[]): Set<string>;
|
|
12
|
+
//# sourceMappingURL=numerals.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"numerals.d.ts","sourceRoot":"","sources":["../src/numerals.ts"],"names":[],"mappings":"AA2BA,MAAM,WAAW,OAAO;IACtB,iDAAiD;IACjD,KAAK,EAAE,MAAM,CAAC;IACd,sFAAsF;IACtF,KAAK,EAAE,MAAM,EAAE,CAAC;IAChB,+EAA+E;IAC/E,IAAI,EAAE,QAAQ,GAAG,WAAW,GAAG,eAAe,CAAC;CAChD;AAoJD,wBAAgB,UAAU,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,EAAE,CAIlD;AAmDD,6DAA6D;AAC7D,wBAAgB,eAAe,CAAC,MAAM,EAAE,SAAS,CAAC,MAAM,GAAG,MAAM,GAAG,MAAM,CAAC,EAAE,GAAG,GAAG,CAAC,MAAM,CAAC,CAQ1F"}
|