@stapel/attributes-react 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 (74) hide show
  1. package/README.md +123 -0
  2. package/dist/default/FeatureBadges.d.ts +33 -0
  3. package/dist/default/FeatureBadges.d.ts.map +1 -0
  4. package/dist/default/FeatureBadges.js +56 -0
  5. package/dist/default/FeatureBadges.js.map +1 -0
  6. package/dist/default/FeatureFields.d.ts +52 -0
  7. package/dist/default/FeatureFields.d.ts.map +1 -0
  8. package/dist/default/FeatureFields.js +60 -0
  9. package/dist/default/FeatureFields.js.map +1 -0
  10. package/dist/default/editors.d.ts +24 -0
  11. package/dist/default/editors.d.ts.map +1 -0
  12. package/dist/default/editors.js +354 -0
  13. package/dist/default/editors.js.map +1 -0
  14. package/dist/default/index.d.ts +27 -0
  15. package/dist/default/index.d.ts.map +1 -0
  16. package/dist/default/index.js +25 -0
  17. package/dist/default/index.js.map +1 -0
  18. package/dist/dto.d.ts +40 -0
  19. package/dist/dto.d.ts.map +1 -0
  20. package/dist/dto.js +64 -0
  21. package/dist/dto.js.map +1 -0
  22. package/dist/errors.d.ts +57 -0
  23. package/dist/errors.d.ts.map +1 -0
  24. package/dist/errors.js +98 -0
  25. package/dist/errors.js.map +1 -0
  26. package/dist/format.d.ts +46 -0
  27. package/dist/format.d.ts.map +1 -0
  28. package/dist/format.js +159 -0
  29. package/dist/format.js.map +1 -0
  30. package/dist/i18n/es.d.ts +13 -0
  31. package/dist/i18n/es.d.ts.map +1 -0
  32. package/dist/i18n/es.js +36 -0
  33. package/dist/i18n/es.js.map +1 -0
  34. package/dist/i18n/keys.d.ts +56 -0
  35. package/dist/i18n/keys.d.ts.map +1 -0
  36. package/dist/i18n/keys.js +80 -0
  37. package/dist/i18n/keys.js.map +1 -0
  38. package/dist/i18n/ru.d.ts +19 -0
  39. package/dist/i18n/ru.d.ts.map +1 -0
  40. package/dist/i18n/ru.js +42 -0
  41. package/dist/i18n/ru.js.map +1 -0
  42. package/dist/index.d.ts +58 -0
  43. package/dist/index.d.ts.map +1 -0
  44. package/dist/index.js +54 -0
  45. package/dist/index.js.map +1 -0
  46. package/dist/registry.d.ts +122 -0
  47. package/dist/registry.d.ts.map +1 -0
  48. package/dist/registry.js +82 -0
  49. package/dist/registry.js.map +1 -0
  50. package/dist/types.d.ts +134 -0
  51. package/dist/types.d.ts.map +1 -0
  52. package/dist/types.js +35 -0
  53. package/dist/types.js.map +1 -0
  54. package/dist/validate.d.ts +88 -0
  55. package/dist/validate.d.ts.map +1 -0
  56. package/dist/validate.js +410 -0
  57. package/dist/validate.js.map +1 -0
  58. package/manifest.json +96 -0
  59. package/package.json +107 -0
  60. package/src/default/FeatureBadges.tsx +123 -0
  61. package/src/default/FeatureFields.tsx +140 -0
  62. package/src/default/editors.tsx +578 -0
  63. package/src/default/index.ts +34 -0
  64. package/src/dto.ts +78 -0
  65. package/src/errors.ts +127 -0
  66. package/src/format.ts +210 -0
  67. package/src/i18n/es.ts +41 -0
  68. package/src/i18n/keys.ts +89 -0
  69. package/src/i18n/ru.ts +48 -0
  70. package/src/index.ts +98 -0
  71. package/src/registry.ts +167 -0
  72. package/src/types.ts +166 -0
  73. package/src/validate.ts +507 -0
  74. package/tsconfig.json +26 -0
