gbs-add-block 2.0.4 → 2.3.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/.gbs/skills/gbs-components/SKILL.md +190 -134
- package/.gbs/skills/gbs-components/references/install.md +25 -3
- package/.gbs/skills/gbs-components/references/styling.md +246 -201
- package/CHANGELOG.md +74 -0
- package/README.md +157 -11
- package/index.cjs +212 -3
- package/package.json +41 -10
- package/schema/passport-v1.schema.json +204 -0
- package/source/beta-components/accordion/passport.json +259 -0
- package/source/beta-components/accordion/styles.css +207 -208
- package/source/beta-components/alert/passport.json +250 -0
- package/source/beta-components/alert/styles.css +154 -155
- package/source/beta-components/avatar/passport.json +294 -0
- package/source/beta-components/avatar/styles.css +201 -203
- package/source/beta-components/badge/passport.json +332 -0
- package/source/beta-components/badge/styles.css +203 -204
- package/source/beta-components/breadcrumb/passport.json +243 -0
- package/source/beta-components/breadcrumb/styles.css +138 -139
- package/source/beta-components/button/passport.json +402 -0
- package/source/beta-components/button/passport.manual.json +31 -0
- package/source/beta-components/button/styles.css +232 -233
- package/source/beta-components/card/passport.json +337 -0
- package/source/beta-components/card/styles.css +230 -231
- package/source/beta-components/checkbox/passport.json +456 -0
- package/source/beta-components/checkbox/styles.css +211 -212
- package/source/beta-components/combobox/passport.json +456 -0
- package/source/beta-components/combobox/styles.css +419 -417
- package/source/beta-components/data-grid/agent/coerce.ts +368 -0
- package/source/beta-components/data-grid/agent/contract.ts +410 -0
- package/source/beta-components/data-grid/agent/dataset.ts +92 -0
- package/source/beta-components/data-grid/agent/engine.ts +470 -0
- package/source/beta-components/data-grid/agent/executors.ts +155 -0
- package/source/beta-components/data-grid/agent/index.ts +79 -0
- package/source/beta-components/data-grid/agent/intent.ts +324 -0
- package/source/beta-components/data-grid/agent/operations.ts +335 -0
- package/source/beta-components/data-grid/agent/validate.ts +630 -0
- package/source/beta-components/data-grid/agent/webmcp.ts +107 -0
- package/source/beta-components/data-grid/index.ts +14 -7
- package/source/beta-components/data-grid/passport.json +1051 -0
- package/source/beta-components/data-grid/passport.manual.json +255 -0
- package/source/beta-components/data-grid/react/AskGrid.tsx +164 -0
- package/source/beta-components/data-grid/react/DataGrid.tsx +39 -0
- package/source/beta-components/data-grid/styles.css +874 -717
- package/source/beta-components/date-picker/passport.json +407 -0
- package/source/beta-components/date-picker/styles.css +445 -446
- package/source/beta-components/dialog/passport.json +344 -0
- package/source/beta-components/dialog/styles.css +280 -278
- package/source/beta-components/file-uploader/passport.json +518 -0
- package/source/beta-components/file-uploader/styles.css +394 -395
- package/source/beta-components/input/passport.json +536 -0
- package/source/beta-components/input/styles.css +295 -296
- package/source/beta-components/menu/passport.json +322 -0
- package/source/beta-components/menu/styles.css +224 -222
- package/source/beta-components/modal/passport.json +289 -0
- package/source/beta-components/modal/styles.css +241 -239
- package/source/beta-components/number-input/passport.json +541 -0
- package/source/beta-components/number-input/styles.css +230 -231
- package/source/beta-components/popover/passport.json +238 -0
- package/source/beta-components/popover/styles.css +148 -146
- package/source/beta-components/progress/passport.json +270 -0
- package/source/beta-components/progress/styles.css +200 -202
- package/source/beta-components/radio-group/passport.json +477 -0
- package/source/beta-components/radio-group/styles.css +241 -242
- package/source/beta-components/shared/core/agent/adapter.ts +65 -0
- package/source/beta-components/shared/core/agent/history.ts +120 -0
- package/source/beta-components/shared/core/agent/index.ts +46 -0
- package/source/beta-components/shared/core/agent/numbers.ts +217 -0
- package/source/beta-components/shared/core/agent/schema.ts +180 -0
- package/source/beta-components/shared/core/agent/types.ts +169 -0
- package/source/beta-components/shared/core/agent/webmcp.ts +328 -0
- package/source/beta-components/shared/index.ts +9 -0
- package/source/beta-components/shared/react/GramproAIProvider.tsx +50 -0
- package/source/beta-components/shared/react/useAskAgent.ts +217 -0
- package/source/beta-components/shared/styles.css +79 -0
- package/source/beta-components/shared/version.json +4 -4
- package/source/beta-components/shared/version.ts +6 -6
- package/source/beta-components/skeleton/passport.json +251 -0
- package/source/beta-components/skeleton/styles.css +185 -186
- package/source/beta-components/spinner/passport.json +245 -0
- package/source/beta-components/spinner/styles.css +145 -146
- package/source/beta-components/switch/passport.json +421 -0
- package/source/beta-components/switch/styles.css +194 -196
- package/source/beta-components/tabs/passport.json +315 -0
- package/source/beta-components/tabs/styles.css +234 -235
- package/source/beta-components/textarea/passport.json +382 -0
- package/source/beta-components/textarea/styles.css +158 -159
- package/source/beta-components/toaster/passport.json +221 -0
- package/source/beta-components/toaster/styles.css +282 -283
- package/source/beta-components/tooltip/passport.json +170 -0
- package/source/beta-components/tooltip/styles.css +71 -72
- package/tools/env.cjs +61 -0
- package/tools/passport/cli.cjs +79 -0
- package/tools/passport/extract.cjs +493 -0
- package/tools/passport/index.cjs +185 -0
- package/tools/passport/merge.cjs +131 -0
- package/tools/passport/policy.cjs +65 -0
- package/tools/passport/validate.cjs +277 -0
- package/tools/ts-require.cjs +79 -0
- package/source/beta-components/accordion/__tests__/core.test.ts +0 -58
- package/source/beta-components/alert/__tests__/core.test.ts +0 -17
- package/source/beta-components/avatar/__tests__/core.test.ts +0 -88
- package/source/beta-components/badge/__tests__/core.test.ts +0 -46
- package/source/beta-components/breadcrumb/__tests__/core.test.ts +0 -58
- package/source/beta-components/button/__tests__/core.test.ts +0 -31
- package/source/beta-components/card/__tests__/core.test.ts +0 -57
- package/source/beta-components/checkbox/__tests__/core.test.ts +0 -40
- package/source/beta-components/combobox/__tests__/core.test.ts +0 -134
- package/source/beta-components/data-grid/__tests__/core.test.ts +0 -356
- package/source/beta-components/data-grid/__tests__/export.test.ts +0 -70
- package/source/beta-components/data-grid/__tests__/pdf.test.ts +0 -209
- package/source/beta-components/date-picker/__tests__/core.test.ts +0 -273
- package/source/beta-components/dialog/__tests__/core.test.ts +0 -86
- package/source/beta-components/file-uploader/__tests__/core.test.ts +0 -395
- package/source/beta-components/input/__tests__/core.test.ts +0 -75
- package/source/beta-components/menu/__tests__/core.test.ts +0 -120
- package/source/beta-components/modal/__tests__/core.test.ts +0 -55
- package/source/beta-components/number-input/__tests__/core.test.ts +0 -151
- package/source/beta-components/progress/__tests__/core.test.ts +0 -56
- package/source/beta-components/radio-group/__tests__/core.test.ts +0 -64
- package/source/beta-components/shared/__tests__/boundaries.test.ts +0 -95
- package/source/beta-components/shared/__tests__/core.test.ts +0 -55
- package/source/beta-components/shared/__tests__/position.test.ts +0 -143
- package/source/beta-components/skeleton/__tests__/core.test.ts +0 -41
- package/source/beta-components/spinner/__tests__/core.test.ts +0 -48
- package/source/beta-components/switch/__tests__/core.test.ts +0 -64
- package/source/beta-components/tabs/__tests__/core.test.ts +0 -51
- package/source/beta-components/textarea/__tests__/core.test.ts +0 -38
- package/source/beta-components/toaster/__tests__/core.test.ts +0 -256
- package/source/beta-components/tooltip/__tests__/core.test.ts +0 -42
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Undo by snapshot, not by inverse.
|
|
3
|
+
*
|
|
4
|
+
* Writing an inverse for every operation means writing it again for every
|
|
5
|
+
* operation added later, and getting it wrong once is a corrupted document.
|
|
6
|
+
* When the state is small and serialisable — which is the precondition for a
|
|
7
|
+
* component to be agent-operable at all — storing it whole is both cheaper to
|
|
8
|
+
* maintain and exactly correct.
|
|
9
|
+
*
|
|
10
|
+
* The history never reads or writes state itself; it is handed `read` and
|
|
11
|
+
* `write` and knows nothing about what is in there.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export interface SnapshotEntry<S> {
|
|
15
|
+
/** Human-readable, for an audit trail: "Filter Revenue greater than 100000". */
|
|
16
|
+
label: string;
|
|
17
|
+
before: S;
|
|
18
|
+
after: S;
|
|
19
|
+
at: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface SnapshotHistoryOptions<S> {
|
|
23
|
+
read(): S;
|
|
24
|
+
write(snapshot: S): void;
|
|
25
|
+
/** Entries kept. Older ones fall off the back. Default 50. */
|
|
26
|
+
limit?: number;
|
|
27
|
+
/** Injected in tests so entries are comparable. Defaults to `Date.now`. */
|
|
28
|
+
now?(): number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface SnapshotHistory<S> {
|
|
32
|
+
/**
|
|
33
|
+
* Snapshots, runs `apply`, snapshots again, and records the pair. A run that
|
|
34
|
+
* leaves the state untouched records nothing, so a no-op never costs an undo.
|
|
35
|
+
*
|
|
36
|
+
* An `apply` that returns a promise is awaited before the second snapshot is
|
|
37
|
+
* taken, so an operation that finishes asynchronously is still recorded with
|
|
38
|
+
* the state it actually produced.
|
|
39
|
+
*/
|
|
40
|
+
record<R>(label: string, apply: () => R): R;
|
|
41
|
+
undo(): boolean;
|
|
42
|
+
redo(): boolean;
|
|
43
|
+
canUndo(): boolean;
|
|
44
|
+
canRedo(): boolean;
|
|
45
|
+
/** Oldest first. The entry at `index() - 1` is the one `undo` would reverse. */
|
|
46
|
+
entries(): readonly SnapshotEntry<S>[];
|
|
47
|
+
index(): number;
|
|
48
|
+
clear(): void;
|
|
49
|
+
subscribe(listener: () => void): () => void;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function createSnapshotHistory<S>(options: SnapshotHistoryOptions<S>): SnapshotHistory<S> {
|
|
53
|
+
const { read, write, limit = 50, now = Date.now } = options;
|
|
54
|
+
let entries: SnapshotEntry<S>[] = [];
|
|
55
|
+
let index = 0;
|
|
56
|
+
const listeners = new Set<() => void>();
|
|
57
|
+
|
|
58
|
+
const notify = () => {
|
|
59
|
+
for (const listener of listeners) listener();
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
return {
|
|
63
|
+
record(label, apply) {
|
|
64
|
+
const before = read();
|
|
65
|
+
|
|
66
|
+
const commit = () => {
|
|
67
|
+
const after = read();
|
|
68
|
+
if (Object.is(before, after)) return;
|
|
69
|
+
// A new branch discards whatever redo was holding.
|
|
70
|
+
entries = [...entries.slice(0, index), { label, before, after, at: now() }];
|
|
71
|
+
if (entries.length > limit) entries = entries.slice(entries.length - limit);
|
|
72
|
+
index = entries.length;
|
|
73
|
+
notify();
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const result = apply();
|
|
77
|
+
if (result instanceof Promise) {
|
|
78
|
+
return result.then((value) => {
|
|
79
|
+
commit();
|
|
80
|
+
return value;
|
|
81
|
+
}) as typeof result;
|
|
82
|
+
}
|
|
83
|
+
commit();
|
|
84
|
+
return result;
|
|
85
|
+
},
|
|
86
|
+
|
|
87
|
+
undo() {
|
|
88
|
+
if (index === 0) return false;
|
|
89
|
+
index -= 1;
|
|
90
|
+
write(entries[index].before);
|
|
91
|
+
notify();
|
|
92
|
+
return true;
|
|
93
|
+
},
|
|
94
|
+
|
|
95
|
+
redo() {
|
|
96
|
+
if (index >= entries.length) return false;
|
|
97
|
+
write(entries[index].after);
|
|
98
|
+
index += 1;
|
|
99
|
+
notify();
|
|
100
|
+
return true;
|
|
101
|
+
},
|
|
102
|
+
|
|
103
|
+
canUndo: () => index > 0,
|
|
104
|
+
canRedo: () => index < entries.length,
|
|
105
|
+
entries: () => entries,
|
|
106
|
+
index: () => index,
|
|
107
|
+
|
|
108
|
+
clear() {
|
|
109
|
+
if (entries.length === 0) return;
|
|
110
|
+
entries = [];
|
|
111
|
+
index = 0;
|
|
112
|
+
notify();
|
|
113
|
+
},
|
|
114
|
+
|
|
115
|
+
subscribe(listener) {
|
|
116
|
+
listeners.add(listener);
|
|
117
|
+
return () => listeners.delete(listener);
|
|
118
|
+
},
|
|
119
|
+
};
|
|
120
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* The component-agnostic half of the agent runtime. Nothing here knows about
|
|
3
|
+
* a grid, a date picker, or React.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export type {
|
|
7
|
+
ConfirmRequest,
|
|
8
|
+
ExecutionContext,
|
|
9
|
+
ExecutorRegistry,
|
|
10
|
+
JsonSchema,
|
|
11
|
+
JsonSchemaType,
|
|
12
|
+
OperationDefinition,
|
|
13
|
+
OperationExecutor,
|
|
14
|
+
RegisteredExecutor,
|
|
15
|
+
ValidationIssue,
|
|
16
|
+
ValidationLayer,
|
|
17
|
+
ValidationResult,
|
|
18
|
+
} from "./types";
|
|
19
|
+
export { fail, ok } from "./types";
|
|
20
|
+
export { checkSchema, type SchemaIssue } from "./schema";
|
|
21
|
+
export {
|
|
22
|
+
createSnapshotHistory,
|
|
23
|
+
type SnapshotEntry,
|
|
24
|
+
type SnapshotHistory,
|
|
25
|
+
type SnapshotHistoryOptions,
|
|
26
|
+
} from "./history";
|
|
27
|
+
export { parseQuantity, type ParseQuantityOptions, type Quantity } from "./numbers";
|
|
28
|
+
export {
|
|
29
|
+
buildToolInputSchema,
|
|
30
|
+
registerAgentTool,
|
|
31
|
+
webmcpAvailable,
|
|
32
|
+
type ModelContext,
|
|
33
|
+
type ProjectableAgent,
|
|
34
|
+
type ProjectedCommand,
|
|
35
|
+
type ProjectedOutcome,
|
|
36
|
+
type RegisterAgentToolOptions,
|
|
37
|
+
type RegistrationResult,
|
|
38
|
+
type ToolCallLog,
|
|
39
|
+
type ToolResultPayload,
|
|
40
|
+
} from "./webmcp";
|
|
41
|
+
export type {
|
|
42
|
+
AgentAdapter,
|
|
43
|
+
AgentProposalRequest,
|
|
44
|
+
AskOutcome,
|
|
45
|
+
AskPhase,
|
|
46
|
+
} from "./adapter";
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Reading a number the way a person wrote it.
|
|
3
|
+
*
|
|
4
|
+
* "1 lakh", "₹1,00,000", "20%", "1.5k", "(2,400)" all denote numbers, and a
|
|
5
|
+
* language model should not be the thing that converts them — it will be
|
|
6
|
+
* confidently wrong about crore once in a while, and there is no way to tell
|
|
7
|
+
* which time. This is deterministic code with a test for every form it claims
|
|
8
|
+
* to read, which is what the coercion layer of the validator is for.
|
|
9
|
+
*
|
|
10
|
+
* Indian digit grouping (1,00,000) needs no special case: grouping separators
|
|
11
|
+
* are stripped wholesale, so 1,00,000 and 100,000 land on the same number.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export interface Quantity {
|
|
15
|
+
value: number;
|
|
16
|
+
/** The written form carried a percent sign or the word "percent". */
|
|
17
|
+
percent: boolean;
|
|
18
|
+
/** ISO code for the currency marker found, if any. */
|
|
19
|
+
currency: string | null;
|
|
20
|
+
/** The scale word that was applied, e.g. "lakh". Null when none was. */
|
|
21
|
+
scale: string | null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const CURRENCY_SYMBOLS: Record<string, string> = {
|
|
25
|
+
"₹": "INR", // ₹
|
|
26
|
+
$: "USD",
|
|
27
|
+
"€": "EUR", // €
|
|
28
|
+
"£": "GBP", // £
|
|
29
|
+
"¥": "JPY", // ¥
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const CURRENCY_WORDS: Record<string, string> = {
|
|
33
|
+
inr: "INR",
|
|
34
|
+
rs: "INR",
|
|
35
|
+
rupee: "INR",
|
|
36
|
+
rupees: "INR",
|
|
37
|
+
usd: "USD",
|
|
38
|
+
dollar: "USD",
|
|
39
|
+
dollars: "USD",
|
|
40
|
+
eur: "EUR",
|
|
41
|
+
euro: "EUR",
|
|
42
|
+
euros: "EUR",
|
|
43
|
+
gbp: "GBP",
|
|
44
|
+
pound: "GBP",
|
|
45
|
+
pounds: "GBP",
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Scale words, longest first so "crores" is consumed before "cr" can match a
|
|
50
|
+
* prefix of it.
|
|
51
|
+
*/
|
|
52
|
+
const SCALES: ReadonlyArray<readonly [string, number]> = [
|
|
53
|
+
["crores", 1e7],
|
|
54
|
+
["crore", 1e7],
|
|
55
|
+
["cr", 1e7],
|
|
56
|
+
["lakhs", 1e5],
|
|
57
|
+
["lakh", 1e5],
|
|
58
|
+
["lacs", 1e5],
|
|
59
|
+
["lac", 1e5],
|
|
60
|
+
["billion", 1e9],
|
|
61
|
+
["billions", 1e9],
|
|
62
|
+
["bn", 1e9],
|
|
63
|
+
["million", 1e6],
|
|
64
|
+
["millions", 1e6],
|
|
65
|
+
["mn", 1e6],
|
|
66
|
+
["thousand", 1e3],
|
|
67
|
+
["thousands", 1e3],
|
|
68
|
+
["hundred", 1e2],
|
|
69
|
+
["hundreds", 1e2],
|
|
70
|
+
["k", 1e3],
|
|
71
|
+
["m", 1e6],
|
|
72
|
+
["b", 1e9],
|
|
73
|
+
];
|
|
74
|
+
|
|
75
|
+
const PERCENT_WORDS = ["percent", "percentage", "pct", "pc"];
|
|
76
|
+
|
|
77
|
+
/*
|
|
78
|
+
* Numbers written as words.
|
|
79
|
+
*
|
|
80
|
+
* "Revenue above one crore" is a sentence people type, and it used to fail
|
|
81
|
+
* here — which was worse than it sounds, because the layer above tells a model
|
|
82
|
+
* to pass quantities through *exactly as written* rather than convert them. So
|
|
83
|
+
* the model did as it was told and the conversion it had been relieved of
|
|
84
|
+
* simply never happened.
|
|
85
|
+
*
|
|
86
|
+
* Deliberately bounded: cardinals up to ninety-nine, and the scale words
|
|
87
|
+
* already in `SCALES` do the rest ("five hundred", "ten lakh", "two crore").
|
|
88
|
+
* `a`/`an` are left out — "a crore" is natural Indian English, but mapping a
|
|
89
|
+
* bare article to 1 would turn any stray "a" into a number, and silently
|
|
90
|
+
* reading junk as 1 is worse than refusing it.
|
|
91
|
+
*/
|
|
92
|
+
const ONES: Record<string, number> = {
|
|
93
|
+
zero: 0, one: 1, two: 2, three: 3, four: 4, five: 5, six: 6, seven: 7,
|
|
94
|
+
eight: 8, nine: 9, ten: 10, eleven: 11, twelve: 12, thirteen: 13,
|
|
95
|
+
fourteen: 14, fifteen: 15, sixteen: 16, seventeen: 17, eighteen: 18,
|
|
96
|
+
nineteen: 19,
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
const TENS: Record<string, number> = {
|
|
100
|
+
twenty: 20, thirty: 30, forty: 40, fifty: 50,
|
|
101
|
+
sixty: 60, seventy: 70, eighty: 80, ninety: 90,
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
/** Replaces number words with digits, so the rest of the parser sees digits. */
|
|
105
|
+
function digitiseWords(text: string): string {
|
|
106
|
+
const tens = Object.keys(TENS).join("|");
|
|
107
|
+
const ones = Object.keys(ONES).join("|");
|
|
108
|
+
|
|
109
|
+
return text
|
|
110
|
+
// Compounds first: "twenty five" and "twenty-five" are one number.
|
|
111
|
+
.replace(new RegExp(`\\b(${tens})[\\s-](${ones})\\b`, "g"), (_, ten, one) =>
|
|
112
|
+
String(TENS[ten] + ONES[one]),
|
|
113
|
+
)
|
|
114
|
+
.replace(new RegExp(`\\b(${tens})\\b`, "g"), (word) => String(TENS[word]))
|
|
115
|
+
.replace(new RegExp(`\\b(${ones})\\b`, "g"), (word) => String(ONES[word]));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export interface ParseQuantityOptions {
|
|
119
|
+
/** Which character separates the fractional part. Default `"."`. */
|
|
120
|
+
decimal?: "." | ",";
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Reads a written quantity, or returns null when the text is not one.
|
|
125
|
+
*
|
|
126
|
+
* Null means "this is not a number", which the caller should surface as a
|
|
127
|
+
* validation failure rather than silently coercing to 0 or NaN.
|
|
128
|
+
*/
|
|
129
|
+
export function parseQuantity(
|
|
130
|
+
input: string | number,
|
|
131
|
+
options: ParseQuantityOptions = {},
|
|
132
|
+
): Quantity | null {
|
|
133
|
+
if (typeof input === "number") {
|
|
134
|
+
return Number.isFinite(input)
|
|
135
|
+
? { value: input, percent: false, currency: null, scale: null }
|
|
136
|
+
: null;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
let text = digitiseWords(input.trim().toLowerCase());
|
|
140
|
+
if (text === "") return null;
|
|
141
|
+
|
|
142
|
+
let negative = false;
|
|
143
|
+
// Accountants' parentheses.
|
|
144
|
+
if (text.startsWith("(") && text.endsWith(")")) {
|
|
145
|
+
negative = true;
|
|
146
|
+
text = text.slice(1, -1).trim();
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
let currency: string | null = null;
|
|
150
|
+
for (const [symbol, code] of Object.entries(CURRENCY_SYMBOLS)) {
|
|
151
|
+
if (text.includes(symbol)) {
|
|
152
|
+
currency = code;
|
|
153
|
+
text = text.split(symbol).join(" ");
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
let percent = false;
|
|
158
|
+
if (text.includes("%")) {
|
|
159
|
+
percent = true;
|
|
160
|
+
text = text.split("%").join(" ");
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Split off alphabetic words so currency and scale can be matched exactly
|
|
164
|
+
// rather than by substring, which would see "lakh" inside "lakhsomething".
|
|
165
|
+
const words = text.match(/[a-z.]+/g) ?? [];
|
|
166
|
+
for (const word of words) {
|
|
167
|
+
const bare = word.replace(/\.$/, "");
|
|
168
|
+
if (CURRENCY_WORDS[bare]) {
|
|
169
|
+
currency ??= CURRENCY_WORDS[bare];
|
|
170
|
+
text = text.replace(word, " ");
|
|
171
|
+
} else if (PERCENT_WORDS.includes(bare)) {
|
|
172
|
+
percent = true;
|
|
173
|
+
text = text.replace(word, " ");
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
let scale: string | null = null;
|
|
178
|
+
let multiplier = 1;
|
|
179
|
+
for (const [word, factor] of SCALES) {
|
|
180
|
+
// Either a separate word, or glued to the digits as in "1.5k".
|
|
181
|
+
const pattern = new RegExp(`(^|[\\s\\d.])${word}\\s*$`);
|
|
182
|
+
if (pattern.test(text)) {
|
|
183
|
+
scale = word;
|
|
184
|
+
multiplier = factor;
|
|
185
|
+
text = text.replace(new RegExp(`${word}\\s*$`), " ");
|
|
186
|
+
break;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const decimal = options.decimal ?? ".";
|
|
191
|
+
let digits = text.replace(/\s+/g, "");
|
|
192
|
+
if (digits.startsWith("-")) {
|
|
193
|
+
negative = true;
|
|
194
|
+
digits = digits.slice(1);
|
|
195
|
+
} else if (digits.startsWith("+")) {
|
|
196
|
+
digits = digits.slice(1);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
if (decimal === ",") {
|
|
200
|
+
digits = digits.split(".").join("");
|
|
201
|
+
digits = digits.replace(",", ".");
|
|
202
|
+
} else {
|
|
203
|
+
digits = digits.split(",").join("");
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (digits === "" || !/^\d*\.?\d*$/.test(digits) || !/\d/.test(digits)) return null;
|
|
207
|
+
|
|
208
|
+
const magnitude = Number(digits);
|
|
209
|
+
if (!Number.isFinite(magnitude)) return null;
|
|
210
|
+
|
|
211
|
+
const value = (negative ? -1 : 1) * magnitude * multiplier;
|
|
212
|
+
// Scaling by 1e5 and friends drifts in binary floating point; the inputs are
|
|
213
|
+
// human-written, so rounding back to a sane precision is the honest answer.
|
|
214
|
+
const rounded = multiplier === 1 ? value : Number(value.toPrecision(15));
|
|
215
|
+
|
|
216
|
+
return { value: rounded, percent, currency, scale };
|
|
217
|
+
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* A JSON Schema checker for the subset in `types.ts`.
|
|
3
|
+
*
|
|
4
|
+
* Written rather than installed, for the same reason the rest of the library
|
|
5
|
+
* ships no dependencies: a component you copy into your project must not drag
|
|
6
|
+
* a validator in with it. The subset is small enough that the whole thing fits
|
|
7
|
+
* on one screen, and large enough to express every intent schema we generate.
|
|
8
|
+
*
|
|
9
|
+
* `oneOf` is where the care goes. A union of discriminated branches that all
|
|
10
|
+
* fail produces one useless error ("matched no branch") unless you work out
|
|
11
|
+
* which branch the caller *meant*. So branches are scored by how many of their
|
|
12
|
+
* `const` discriminators matched, and the errors reported come from the best
|
|
13
|
+
* candidate.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type { JsonSchema, JsonSchemaType } from "./types";
|
|
17
|
+
|
|
18
|
+
export interface SchemaIssue {
|
|
19
|
+
path: string;
|
|
20
|
+
code: string;
|
|
21
|
+
message: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const typeOf = (value: unknown): JsonSchemaType => {
|
|
25
|
+
if (value === null) return "null";
|
|
26
|
+
if (Array.isArray(value)) return "array";
|
|
27
|
+
if (typeof value === "number") return Number.isInteger(value) ? "integer" : "number";
|
|
28
|
+
if (typeof value === "string") return "string";
|
|
29
|
+
if (typeof value === "boolean") return "boolean";
|
|
30
|
+
return "object";
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const matchesType = (value: unknown, expected: JsonSchemaType): boolean => {
|
|
34
|
+
const actual = typeOf(value);
|
|
35
|
+
if (expected === "number") return actual === "number" || actual === "integer";
|
|
36
|
+
return actual === expected;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const show = (value: unknown): string =>
|
|
40
|
+
typeof value === "string" ? JSON.stringify(value) : String(value);
|
|
41
|
+
|
|
42
|
+
const join = (path: string, key: string | number): string =>
|
|
43
|
+
typeof key === "number" ? `${path}[${key}]` : path === "" ? key : `${path}.${key}`;
|
|
44
|
+
|
|
45
|
+
/** Every `const`-valued property this schema pins, for scoring `oneOf` branches. */
|
|
46
|
+
function discriminators(schema: JsonSchema): [string, unknown][] {
|
|
47
|
+
const out: [string, unknown][] = [];
|
|
48
|
+
for (const [key, child] of Object.entries(schema.properties ?? {})) {
|
|
49
|
+
if (child.const !== undefined) out.push([key, child.const]);
|
|
50
|
+
}
|
|
51
|
+
return out;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function scoreBranch(value: unknown, schema: JsonSchema): number {
|
|
55
|
+
if (typeOf(value) !== "object") return 0;
|
|
56
|
+
const record = value as Record<string, unknown>;
|
|
57
|
+
let score = 0;
|
|
58
|
+
for (const [key, expected] of discriminators(schema)) {
|
|
59
|
+
if (record[key] === expected) score += 1;
|
|
60
|
+
}
|
|
61
|
+
return score;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function checkInto(out: SchemaIssue[], value: unknown, schema: JsonSchema, path: string): void {
|
|
65
|
+
const add = (code: string, message: string, at = path) => out.push({ path: at, code, message });
|
|
66
|
+
|
|
67
|
+
if (schema.oneOf) {
|
|
68
|
+
const results = schema.oneOf.map((branch) => {
|
|
69
|
+
const issues: SchemaIssue[] = [];
|
|
70
|
+
checkInto(issues, value, branch, path);
|
|
71
|
+
return { branch, issues, score: scoreBranch(value, branch) };
|
|
72
|
+
});
|
|
73
|
+
const passing = results.filter((r) => r.issues.length === 0);
|
|
74
|
+
if (passing.length === 1) return;
|
|
75
|
+
if (passing.length > 1) {
|
|
76
|
+
add("ambiguous-union", "value matches more than one schema branch");
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
// Prefer the branch whose discriminators matched; fall back to the nearest miss.
|
|
80
|
+
const best = results.reduce((a, b) =>
|
|
81
|
+
b.score > a.score || (b.score === a.score && b.issues.length < a.issues.length) ? b : a,
|
|
82
|
+
);
|
|
83
|
+
if (best.score === 0) {
|
|
84
|
+
add("no-matching-branch", "value does not match any permitted shape");
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
out.push(...best.issues);
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (schema.anyOf) {
|
|
92
|
+
const passes = schema.anyOf.some((branch) => {
|
|
93
|
+
const issues: SchemaIssue[] = [];
|
|
94
|
+
checkInto(issues, value, branch, path);
|
|
95
|
+
return issues.length === 0;
|
|
96
|
+
});
|
|
97
|
+
if (!passes) add("no-matching-branch", "value does not match any permitted shape");
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (schema.const !== undefined && value !== schema.const) {
|
|
102
|
+
add("const", `expected ${show(schema.const)}`);
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (schema.enum && !schema.enum.some((option) => option === value)) {
|
|
107
|
+
add("enum", `expected one of ${schema.enum.map(show).join(", ")}, got ${show(value)}`);
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (schema.type !== undefined) {
|
|
112
|
+
const expected = Array.isArray(schema.type) ? schema.type : [schema.type];
|
|
113
|
+
if (!expected.some((t) => matchesType(value, t))) {
|
|
114
|
+
add("type", `expected ${expected.join(" or ")}, got ${typeOf(value)}`);
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (typeof value === "number") {
|
|
120
|
+
if (schema.minimum !== undefined && value < schema.minimum)
|
|
121
|
+
add("minimum", `must be at least ${schema.minimum}`);
|
|
122
|
+
if (schema.maximum !== undefined && value > schema.maximum)
|
|
123
|
+
add("maximum", `must be at most ${schema.maximum}`);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (typeof value === "string") {
|
|
127
|
+
if (schema.minLength !== undefined && value.length < schema.minLength)
|
|
128
|
+
add("min-length", `must be at least ${schema.minLength} characters`);
|
|
129
|
+
if (schema.maxLength !== undefined && value.length > schema.maxLength)
|
|
130
|
+
add("max-length", `must be at most ${schema.maxLength} characters`);
|
|
131
|
+
/*
|
|
132
|
+
* `pattern` compiles to a RegExp, so a catastrophic one is a denial of
|
|
133
|
+
* service. Every schema this library generates comes from a live runtime
|
|
134
|
+
* contract and carries no pattern at all. If you validate against a schema
|
|
135
|
+
* that travelled with an installed component — a passport's
|
|
136
|
+
* `operations[].input`, say — strip `pattern` first.
|
|
137
|
+
*/
|
|
138
|
+
if (schema.pattern !== undefined && !new RegExp(schema.pattern).test(value))
|
|
139
|
+
add("pattern", `must match ${schema.pattern}`);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (Array.isArray(value)) {
|
|
143
|
+
if (schema.minItems !== undefined && value.length < schema.minItems)
|
|
144
|
+
add("min-items", `needs at least ${schema.minItems} item(s)`);
|
|
145
|
+
if (schema.maxItems !== undefined && value.length > schema.maxItems)
|
|
146
|
+
add("max-items", `takes at most ${schema.maxItems} item(s)`);
|
|
147
|
+
if (schema.items) {
|
|
148
|
+
value.forEach((item, i) => checkInto(out, item, schema.items!, join(path, i)));
|
|
149
|
+
}
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (typeOf(value) === "object") {
|
|
154
|
+
const record = value as Record<string, unknown>;
|
|
155
|
+
for (const key of schema.required ?? []) {
|
|
156
|
+
if (record[key] === undefined) add("required", `"${key}" is required`, join(path, key));
|
|
157
|
+
}
|
|
158
|
+
for (const [key, child] of Object.entries(schema.properties ?? {})) {
|
|
159
|
+
if (record[key] !== undefined) checkInto(out, record[key], child, join(path, key));
|
|
160
|
+
}
|
|
161
|
+
if (schema.additionalProperties === false) {
|
|
162
|
+
const known = new Set(Object.keys(schema.properties ?? {}));
|
|
163
|
+
for (const key of Object.keys(record)) {
|
|
164
|
+
if (!known.has(key)) add("additional-property", `"${key}" is not a known field`, join(path, key));
|
|
165
|
+
}
|
|
166
|
+
} else if (schema.additionalProperties && typeof schema.additionalProperties === "object") {
|
|
167
|
+
const known = new Set(Object.keys(schema.properties ?? {}));
|
|
168
|
+
for (const [key, item] of Object.entries(record)) {
|
|
169
|
+
if (!known.has(key)) checkInto(out, item, schema.additionalProperties, join(path, key));
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Every way `value` fails `schema`. An empty array means it passed. */
|
|
176
|
+
export function checkSchema(value: unknown, schema: JsonSchema): SchemaIssue[] {
|
|
177
|
+
const out: SchemaIssue[] = [];
|
|
178
|
+
checkInto(out, value, schema, "");
|
|
179
|
+
return out;
|
|
180
|
+
}
|