@jxsuite/schema 1.5.0 → 1.8.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/ijson.ts ADDED
@@ -0,0 +1,205 @@
1
+ /**
2
+ * I-JSON (RFC 7493): the two ways a JSON document can parse cleanly and still mean something other
3
+ * than what it says.
4
+ *
5
+ * Both matter here more than they would elsewhere, because a Jx document does not stay JSON. It
6
+ * round-trips through markdown frontmatter and through a Yjs CRDT, and each crossing rebuilds the
7
+ * object from the parsed value — so anything `JSON.parse` quietly discarded is discarded for good,
8
+ * and the file the author reopens is not the file they wrote.
9
+ *
10
+ * - **Duplicate names** (§2.3). `JSON.parse` keeps the last and says nothing. In a hand-edited
11
+ * document — two `state` keys after a bad merge, say — the first one's contents simply vanish.
12
+ * - **Numbers outside the IEEE 754 double range** (§2.2). `9007199254740993` parses to
13
+ * `9007199254740992`, and the next serialization writes the wrong number back to disk.
14
+ *
15
+ * Implemented as a scan of the source text rather than a `JSON.parse` reviver, because by the time
16
+ * a reviver runs the duplicate is already gone. The scanner is deliberately small: it only needs to
17
+ * know where strings and object keys are, so it does not build a tree or validate the grammar —
18
+ * `JSON.parse` has already done that by the time this is called.
19
+ *
20
+ * @docs framework/agents/authoring-rules
21
+ */
22
+
23
+ /** One I-JSON violation, located by the path of the object that contains it. */
24
+ export interface IJsonProblem {
25
+ kind: "duplicate-key" | "unsafe-number";
26
+ /** Dotted path to the containing object, `""` at the root. */
27
+ path: string;
28
+ /** The duplicated key, or the number literal as written. */
29
+ detail: string;
30
+ }
31
+
32
+ const WHITESPACE = new Set([" ", "\t", "\n", "\r"]);
33
+
34
+ /**
35
+ * Find I-JSON violations in already-valid JSON text.
36
+ *
37
+ * @param {string} text - JSON source that `JSON.parse` accepts
38
+ * @returns {IJsonProblem[]} Empty when the document is I-JSON
39
+ */
40
+ export function findIJsonProblems(text: string): IJsonProblem[] {
41
+ const problems: IJsonProblem[] = [];
42
+ /**
43
+ * One frame per open `{` or `[`. Arrays carry a path too, so an object inside one is reported
44
+ * under the key that names the array rather than under whatever enclosed it.
45
+ */
46
+ const stack: { kind: "object" | "array"; path: string; keys?: Set<string> }[] = [];
47
+ /** The key most recently read, naming the value that follows it. */
48
+ let pendingKey: string | null = null;
49
+ let index = 0;
50
+
51
+ const currentPath = () => stack.at(-1)?.path ?? "";
52
+ const childPath = () =>
53
+ pendingKey === null ? currentPath() : joinPath(currentPath(), pendingKey);
54
+
55
+ const readString = (): string => {
56
+ // Assumes text[index] === '"'; returns the decoded value and leaves index past the closer.
57
+ index += 1;
58
+ let out = "";
59
+ while (index < text.length) {
60
+ const char = text[index]!;
61
+ if (char === "\\") {
62
+ const next = text[index + 1] ?? "";
63
+ // Only `\uXXXX` needs decoding to compare keys correctly; the rest map one-to-one.
64
+ if (next === "u") {
65
+ out += String.fromCodePoint(Number.parseInt(text.slice(index + 2, index + 6), 16));
66
+ index += 6;
67
+ } else {
68
+ out += ESCAPES[next] ?? next;
69
+ index += 2;
70
+ }
71
+ continue;
72
+ }
73
+ if (char === '"') {
74
+ index += 1;
75
+ return out;
76
+ }
77
+ out += char;
78
+ index += 1;
79
+ }
80
+ return out;
81
+ };
82
+
83
+ while (index < text.length) {
84
+ const char = text[index]!;
85
+
86
+ if (WHITESPACE.has(char) || char === "," || char === ":") {
87
+ index += 1;
88
+ continue;
89
+ }
90
+
91
+ if (char === "{" || char === "[") {
92
+ stack.push(
93
+ char === "{"
94
+ ? { keys: new Set(), kind: "object", path: childPath() }
95
+ : { kind: "array", path: childPath() },
96
+ );
97
+ pendingKey = null;
98
+ index += 1;
99
+ continue;
100
+ }
101
+ if (char === "}" || char === "]") {
102
+ stack.pop();
103
+ pendingKey = null;
104
+ index += 1;
105
+ continue;
106
+ }
107
+
108
+ if (char === '"') {
109
+ const value = readString();
110
+ /*
111
+ * A string is a key when it sits directly inside an object and a `:` follows. Inside an
112
+ * array it is an element, and the same text on the right of a `:` is a value.
113
+ */
114
+ const frame = stack.at(-1);
115
+ if (frame?.kind === "object" && nextMeaningful(text, index) === ":") {
116
+ if (frame.keys!.has(value)) {
117
+ problems.push({ detail: value, kind: "duplicate-key", path: frame.path });
118
+ }
119
+ frame.keys!.add(value);
120
+ pendingKey = value;
121
+ } else {
122
+ pendingKey = null;
123
+ }
124
+ continue;
125
+ }
126
+
127
+ if (char === "-" || (char >= "0" && char <= "9")) {
128
+ const start = index;
129
+ while (index < text.length && /[\d+.eE-]/.test(text[index]!)) {
130
+ index += 1;
131
+ }
132
+ const literal = text.slice(start, index);
133
+ if (!isSafeJsonNumber(literal)) {
134
+ problems.push({ detail: literal, kind: "unsafe-number", path: childPath() });
135
+ }
136
+ pendingKey = null;
137
+ continue;
138
+ }
139
+
140
+ // `true`, `false`, `null`. The grammar is already known good, so a run of letters is enough.
141
+ const before = index;
142
+ while (index < text.length && /[A-Za-z]/.test(text[index]!)) {
143
+ index += 1;
144
+ }
145
+ if (index === before) {
146
+ index += 1;
147
+ }
148
+ pendingKey = null;
149
+ }
150
+
151
+ return problems;
152
+ }
153
+
154
+ const ESCAPES: Record<string, string> = {
155
+ b: "\b",
156
+ f: "\f",
157
+ n: "\n",
158
+ r: "\r",
159
+ t: "\t",
160
+ };
161
+
162
+ /** The next non-whitespace character at or after `from`, or "" at the end. */
163
+ function nextMeaningful(text: string, from: number): string {
164
+ let index = from;
165
+ while (index < text.length && WHITESPACE.has(text[index]!)) {
166
+ index += 1;
167
+ }
168
+ return text[index] ?? "";
169
+ }
170
+
171
+ function joinPath(parent: string, key: string): string {
172
+ return parent === "" ? key : `${parent}.${key}`;
173
+ }
174
+
175
+ /**
176
+ * True when a number literal survives a round trip through a double.
177
+ *
178
+ * Only integers are judged. A fractional literal is _always_ approximate in binary floating point —
179
+ * `0.1` is not representable either — so flagging those would flag most real documents and say
180
+ * nothing about whether the author's value survived.
181
+ *
182
+ * @param {string} literal
183
+ * @returns {boolean}
184
+ */
185
+ export function isSafeJsonNumber(literal: string): boolean {
186
+ if (/[.eE]/.test(literal)) {
187
+ return true;
188
+ }
189
+ const value = Number(literal);
190
+ if (!Number.isFinite(value)) {
191
+ return false;
192
+ }
193
+ // The literal is an integer, so `String(Number(x))` reproduces it exactly when nothing was lost.
194
+ return String(value) === literal.replace(/^\+/, "");
195
+ }
196
+
197
+ /** A one-line description of a problem, for a build error or a warning. */
198
+ export function describeIJsonProblem(problem: IJsonProblem): string {
199
+ const where = problem.path === "" ? "the document root" : `"${problem.path}"`;
200
+ return problem.kind === "duplicate-key"
201
+ ? `duplicate key "${problem.detail}" in ${where} — JSON.parse keeps the last one and ` +
202
+ "silently discards the first (RFC 7493 §2.3)"
203
+ : `number ${problem.detail} in ${where} cannot be represented exactly and will change when ` +
204
+ "the document is written back (RFC 7493 §2.2)";
205
+ }
package/src/intl.ts ADDED
@@ -0,0 +1,155 @@
1
+ /**
2
+ * The blessed `Intl` helpers: one list, read by everything that needs to know them.
3
+ *
4
+ * **The list used to exist in four places** — the runtime's allow-set, the runtime's dispatch
5
+ * table, the compiler's emitter, and a prose JSON-Schema description enumerating them by name. The
6
+ * description was the dangerous one: nothing checked it, so a helper could ship and the schema go
7
+ * on telling authors it did not exist. The names live here; the runtime and the schema description
8
+ * both read them, and a test asserts the two agree.
9
+ *
10
+ * **Why helpers and not the constructors.** ECMA-402's formatters are constructors, and a formula
11
+ * is a pure call — `new` is not in the expression grammar and should not be. Each helper wraps
12
+ * construct-then-format, which is the shape a document author wants anyway.
13
+ *
14
+ * **Every helper takes an explicit locale.** `new Intl.NumberFormat(undefined)` reads the _host's_
15
+ * locale, so the same document compiled on two machines emits different text. A call that names no
16
+ * locale gets the **page's** — which is a function of the route, not of the build machine — and
17
+ * failing that the fixed default below; see `site-architecture.md` §13.7.
18
+ *
19
+ * Pure data and no imports, so a browser bundle, the compiler and the schema generator can all read
20
+ * it without pulling anything else in.
21
+ *
22
+ * @docs studio/logic/formulas
23
+ */
24
+
25
+ /**
26
+ * The locale a helper uses when a formula names none **and there is no page to ask**.
27
+ *
28
+ * **Not the host's.** `new Intl.NumberFormat(undefined)` reads the machine's locale, so the same
29
+ * document renders `1,234.5` on one build machine and `1.234,5` on another, and a site's output
30
+ * stops being a function of its input.
31
+ *
32
+ * A page's own locale is the better answer where there is one — a page under `/fr/` that formats a
33
+ * date means French, and it already said so in `<html lang>` — so `$page.locale` is consulted first
34
+ * (see `INTL_LOCALE_PARAM`). That preserves the property this constant exists for, because
35
+ * `$page.locale` is a function of the route and the document rather than of the machine. This value
36
+ * is what remains for a scope with no page: a component's own state, or the runtime evaluated
37
+ * standalone.
38
+ */
39
+ export const DEFAULT_FORMAT_LOCALE = "en-US";
40
+
41
+ /**
42
+ * The time zone `Intl/formatDate` uses when the formula's options name none.
43
+ *
44
+ * The same determinism argument as the locale, and worse in effect: a locale changes how a date
45
+ * reads, a time zone can change **which day it is**. A post timestamped `2026-08-16T02:00Z` renders
46
+ * as the 16th in UTC and the 15th in New York, so a build machine's zone would silently move dates
47
+ * in published HTML. UTC is the one zone that is the same everywhere.
48
+ */
49
+ export const DEFAULT_TIME_ZONE = "UTC";
50
+
51
+ /** One blessed helper: its callee path (after `window#/`) and what it wraps. */
52
+ export interface IntlHelper {
53
+ /** The callee path an expression names, e.g. `Intl/formatNumber`. */
54
+ path: string;
55
+ /** The ECMA-402 constructor it wraps. */
56
+ api: string;
57
+ /** Parameter names, in order, for documentation and for the compiled call. */
58
+ params: string[];
59
+ /** One line, for the formula catalogue and the schema description. */
60
+ summary: string;
61
+ }
62
+
63
+ /**
64
+ * Every blessed `Intl` helper, in the order they are documented.
65
+ *
66
+ * **`DurationFormat` is deliberately absent.** Its baseline support is not universal, and a blessed
67
+ * global that throws on a browser Jx claims to support is worse than one that does not exist: the
68
+ * author writes a formula that works on their machine and fails on a visitor's.
69
+ */
70
+ export const INTL_HELPERS: readonly IntlHelper[] = [
71
+ {
72
+ api: "Intl.NumberFormat",
73
+ params: ["value", "locale", "options"],
74
+ path: "Intl/formatNumber",
75
+ summary: "Format a number for a locale — grouping, decimals, currency, percent, units.",
76
+ },
77
+ {
78
+ api: "Intl.DateTimeFormat",
79
+ params: ["value", "locale", "options"],
80
+ path: "Intl/formatDate",
81
+ summary: "Format a date or timestamp for a locale. Pass `timeZone` in options.",
82
+ },
83
+ {
84
+ api: "Intl.RelativeTimeFormat",
85
+ params: ["value", "unit", "locale", "options"],
86
+ path: "Intl/formatRelativeTime",
87
+ summary: 'Format a relative time — `-3, "day"` becomes "3 days ago".',
88
+ },
89
+ {
90
+ api: "Intl.ListFormat",
91
+ params: ["values", "locale", "options"],
92
+ path: "Intl/formatList",
93
+ summary: 'Join a list the way a locale does — "a, b, and c", not a hand-written comma join.',
94
+ },
95
+ {
96
+ api: "Intl.PluralRules",
97
+ params: ["value", "locale", "options"],
98
+ path: "Intl/plural",
99
+ summary:
100
+ 'Which plural category a number falls in ("one", "few", "other") — many languages have ' +
101
+ "more than two, so a ternary on `n === 1` is wrong outside English.",
102
+ },
103
+ {
104
+ api: "Intl.Collator",
105
+ params: ["a", "b", "locale", "options"],
106
+ path: "Intl/compare",
107
+ summary:
108
+ "Compare two strings for sorting. `<` and `sort()` order by UTF-16 code unit, which puts " +
109
+ '"Zebra" before "apple" and sorts every accented word into the wrong place.',
110
+ },
111
+ {
112
+ api: "Intl.DisplayNames",
113
+ params: ["code", "type", "locale", "options"],
114
+ path: "Intl/displayName",
115
+ summary:
116
+ 'The name of a language, region, script or currency in a locale — "de" as "German" or ' +
117
+ '"Deutsch", from CLDR rather than a hand-kept table.',
118
+ },
119
+ {
120
+ api: "Intl.Segmenter",
121
+ params: ["value", "granularity", "locale"],
122
+ path: "Intl/segment",
123
+ summary:
124
+ "Split text into graphemes, words or sentences (UAX #29). `[...str]` splits code points, " +
125
+ "so it cuts an emoji with a skin-tone modifier in half.",
126
+ },
127
+ ] as const;
128
+
129
+ /** Just the callee paths, for the runtime's allow-set. */
130
+ export const INTL_HELPER_PATHS: readonly string[] = INTL_HELPERS.map((helper) => helper.path);
131
+
132
+ /**
133
+ * Where each helper's `locale` argument sits, so a call that omits it can be given the page's.
134
+ *
135
+ * Derived from `params` rather than written out, for the reason this module exists at all: a
136
+ * hand-kept second list is what let the schema description drift from the implementation. The
137
+ * position differs per helper — `formatNumber` takes it second, `formatRelativeTime` third — and a
138
+ * table that disagreed with the signature would pass the wrong argument silently, which reads as a
139
+ * formatting bug rather than a wiring one.
140
+ */
141
+ export const INTL_LOCALE_PARAM: Readonly<Record<string, number>> = Object.fromEntries(
142
+ INTL_HELPERS.map((helper) => [helper.path, helper.params.indexOf("locale")]),
143
+ );
144
+
145
+ /**
146
+ * The helper names as one clause, for the `call` operator's JSON-Schema description.
147
+ *
148
+ * Generated rather than typed out, because a hand-written enumeration is what let the schema drift
149
+ * from the implementation in the first place.
150
+ *
151
+ * @returns {string}
152
+ */
153
+ export function intlHelpersClause(): string {
154
+ return `the Intl helpers ${INTL_HELPER_PATHS.join(", ")}`;
155
+ }