@@ -0,0 +1,507 @@
1
+ /**
2
+ * The client-side validation MIRROR — instant feedback, never a verdict.
3
+ *
4
+ * "Client-side validation mirrors, server decides." Every rule here is
5
+ * derived from the feature's own `config` so a person sees a problem as they
6
+ * type instead of after a round trip; none of it is trusted. The server
7
+ * re-runs `stapel_attributes.validate_dto_structured` on
8
+ * `POST /categories/{pk}/validate-dto/` and again on
9
+ * `POST /listings/{pk}/publish/`, and its `ValidationBatchResult` is the one
10
+ * that counts. This function returns the SAME shape so a server answer can
11
+ * replace a mirrored one with no translation step.
12
+ *
13
+ * ── Two contract details that are easy to get subtly wrong ─────────────────
14
+ *
15
+ * 1. **`pattern` matches the WHOLE value.** The engine uses `re.fullmatch`,
16
+ * and the admin JS mirrors it as `^(?:<pattern>)$`
17
+ * (`stapel-attributes/MODULE.md`, "Pattern contract"). A bare
18
+ * `RegExp.test` is a PREFIX match, so `^\d{4}$`-less patterns would pass
19
+ * here and fail there — the mirror telling a person their input is fine
20
+ * right before the server refuses it.
21
+ *
22
+ * 2. **String length is counted in Unicode CODE POINTS**, on both sides —
23
+ * same source. JavaScript's `String.length` counts UTF-16 code units, so
24
+ * one emoji is 2 and one astral CJK ideograph is 2. A `maxLength: 10`
25
+ * field would refuse ten emoji locally and accept them server-side.
26
+ *
27
+ * ── What the mirror deliberately does NOT judge ────────────────────────────
28
+ *
29
+ * - **A value type it does not know.** An unknown `config.type` may be a
30
+ * perfectly valid `EXTRA_TYPES` registration whose rules live only in
31
+ * Python. The mirror runs the type-independent checks (mandatory/empty) and
32
+ * leaves the rest to the server — a mirror that invented a refusal for a
33
+ * type it cannot read would block a submit the backend would have accepted.
34
+ * Whether such a type can be DRAWN is a separate question, answered loudly
35
+ * by `unsupportedTypes`/`unsupportedTypeGate`.
36
+ *
37
+ * - **`convertible_unit` range.** `min`/`max` are expressed in the unit
38
+ * family's canonical base unit and the value is converted before comparison
39
+ * (`stapel_attributes.types.convertible_unit`), and the family table is
40
+ * Python-side. Mirroring the comparison without the conversion would report
41
+ * "too large" for a perfectly good number in the other unit system. The
42
+ * number and the unit code ARE checked; the range is the server's.
43
+ */
44
+ import type {
45
+ FeatureDef,
46
+ FeatureValidationResult,
47
+ FeaturesDto,
48
+ FeatureValueDto,
49
+ ValidationBatchResult,
50
+ ValidationErrorCode,
51
+ } from "./types.js";
52
+ import { featureConfig, featureName, featureType } from "./types.js";
53
+ import { ERROR_CODE_TO_KEY } from "./errors.js";
54
+
55
+ /** `stapel_attributes.types.hex_color.constants.SIMPLE_COLORS` — the closed
56
+ * set of colour categories a `hex_color` value must name. */
57
+ export const SIMPLE_COLORS: readonly string[] = [
58
+ "black",
59
+ "white",
60
+ "gray",
61
+ "silver",
62
+ "red",
63
+ "pink",
64
+ "orange",
65
+ "yellow",
66
+ "green",
67
+ "blue",
68
+ "purple",
69
+ "brown",
70
+ "gold",
71
+ "beige",
72
+ "turquoise",
73
+ "clear",
74
+ "multicolor",
75
+ "custom",
76
+ ];
77
+
78
+ const HEX_PATTERN = /^#(?:[0-9a-fA-F]{3}){1,2}$/;
79
+ const TRUE_STRINGS = new Set(["true", "1", "yes", "on"]);
80
+ const FALSE_STRINGS = new Set(["false", "0", "no", "off"]);
81
+
82
+ /** What a rule reports: the engine's machine code plus the constraint that
83
+ * was violated, exactly as `FeatureValidationError` carries them. */
84
+ interface Refusal {
85
+ readonly code: ValidationErrorCode;
86
+ readonly ref_value?: unknown;
87
+ }
88
+
89
+ /** Length in Unicode code points — the unit BOTH sides count in. */
90
+ export function codePointLength(text: string): number {
91
+ return [...text].length;
92
+ }
93
+
94
+ /**
95
+ * Does `pattern` match the WHOLE of `text`? `re.fullmatch`, in JavaScript.
96
+ *
97
+ * A pattern JS cannot compile (a Python-only construct) is NOT the person's
98
+ * problem: the mirror stands down and lets the server, which compiled it, be
99
+ * the one to refuse. The `u` flag is tried first because it is what makes `.`
100
+ * and quantifiers count code points rather than surrogate halves, and dropped
101
+ * when a pattern is only valid without it.
102
+ */
103
+ export function patternFullMatch(pattern: string, text: string): boolean | undefined {
104
+ const anchored = `^(?:${pattern})$`;
105
+ for (const flags of ["u", ""]) {
106
+ try {
107
+ return new RegExp(anchored, flags).test(text);
108
+ } catch {
109
+ continue;
110
+ }
111
+ }
112
+ return undefined;
113
+ }
114
+
115
+ function num(config: Readonly<Record<string, unknown>>, key: string): number | undefined {
116
+ const raw = config[key];
117
+ return typeof raw === "number" && Number.isFinite(raw) ? raw : undefined;
118
+ }
119
+
120
+ function list(config: Readonly<Record<string, unknown>>, key: string): readonly unknown[] {
121
+ const raw = config[key];
122
+ return Array.isArray(raw) ? raw : [];
123
+ }
124
+
125
+ /** An option's value, from either shape the engine's types allow (a bare
126
+ * scalar, or an object with `value`). */
127
+ function optionValue(option: unknown): unknown {
128
+ if (option !== null && typeof option === "object" && "value" in option) {
129
+ return (option as { value: unknown }).value;
130
+ }
131
+ return option;
132
+ }
133
+
134
+ /**
135
+ * "Nothing was entered" — the one shape the mandatory rule fires on, and the
136
+ * engine's own predicate: `raw_value is None or raw_value == '' or
137
+ * raw_value == []` (`validation.py`). An explicit `false` is an ANSWER for a
138
+ * `bool`, and `0` is an answer for an `int`, which is why this is not a
139
+ * falsiness check.
140
+ */
141
+ export function isBlank(value: unknown): boolean {
142
+ if (value === undefined || value === null) return true;
143
+ if (typeof value === "string") return value === "";
144
+ if (Array.isArray(value)) return value.length === 0;
145
+ return false;
146
+ }
147
+
148
+ // ── per-type rules ───────────────────────────────────────────────────────────
149
+
150
+ function validateString(
151
+ config: Readonly<Record<string, unknown>>,
152
+ value: unknown
153
+ ): Refusal | undefined {
154
+ const text = typeof value === "string" ? value : String(value);
155
+ const length = codePointLength(text);
156
+ const minLength = num(config, "minLength");
157
+ const maxLength = num(config, "maxLength");
158
+ if (minLength !== undefined && length < minLength) {
159
+ return { code: "below_minimum", ref_value: minLength };
160
+ }
161
+ if (maxLength !== undefined && length > maxLength) {
162
+ return { code: "above_maximum", ref_value: maxLength };
163
+ }
164
+ const pattern = config["pattern"];
165
+ if (typeof pattern === "string" && pattern.length > 0) {
166
+ const matched = patternFullMatch(pattern, text);
167
+ if (matched === false) return { code: "invalid_format", ref_value: pattern };
168
+ }
169
+ const options = list(config, "options");
170
+ // `allowCustom` absent means TRUE for `string` (the dataclass default), so
171
+ // an options list without an explicit `allowCustom: false` constrains
172
+ // nothing. Reading an absent key as "closed set" would refuse values the
173
+ // server accepts.
174
+ if (options.length > 0 && config["allowCustom"] === false) {
175
+ if (!options.some((option) => optionValue(option) === text)) {
176
+ return { code: "not_in_options", ref_value: [...options].map(optionValue) };
177
+ }
178
+ }
179
+ return undefined;
180
+ }
181
+
182
+ function validateNumber(
183
+ config: Readonly<Record<string, unknown>>,
184
+ value: unknown,
185
+ isInt: boolean
186
+ ): Refusal | undefined {
187
+ const parsed = typeof value === "number" ? value : Number(value);
188
+ if (typeof value === "boolean" || !Number.isFinite(parsed)) {
189
+ return { code: "invalid_type" };
190
+ }
191
+ // `int`'s normalizer is Python's `int()`, which TRUNCATES toward zero
192
+ // rather than refusing a fractional input — so the range check runs on the
193
+ // truncated number, exactly as it does server-side.
194
+ const number = isInt ? Math.trunc(parsed) : parsed;
195
+ const min = num(config, "min");
196
+ const max = num(config, "max");
197
+ if (min !== undefined && number < min) return { code: "below_minimum", ref_value: min };
198
+ if (max !== undefined && number > max) return { code: "above_maximum", ref_value: max };
199
+ const options = list(config, "options");
200
+ if (options.length > 0 && config["allowCustom"] === false) {
201
+ const precision = isInt ? 0 : (num(config, "precision") ?? 2);
202
+ const round = (n: number): number => Number(n.toFixed(precision));
203
+ if (!options.some((option) => round(Number(optionValue(option))) === round(number))) {
204
+ return { code: "not_in_options", ref_value: [...options].map(optionValue) };
205
+ }
206
+ }
207
+ return undefined;
208
+ }
209
+
210
+ function validateBool(value: unknown): Refusal | undefined {
211
+ if (typeof value === "boolean") return undefined;
212
+ if (typeof value === "number") return undefined; // bool(n), server-side
213
+ if (typeof value === "string") {
214
+ const lower = value.toLowerCase();
215
+ return TRUE_STRINGS.has(lower) || FALSE_STRINGS.has(lower)
216
+ ? undefined
217
+ : { code: "invalid_type" };
218
+ }
219
+ return { code: "invalid_type" };
220
+ }
221
+
222
+ function validateSelect(
223
+ config: Readonly<Record<string, unknown>>,
224
+ value: unknown
225
+ ): Refusal | undefined {
226
+ if (!Array.isArray(value)) return { code: "invalid_type" };
227
+ const minSelected = num(config, "minSelected") ?? 0;
228
+ const maxSelected = num(config, "maxSelected");
229
+ if (value.length < minSelected) {
230
+ return { code: "below_minimum", ref_value: minSelected };
231
+ }
232
+ if (maxSelected !== undefined && value.length > maxSelected) {
233
+ return { code: "above_maximum", ref_value: maxSelected };
234
+ }
235
+ if (new Set(value).size !== value.length) return { code: "invalid_format" };
236
+ const allowed = list(config, "options").map((option) => optionValue(option));
237
+ for (const item of value) {
238
+ if (typeof item !== "string") return { code: "invalid_type" };
239
+ if (!allowed.includes(item)) {
240
+ return {
241
+ code: "not_in_options",
242
+ ref_value: [...allowed].map(String).sort(),
243
+ };
244
+ }
245
+ }
246
+ return undefined;
247
+ }
248
+
249
+ function validateDate(
250
+ config: Readonly<Record<string, unknown>>,
251
+ value: unknown
252
+ ): Refusal | undefined {
253
+ // The engine's `normalize_dto` coerces int/float/numeric-string to an int
254
+ // and turns ANYTHING else into `None`, which its `validate_dto` then
255
+ // accepts. So a garbage date is silently dropped rather than refused — a
256
+ // known upstream wart, mirrored rather than "improved", because a mirror
257
+ // that refuses what the server accepts is a mirror that blocks a valid
258
+ // submit. (`stapel_attributes.types.date.type.DateFeatureType`.)
259
+ const timestamp =
260
+ typeof value === "number" && Number.isFinite(value)
261
+ ? Math.trunc(value)
262
+ : typeof value === "string" && /^-?\d+$/.test(value.trim())
263
+ ? Number.parseInt(value.trim(), 10)
264
+ : undefined;
265
+ if (timestamp === undefined) return undefined;
266
+ const now = Math.floor(Date.now() / 1000);
267
+ if (config["allowFuture"] === false && timestamp > now) {
268
+ return { code: "above_maximum", ref_value: now };
269
+ }
270
+ if (config["allowPast"] === false && timestamp < now) {
271
+ return { code: "below_minimum", ref_value: now };
272
+ }
273
+ const minDate = num(config, "minDate");
274
+ const maxDate = num(config, "maxDate");
275
+ if (minDate !== undefined && timestamp < minDate) {
276
+ return { code: "below_minimum", ref_value: minDate };
277
+ }
278
+ if (maxDate !== undefined && timestamp > maxDate) {
279
+ return { code: "above_maximum", ref_value: maxDate };
280
+ }
281
+ return undefined;
282
+ }
283
+
284
+ function validateHexColor(
285
+ config: Readonly<Record<string, unknown>>,
286
+ value: unknown
287
+ ): Refusal | undefined {
288
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
289
+ return { code: "invalid_type" };
290
+ }
291
+ const entry = value as { hex?: unknown; simple?: unknown };
292
+ if (entry.hex !== undefined && entry.hex !== null) {
293
+ if (typeof entry.hex !== "string") return { code: "invalid_type" };
294
+ const hex = entry.hex.trim();
295
+ if (hex.length > 0 && !HEX_PATTERN.test(hex)) return { code: "invalid_format" };
296
+ }
297
+ if (entry.simple === undefined || entry.simple === null || entry.simple === "") {
298
+ return { code: "invalid_format" };
299
+ }
300
+ if (typeof entry.simple !== "string") return { code: "invalid_type" };
301
+ if (!SIMPLE_COLORS.includes(entry.simple)) {
302
+ return { code: "not_in_options", ref_value: [...SIMPLE_COLORS] };
303
+ }
304
+ const options = list(config, "options");
305
+ if (options.length > 0 && config["allowCustom"] !== true) {
306
+ const matched = options.some(
307
+ (option) =>
308
+ option !== null &&
309
+ typeof option === "object" &&
310
+ (option as { simple?: unknown }).simple === entry.simple
311
+ );
312
+ if (!matched) {
313
+ return {
314
+ code: "not_in_options",
315
+ ref_value: options.map((option) =>
316
+ option !== null && typeof option === "object"
317
+ ? (option as { simple?: unknown }).simple
318
+ : option
319
+ ),
320
+ };
321
+ }
322
+ }
323
+ return undefined;
324
+ }
325
+
326
+ function findOption(
327
+ options: readonly unknown[],
328
+ value: string
329
+ ): { readonly children?: unknown } | undefined {
330
+ for (const option of options) {
331
+ if (option !== null && typeof option === "object") {
332
+ if ((option as { value?: unknown }).value === value) {
333
+ return option as { children?: unknown };
334
+ }
335
+ } else if (option === value) {
336
+ return {};
337
+ }
338
+ }
339
+ return undefined;
340
+ }
341
+
342
+ function validateHierarchicalSelect(
343
+ config: Readonly<Record<string, unknown>>,
344
+ value: unknown
345
+ ): Refusal | undefined {
346
+ if (!Array.isArray(value)) return { code: "invalid_type" };
347
+ if (value.length === 0) return undefined; // the empty pre-check already ruled
348
+ const minDepth = num(config, "minDepth") ?? 1;
349
+ const maxDepth = num(config, "maxDepth");
350
+ if (value.length < minDepth) return { code: "below_minimum", ref_value: minDepth };
351
+ if (maxDepth !== undefined && value.length > maxDepth) {
352
+ return { code: "above_maximum", ref_value: maxDepth };
353
+ }
354
+ let level: readonly unknown[] = list(config, "options");
355
+ for (const step of value) {
356
+ if (typeof step !== "string") return { code: "invalid_type" };
357
+ const option = findOption(level, step);
358
+ if (option === undefined) {
359
+ return { code: "not_in_options", ref_value: level.map(optionValue) };
360
+ }
361
+ level = Array.isArray(option.children) ? option.children : [];
362
+ }
363
+ return undefined;
364
+ }
365
+
366
+ function validateConvertibleUnit(
367
+ config: Readonly<Record<string, unknown>>,
368
+ dto: FeatureValueDto
369
+ ): Refusal | undefined {
370
+ if (dto.value !== null && dto.value !== undefined) {
371
+ const parsed = typeof dto.value === "number" ? dto.value : Number(dto.value);
372
+ if (typeof dto.value === "boolean" || !Number.isFinite(parsed)) {
373
+ return { code: "invalid_type" };
374
+ }
375
+ }
376
+ const unit = dto["unit"];
377
+ if (unit === undefined || unit === null) return undefined;
378
+ const allowed = [config["unit_m"], config["unit_i"]].filter(
379
+ (code): code is string => typeof code === "string" && code.length > 0
380
+ );
381
+ if (!allowed.includes(String(unit))) {
382
+ return { code: "not_in_options", ref_value: allowed };
383
+ }
384
+ // `min`/`max` are in the family's BASE unit and the conversion table is
385
+ // server-side — see this module's header.
386
+ return undefined;
387
+ }
388
+
389
+ /**
390
+ * Validate one submitted value against its feature's config. Returns the
391
+ * mirrored refusal, or `undefined` when this side of the wire is satisfied.
392
+ *
393
+ * `header` is never validated: the engine regenerates a header's DAO from its
394
+ * config and skips it outright in the batch validator, so a header has
395
+ * nothing to check and must never carry a value.
396
+ */
397
+ export function validateFeatureValue(
398
+ feature: FeatureDef,
399
+ dto: FeatureValueDto
400
+ ): FeatureValidationResult | undefined {
401
+ const type = featureType(feature);
402
+ if (type === "header") return undefined;
403
+ const config = featureConfig(feature);
404
+
405
+ if (isBlank(dto.value)) {
406
+ return feature.mandatory === true
407
+ ? failed(feature, { code: "mandatory_missing" })
408
+ : ok(feature);
409
+ }
410
+
411
+ const refusal = ((): Refusal | undefined => {
412
+ switch (type) {
413
+ case "string":
414
+ return validateString(config, dto.value);
415
+ case "int":
416
+ return validateNumber(config, dto.value, true);
417
+ case "float":
418
+ return validateNumber(config, dto.value, false);
419
+ case "bool":
420
+ return validateBool(dto.value);
421
+ case "select":
422
+ return validateSelect(config, dto.value);
423
+ case "date":
424
+ return validateDate(config, dto.value);
425
+ case "hex_color":
426
+ return validateHexColor(config, dto.value);
427
+ case "hierarchical_select":
428
+ return validateHierarchicalSelect(config, dto.value);
429
+ case "convertible_unit":
430
+ return validateConvertibleUnit(config, dto);
431
+ default:
432
+ // An unknown type is the server's to judge — see the module header.
433
+ return undefined;
434
+ }
435
+ })();
436
+
437
+ return refusal === undefined ? ok(feature) : failed(feature, refusal);
438
+ }
439
+
440
+ function ok(feature: FeatureDef): FeatureValidationResult {
441
+ return {
442
+ slug: feature.slug,
443
+ status: "ok",
444
+ ...(feature.id === undefined || feature.id === null ? {} : { id: feature.id }),
445
+ };
446
+ }
447
+
448
+ function failed(feature: FeatureDef, refusal: Refusal): FeatureValidationResult {
449
+ return {
450
+ slug: feature.slug,
451
+ status: "validation_failed",
452
+ error: refusal.code,
453
+ localizable_error: ERROR_CODE_TO_KEY[refusal.code],
454
+ // The engine's own params, verbatim: `{feature, slug}`. `field` is added
455
+ // later, by `featureErrorsBySlug`, so the mirror's rows and the server's
456
+ // rows go through the same one step.
457
+ params: { feature: featureName(feature), slug: feature.slug },
458
+ ...(feature.id === undefined || feature.id === null ? {} : { id: feature.id }),
459
+ ...(refusal.ref_value === undefined ? {} : { ref_value: refusal.ref_value }),
460
+ };
461
+ }
462
+
463
+ /**
464
+ * Validate a whole answer set against a category's features — the client-side
465
+ * twin of `POST /categories/{pk}/validate-dto/`.
466
+ *
467
+ * Mirrors the engine's two passes and their order, because the order is what
468
+ * a caller sees: first every SUBMITTED entry whose slug the category allows
469
+ * (an unknown slug is ignored, not refused — the engine's documented
470
+ * behaviour), then every allowed feature that was never submitted, of which
471
+ * only a mandatory non-header one produces a row.
472
+ */
473
+ export function mirrorValidate(
474
+ features: readonly FeatureDef[],
475
+ dto: FeaturesDto
476
+ ): ValidationBatchResult {
477
+ const bySlug = new Map<string, FeatureDef>();
478
+ for (const feature of features) {
479
+ bySlug.set(feature.slug, feature);
480
+ if (feature.id !== undefined && feature.id !== null) {
481
+ bySlug.set(String(feature.id), feature);
482
+ }
483
+ }
484
+
485
+ const results: FeatureValidationResult[] = [];
486
+ const seen = new Set<string>();
487
+
488
+ for (const [key, entry] of Object.entries(dto)) {
489
+ const feature = bySlug.get(key);
490
+ if (feature === undefined) continue; // unknown slug — ignored, per the engine
491
+ seen.add(feature.slug);
492
+ const result = validateFeatureValue(feature, entry);
493
+ if (result !== undefined) results.push(result);
494
+ }
495
+
496
+ for (const feature of features) {
497
+ if (seen.has(feature.slug)) continue;
498
+ if (featureType(feature) === "header") continue;
499
+ if (feature.mandatory !== true) continue;
500
+ results.push(failed(feature, { code: "mandatory_missing" }));
501
+ }
502
+
503
+ return {
504
+ valid: results.every((result) => result.status === "ok"),
505
+ results,
506
+ };
507
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,26 @@
1
+ {
2
+ "$schema": "https://json.schemastore.org/tsconfig",
3
+ "_comment": "Self-contained on purpose: standalone-buildable per frontend-standard §7. Mirrors the root tsconfig.base.json settings.",
4
+ "compilerOptions": {
5
+ "target": "ES2022",
6
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
7
+ "module": "ESNext",
8
+ "moduleResolution": "bundler",
9
+ "jsx": "react-jsx",
10
+ "strict": true,
11
+ "noUncheckedIndexedAccess": true,
12
+ "noImplicitOverride": true,
13
+ "exactOptionalPropertyTypes": true,
14
+ "isolatedModules": true,
15
+ "isolatedDeclarations": true,
16
+ "verbatimModuleSyntax": true,
17
+ "declaration": true,
18
+ "declarationMap": true,
19
+ "sourceMap": true,
20
+ "skipLibCheck": true,
21
+ "forceConsistentCasingInFileNames": true,
22
+ "outDir": "dist",
23
+ "rootDir": "src"
24
+ },
25
+ "include": ["src"]
26
+ }