@jarenjs/forms 0.9.2 → 0.34.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/src/data.js CHANGED
@@ -13,11 +13,19 @@
13
13
  * cached by pointer string and reads are allocation-free.
14
14
  */
15
15
 
16
+ import { equalsJson } from '@jarenjs/core/object';
17
+ import { createBoundedCache } from '@jarenjs/core/cache';
16
18
  import {
17
19
  parseJSONPointer,
18
20
  compileJSONPointer,
21
+ encodeJSONPointerSegment,
19
22
  JSONPOINTER_NOTHING,
20
23
  } from '@jarenjs/json/pointer';
24
+ import {
25
+ compileJSONPointerSetter,
26
+ compileJSONPointerRemover,
27
+ JsonWriteError,
28
+ } from '@jarenjs/json/write';
21
29
 
22
30
  /**
23
31
  * Split a JSON pointer into decoded segments per RFC 6901. '' -> [].
@@ -31,25 +39,17 @@ export function parsePointer(pointer) {
31
39
  return parseJSONPointer(pointer);
32
40
  }
33
41
 
34
- const getterCache = new Map();
35
- const GETTER_CACHE_LIMIT = 512;
42
+ const getterCache = createBoundedCache(512);
36
43
 
37
44
  /**
38
- * Compiled getter for a pointer string, cached FIFO (the same pattern as
39
- * the query engine's string cache): form field pointers are a small,
40
- * stable set, so every keystroke after the first hits the cache.
45
+ * Compiled getter for a pointer string, cached in the shared bounded
46
+ * LRU (`@jarenjs/core/cache`): form field pointers are a small, stable
47
+ * set, so every keystroke after the first hits the cache.
41
48
  * @param {string} pointer
42
49
  * @returns {(root: any) => any}
43
50
  */
44
51
  function getPointerGetter(pointer) {
45
- let getter = getterCache.get(pointer);
46
- if (getter === undefined) {
47
- getter = compileJSONPointer(pointer);
48
- if (getterCache.size >= GETTER_CACHE_LIMIT)
49
- getterCache.delete(getterCache.keys().next().value);
50
- getterCache.set(pointer, getter);
51
- }
52
- return getter;
52
+ return getterCache.getOrCreate(pointer, compileJSONPointer);
53
53
  }
54
54
 
55
55
  /**
@@ -63,49 +63,49 @@ export function getValueAtPointer(data, pointer) {
63
63
  return value === JSONPOINTER_NOTHING ? undefined : value;
64
64
  }
65
65
 
66
- //#region roadmap
67
- // Immutable write ops (set/append/remove by pointer) are a @jarenjs/json
68
- // roadmap item (compiled setters beside the compiled getters, feeding the
69
- // JSON Patch work). Until that lands they live here; parsing already goes
70
- // through the shared parseJSONPointer above.
66
+ //#region write operations
67
+ // One write engine in the whole repo: the copy-on-write kernel behind
68
+ // @jarenjs/json's patch and write modules. Forms adds only its two
69
+ // data disciplines - missing parents are CREATED (a rendered field may
70
+ // be the first write into an untouched branch) and setting `undefined`
71
+ // deletes (parseFieldInput maps a cleared input to undefined).
72
+
73
+ const setterCache = createBoundedCache(512);
74
+ const removerCache = createBoundedCache(512);
75
+
76
+ function getPointerSetter(pointer) {
77
+ return setterCache.getOrCreate(pointer,
78
+ (p) => compileJSONPointerSetter(p, { parents: 'create' }));
79
+ }
80
+
81
+ function getPointerRemover(pointer) {
82
+ return removerCache.getOrCreate(pointer, compileJSONPointerRemover);
83
+ }
71
84
 
72
85
  /**
73
86
  * Return a copy of `data` with the value at `pointer` replaced.
74
- * Setting `undefined` REMOVES the property (array items become undefined
75
- * holes only when explicitly set; use removeItemAt to delete them).
76
- * Missing intermediate containers are created (objects for name segments,
77
- * arrays for numeric segments).
87
+ * Setting `undefined` REMOVES the location (deleting something that does
88
+ * not exist is a no-op returning `data` unchanged). Missing intermediate
89
+ * containers are created (objects for name segments, arrays for numeric
90
+ * segments), and untouched siblings are shared by reference — the same
91
+ * copy-on-write engine as `@jarenjs/json`'s patch and write modules.
78
92
  * @param {any} data
79
93
  * @param {string} pointer
80
94
  * @param {any} value
81
95
  * @returns {any} The new root value
82
96
  */
