@jxsuite/runtime 1.3.2 → 2.0.2
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/dist/runtime.js +262 -100
- package/dist/runtime.js.map +11 -8
- package/package.json +5 -4
- package/src/cookie.ts +104 -0
- package/src/expression.ts +146 -43
- package/src/pointer.ts +130 -0
- package/src/runtime.ts +140 -46
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jxsuite/runtime",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.0.2",
|
|
4
4
|
"description": "JSON-native reactive web component runtime",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
"exports": {
|
|
17
17
|
".": "./src/runtime.ts",
|
|
18
18
|
"./expression": "./src/expression.ts",
|
|
19
|
+
"./pointer": "./src/pointer.ts",
|
|
19
20
|
"./statements": "./src/statements.ts",
|
|
20
21
|
"./types": "./src/types.ts"
|
|
21
22
|
},
|
|
@@ -30,10 +31,10 @@
|
|
|
30
31
|
"upgrade": "bunx npm-check-updates -u && bun install"
|
|
31
32
|
},
|
|
32
33
|
"dependencies": {
|
|
33
|
-
"@jxsuite/schema": "^1.
|
|
34
|
-
"@vue/reactivity": "3.5.
|
|
34
|
+
"@jxsuite/schema": "^1.8.0",
|
|
35
|
+
"@vue/reactivity": "3.5.41"
|
|
35
36
|
},
|
|
36
37
|
"devDependencies": {
|
|
37
|
-
"@happy-dom/global-registrator": "^20.11.
|
|
38
|
+
"@happy-dom/global-registrator": "^20.11.6"
|
|
38
39
|
}
|
|
39
40
|
}
|
package/src/cookie.ts
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `Cookie` state `$prototype`'s reading and writing, split out because both halves are exactly
|
|
3
|
+
* the places a cookie binding goes wrong (RFC 6265bis).
|
|
4
|
+
*
|
|
5
|
+
* **Reading does not build a regular expression.** The cookie's name comes from the document, and
|
|
6
|
+
* the old reader interpolated it straight into a pattern — so a name containing a dot, a paren or a
|
|
7
|
+
* star silently matched the wrong cookie, and one containing an unbalanced bracket threw
|
|
8
|
+
* SyntaxError from a document that validates. There is nothing to escape here: the cookie header is
|
|
9
|
+
* a semicolon-separated list, so splitting it and comparing names is both correct and cheaper.
|
|
10
|
+
*
|
|
11
|
+
* **`HttpOnly` is absent on purpose, and must stay absent.** A cookie this prototype writes is
|
|
12
|
+
* written by script through `document.cookie`; the browser refuses to _set_ `HttpOnly` from script
|
|
13
|
+
* and refuses to _read back_ an `HttpOnly` cookie, so adding the attribute would produce a binding
|
|
14
|
+
* that writes a value it can never see again. Its absence is the correct behavior for a
|
|
15
|
+
* script-written cookie, not a missing feature.
|
|
16
|
+
*
|
|
17
|
+
* **`Expires` is not supported, and that is also deliberate.** `Max-Age` covers the same ground,
|
|
18
|
+
* RFC 6265bis §5.5 makes `Max-Age` win wherever both appear, and `Expires` takes an HTTP-date whose
|
|
19
|
+
* mis-spelling fails silently in the direction of a cookie that never expires.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/** The cookie attributes an `external-class-def` may declare for the `Cookie` `$prototype`. */
|
|
23
|
+
export interface CookieOptions {
|
|
24
|
+
domain?: string | undefined;
|
|
25
|
+
maxAge?: number | undefined;
|
|
26
|
+
path?: string | undefined;
|
|
27
|
+
sameSite?: string | undefined;
|
|
28
|
+
secure?: boolean | undefined;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Cookie names carrying RFC 6265bis §4.1.3 prefixes, which constrain their own attributes. */
|
|
32
|
+
const HOST_PREFIX = "__Host-";
|
|
33
|
+
const SECURE_PREFIX = "__Secure-";
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The raw (still percent-encoded) value of `name` in a `document.cookie` header, or null.
|
|
37
|
+
*
|
|
38
|
+
* @param {string} header The full `name=value; name=value` string.
|
|
39
|
+
* @param {string} name
|
|
40
|
+
* @returns {string | null}
|
|
41
|
+
*/
|
|
42
|
+
export function readCookie(header: string, name: string): string | null {
|
|
43
|
+
for (const pair of header.split(";")) {
|
|
44
|
+
const eq = pair.indexOf("=");
|
|
45
|
+
if (eq === -1) {
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
if (pair.slice(0, eq).trim() === name) {
|
|
49
|
+
return pair.slice(eq + 1);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Serialize one `document.cookie` assignment, applying the attribute rules a name's prefix and its
|
|
57
|
+
* `SameSite` value already imply.
|
|
58
|
+
*
|
|
59
|
+
* Three of those rules are enforced rather than left to the author, because a browser that
|
|
60
|
+
* disagrees with the attributes **silently drops the cookie** — the write appears to succeed and
|
|
61
|
+
* the value is simply never there again:
|
|
62
|
+
*
|
|
63
|
+
* - `__Host-` requires `Secure`, requires `Path=/`, and forbids `Domain` (RFC 6265bis §4.1.3.2). A
|
|
64
|
+
* declared `path` or `domain` is overridden rather than honored, since honoring it would produce
|
|
65
|
+
* a cookie no browser will store.
|
|
66
|
+
* - `__Secure-` requires `Secure` (§4.1.3.1).
|
|
67
|
+
* - `SameSite=None` requires `Secure` (§5.4.7); without it the cookie is rejected outright.
|
|
68
|
+
*
|
|
69
|
+
* @param {string} name
|
|
70
|
+
* @param {unknown} value JSON-encoded, then percent-encoded.
|
|
71
|
+
* @param {CookieOptions} options
|
|
72
|
+
* @returns {string}
|
|
73
|
+
*/
|
|
74
|
+
export function serializeCookie(name: string, value: unknown, options: CookieOptions): string {
|
|
75
|
+
const hostPrefixed = name.startsWith(HOST_PREFIX);
|
|
76
|
+
const parts = [`${name}=${encodeURIComponent(JSON.stringify(value))}`];
|
|
77
|
+
|
|
78
|
+
if (options.maxAge !== undefined) {
|
|
79
|
+
parts.push(`Max-Age=${options.maxAge}`);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const path = hostPrefixed ? "/" : options.path;
|
|
83
|
+
if (path) {
|
|
84
|
+
parts.push(`Path=${path}`);
|
|
85
|
+
}
|
|
86
|
+
if (options.domain && !hostPrefixed) {
|
|
87
|
+
parts.push(`Domain=${options.domain}`);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const { sameSite } = options;
|
|
91
|
+
const secure =
|
|
92
|
+
options.secure === true ||
|
|
93
|
+
hostPrefixed ||
|
|
94
|
+
name.startsWith(SECURE_PREFIX) ||
|
|
95
|
+
sameSite?.toLowerCase() === "none";
|
|
96
|
+
if (secure) {
|
|
97
|
+
parts.push("Secure");
|
|
98
|
+
}
|
|
99
|
+
if (sameSite) {
|
|
100
|
+
parts.push(`SameSite=${sameSite}`);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return parts.join("; ");
|
|
104
|
+
}
|
package/src/expression.ts
CHANGED
|
@@ -7,8 +7,15 @@
|
|
|
7
7
|
* @module expression
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
+
import {
|
|
11
|
+
DEFAULT_FORMAT_LOCALE,
|
|
12
|
+
DEFAULT_TIME_ZONE,
|
|
13
|
+
INTL_HELPER_PATHS,
|
|
14
|
+
INTL_LOCALE_PARAM,
|
|
15
|
+
} from "@jxsuite/schema/intl";
|
|
10
16
|
import type { JxExpressionNode, JxExpressionOperand } from "@jxsuite/schema/types";
|
|
11
17
|
import type { JxScope } from "./types.ts";
|
|
18
|
+
import { readPath, refAccessor, refSegments } from "./pointer.ts";
|
|
12
19
|
|
|
13
20
|
/** The runtime's expression node — the schema's expression model. */
|
|
14
21
|
export type ExpressionNode = JxExpressionNode;
|
|
@@ -177,11 +184,9 @@ export const BLESSED_GLOBALS = new Set([
|
|
|
177
184
|
// String
|
|
178
185
|
"String/fromCharCode",
|
|
179
186
|
"String/fromCodePoint",
|
|
180
|
-
// Intl
|
|
181
|
-
//
|
|
182
|
-
|
|
183
|
-
"Intl/formatDate",
|
|
184
|
-
"Intl/formatRelativeTime",
|
|
187
|
+
// Intl helpers come from @jxsuite/schema/intl — see BLESSED_HELPERS. One list, so the runtime,
|
|
188
|
+
// The compiler and the JSON-Schema description cannot drift apart.
|
|
189
|
+
...INTL_HELPER_PATHS,
|
|
185
190
|
]);
|
|
186
191
|
|
|
187
192
|
/**
|
|
@@ -190,23 +195,85 @@ export const BLESSED_GLOBALS = new Set([
|
|
|
190
195
|
* these; the compiler emits the equivalent inline construct-then-format expression.
|
|
191
196
|
*/
|
|
192
197
|
const BLESSED_HELPERS: Record<string, (...a: unknown[]) => unknown> = {
|
|
198
|
+
"Intl/compare": (a, b, locale, options) =>
|
|
199
|
+
new Intl.Collator(
|
|
200
|
+
(locale as string | undefined) ?? DEFAULT_FORMAT_LOCALE,
|
|
201
|
+
options as Intl.CollatorOptions | undefined,
|
|
202
|
+
).compare(a as string, b as string),
|
|
203
|
+
"Intl/displayName": (code, type, locale, options) =>
|
|
204
|
+
new Intl.DisplayNames((locale as string | undefined) ?? DEFAULT_FORMAT_LOCALE, {
|
|
205
|
+
...(options as Intl.DisplayNamesOptions | undefined),
|
|
206
|
+
type: (type as Intl.DisplayNamesType | undefined) ?? "language",
|
|
207
|
+
}).of(code as string),
|
|
193
208
|
"Intl/formatDate": (value, locale, options) =>
|
|
194
|
-
new Intl.DateTimeFormat(
|
|
195
|
-
|
|
196
|
-
options as Intl.DateTimeFormatOptions | undefined,
|
|
197
|
-
).format(new Date(value as string | number | Date)),
|
|
209
|
+
new Intl.DateTimeFormat((locale as string | undefined) ?? DEFAULT_FORMAT_LOCALE, {
|
|
210
|
+
timeZone: DEFAULT_TIME_ZONE,
|
|
211
|
+
...(options as Intl.DateTimeFormatOptions | undefined),
|
|
212
|
+
}).format(new Date(value as string | number | Date)),
|
|
213
|
+
"Intl/formatList": (values, locale, options) =>
|
|
214
|
+
new Intl.ListFormat(
|
|
215
|
+
(locale as string | undefined) ?? DEFAULT_FORMAT_LOCALE,
|
|
216
|
+
options as Intl.ListFormatOptions | undefined,
|
|
217
|
+
).format(values as string[]),
|
|
198
218
|
"Intl/formatNumber": (value, locale, options) =>
|
|
199
219
|
new Intl.NumberFormat(
|
|
200
|
-
locale as string | undefined,
|
|
220
|
+
(locale as string | undefined) ?? DEFAULT_FORMAT_LOCALE,
|
|
201
221
|
options as Intl.NumberFormatOptions | undefined,
|
|
202
222
|
).format(value as number),
|
|
203
223
|
"Intl/formatRelativeTime": (value, unit, locale, options) =>
|
|
204
224
|
new Intl.RelativeTimeFormat(
|
|
205
|
-
locale as string | undefined,
|
|
225
|
+
(locale as string | undefined) ?? DEFAULT_FORMAT_LOCALE,
|
|
206
226
|
options as Intl.RelativeTimeFormatOptions | undefined,
|
|
207
227
|
).format(value as number, unit as Intl.RelativeTimeFormatUnit),
|
|
228
|
+
"Intl/plural": (value, locale, options) =>
|
|
229
|
+
new Intl.PluralRules(
|
|
230
|
+
(locale as string | undefined) ?? DEFAULT_FORMAT_LOCALE,
|
|
231
|
+
options as Intl.PluralRulesOptions | undefined,
|
|
232
|
+
).select(value as number),
|
|
233
|
+
/* Returns the segments as plain strings, because a formula's value has to be JSON — the
|
|
234
|
+
Segmenter's own iterator of {segment, index, isWordLike} records is not. */
|
|
235
|
+
"Intl/segment": (value, granularity, locale) =>
|
|
236
|
+
[
|
|
237
|
+
...new Intl.Segmenter((locale as string | undefined) ?? DEFAULT_FORMAT_LOCALE, {
|
|
238
|
+
granularity: (granularity as "grapheme" | "sentence" | "word" | undefined) ?? "grapheme",
|
|
239
|
+
}).segment(value as string),
|
|
240
|
+
].map((part) => part.segment),
|
|
208
241
|
};
|
|
209
242
|
|
|
243
|
+
/**
|
|
244
|
+
* Fill in a helper's omitted `locale` argument from the page's own locale.
|
|
245
|
+
*
|
|
246
|
+
* A page under `/fr/` that formats a date without naming a locale means French: the route said so,
|
|
247
|
+
* `<html lang>` says so, and `hreflang` tells every crawler so. Requiring the author to repeat it
|
|
248
|
+
* at every call site is how a translated page ends up with English dates and English number
|
|
249
|
+
* grouping on it — a defect that is invisible to the person who built the site in their own
|
|
250
|
+
* language, and obvious to every reader of the other one.
|
|
251
|
+
*
|
|
252
|
+
* Determinism is untouched, which is the property `DEFAULT_FORMAT_LOCALE` exists to protect:
|
|
253
|
+
* `$page.locale` is a function of the route and the document (site-architecture.md §13.4), not of
|
|
254
|
+
* the machine the build runs on. A scope with no `$page` — a component's own state, the runtime
|
|
255
|
+
* used standalone — keeps the fixed default.
|
|
256
|
+
*
|
|
257
|
+
* @param {string} path - Helper callee path, e.g. `Intl/formatNumber`
|
|
258
|
+
* @param {unknown[]} args - Resolved arguments, in declaration order
|
|
259
|
+
* @param {JxScope} state
|
|
260
|
+
* @returns {unknown[]}
|
|
261
|
+
*/
|
|
262
|
+
function withPageLocale(path: string, args: unknown[], state: JxScope): unknown[] {
|
|
263
|
+
const index = INTL_LOCALE_PARAM[path];
|
|
264
|
+
if (index === undefined || index < 0 || args[index] != null) {
|
|
265
|
+
return args;
|
|
266
|
+
}
|
|
267
|
+
const page = (state as { $page?: { locale?: unknown } }).$page;
|
|
268
|
+
const locale = page?.locale;
|
|
269
|
+
if (typeof locale !== "string" || locale === "") {
|
|
270
|
+
return args;
|
|
271
|
+
}
|
|
272
|
+
const filled = [...args];
|
|
273
|
+
filled[index] = locale;
|
|
274
|
+
return filled;
|
|
275
|
+
}
|
|
276
|
+
|
|
210
277
|
/** Whether a `window#/…` callee ref is on the blessed pure-globals list. */
|
|
211
278
|
export function isBlessedGlobal(ref: string): boolean {
|
|
212
279
|
return ref.startsWith("window#/") && BLESSED_GLOBALS.has(ref.slice("window#/".length));
|
|
@@ -339,15 +406,16 @@ function resolveExprRef(ref: string, state: JxScope, event: Event | null, iterCt
|
|
|
339
406
|
return parts.length > 2 ? getPath(base, parts.slice(2).join("/")) : base;
|
|
340
407
|
}
|
|
341
408
|
if (ref.startsWith("#/state/")) {
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
return state[sub];
|
|
346
|
-
}
|
|
347
|
-
return getPath(state[sub.slice(0, slash)], sub.slice(slash + 1));
|
|
409
|
+
// One call, not a hand-split leading token: slicing at the first `/` skipped unescaping it, so
|
|
410
|
+
// `#/state/a~1b/c` looked for a member called `a~1b` rather than `a/b`.
|
|
411
|
+
return readPath(state, ref.slice("#/state/".length));
|
|
348
412
|
}
|
|
349
413
|
if (ref.startsWith("parent#/")) {
|
|
350
|
-
|
|
414
|
+
/*
|
|
415
|
+
* A prop name may be a path into the prop. This read the whole path as one key and returned
|
|
416
|
+
* undefined for `parent#/user/name`, while the lowerer above compiled it to a walk.
|
|
417
|
+
*/
|
|
418
|
+
return readPath(state, ref.slice("parent#/".length));
|
|
351
419
|
}
|
|
352
420
|
if (ref.startsWith("window#/")) {
|
|
353
421
|
return getPath(globalThis.window, ref.slice("window#/".length));
|
|
@@ -355,7 +423,7 @@ function resolveExprRef(ref: string, state: JxScope, event: Event | null, iterCt
|
|
|
355
423
|
if (ref.startsWith("document#/")) {
|
|
356
424
|
return getPath(globalThis.document, ref.slice("document#/".length));
|
|
357
425
|
}
|
|
358
|
-
return state
|
|
426
|
+
return readPath(state, ref) ?? null;
|
|
359
427
|
}
|
|
360
428
|
|
|
361
429
|
/** Resolve a $ref to a writable location — returns { obj, key } for assignment. */
|
|
@@ -394,7 +462,12 @@ function resolveWritableRef(
|
|
|
394
462
|
if (slash === -1) {
|
|
395
463
|
return { key: sub, obj: state };
|
|
396
464
|
}
|
|
397
|
-
|
|
465
|
+
/*
|
|
466
|
+
* The same tokenizer the read path uses. It used to split on `/` alone while the read split on
|
|
467
|
+
* `/[./]/`, so a write to `#/state/a/b.c` created a key `"b.c"` that the matching read — which
|
|
468
|
+
* walked `b` then `c` — could never see. The write simply vanished.
|
|
469
|
+
*/
|
|
470
|
+
const parts = refSegments(sub);
|
|
398
471
|
const lastKey = parts.pop();
|
|
399
472
|
let obj = state;
|
|
400
473
|
// Pointer paths address objects by construction (validated by the schema).
|
|
@@ -473,7 +546,7 @@ function evaluateNode(
|
|
|
473
546
|
const globalPath = calleeRef.slice("window#/".length);
|
|
474
547
|
const helper = BLESSED_HELPERS[globalPath];
|
|
475
548
|
if (helper) {
|
|
476
|
-
return helper(...argValues);
|
|
549
|
+
return helper(...withPageLocale(globalPath, argValues, state));
|
|
477
550
|
}
|
|
478
551
|
const fn = getPath(globalThis.window, globalPath) as (...a: unknown[]) => unknown;
|
|
479
552
|
const lastSlash = globalPath.lastIndexOf("/");
|
|
@@ -822,20 +895,18 @@ function compileRef(ref: string, opts: CompileOpts) {
|
|
|
822
895
|
return "_acc";
|
|
823
896
|
}
|
|
824
897
|
if (ref.startsWith("$args/")) {
|
|
825
|
-
|
|
826
|
-
return `_args${parts.map((p) => `[${JSON.stringify(p)}]`).join("")}`;
|
|
898
|
+
return refAccessor("_args", ref.slice("$args/".length));
|
|
827
899
|
}
|
|
828
900
|
|
|
829
901
|
if (ref.startsWith("event#/")) {
|
|
830
|
-
|
|
831
|
-
return `${e}.${path.replaceAll("/", ".")}`;
|
|
902
|
+
return refAccessor(e, ref.slice("event#/".length));
|
|
832
903
|
}
|
|
833
904
|
|
|
834
905
|
if (ref.startsWith("$map/")) {
|
|
835
906
|
const parts = ref.split("/");
|
|
836
907
|
const [, key] = parts;
|
|
837
908
|
if (key === "item") {
|
|
838
|
-
return parts.length > 2 ?
|
|
909
|
+
return parts.length > 2 ? refAccessor("_item", parts.slice(2).join("/")) : "_item";
|
|
839
910
|
}
|
|
840
911
|
if (key === "index") {
|
|
841
912
|
return "_index";
|
|
@@ -844,21 +915,24 @@ function compileRef(ref: string, opts: CompileOpts) {
|
|
|
844
915
|
}
|
|
845
916
|
|
|
846
917
|
if (ref.startsWith("#/state/")) {
|
|
847
|
-
|
|
848
|
-
return `${s}.${path.replaceAll("/", ".")}`;
|
|
918
|
+
return refAccessor(s, ref.slice("#/state/".length));
|
|
849
919
|
}
|
|
850
920
|
|
|
851
921
|
if (ref.startsWith("parent#/")) {
|
|
852
|
-
return
|
|
922
|
+
return refAccessor(s, ref.slice("parent#/".length));
|
|
853
923
|
}
|
|
854
924
|
if (ref.startsWith("window#/")) {
|
|
855
|
-
return
|
|
925
|
+
return refAccessor("window", ref.slice("window#/".length));
|
|
856
926
|
}
|
|
857
927
|
if (ref.startsWith("document#/")) {
|
|
858
|
-
return
|
|
928
|
+
return refAccessor("document", ref.slice("document#/".length));
|
|
859
929
|
}
|
|
860
930
|
|
|
861
|
-
|
|
931
|
+
/*
|
|
932
|
+
* An unrecognized scheme is still a path under state. Pasting it raw emitted `s.a/b`, which is
|
|
933
|
+
* not a parse error but a division against an undeclared identifier.
|
|
934
|
+
*/
|
|
935
|
+
return refAccessor(s, ref);
|
|
862
936
|
}
|
|
863
937
|
|
|
864
938
|
/** Compile a writable $ref target to its JS equivalent (for LHS of assignment). */
|
|
@@ -883,19 +957,52 @@ function compileTarget(target: unknown, opts: CompileOpts): string {
|
|
|
883
957
|
*
|
|
884
958
|
* @param {string} path - The `window#/`-stripped callee path (e.g. "Intl/formatNumber")
|
|
885
959
|
* @param {string[]} args - Already-compiled argument expressions
|
|
960
|
+
* @param {CompileOpts} opts
|
|
886
961
|
* @returns {string | null}
|
|
887
962
|
*/
|
|
888
|
-
function compileHelperCall(path: string, args: string[]): string | null {
|
|
963
|
+
function compileHelperCall(path: string, args: string[], opts: CompileOpts): string | null {
|
|
889
964
|
const a = (i: number) => args[i] ?? "undefined";
|
|
965
|
+
/*
|
|
966
|
+
* The same defaults the interpreter applies, inlined — see @jxsuite/schema/intl. The page's own
|
|
967
|
+
* locale comes first and the fixed one behind it, so an island formats in the language of the
|
|
968
|
+
* page it is on. `?.` and not `.`: a component's state has no `$page`, and neither does the
|
|
969
|
+
* runtime evaluated standalone.
|
|
970
|
+
*/
|
|
971
|
+
const L = `(${opts.statePrefix ?? "state"}?.$page?.locale ?? ${JSON.stringify(DEFAULT_FORMAT_LOCALE)})`;
|
|
972
|
+
const Z = JSON.stringify(DEFAULT_TIME_ZONE);
|
|
890
973
|
switch (path) {
|
|
891
974
|
case "Intl/formatNumber": {
|
|
892
|
-
return `new Intl.NumberFormat(${a(1)}, ${a(2)}).format(${a(0)})`;
|
|
975
|
+
return `new Intl.NumberFormat(${a(1)} ?? ${L}, ${a(2)}).format(${a(0)})`;
|
|
893
976
|
}
|
|
894
977
|
case "Intl/formatDate": {
|
|
895
|
-
return
|
|
978
|
+
return (
|
|
979
|
+
`new Intl.DateTimeFormat(${a(1)} ?? ${L}, ` +
|
|
980
|
+
`{timeZone: ${Z}, ...${a(2)}}).format(new Date(${a(0)}))`
|
|
981
|
+
);
|
|
896
982
|
}
|
|
897
983
|
case "Intl/formatRelativeTime": {
|
|
898
|
-
return `new Intl.RelativeTimeFormat(${a(2)}, ${a(3)}).format(${a(0)}, ${a(1)})`;
|
|
984
|
+
return `new Intl.RelativeTimeFormat(${a(2)} ?? ${L}, ${a(3)}).format(${a(0)}, ${a(1)})`;
|
|
985
|
+
}
|
|
986
|
+
case "Intl/formatList": {
|
|
987
|
+
return `new Intl.ListFormat(${a(1)} ?? ${L}, ${a(2)}).format(${a(0)})`;
|
|
988
|
+
}
|
|
989
|
+
case "Intl/plural": {
|
|
990
|
+
return `new Intl.PluralRules(${a(1)} ?? ${L}, ${a(2)}).select(${a(0)})`;
|
|
991
|
+
}
|
|
992
|
+
case "Intl/compare": {
|
|
993
|
+
return `new Intl.Collator(${a(2)} ?? ${L}, ${a(3)}).compare(${a(0)}, ${a(1)})`;
|
|
994
|
+
}
|
|
995
|
+
case "Intl/displayName": {
|
|
996
|
+
return (
|
|
997
|
+
`new Intl.DisplayNames(${a(2)} ?? ${L}, ` +
|
|
998
|
+
`{...${a(3)}, type: ${a(1)} ?? 'language'}).of(${a(0)})`
|
|
999
|
+
);
|
|
1000
|
+
}
|
|
1001
|
+
case "Intl/segment": {
|
|
1002
|
+
return (
|
|
1003
|
+
`[...new Intl.Segmenter(${a(2)} ?? ${L}, {granularity: ${a(1)} ?? 'grapheme'})` +
|
|
1004
|
+
`.segment(${a(0)})].map((p) => p.segment)`
|
|
1005
|
+
);
|
|
899
1006
|
}
|
|
900
1007
|
default: {
|
|
901
1008
|
return null;
|
|
@@ -925,7 +1032,7 @@ export function compileExpression(node: ExpressionNode, opts: CompileOpts = {}):
|
|
|
925
1032
|
if (!isBlessedGlobal(calleeRef)) {
|
|
926
1033
|
throw new Error(`$expression: "${calleeRef}" is not a blessed pure global`);
|
|
927
1034
|
}
|
|
928
|
-
const helperJs = compileHelperCall(calleeRef.slice("window#/".length), args);
|
|
1035
|
+
const helperJs = compileHelperCall(calleeRef.slice("window#/".length), args, opts);
|
|
929
1036
|
if (helperJs) {
|
|
930
1037
|
return helperJs;
|
|
931
1038
|
}
|
|
@@ -1038,13 +1145,9 @@ export function compileExpression(node: ExpressionNode, opts: CompileOpts = {}):
|
|
|
1038
1145
|
|
|
1039
1146
|
// ─── Utilities ───────────────────────────────────────────────────────────────
|
|
1040
1147
|
|
|
1041
|
-
/** Resolve a
|
|
1148
|
+
/** Resolve a `$ref` path on an object, through the one tokenizer (`pointer.ts`). */
|
|
1042
1149
|
function getPath(obj: unknown, path: string): unknown {
|
|
1043
|
-
|
|
1044
|
-
for (const k of path.split(/[./]/)) {
|
|
1045
|
-
current = (current as Record<string, unknown>)?.[k];
|
|
1046
|
-
}
|
|
1047
|
-
return current;
|
|
1150
|
+
return readPath(obj, path);
|
|
1048
1151
|
}
|
|
1049
1152
|
|
|
1050
1153
|
// ─── Statement-Engine Surface (spec §20) ─────────────────────────────────────
|
package/src/pointer.ts
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one `$ref` path grammar — the tokenizer, and the JavaScript accessor built from it.
|
|
3
|
+
*
|
|
4
|
+
* There used to be five, and they disagreed. Two read paths split a path on `/` **and** `.`; the
|
|
5
|
+
* compiler's build-time evaluator split on `/` alone; `resolveWritableRef` split on `/` alone, so a
|
|
6
|
+
* write to `#/state/a/b.c` landed on a key the matching read could never see; and four lowering
|
|
7
|
+
* functions did neither — they ran `replaceAll("/", ".")` and emitted the result as raw member
|
|
8
|
+
* access. That last one is why `#/state/items/0` compiled to `s.items.0`, a SyntaxError, while the
|
|
9
|
+
* build reported success.
|
|
10
|
+
*
|
|
11
|
+
* Segments are separated by `/` and nothing else, which is RFC 6901 §3: the ABNF excludes only `/`
|
|
12
|
+
* and `~` from a reference token, so `.` is an ordinary character and `#/state/user.name` denotes
|
|
13
|
+
* one member literally named `user.name`. Making the dot ordinary is what lets all five paths share
|
|
14
|
+
* this file — it was the only rule they differed on.
|
|
15
|
+
*
|
|
16
|
+
* @docs framework/concepts/references
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** `~1` is `/` and `~0` is `~`; RFC 6901 §4 fixes this order so `~01` becomes `~1`, not `/`. */
|
|
20
|
+
export function unescapeToken(token: string): string {
|
|
21
|
+
return token.replaceAll("~1", "/").replaceAll("~0", "~");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** `~` and `/` are the two characters a token cannot carry literally (RFC 6901 §3). */
|
|
25
|
+
export function escapeToken(key: string): string {
|
|
26
|
+
return key.replaceAll("~", "~0").replaceAll("/", "~1");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Split a `$ref` path — the part after its scheme — into member names.
|
|
31
|
+
*
|
|
32
|
+
* An empty path is no segments rather than one empty segment, so `#/state/` reads `state` itself
|
|
33
|
+
* rather than a member named "".
|
|
34
|
+
*
|
|
35
|
+
* @param {string} path - E.g. `user/name`, `items/0`, `a~1b`
|
|
36
|
+
* @returns {string[]} Unescaped member names
|
|
37
|
+
*/
|
|
38
|
+
export function refSegments(path: string): string[] {
|
|
39
|
+
if (path === "") {
|
|
40
|
+
return [];
|
|
41
|
+
}
|
|
42
|
+
return path.split("/").map((token) => unescapeToken(token));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Walk a value along a `$ref` path, or `undefined` where the path leaves the object.
|
|
47
|
+
*
|
|
48
|
+
* @param {unknown} root
|
|
49
|
+
* @param {string} path
|
|
50
|
+
* @returns {unknown}
|
|
51
|
+
*/
|
|
52
|
+
export function readPath(root: unknown, path: string): unknown {
|
|
53
|
+
let current = root;
|
|
54
|
+
for (const key of refSegments(path)) {
|
|
55
|
+
current = (current as Record<string, unknown> | null | undefined)?.[key];
|
|
56
|
+
}
|
|
57
|
+
return current;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* A segment that can follow a `.` — deliberately ASCII-only, so anything exotic takes the bracket
|
|
62
|
+
* branch rather than relying on this regex to model ECMAScript's full identifier grammar. Reserved
|
|
63
|
+
* words are fine: `s.class` and `s.new` are legal member accesses.
|
|
64
|
+
*/
|
|
65
|
+
const IDENTIFIER = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* A JavaScript expression reading `path` from `base`.
|
|
69
|
+
*
|
|
70
|
+
* Dotted where a segment is genuinely an identifier, bracketed with a JSON-quoted key where it is
|
|
71
|
+
* not — the emitted module is read in devtools by the site's author, and `state.cart.push(3)` is
|
|
72
|
+
* worth keeping over `state["cart"].push(3)`.
|
|
73
|
+
*
|
|
74
|
+
* The bug was never dot notation; it was dot notation with no other branch. The compiler ran
|
|
75
|
+
* `replaceAll("/", ".")` unconditionally, so `#/state/items/0` became `s.items.0` — a SyntaxError
|
|
76
|
+
* emitted by a build that reported success — and a key holding a hyphen or a space did the same.
|
|
77
|
+
* `$args/` already bracketed everything, which is why shipped `$args/values/0` formulas worked
|
|
78
|
+
* while `#/state/items/0` did not. One rule now, with the safe branch as the fallback.
|
|
79
|
+
*
|
|
80
|
+
* @param {string} base - Receiver expression, e.g. `s` or `_args`
|
|
81
|
+
* @param {string} path
|
|
82
|
+
* @returns {string}
|
|
83
|
+
*/
|
|
84
|
+
export function refAccessor(base: string, path: string): string {
|
|
85
|
+
let expr = base;
|
|
86
|
+
for (const key of refSegments(path)) {
|
|
87
|
+
expr += IDENTIFIER.test(key) ? `.${key}` : `[${JSON.stringify(key)}]`;
|
|
88
|
+
}
|
|
89
|
+
return expr;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* An object-literal key: bare where it is an identifier, JSON-quoted where it is not.
|
|
94
|
+
*
|
|
95
|
+
* Both forms are valid JavaScript, so this is only about what the emitted module reads like — and
|
|
96
|
+
* quoting is the branch that has to exist, because a document may legally declare a state key of
|
|
97
|
+
* `user.name` and pasting that raw produced `user.name: 1,`, a SyntaxError.
|
|
98
|
+
*
|
|
99
|
+
* @param {string} key
|
|
100
|
+
* @returns {string}
|
|
101
|
+
*/
|
|
102
|
+
export function objectKey(key: string): string {
|
|
103
|
+
return IDENTIFIER.test(key) ? key : JSON.stringify(key);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* A safe identifier naming one binding in a generated `bind`/`on` map.
|
|
108
|
+
*
|
|
109
|
+
* The key is emitted as an object-literal key _and_ as a `data-bind` attribute value _and_ as
|
|
110
|
+
* `on.<key>` member access, so unlike {@link objectKey} it cannot fall back to quoting — it has to
|
|
111
|
+
* be an identifier outright. Every character outside `[A-Za-z0-9_$]` becomes `_` and a leading
|
|
112
|
+
* digit gains a prefix; nothing else is touched.
|
|
113
|
+
*
|
|
114
|
+
* `$` is deliberately kept. Folding it would rename the `$handler` key while the emitted call site
|
|
115
|
+
* still said `on.$handler`, unbinding the handler with no error anywhere. Runs of `_` are likewise
|
|
116
|
+
* left alone: collapsing them made `a__b` and `a_b` collide for cosmetic reasons.
|
|
117
|
+
*
|
|
118
|
+
* Distinct paths can still collide (`a/b` and `a.b` both give `a_b`); callers needing uniqueness
|
|
119
|
+
* compare the paths themselves.
|
|
120
|
+
*
|
|
121
|
+
* @param {string} ref - The ref, prefix already stripped by the caller
|
|
122
|
+
* @returns {string}
|
|
123
|
+
*/
|
|
124
|
+
export function refBindingKey(ref: string): string {
|
|
125
|
+
const safe = ref.replaceAll(/[^A-Za-z0-9_$]/g, "_");
|
|
126
|
+
if (safe === "") {
|
|
127
|
+
return "ref";
|
|
128
|
+
}
|
|
129
|
+
return /^\d/.test(safe) ? `_${safe}` : safe;
|
|
130
|
+
}
|