@johnhenry/graceholder 0.0.0-stage → 0.1.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.
Files changed (64) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +492 -2
  3. package/STABILITY.md +24 -0
  4. package/package.json +126 -4
  5. package/src/adapters/apollo.d.ts +13 -0
  6. package/src/adapters/apollo.js +48 -0
  7. package/src/adapters/fetch.d.ts +15 -0
  8. package/src/adapters/fetch.js +150 -0
  9. package/src/adapters/shared.d.ts +14 -0
  10. package/src/adapters/shared.js +65 -0
  11. package/src/adapters/urql.d.ts +8 -0
  12. package/src/adapters/urql.js +53 -0
  13. package/src/anchor.js +13 -0
  14. package/src/args.js +41 -0
  15. package/src/bulk/csv.js +118 -0
  16. package/src/bulk/ndjson.js +10 -0
  17. package/src/bulk/plan.js +195 -0
  18. package/src/bulk/shape.js +67 -0
  19. package/src/bulk/source.js +24 -0
  20. package/src/bulk/sql.js +366 -0
  21. package/src/bulk/stream.js +55 -0
  22. package/src/cursor.js +25 -0
  23. package/src/dbg.d.ts +720 -0
  24. package/src/detmath.js +74 -0
  25. package/src/directives.js +113 -0
  26. package/src/dist.js +90 -0
  27. package/src/errors.js +17 -0
  28. package/src/execute.js +577 -0
  29. package/src/explain.js +473 -0
  30. package/src/fast-check.d.ts +31 -0
  31. package/src/fast-check.js +58 -0
  32. package/src/generate.js +84 -0
  33. package/src/generator-types.d.ts +90 -0
  34. package/src/generators.js +361 -0
  35. package/src/image.js +55 -0
  36. package/src/index.d.ts +507 -0
  37. package/src/index.js +62 -0
  38. package/src/infer.js +888 -0
  39. package/src/key.js +30 -0
  40. package/src/learn.js +397 -0
  41. package/src/naming.js +74 -0
  42. package/src/pack-registry.js +59 -0
  43. package/src/packs/PROVENANCE.md +26 -0
  44. package/src/packs/commerce.d.ts +4 -0
  45. package/src/packs/commerce.js +83 -0
  46. package/src/packs/finance.d.ts +4 -0
  47. package/src/packs/finance.js +180 -0
  48. package/src/packs/geo.d.ts +4 -0
  49. package/src/packs/geo.js +227 -0
  50. package/src/packs/health.d.ts +4 -0
  51. package/src/packs/health.js +76 -0
  52. package/src/packs/index.d.ts +7 -0
  53. package/src/packs/index.js +9 -0
  54. package/src/rng.js +89 -0
  55. package/src/rules.js +228 -0
  56. package/src/scenario.js +120 -0
  57. package/src/schema.js +148 -0
  58. package/src/spec.js +235 -0
  59. package/src/suggest.js +138 -0
  60. package/src/typed.d.ts +723 -0
  61. package/src/vocab/PROVENANCE.md +12 -0
  62. package/src/vocab/en.js +106 -0
  63. package/src/world.js +461 -0
  64. package/src/xxh64.js +87 -0
