@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jxsuite/runtime",
3
- "version": "1.3.2",
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.5.0",
34
- "@vue/reactivity": "3.5.40"
34
+ "@jxsuite/schema": "^1.8.0",
35
+ "@vue/reactivity": "3.5.41"
35
36
  },
36
37
  "devDependencies": {
37
- "@happy-dom/global-registrator": "^20.11.1"
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 (synthetic helpers — see BLESSED_HELPERS; Intl formatters are constructors,
181
- // So the plain-function call shape wraps construct-then-format)
182
- "Intl/formatNumber",
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
- locale as string | undefined,
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
- const sub = ref.slice("#/state/".length);
343
- const slash = sub.indexOf("/");
344
- if (slash === -1) {
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
- return state[ref.slice("parent#/".length)];
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[ref] ?? null;
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
- const parts = sub.split("/");
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
- const parts = ref.slice("$args/".length).split("/");
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
- const path = ref.slice("event#/".length);
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 ? `_item.${parts.slice(2).join(".")}` : "_item";
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
- const path = ref.slice("#/state/".length);
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 `${s}.${ref.slice("parent#/".length)}`;
922
+ return refAccessor(s, ref.slice("parent#/".length));
853
923
  }
854
924
  if (ref.startsWith("window#/")) {
855
- return `window.${ref.slice("window#/".length).replaceAll("/", ".")}`;
925
+ return refAccessor("window", ref.slice("window#/".length));
856
926
  }
857
927
  if (ref.startsWith("document#/")) {
858
- return `document.${ref.slice("document#/".length).replaceAll("/", ".")}`;
928
+ return refAccessor("document", ref.slice("document#/".length));
859
929
  }
860
930
 
861
- return `${s}.${ref}`;
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 `new Intl.DateTimeFormat(${a(1)}, ${a(2)}).format(new Date(${a(0)}))`;
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 dotted/slashed path on an object. */
1148
+ /** Resolve a `$ref` path on an object, through the one tokenizer (`pointer.ts`). */
1042
1149
  function getPath(obj: unknown, path: string): unknown {
1043
- let current: unknown = obj;
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
+ }