83
97
  export function setValueAtPointer(data, pointer, value) {
84
- const keys = parsePointer(pointer);
85
- if (keys.length === 0) return value;
86
-
87
- const root = cloneContainer(data, keys[0]);
88
- let current = root;
89
- for (let i = 0; i < keys.length - 1; i++) {
90
- const key = keys[i];
91
- current[key] = cloneContainer(current[key], keys[i + 1]);
92
- current = current[key];
93
- }
94
-
95
- const last = keys[keys.length - 1];
96
- if (value === undefined && !Array.isArray(current)) {
97
- delete current[last];
98
- }
99
- else {
100
- current[last] = value;
98
+ if (value === undefined) {
99
+ if (pointer === '') return undefined;
100
+ try {
101
+ return getPointerRemover(pointer)(data);
102
+ }
103
+ catch (error) {
104
+ if (error instanceof JsonWriteError) return data;
105
+ throw error;
106
+ }
101
107
  }
102
- return root;
103
- }
104
-
105
- function cloneContainer(value, nextKey) {
106
- if (Array.isArray(value)) return value.slice();
107
- if (value != null && typeof value === 'object') return { ...value };
108
- return /^\d+$/.test(String(nextKey)) ? [] : {};
108
+ return getPointerSetter(pointer)(data, value);
109
109
  }
110
110
 
111
111
  /**
@@ -138,6 +138,93 @@ export function appendItem(data, pointer, value) {
138
138
  return setValueAtPointer(data, pointer, next);
139
139
  }
140
140
 
141
+ /**
142
+ * Every pointer whose value differs between two documents, in
143
+ * document order.
144
+ *
145
+ * Membership is significant: an added or removed member (or array tail
146
+ * slot) contributes its pointer even when both sides read back as
147
+ * `null` through a pointer lookup. Reference-equal subtrees are skipped
148
+ * whole, so over copy-on-write edits — which is how every writer in
149
+ * this package produces its next document — the walk costs O(change),
150
+ * not O(document).
151
+ *
152
+ * Two callers rely on it: the session's navigation-guard evidence
153
+ * (`dirtyPaths`) and the rule memo's invalidation set.
154
+ * @param {any} previous
155
+ * @param {any} current
156
+ * @returns {string[]}
157
+ */
158
+ export function changedPointers(previous, current) {
159
+ /** @type {string[]} */
160
+ const out = [];
161
+ collectChanged(previous, current, '', out);
162
+ return out;
163
+ }
164
+
165
+ function collectChanged(previous, current, pointer, out) {
166
+ // Reference equality first, at every level and before any pointer
167
+ // string is built: over a copy-on-write edit almost every member of
168
+ // the touched container is the identical value, and formatting a
169
+ // pointer for each of them would put the document's WIDTH back into a
170
+ // walk whose whole point is to cost only its depth.
171
+ if (previous === current) return;
172
+ if (Array.isArray(previous) && Array.isArray(current)) {
173
+ const shared = Math.min(previous.length, current.length);
174
+ for (let i = 0; i < shared; i++) {
175
+ if (previous[i] !== current[i])
176
+ collectChanged(previous[i], current[i], `${pointer}/${i}`, out);
177
+ }
178
+ const longest = Math.max(previous.length, current.length);
179
+ for (let i = shared; i < longest; i++)
180
+ out.push(`${pointer}/${i}`); // added or removed tail slot
181
+ return;
182
+ }
183
+ if (previous !== null && typeof previous === 'object' && !Array.isArray(previous)
184
+ && current !== null && typeof current === 'object' && !Array.isArray(current)) {
185
+ const before = Object.keys(previous);
186
+ const after = Object.keys(current);
187
+ // Same members in the same order — which is what a copy-on-write
188
+ // edit of one member produces — needs no membership probing at all,
189
+ // just a value compare per key. The general path below is for real
190
+ // shape changes.
191
+ if (before.length === after.length && sameOrder(before, after)) {
192
+ for (let i = 0; i < before.length; i++) {
193
+ const key = before[i];
194
+ if (previous[key] !== current[key]) {
195
+ collectChanged(previous[key], current[key],
196
+ `${pointer}/${encodeJSONPointerSegment(key)}`, out);
197
+ }
198
+ }
199
+ return;
200
+ }
201
+ // own keys only, membership by Object.hasOwn — JSON member names
202
+ // like 'constructor', 'toString' or a parsed own '__proto__' are
203
+ // legal data and must diff as data, never through the prototype
204
+ // chain (null-prototype records diff identically)
205
+ for (const key of before) {
206
+ if (!Object.hasOwn(current, key))
207
+ out.push(`${pointer}/${encodeJSONPointerSegment(key)}`); // removed member
208
+ else if (previous[key] !== current[key])
209
+ collectChanged(previous[key], current[key], `${pointer}/${encodeJSONPointerSegment(key)}`, out);
210
+ }
211
+ for (const key of after) {
212
+ if (!Object.hasOwn(previous, key))
213
+ out.push(`${pointer}/${encodeJSONPointerSegment(key)}`); // added member
214
+ }
215
+ return;
216
+ }
217
+ if (!equalsJson(previous, current)) out.push(pointer);
218
+ }
219
+
220
+ /** Whether two key lists hold the same names in the same positions. */
221
+ function sameOrder(a, b) {
222
+ for (let i = 0; i < a.length; i++) {
223
+ if (a[i] !== b[i]) return false;
224
+ }
225
+ return true;
226
+ }
227
+
141
228
  //#endregion
142
229
 
143
230
  /**
package/src/deps.js ADDED
@@ -0,0 +1,179 @@
1
+ //@ts-check
2
+
3
+ /**
4
+ * What a rule reads: the data dependencies of an `x-form` rule query,
5
+ * as JSON Pointer prefixes.
6
+ *
7
+ * The point is invalidation. `evaluateFormRules` re-runs every rule on
8
+ * every keystroke; with a dependency set per rule it can re-run only
9
+ * the rules a change can possibly have affected (rules.js, the memo).
10
+ * A dependency set is therefore allowed to be too LARGE — a rule that
11
+ * re-runs needlessly is slow, not wrong — and must never be too small.
12
+ *
13
+ * The analysis is deliberately shallow, and sound because of one
14
+ * property of the query language: the only way into the input document
15
+ * is a ROOT-ANCHORED path string (`$`, `$.x`, `$['x']`). Everything
16
+ * else a query can read is derived from one of those — a `$let`/`$for`
17
+ * variable holds the result of an expression in the same document, and
18
+ * the only externals a form rule may bind are `value` (the field's own
19
+ * value, which the rule depends on anyway) and `pointer` (a string).
20
+ * So collecting the root-anchored paths, and reducing each to the
21
+ * literal prefix it starts with, over-approximates every read.
22
+ *
23
+ * Reduction stops at the first segment that is not a plain name or
24
+ * index: `$.a.b[*].c` reduces to `/a/b`, and a filter's own comparisons
25
+ * are collected separately because a filter can name another part of
26
+ * the document (`$.lines[?@.id == $.selected]`).
27
+ *
28
+ * RETAINED beside `analyzeQuery` (QUERY-FORMAT.md Appendix C),
29
+ * deliberately: the published analysis reports which OPERATORS,
30
+ * functions and externals a query uses — not the data-pointer prefixes
31
+ * this invalidation needs — and this scanner is total over any JSON
32
+ * value (a rule that does not even normalize still yields a sound
33
+ * "depends on everything" answer) where analysis would throw. Deriving
34
+ * prefixes from the analysis tree's `path` nodes would be exact, at the
35
+ * price of a full normalization per rule per form build; take that
36
+ * route only if the over-approximation ever measurably hurts.
37
+ */
38
+
39
+ import { parseJSONPath } from '@jarenjs/json/path';
40
+ import { encodeJSONPointerSegment } from '@jarenjs/json/pointer';
41
+
42
+ /**
43
+ * The dependency set of a rule: pointer prefixes, or `ALL_POINTERS` for
44
+ * "any change matters" (a query that reads the whole document).
45
+ * @typedef {string[]} RuleDependencies
46
+ */
47
+
48
+ /** A rule that reads the root depends on everything. */
49
+ export const ALL_POINTERS = [''];
50
+
51
+ /**
52
+ * Collect the data dependencies of one rule query document.
53
+ * @param {any} doc - The authored rule document
54
+ * @returns {RuleDependencies} Pointer prefixes, deduplicated
55
+ */
56
+ export function queryDependencies(doc) {
57
+ /** @type {Set<string>} */
58
+ const out = new Set();
59
+ collectStrings(doc, out);
60
+ if (out.has('')) return ALL_POINTERS;
61
+ return [...out];
62
+ }
63
+
64
+ /**
65
+ * Merge dependency sets, collapsing to {@link ALL_POINTERS} when any of
66
+ * them reads the root and dropping prefixes another already covers.
67
+ * @param {RuleDependencies[]} sets
68
+ * @returns {RuleDependencies}
69
+ */
70
+ export function mergeDependencies(sets) {
71
+ /** @type {Set<string>} */
72
+ const all = new Set();
73
+ for (const set of sets) {
74
+ for (const dep of set) {
75
+ if (dep === '') return ALL_POINTERS;
76
+ all.add(dep);
77
+ }
78
+ }
79
+ const deps = [...all];
80
+ return deps.filter((dep) => !deps.some((other) => other !== dep && isPointerPrefix(other, dep)));
81
+ }
82
+
83
+ /**
84
+ * Whether a change at `changed` can affect a rule depending on `dep`.
85
+ * True in BOTH nesting directions: a change inside a dependency
86
+ * (`/a` vs `/a/b`) alters what the rule reads, and a change ABOVE one
87
+ * (`/a/b` vs `/a`) can replace the container it reads through.
88
+ * @param {string} dep @param {string} changed
89
+ * @returns {boolean}
90
+ */
91
+ export function dependencyTouched(dep, changed) {
92
+ return isPointerPrefix(dep, changed) || isPointerPrefix(changed, dep);
93
+ }
94
+
95
+ /** Whether `prefix` is `pointer` itself or an ancestor location of it. */
96
+ function isPointerPrefix(prefix, pointer) {
97
+ if (prefix === '') return true;
98
+ if (!pointer.startsWith(prefix)) return false;
99
+ return pointer.length === prefix.length || pointer.charCodeAt(prefix.length) === 0x2f;
100
+ }
101
+
102
+ /**
103
+ * Walk a rule document for path strings. A bare string is a path
104
+ * expression in this language, so every string is a candidate — except
105
+ * under `$const`, which is exactly the marker that its value is a
106
+ * literal.
107
+ */
108
+ function collectStrings(node, out) {
109
+ if (typeof node === 'string') {
110
+ addPath(node, out);
111
+ return;
112
+ }
113
+ if (Array.isArray(node)) {
114
+ for (const item of node) collectStrings(item, out);
115
+ return;
116
+ }
117
+ if (node === null || typeof node !== 'object') return;
118
+ for (const key of Object.keys(node)) {
119
+ if (key === '$const') continue;
120
+ collectStrings(node[key], out);
121
+ }
122
+ }
123
+
124
+ /**
125
+ * Reduce one path string to its dependency prefix, if it is a
126
+ * root-anchored path at all. A variable-rooted path (`$item.price`) is
127
+ * not RFC 9535 and never parses here — deliberately: whatever the
128
+ * variable holds came from a root-anchored path in the same document,
129
+ * which this walk has already seen.
130
+ */
131
+ function addPath(source, out) {
132
+ if (source.length === 0 || source.charCodeAt(0) !== 0x24) return; // not '$'
133
+ let ast;
134
+ try {
135
+ ast = parseJSONPath(source);
136
+ }
137
+ catch {
138
+ return; // a variable-rooted path, or not a path at all
139
+ }
140
+ out.add(prefixOf(ast.segments));
141
+ for (const segment of ast.segments) {
142
+ for (const selector of segment.selectors) {
143
+ if (selector.kind === 'filter') collectFilterQueries(selector.expr, out);
144
+ }
145
+ }
146
+ }
147
+
148
+ /** The literal pointer prefix a segment list starts with. */
149
+ function prefixOf(segments) {
150
+ let pointer = '';
151
+ for (const segment of segments) {
152
+ if (segment.descendant || segment.selectors.length !== 1) break;
153
+ const selector = segment.selectors[0];
154
+ if (selector.kind === 'name')
155
+ pointer += `/${encodeJSONPointerSegment(selector.name)}`;
156
+ else if (selector.kind === 'index' && selector.index >= 0)
157
+ pointer += `/${selector.index}`;
158
+ else break;
159
+ }
160
+ return pointer;
161
+ }
162
+
163
+ /**
164
+ * Collect the root-anchored queries embedded in a filter expression —
165
+ * `$.selected` in `$.lines[?@.id == $.selected]` is a dependency of the
166
+ * enclosing rule as much as any top-level path is.
167
+ */
168
+ function collectFilterQueries(node, out) {
169
+ if (node === null || typeof node !== 'object') return;
170
+ if (node.query !== undefined && Array.isArray(node.query.segments)) {
171
+ if (node.query.relative !== true) out.add(prefixOf(node.query.segments));
172
+ else collectFilterQueries(node.query, out);
173
+ }
174
+ for (const key of ['operands', 'operand', 'left', 'right', 'args', 'expr']) {
175
+ const child = node[key];
176
+ if (Array.isArray(child)) for (const item of child) collectFilterQueries(item, out);
177
+ else if (child !== undefined) collectFilterQueries(child, out);
178
+ }
179
+ }
package/src/formats.js CHANGED
@@ -17,6 +17,10 @@ import {
17
17
  numberFormatTesters,
18
18
  } from '@jarenjs/formats';
19
19
 
20
+ import {
21
+ isValidGeoJson,
22
+ } from '@jarenjs/core/geo';
23
+
20
24
  /**
21
25
  * @typedef {object} FormatInfo
22
26
  * @property {(value: string) => boolean} test - Synchronous validity test
@@ -26,22 +30,28 @@ import {
26
30
 
27
31
  /**
28
32
  * Rendering hints per format name. Formats without an entry render as a
29
- * plain text input (number-group formats as a number input).
30
- * @type {Record<string, { control?: string, placeholder?: string }>}
33
+ * plain text input (number-group formats as a number input). A hint may
34
+ * also override the canonical `test` when what a form field holds is not
35
+ * what the validator's format judges (a field holds `geojson` as text,
36
+ * the format applies to the parsed object).
37
+ * @type {Record<string, { control?: string, placeholder?: string, test?: (value: string) => boolean }>}
31
38
  */
32
39
  const FORM_HINTS = {
33
- // -- date and time (RFC 3339 requires a timezone offset, which the HTML
34
- // time / datetime-local inputs cannot produce; those stay text inputs)
40
+ // -- date and time. Which formats get a native control is decided by
41
+ // the offset, not by convenience: HTML's `datetime-local` and `time`
42
+ // inputs cannot produce one, so binding them to the RFC 3339 formats
43
+ // would make the control emit values its own schema rejects. The ISO
44
+ // formats leave the offset optional and are exactly what those
45
+ // inputs spell, so they map losslessly.
35
46
  'date': { control: 'date', placeholder: '2024-01-15' },
36
47
  'time': { placeholder: '13:45:30Z' },
37
48
  'date-time': { placeholder: '2024-01-15T13:45:30Z' },
38
- 'iso-date-time': { placeholder: '2024-01-15T13:45:30' },
39
- 'iso-time': { placeholder: '13:45:30' },
49
+ 'iso-date-time': { control: 'datetime-local', placeholder: '2024-01-15T13:45:30' },
50
+ 'iso-time': { control: 'time', placeholder: '13:45:30' },
40
51
  'duration': { placeholder: 'P3DT4H' },
41
52
 
42
53
  // -- email
43
54
  'email': { control: 'email', placeholder: 'user@example.com' },
44
- 'email--full': { control: 'email', placeholder: 'user@example.com' },
45
55
  'idn-email': { control: 'email', placeholder: 'user@example.com' },
46
56
 
47
57
  // -- hosts and addresses
@@ -53,12 +63,9 @@ const FORM_HINTS = {
53
63
 
54
64
  // -- uris
55
65
  'uri': { control: 'url', placeholder: 'https://example.com/path' },
56
- 'uri--full': { control: 'url', placeholder: 'https://example.com/path' },
57
66
  'uri-reference': { placeholder: '/relative/path' },
58
- 'uri-reference--full': { placeholder: '/relative/path' },
59
67
  'uri-template': { placeholder: '/users/{id}' },
60
68
  'url': { control: 'url', placeholder: 'https://example.com' },
61
- 'url--full': { control: 'url', placeholder: 'https://example.com' },
62
69
  'iri': { control: 'url', placeholder: 'https://example.com/päth' },
63
70
  'iri-reference': { placeholder: '/relative/päth' },
64
71
 
@@ -77,6 +84,7 @@ const FORM_HINTS = {
77
84
 
78
85
  // -- misc
79
86
  'regex': { placeholder: '^[a-z]+$' },
87
+ 'iregexp': { placeholder: '[a-z]+' },
80
88
  'base64': { control: 'textarea', placeholder: 'SGVsbG8=' },
81
89
  'byte': { control: 'textarea', placeholder: 'SGVsbG8=' },
82
90
  'alpha': { placeholder: 'alpha' },
@@ -90,6 +98,23 @@ const FORM_HINTS = {
90
98
  'isbn13': { placeholder: '978-3-16-148410-0' },
91
99
  'iban': { placeholder: 'NL91ABNA0417164300' },
92
100
  'country2': { placeholder: 'NL' },
101
+
102
+ // -- geospatial. The geojson tester judges the OBJECT, but a form
103
+ // field holds its text, so the field-level test parses first.
104
+ 'geohash': { placeholder: 'u173z' },
105
+ 'wkt': { placeholder: 'POINT (4.9041 52.3676)' },
106
+ 'geojson': {
107
+ control: 'textarea',
108
+ placeholder: '{"type":"Point","coordinates":[4.9,52.4]}',
109
+ test: (text) => {
110
+ try {
111
+ return isValidGeoJson(JSON.parse(text));
112
+ }
113
+ catch {
114
+ return false;
115
+ }
116
+ },
117
+ },
93
118
  };
94
119
 
95
120
  function acceptAnything() {
package/src/index.js CHANGED
@@ -25,6 +25,8 @@ export {
25
25
  compileFormRules,
26
26
  evaluateFormRules,
27
27
  formRulesToQueryAssertions,
28
+ pruneHiddenValues,
29
+ createRuleMemo,
28
30
  } from './rules.js';
29
31
 
30
32
  export {
@@ -32,6 +34,13 @@ export {
32
34
  validateAllFields,
33
35
  } from './validate.js';
34
36
 
37
+ export {
38
+ formsMessagesEn,
39
+ formChromeLabels,
40
+ compileMessageTemplate,
41
+ compileMessageCatalog,
42
+ } from './messages.js';
43
+
35
44
  export {
36
45
  createInitialData,
37
46
  createItemValue,
@@ -47,3 +56,7 @@ export {
47
56
  FORM_FORMATS,
48
57
  getFormatInfo,
49
58
  } from './formats.js';
59
+
60
+ export {
61
+ buildFormViewModel,
62
+ } from './viewmodel.js';
@@ -0,0 +1,106 @@
1
+ //@ts-check
2
+
3
+ /**
4
+ * Forms message catalogs: structured field errors, rendered late.
5
+ *
6
+ * Every failure a form check produces is identified by a stable message
7
+ * key (`msgid`, the `form/`-prefixed keyword or `x-form/assert`) plus raw
8
+ * structured `params`; the human text comes from a catalog - a plain flat
9
+ * object of closures (or template strings that compile into closures).
10
+ * Forms keeps its second-person field voice ("This field is required"),
11
+ * hence the separate `form/*` key space next to the validator's document
12
+ * voice ("must have required property 'x'").
13
+ *
14
+ * Forms and the validator must serve one locale pack
15
+ * (`@jarenjs/locales`) each from its own key space, without either
16
+ * package depending on the other - so the catalog contract they share
17
+ * (template syntax, compilation, value rendering) is kernel property in
18
+ * `@jarenjs/core/message`. Both compilers are re-exported here so a form
19
+ * consumer never has to reach past `@jarenjs/forms`.
20
+ */
21
+
22
+ import {
23
+ formatMessageValue,
24
+ compileMessageCatalog,
25
+ } from '@jarenjs/core/message';
26
+
27
+ export {
28
+ compileMessageTemplate,
29
+ compileMessageCatalog,
30
+ } from '@jarenjs/core/message';
31
+
32
+ //#region English catalog
33
+
34
+ /**
35
+ * The built-in English forms catalog. Key set = exactly the `form/*` keys
36
+ * `validateField` emits, plus `x-form/assert` (the rules default). The
37
+ * strings are byte-identical to the historical inline template literals.
38
+ * @type {Record<string, string | ((params: object, error?: object) => string)>}
39
+ */
40
+ export const formsMessagesEn = {
41
+ 'form/required': 'This field is required',
42
+ 'form/type': (p) => `Must be ${(p.type === 'integer' || p.type === 'array' || p.type === 'object') ? 'an' : 'a'} ${p.type}`,
43
+ 'form/const': (p) => `Must be ${formatMessageValue(p.constValue)}`,
44
+ 'form/enum': (p) => `Must be one of: ${p.enumValues?.map(formatMessageValue).join(', ')}`,
45
+ 'form/minLength': (p) => `Must be at least ${p.limit} character${p.limit === 1 ? '' : 's'} (currently ${p.len})`,
46
+ 'form/maxLength': (p) => `Must be at most ${p.limit} character${p.limit === 1 ? '' : 's'} (currently ${p.len})`,
47
+ 'form/pattern': 'Must match pattern {pattern}',
48
+ 'form/format': 'Must be a valid {format}',
49
+ 'form/minimum': 'Must be at least {limit}',
50
+ 'form/maximum': 'Must be at most {limit}',
51
+ 'form/exclusiveMinimum': 'Must be greater than {limit}',
52
+ 'form/exclusiveMaximum': 'Must be less than {limit}',
53
+ 'form/multipleOf': 'Must be a multiple of {multipleOf}',
54
+ 'form/minItems': (p) => `Must have at least ${p.limit} item${p.limit === 1 ? '' : 's'}`,
55
+ 'form/maxItems': (p) => `Must have at most ${p.limit} item${p.limit === 1 ? '' : 's'}`,
56
+ 'form/uniqueItems': 'Items must be unique',
57
+ 'form/minProperties': 'Must have at least {limit} properties',
58
+ 'form/maxProperties': 'Must have at most {limit} properties',
59
+ 'x-form/assert': 'Invalid value',
60
+ // Form CHROME, not validation: the accessible names of the array
61
+ // buttons. A symbol-only button ('+', '×') is unreadable to a screen
62
+ // reader and untranslatable as a glyph, so the name travels as a
63
+ // message like every other string an operator can hear.
64
+ 'form/addItem': 'Add item',
65
+ 'form/removeItem': 'Remove item',
66
+ };
67
+
68
+ /** The compiled built-in English catalog (module-level singleton). */
69
+ export const formsMessages = compileMessageCatalog(formsMessagesEn);
70
+
71
+ /**
72
+ * Resolve a message key through a caller catalog with built-in English
73
+ * fallback and render it.
74
+ * @param {Readonly<Record<string, (params: object, error?: object) => string>>|undefined} catalog - A compiled catalog, or undefined for English
75
+ * @param {string} msgid - The message key
76
+ * @param {object} params - The structured params
77
+ * @returns {string} The rendered message
78
+ */
79
+ export function renderFormsMessage(catalog, msgid, params) {
80
+ let render = catalog !== undefined ? catalog[msgid] : undefined;
81
+ if (render === undefined) render = formsMessages[msgid];
82
+ if (render === undefined) return msgid;
83
+ return render(params);
84
+ }
85
+
86
+ /**
87
+ * The localized chrome strings a form renderer needs: the accessible
88
+ * names of the array add/remove buttons.
89
+ *
90
+ * The stylesheet that renders a form is plain JSON built once, so it
91
+ * cannot look anything up at render time — the host resolves these and
92
+ * hands them to `createFormView`. Kept next to the error catalog on
93
+ * purpose: one keyspace, one parity gate across every locale pack.
94
+ * @param {Readonly<Record<string, (params: object, error?: object) => string>>} [catalog] - A compiled catalog, or undefined for English
95
+ * @returns {{addItem: string, removeItem: string}}
96
+ * @example
97
+ * createFormView({ labels: formChromeLabels(catalogs[locale]) });
98
+ */
99
+ export function formChromeLabels(catalog = undefined) {
100
+ return {
101
+ addItem: renderFormsMessage(catalog, 'form/addItem', {}),
102
+ removeItem: renderFormsMessage(catalog, 'form/removeItem', {}),
103
+ };
104
+ }
105
+
106
+ //#endregion