@@ -0,0 +1,90 @@
1
+ // GENERATED by scripts/gen-generator-types.mjs from the runtime catalogue. Do not edit.
2
+ /** The JSON type each named generator produces (used by the type-level query parser for @gen). */
3
+ export interface GeneratorTypes {
4
+ "bool": boolean;
5
+ "color": string;
6
+ "commerce.brand": string;
7
+ "commerce.category": string;
8
+ "commerce.price": number;
9
+ "commerce.product": string;
10
+ "commerce.sku": string;
11
+ "company.domain": string;
12
+ "company.industry": string;
13
+ "company.name": string;
14
+ "count.of": number;
15
+ "datetime": string;
16
+ "finance.accountNumber": string;
17
+ "finance.accountType": string;
18
+ "finance.bic": string;
19
+ "finance.cardNumber": string;
20
+ "finance.currency": string;
21
+ "finance.currencyName": string;
22
+ "finance.currencySymbol": string;
23
+ "finance.iban": string;
24
+ "finance.merchant": string;
25
+ "finance.routingNumber": string;
26
+ "finance.transactionType": string;
27
+ "float": number;
28
+ "geo.callingCode": string;
29
+ "geo.countryCode": string;
30
+ "geo.region": string;
31
+ "geo.regionCode": string;
32
+ "geo.timezone": string;
33
+ "health.condition": string;
34
+ "health.dosage": string;
35
+ "health.medication": string;
36
+ "health.recordNumber": string;
37
+ "health.severity": string;
38
+ "health.specialty": string;
39
+ "hex": string;
40
+ "id.int": string | number;
41
+ "id.key": string;
42
+ "id.uuid": string;
43
+ "image.avatar": string;
44
+ "image.geometric": string;
45
+ "imageUrl": string;
46
+ "int": number;
47
+ "ip": string;
48
+ "lorem": string;
49
+ "money": number;
50
+ "oneOf": unknown;
51
+ "order.subtotal": number;
52
+ "order.tax": number;
53
+ "order.total": number;
54
+ "paragraph": string;
55
+ "pattern": string;
56
+ "percent": number;
57
+ "person.age": number;
58
+ "person.bio": string;
59
+ "person.email": string;
60
+ "person.first": string;
61
+ "person.full": string;
62
+ "person.jobTitle": string;
63
+ "person.last": string;
64
+ "person.phone": string;
65
+ "person.username": string;
66
+ "place.address": string;
67
+ "place.city": string;
68
+ "place.country": string;
69
+ "place.lat": number;
70
+ "place.lng": number;
71
+ "place.state": string;
72
+ "place.street": string;
73
+ "place.zip": string;
74
+ "product.name": string;
75
+ "sentence": string;
76
+ "sentences": string;
77
+ "series.date": string;
78
+ "series.value": number;
79
+ "slug": string;
80
+ "text": string;
81
+ "time.birthday": string;
82
+ "time.created": string;
83
+ "time.deleted": string | null;
84
+ "time.published": string;
85
+ "time.updated": string;
86
+ "title": string;
87
+ "url": string;
88
+ "word": string;
89
+ "words": string;
90
+ }
@@ -0,0 +1,361 @@
1
+ // Generator catalogue (docs/design.md section 7). Generators are pure functions of a
2
+ // ctx. `ctx.stream` is local to one key; `ctx.slot(name)` is the stream of one persona
3
+ // slot (scope "p"), so coherent fields read the same slots and adding a slot never
4
+ // changes another. Output for a given draw sequence and vocabulary is FROZEN at 1.0.0.
5
+ import { LOCALES } from "./vocab/en.js";
6
+ import { GraceholderError } from "./errors.js";
7
+ import { distArgs, sampleInt, sampleReal, normal01 } from "./dist.js";
8
+ import { sin, TWO_PI } from "./detmath.js";
9
+ import { avatarSvg, geometricSvg, svgDataUri, PALETTE } from "./image.js";
10
+ import { personaKind, tokenize } from "./naming.js";
11
+
12
+ const DAY_MS = 86400000;
13
+ const YEAR_MS = 365.25 * DAY_MS;
14
+ const HEX = "0123456789abcdef";
15
+ const LOWER = "abcdefghijklmnopqrstuvwxyz";
16
+ const UPPER = LOWER.toUpperCase();
17
+ const ALNUM = LOWER + UPPER + "0123456789";
18
+
19
+ const cap = (s) => s[0].toUpperCase() + s.slice(1);
20
+ const pad = (n, w) => String(n).padStart(w, "0");
21
+ const badArgs = (name, msg) => new GraceholderError("bad-generator-args", `${name}: ${msg}`);
22
+
23
+ const AVATAR_WORDS = new Set(["avatar", "profile", "headshot", "portrait"]);
24
+
25
+ const SPECIAL_FOLD = { "ø": "o", "Ø": "o", "ł": "l", "Ł": "l", "æ": "ae", "Æ": "ae", "ß": "ss", "đ": "d", "ð": "d", "þ": "th" };
26
+
27
+ /** Lowercase ASCII-only form of a name, for usernames and email local parts. */
28
+ export function asciiFold(text) {
29
+ let out = "";
30
+ for (const ch of text) out += SPECIAL_FOLD[ch] ?? ch;
31
+ return out.normalize("NFD").replace(/\p{M}/gu, "").toLowerCase().replace(/[^a-z0-9]/g, "");
32
+ }
33
+
34
+ function intArg(name, args, key, dflt) {
35
+ const v = args[key] ?? dflt;
36
+ if (!Number.isFinite(v)) throw badArgs(name, `${key} must be a number`);
37
+ return v;
38
+ }
39
+
40
+ function range(name, args, dMin, dMax) {
41
+ const min = intArg(name, args, "min", dMin);
42
+ const max = intArg(name, args, "max", dMax);
43
+ if (min > max) throw badArgs(name, `min (${min}) is greater than max (${max})`);
44
+ return [min, max];
45
+ }
46
+
47
+ const hexString = (s, n) => {
48
+ let out = "";
49
+ for (let i = 0; i < n; i++) out += HEX[s.int(0, 15)];
50
+ return out;
51
+ };
52
+
53
+ function pattern(name, p, s) {
54
+ if (typeof p !== "string") throw badArgs(name, "p must be a string");
55
+ let out = "";
56
+ for (let i = 0; i < p.length; i++) {
57
+ const c = p[i];
58
+ if (c === "\\") out += p[++i] ?? "";
59
+ else if (c === "#") out += s.int(0, 9);
60
+ else if (c === "?") out += LOWER[s.int(0, 25)];
61
+ else if (c === "A") out += UPPER[s.int(0, 25)];
62
+ else if (c === "*") out += ALNUM[s.int(0, ALNUM.length - 1)];
63
+ else out += c;
64
+ }
65
+ return out;
66
+ }
67
+
68
+ const round = (x, decimals) => {
69
+ const f = 10 ** decimals;
70
+ return Math.round(x * f) / f;
71
+ };
72
+
73
+ /** Age of the persona's `created` slot, in ms before the anchor: 30 to 1500 days. */
74
+ const createdAge = (ctx) => ctx.slot("created").int(30 * DAY_MS, 1500 * DAY_MS);
75
+ const updatedAge = (ctx) => {
76
+ const c = createdAge(ctx);
77
+ return c - ctx.slot("updated").int(0, c);
78
+ };
79
+
80
+ /** @param {typeof LOCALES.en} V */
81
+ function buildBase(V) {
82
+ const wordList = (s, n) => Array.from({ length: n }, () => s.pick(V.words));
83
+ const sentence = (s) => cap(wordList(s, s.int(6, 14)).join(" ")) + ".";
84
+ const sentences = (s, n) => Array.from({ length: n }, () => sentence(s)).join(" ");
85
+
86
+ const first = (ctx) => ctx.pinned?.first ?? ctx.slot("first").pick(V.firstNames);
87
+ const last = (ctx) => ctx.pinned?.last ?? ctx.slot("last").pick(V.lastNames);
88
+ const place = (ctx) => ctx.slot("place").pick(V.places);
89
+ const street = (ctx) => {
90
+ const s = ctx.slot("street");
91
+ return `${s.int(1, 9999)} ${s.pick(V.streets)} ${s.pick(V.streetSuffixes)}`;
92
+ };
93
+ const postal = (ctx, row) => {
94
+ const s = ctx.slot("zip");
95
+ return /^[0-9]/.test(row.zip) ? row.zip + pad(s.int(0, 99), 2) : `${row.zip} ${s.int(0, 9)}${UPPER[s.int(0, 25)]}${s.int(0, 9)}`;
96
+ };
97
+ const orgName = (ctx) => `${ctx.slot("orgHead").pick(V.companyHeads)} ${ctx.slot("orgSuffix").pick(V.companySuffixes)}`;
98
+ const age = (ctx) => ctx.slot("age").int(18, 90);
99
+
100
+ const registry = {
101
+ // ids
102
+ "id.key": ({ entityKey, stream }) => (/^[0-9]+$/.test(entityKey) ? entityKey : String(stream.int(1, 999999))),
103
+ "id.uuid": ({ stream: s }) => {
104
+ const h = hexString(s, 32).split("");
105
+ h[12] = "4";
106
+ h[16] = "89ab"[s.int(0, 3)];
107
+ const j = h.join("");
108
+ return `${j.slice(0, 8)}-${j.slice(8, 12)}-${j.slice(12, 16)}-${j.slice(16, 20)}-${j.slice(20)}`;
109
+ },
110
+ "id.int": ({ stream: s, args }) => {
111
+ const [min, max] = range("id.int", args, 1, 1000);
112
+ const v = s.int(min, max);
113
+ return args.string ? String(v) : v;
114
+ },
115
+ // numbers
116
+ int: ({ stream: s, args }) => {
117
+ const [min, max] = range("int", args, 0, 100);
118
+ const dist = distArgs("int", args);
119
+ if (dist === "uniform") return s.int(min, max);
120
+ return sampleInt(s, dist, Math.ceil(min), Math.floor(max), args);
121
+ },
122
+ float: ({ stream: s, args }) => {
123
+ const [min, max] = range("float", args, 0, 1);
124
+ const decimals = intArg("float", args, "decimals", 2);
125
+ const dist = distArgs("float", args);
126
+ if (dist !== "uniform") return Math.min(max, Math.max(min, round(sampleReal(s, dist, min, max, args), decimals)));
127
+ return Math.min(max, Math.max(min, round(min + s.float() * (max - min), decimals)));
128
+ },
129
+ // whole cents; endings .00, .99, .49 or any cents, weighted
130
+ money: ({ stream: s, args }) => {
131
+ const [min, max] = range("money", args, 1, 100);
132
+ const lo = Math.round(min * 100);
133
+ const hi = Math.round(max * 100);
134
+ const dist = distArgs("money", args);
135
+ if (dist !== "uniform") return Math.min(hi, Math.max(lo, Math.round(sampleReal(s, dist, min, max, args) * 100))) / 100;
136
+ const minD = Math.ceil(min);
137
+ const maxD = Math.floor(max);
138
+ if (maxD < minD) return s.int(lo, hi) / 100;
139
+ const dollars = s.int(minD, maxD);
140
+ const ending = s.weighted([[0, 3], [1, 4], [2, 2], [3, 3]]);
141
+ const cents = ending === 0 ? 0 : ending === 1 ? 99 : ending === 2 ? 49 : s.int(0, 99);
142
+ return Math.min(hi, Math.max(lo, dollars * 100 + cents)) / 100;
143
+ },
144
+ percent: ({ stream: s }) => round(s.float() * 100, 2),
145
+ bool: ({ stream: s, args }) => s.bool(args.p ?? 0.5),
146
+ // text
147
+ word: ({ stream: s }) => s.pick(V.words),
148
+ words: ({ stream: s, args }) => wordList(s, intArg("words", args, "n", 2)).join(" "),
149
+ sentence: ({ stream: s }) => sentence(s),
150
+ sentences: ({ stream: s, args }) => sentences(s, intArg("sentences", args, "n", 2)),
151
+ paragraph: ({ stream: s }) => sentences(s, s.int(3, 6)),
152
+ // vocabulary words joined by spaces, cut to a length drawn from [min, max] characters (specFromSamples)
153
+ text: ({ stream: s, args }) => {
154
+ const [min, max] = range("text", args, 0, 40);
155
+ if (!Number.isInteger(min) || !Number.isInteger(max) || min < 0) throw badArgs("text", "min and max must be non-negative integers");
156
+ const len = s.int(min, max);
157
+ let out = "";
158
+ while (out.length < len) out += (out ? " " : "") + s.pick(V.words);
159
+ out = out.slice(0, len);
160
+ return out.endsWith(" ") ? out.slice(0, -1) + "e" : out;
161
+ },
162
+ title: ({ stream: s }) => wordList(s, s.int(3, 6)).map(cap).join(" "),
163
+ slug: ({ stream: s }) => wordList(s, s.int(2, 4)).join("-"),
164
+ lorem: ({ stream: s, args }) =>
165
+ Array.from({ length: intArg("lorem", args, "paragraphs", 1) }, () => sentences(s, s.int(3, 6))).join("\n\n"),
166
+ // person: every field reads the persona slots, so they agree with each other
167
+ "person.first": (ctx) => first(ctx),
168
+ "person.last": (ctx) => last(ctx),
169
+ "person.full": (ctx) => `${first(ctx)} ${last(ctx)}`,
170
+ "person.username": (ctx) => {
171
+ const f = asciiFold(first(ctx));
172
+ const l = asciiFold(last(ctx));
173
+ const style = ctx.slot("userStyle").int(0, 2);
174
+ if (style === 0) return f[0] + l;
175
+ if (style === 1) return `${f}.${l}`;
176
+ return f + pad(ctx.slot("userNum").int(0, 99), 2);
177
+ },
178
+ "person.email": (ctx) => {
179
+ const f = asciiFold(first(ctx));
180
+ const l = asciiFold(last(ctx));
181
+ const style = ctx.slot("emailStyle").int(0, 2);
182
+ const local = style === 0 ? `${f}.${l}` : style === 1 ? `${f}${l}` : `${f}.${l}${ctx.slot("emailNum").int(1, 99)}`;
183
+ const domain = ctx.slot("domain").weighted(V.emailDomains.map((d, i) => [d, [4, 3, 2, 1, 1][i] ?? 1]));
184
+ return `${local}@${domain}`;
185
+ },
186
+ "person.phone": (ctx) => `(${ctx.slot("phoneArea").int(201, 989)}) 555-01${pad(ctx.slot("phoneLine").int(0, 99), 2)}`,
187
+ "person.jobTitle": (ctx) => ctx.slot("job").pick(V.jobTitles),
188
+ "person.bio": (ctx) => {
189
+ const s = ctx.slot("bio");
190
+ return sentences(s, s.int(1, 2));
191
+ },
192
+ "person.age": (ctx) => age(ctx),
193
+ // org / product
194
+ "company.name": (ctx) => orgName(ctx),
195
+ "company.domain": (ctx) => `${ctx.slot("orgHead").pick(V.companyHeads).toLowerCase()}.example.com`,
196
+ "company.industry": (ctx) => ctx.slot("industry").pick(V.industries),
197
+ "product.name": (ctx) => `${ctx.slot("adj").pick(V.productAdjectives)} ${ctx.slot("noun").pick(V.productNouns)}`,
198
+ // place: one persona row, so city, state, country and zip agree
199
+ "place.city": (ctx) => place(ctx).city,
200
+ "place.state": (ctx) => place(ctx).state,
201
+ "place.country": (ctx) => place(ctx).country,
202
+ "place.zip": (ctx) => postal(ctx, place(ctx)),
203
+ "place.street": (ctx) => street(ctx),
204
+ "place.address": (ctx) => {
205
+ const row = place(ctx);
206
+ return `${street(ctx)}, ${row.city}, ${row.code} ${postal(ctx, row)}`;
207
+ },
208
+ "place.lat": ({ stream: s }) => round(-90 + s.float() * 180, 4),
209
+ "place.lng": ({ stream: s }) => round(-180 + s.float() * 360, 4),
210
+ // web
211
+ url: ({ stream: s }) => `https://${s.pick(V.emailDomains)}/${s.pick(V.words)}-${s.pick(V.words)}`,
212
+ imageUrl: (ctx) => {
213
+ if (ctx.images === "svg") return registry["image.svg"](ctx);
214
+ return `https://images.example.org/${hexString(ctx.stream, 12)}.png`;
215
+ },
216
+ // option images: "svg": avatars show the entity's initials, everything else is a geometric placeholder
217
+ "image.svg": (ctx) => (tokenize(ctx.fieldName).some((t) => AVATAR_WORDS.has(t)) ? registry["image.avatar"](ctx) : registry["image.geometric"](ctx)),
218
+ "image.avatar": (ctx) => {
219
+ const size = intArg("image.avatar", ctx.args, "size", 128);
220
+ if (!Number.isInteger(size) || size < 16 || size > 1024) throw badArgs("image.avatar", "size must be an integer from 16 to 1024");
221
+ const org = personaKind(ctx.typeName) === "org";
222
+ const a = org ? ctx.slot("orgHead").pick(V.companyHeads) : first(ctx);
223
+ const b = org ? ctx.slot("orgSuffix").pick(V.companySuffixes) : last(ctx);
224
+ return svgDataUri(avatarSvg(a, b, ctx.slot("avatarColor").int(0, PALETTE.length - 1), size));
225
+ },
226
+ "image.geometric": ({ stream: s, args }) => {
227
+ const w = intArg("image.geometric", args, "width", 320);
228
+ const h = intArg("image.geometric", args, "height", 240);
229
+ if (!Number.isInteger(w) || !Number.isInteger(h) || w < 16 || h < 16 || w > 4096 || h > 4096) throw badArgs("image.geometric", "width and height must be integers from 16 to 4096");
230
+ return svgDataUri(geometricSvg(s, w, h));
231
+ },
232
+ ip: ({ stream: s }) => [s.int(1, 254), s.int(0, 254), s.int(0, 254), s.int(1, 254)].join("."),
233
+ hex: ({ stream: s, args }) => hexString(s, intArg("hex", args, "length", 16)),
234
+ color: ({ stream: s }) => s.pick(V.colors),
235
+ // time: coherent per entity, relative to the anchor (createdAt <= updatedAt <= anchor)
236
+ datetime: ({ stream: s, args, anchorMs }) => {
237
+ const min = intArg("datetime", args, "minDaysAgo", 0);
238
+ const max = intArg("datetime", args, "maxDaysAgo", 365);
239
+ if (min > max) throw badArgs("datetime", "minDaysAgo is greater than maxDaysAgo");
240
+ const iso = new Date(anchorMs - s.int(Math.round(min * DAY_MS), Math.round(max * DAY_MS))).toISOString();
241
+ if (args.format === undefined || args.format === "datetime") return iso;
242
+ if (args.format === "date") return iso.slice(0, 10);
243
+ throw badArgs("datetime", 'format must be "datetime" or "date"');
244
+ },
245
+ "time.created": (ctx) => new Date(ctx.anchorMs - createdAge(ctx)).toISOString(),
246
+ "time.updated": (ctx) => new Date(ctx.anchorMs - updatedAge(ctx)).toISOString(),
247
+ "time.published": (ctx) => {
248
+ const c = createdAge(ctx);
249
+ return new Date(ctx.anchorMs - (c - ctx.slot("published").int(0, c))).toISOString();
250
+ },
251
+ // null unless args.nullRate < 1; then never before updatedAt and never after the anchor
252
+ "time.deleted": (ctx) => {
253
+ if (ctx.stream.float() < (ctx.args.nullRate ?? 1)) return null;
254
+ const u = updatedAge(ctx);
255
+ return new Date(ctx.anchorMs - (u - ctx.slot("deleted").int(0, u))).toISOString();
256
+ },
257
+ "time.birthday": (ctx) => new Date(ctx.anchorMs - Math.floor(age(ctx) * YEAR_MS) - ctx.slot("birthDay").int(0, 364) * DAY_MS).toISOString(),
258
+ // time series: one series per owning entity and field, one point per list element
259
+ "series.date": ({ anchorMs, series }) => {
260
+ const { i, n } = series ?? { i: 1, n: 1 };
261
+ return new Date(Math.floor(anchorMs / DAY_MS) * DAY_MS - (n - i) * DAY_MS).toISOString();
262
+ },
263
+ "series.value": ({ stream, args, series, slot }) => {
264
+ const { i, n, slot: ss } = series ?? { i: 1, n: 1, slot };
265
+ const min = args.min;
266
+ const max = args.max;
267
+ if ((min !== undefined || max !== undefined) && (!Number.isFinite(min) || !Number.isFinite(max) || min > max)) throw badArgs("series.value", "min and max must both be numbers with min <= max");
268
+ const base = args.base ?? (min === undefined ? 10 + ss("base").float() * 990 : min + (0.25 + 0.5 * ss("base").float()) * (max - min));
269
+ const trend = args.trend ?? -0.3 + 1.1 * ss("trend").float();
270
+ const amp = args.season ?? 0.05 + 0.25 * ss("amp").float();
271
+ const period = args.period ?? ss("period").pick([7, 14, 30]);
272
+ const noise = args.noise ?? 0.04 + 0.08 * ss("noise").float();
273
+ const phase = ss("phase").float();
274
+ const t = n > 1 ? (i - 1) / (n - 1) : 0;
275
+ const z = Math.min(3, Math.max(-3, normal01(stream)));
276
+ const v = Math.max(0, base * (1 + trend * t) * (1 + amp * sin(TWO_PI * ((i - 1) / period + phase))) * (1 + noise * z));
277
+ const out = round(v, 2);
278
+ return min === undefined ? out : Math.min(max, Math.max(min, out));
279
+ },
280
+ // choice
281
+ oneOf: ({ stream: s, args }) => {
282
+ if (args.vocab !== undefined) {
283
+ const table = V[args.vocab];
284
+ if (!Array.isArray(table) || table.length === 0 || typeof table[0] !== "string") {
285
+ throw badArgs("oneOf", `unknown vocabulary ${JSON.stringify(args.vocab)}`);
286
+ }
287
+ return s.pick(table);
288
+ }
289
+ if (!Array.isArray(args.values) || args.values.length === 0) throw badArgs("oneOf", "values must be a non-empty array");
290
+ if (args.values.every((v) => v !== null && typeof v === "object" && "weight" in v)) {
291
+ return s.weighted(args.values.map((v) => [v.value, v.weight]));
292
+ }
293
+ return s.pick(args.values);
294
+ },
295
+ pattern: ({ stream: s, args }) => pattern("pattern", args.p, s),
296
+ };
297
+ return registry;
298
+ }
299
+
300
+ /** Names that are computed by the executor (they need the entity graph), not by a pure generator. */
301
+ export const GRAPH_GENERATORS = Object.freeze(["count.of", "order.subtotal", "order.tax", "order.total"]);
302
+
303
+ const REGISTRIES = new Map();
304
+ /** Generators of one locale plus the generators of the given packs (a later pack overrides an earlier one). */
305
+ function registryFor(locale, packs = []) {
306
+ const key = `${locale}|${packs.map((p) => p.name).join(",")}`;
307
+ let r = REGISTRIES.get(key);
308
+ if (!r) {
309
+ if (!Object.hasOwn(LOCALES, locale)) throw new GraceholderError("unknown-locale", `unknown locale ${JSON.stringify(locale)}`);
310
+ r = buildBase(LOCALES[locale]);
311
+ for (const pack of packs) Object.assign(r, pack.generators({ ...LOCALES[locale], ...pack.vocab }, r));
312
+ REGISTRIES.set(key, r);
313
+ }
314
+ return r;
315
+ }
316
+
317
+ export const GENERATOR_NAMES = Object.freeze([...Object.keys(registryFor("en")), ...GRAPH_GENERATORS].filter((n) => n !== "image.svg"));
318
+
319
+ /** Generator names available with the given packs (the pack generators are included). */
320
+ export function generatorNames(packs = []) {
321
+ if (packs.length === 0) return GENERATOR_NAMES;
322
+ return [...new Set([...GENERATOR_NAMES, ...Object.keys(registryFor("en", packs))])].filter((n) => n !== "image.svg");
323
+ }
324
+
325
+ function distance(a, b) {
326
+ const d = Array.from({ length: a.length + 1 }, (_, i) => [i, ...Array(b.length).fill(0)]);
327
+ for (let j = 1; j <= b.length; j++) d[0][j] = j;
328
+ for (let i = 1; i <= a.length; i++) {
329
+ for (let j = 1; j <= b.length; j++) {
330
+ d[i][j] = Math.min(d[i - 1][j] + 1, d[i][j - 1] + 1, d[i - 1][j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
331
+ }
332
+ }
333
+ return d[a.length][b.length];
334
+ }
335
+
336
+ /** Nearest known generator name, for "did you mean" hints. */
337
+ export function nearestGenerator(name, names = GENERATOR_NAMES) {
338
+ let best = names[0];
339
+ for (const n of names) if (distance(name, n) < distance(name, best)) best = n;
340
+ return best;
341
+ }
342
+
343
+ /**
344
+ * @typedef {{ stream: import("./rng.js").Stream, slot: (name: string) => import("./rng.js").Stream,
345
+ * anchorMs: number, args: Record<string, any>, typeName: string, entityKey: string,
346
+ * fieldName: string, locale?: string, images?: "url"|"svg", packs?: any[],
347
+ * series?: { i: number, n: number, slot: (name: string) => import("./rng.js").Stream } }} GeneratorContext
348
+ * @param {string} name
349
+ * @param {GeneratorContext} ctx
350
+ */
351
+ export function generate(name, ctx) {
352
+ const registry = registryFor(ctx.locale ?? "en", ctx.packs);
353
+ const fn = Object.hasOwn(registry, name) ? registry[name] : undefined;
354
+ if (!fn) {
355
+ const names = generatorNames(ctx.packs);
356
+ throw new GraceholderError("unknown-generator", `unknown generator ${JSON.stringify(name)}`, {
357
+ hint: `did you mean ${JSON.stringify(nearestGenerator(name, names))}? Known: ${names.join(", ")}`,
358
+ });
359
+ }
360
+ return fn(ctx);
361
+ }
package/src/image.js ADDED
@@ -0,0 +1,55 @@
1
+ // Deterministic inline SVG placeholders (option images: "svg"). No network, no fonts beyond
2
+ // the generic `sans-serif`, no clock. Every number comes from the value's own stream or from
3
+ // persona slots, so an avatar's initials agree with the entity's name.
4
+
5
+ const PALETTE = [
6
+ "#1f6feb", "#8250df", "#bf3989", "#cf222e", "#bc4c00", "#9a6700",
7
+ "#1a7f37", "#0f766e", "#0969da", "#6e7781", "#7d4e57", "#3b6ea5",
8
+ ];
9
+
10
+ const esc = (t) => t.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/'/g, "&apos;").replace(/"/g, "&quot;");
11
+
12
+ /** A `data:image/svg+xml` URI. Markup uses single quotes only, so no quote needs escaping in the URI. */
13
+ export function svgDataUri(svg) {
14
+ return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg).replace(/%3D/g, "=").replace(/%3A/g, ":").replace(/%2F/g, "/")}`;
15
+ }
16
+
17
+ const initialOf = (name) => {
18
+ for (const ch of name) {
19
+ if (/\p{L}/u.test(ch)) return ch.toUpperCase();
20
+ }
21
+ return "?";
22
+ };
23
+
24
+ /** Initials on a coloured square; the colour is a persona slot so it is stable per entity. */
25
+ export function avatarSvg(first, last, colorIndex, size = 128) {
26
+ const initials = initialOf(first) + initialOf(last);
27
+ const half = size / 2;
28
+ const fill = PALETTE[colorIndex % PALETTE.length];
29
+ return (
30
+ `<svg xmlns='http://www.w3.org/2000/svg' width='${size}' height='${size}' viewBox='0 0 ${size} ${size}' role='img' aria-label='${esc(initials)}'>` +
31
+ `<rect width='${size}' height='${size}' fill='${fill}'/>` +
32
+ `<text x='${half}' y='${half}' dy='.35em' text-anchor='middle' font-family='sans-serif' font-size='${Math.round(size * 0.42)}' fill='#fff'>${esc(initials)}</text></svg>`
33
+ );
34
+ }
35
+
36
+ /** A background plus three to five translucent shapes, all from the value's stream. */
37
+ export function geometricSvg(s, width = 320, height = 240) {
38
+ const bg = PALETTE[s.int(0, PALETTE.length - 1)];
39
+ const n = s.int(3, 5);
40
+ let body = `<rect width='${width}' height='${height}' fill='${bg}'/>`;
41
+ for (let i = 0; i < n; i++) {
42
+ const fill = PALETTE[s.int(0, PALETTE.length - 1)];
43
+ const opacity = (s.int(35, 80) / 100).toFixed(2);
44
+ const kind = s.int(0, 2);
45
+ const cx = s.int(0, width);
46
+ const cy = s.int(0, height);
47
+ const r = s.int(Math.round(Math.min(width, height) / 8), Math.round(Math.min(width, height) / 2.5));
48
+ if (kind === 0) body += `<circle cx='${cx}' cy='${cy}' r='${r}' fill='${fill}' fill-opacity='${opacity}'/>`;
49
+ else if (kind === 1) body += `<rect x='${cx - r}' y='${cy - r}' width='${2 * r}' height='${Math.round(r * 1.4)}' fill='${fill}' fill-opacity='${opacity}'/>`;
50
+ else body += `<polygon points='${cx},${cy - r} ${cx + r},${cy + r} ${cx - r},${cy + r}' fill='${fill}' fill-opacity='${opacity}'/>`;
51
+ }
52
+ return `<svg xmlns='http://www.w3.org/2000/svg' width='${width}' height='${height}' viewBox='0 0 ${width} ${height}' role='img' aria-label='placeholder'>${body}</svg>`;
53
+ }
54
+
55
+ export { PALETTE };