@rohal12/spindle 0.52.3 → 0.52.5
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/pkg/format.js +1 -1
- package/dist/pkg/headless.js +1105 -1588
- package/dist/pkg/story-variables.js +155 -469
- package/package.json +1 -1
- package/src/class-registry.ts +6 -182
- package/src/components/macros/Widget.tsx +18 -7
- package/src/execute-mutation.ts +17 -97
- package/src/index.tsx +5 -8
- package/src/interpolation.ts +16 -22
- package/src/js-lexer.ts +20 -9
- package/src/markup/ast.ts +108 -147
- package/src/markup/code-attributes.ts +4 -6
- package/src/markup/tokenizer.ts +218 -574
- package/src/saves/save-manager.ts +190 -259
- package/src/saves/storage.ts +229 -402
- package/src/store.ts +244 -310
- package/src/structural.ts +388 -0
package/package.json
CHANGED
package/src/class-registry.ts
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
// Class registry for preserving class instances across clone/save/load cycles.
|
|
2
2
|
|
|
3
|
+
import { setOwn } from './structural';
|
|
4
|
+
import { hasOwn } from './utils/namespace';
|
|
5
|
+
|
|
6
|
+
// Copying and comparing story values live with the structural diff
|
|
7
|
+
export { deepClone, deepEqual, type DeepCloneOptions } from './structural';
|
|
8
|
+
|
|
3
9
|
type Constructor = new (...args: any[]) => any;
|
|
4
10
|
|
|
5
11
|
const registry = new Map<string, Constructor>();
|
|
@@ -33,188 +39,6 @@ export function clearRegistry(): void {
|
|
|
33
39
|
ctorToName.clear();
|
|
34
40
|
}
|
|
35
41
|
|
|
36
|
-
// --- Deep Clone ---
|
|
37
|
-
|
|
38
|
-
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
39
|
-
if (typeof value !== 'object' || value === null) return false;
|
|
40
|
-
const proto = Object.getPrototypeOf(value);
|
|
41
|
-
return proto === Object.prototype || proto === null;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
/**
|
|
45
|
-
* Set an own enumerable property. A "__proto__" key is defined as an own
|
|
46
|
-
* property (as JSON.parse does) instead of replacing the prototype.
|
|
47
|
-
*/
|
|
48
|
-
function setOwn(target: object, key: string, value: unknown): void {
|
|
49
|
-
if (key === '__proto__') {
|
|
50
|
-
Object.defineProperty(target, key, {
|
|
51
|
-
value,
|
|
52
|
-
enumerable: true,
|
|
53
|
-
writable: true,
|
|
54
|
-
configurable: true,
|
|
55
|
-
});
|
|
56
|
-
} else {
|
|
57
|
-
(target as Record<string, unknown>)[key] = value;
|
|
58
|
-
}
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
export interface DeepCloneOptions {
|
|
62
|
-
/**
|
|
63
|
-
* Return instances of unregistered classes (DOM nodes, promises, other
|
|
64
|
-
* library objects) by reference instead of copying their own keys into a
|
|
65
|
-
* plain object, which would lose their prototype and identity.
|
|
66
|
-
*/
|
|
67
|
-
keepUnregistered?: boolean;
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
export function deepClone<T>(value: T, options: DeepCloneOptions = {}): T {
|
|
71
|
-
const seen = new Map<object, object>();
|
|
72
|
-
|
|
73
|
-
function clone(val: unknown): unknown {
|
|
74
|
-
if (val === null || typeof val !== 'object') return val;
|
|
75
|
-
|
|
76
|
-
const obj = val as object;
|
|
77
|
-
if (seen.has(obj)) return seen.get(obj);
|
|
78
|
-
|
|
79
|
-
if (val instanceof Date) return new Date(val.getTime()) as unknown;
|
|
80
|
-
if (val instanceof RegExp)
|
|
81
|
-
return new RegExp(val.source, val.flags) as unknown;
|
|
82
|
-
|
|
83
|
-
if (Array.isArray(val)) {
|
|
84
|
-
const arr: unknown[] = [];
|
|
85
|
-
seen.set(obj, arr);
|
|
86
|
-
for (let i = 0; i < val.length; i++) {
|
|
87
|
-
arr[i] = clone(val[i]);
|
|
88
|
-
}
|
|
89
|
-
return arr;
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
if (val instanceof Map) {
|
|
93
|
-
const copy = new Map();
|
|
94
|
-
seen.set(obj, copy);
|
|
95
|
-
for (const [k, v] of val) {
|
|
96
|
-
copy.set(clone(k), clone(v));
|
|
97
|
-
}
|
|
98
|
-
return copy;
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
if (val instanceof Set) {
|
|
102
|
-
const copy = new Set();
|
|
103
|
-
seen.set(obj, copy);
|
|
104
|
-
for (const v of val) {
|
|
105
|
-
copy.add(clone(v));
|
|
106
|
-
}
|
|
107
|
-
return copy;
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
// Registered class instance
|
|
111
|
-
const name = registeredClassName(obj);
|
|
112
|
-
if (name !== undefined) {
|
|
113
|
-
const copy = Object.create(Object.getPrototypeOf(obj)) as Record<
|
|
114
|
-
string,
|
|
115
|
-
unknown
|
|
116
|
-
>;
|
|
117
|
-
seen.set(obj, copy);
|
|
118
|
-
for (const key of Object.keys(obj)) {
|
|
119
|
-
setOwn(copy, key, clone((obj as Record<string, unknown>)[key]));
|
|
120
|
-
}
|
|
121
|
-
return copy;
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
// Plain object (or unregistered class — treat as plain). A plain object
|
|
125
|
-
// keeps its prototype, which may be null.
|
|
126
|
-
const plain = isPlainObject(val);
|
|
127
|
-
if (!plain && options.keepUnregistered) return val;
|
|
128
|
-
const copy = (
|
|
129
|
-
plain ? Object.create(Object.getPrototypeOf(obj) as object | null) : {}
|
|
130
|
-
) as Record<string, unknown>;
|
|
131
|
-
seen.set(obj, copy);
|
|
132
|
-
for (const key of Object.keys(obj)) {
|
|
133
|
-
setOwn(copy, key, clone((obj as Record<string, unknown>)[key]));
|
|
134
|
-
}
|
|
135
|
-
return copy;
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
return clone(value) as T;
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
// --- Deep Equal ---
|
|
142
|
-
|
|
143
|
-
/**
|
|
144
|
-
* Structural equality over the value types deepClone() supports: primitives,
|
|
145
|
-
* arrays, plain objects, class instances, Date, RegExp, Map and Set (nested
|
|
146
|
-
* at any depth). Map and Set entries are compared in insertion order. Arrays
|
|
147
|
-
* are compared by length and index, so a hole equals an undefined element
|
|
148
|
-
* (deepClone() and save/load turn holes into undefined elements).
|
|
149
|
-
*/
|
|
150
|
-
export function deepEqual(a: unknown, b: unknown): boolean {
|
|
151
|
-
return equal(a, b, new Map());
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
/**
|
|
155
|
-
* `assumed` holds the pairs already being compared: meeting one again (a
|
|
156
|
-
* cycle) assumes it equal. Every object may pair with several others, since
|
|
157
|
-
* cycles of different lengths can still unfold to the same value.
|
|
158
|
-
*/
|
|
159
|
-
function equal(
|
|
160
|
-
a: unknown,
|
|
161
|
-
b: unknown,
|
|
162
|
-
assumed: Map<object, Set<object>>,
|
|
163
|
-
): boolean {
|
|
164
|
-
if (Object.is(a, b)) return true;
|
|
165
|
-
if (
|
|
166
|
-
a === null ||
|
|
167
|
-
b === null ||
|
|
168
|
-
typeof a !== 'object' ||
|
|
169
|
-
typeof b !== 'object'
|
|
170
|
-
) {
|
|
171
|
-
return false;
|
|
172
|
-
}
|
|
173
|
-
if (Object.getPrototypeOf(a) !== Object.getPrototypeOf(b)) return false;
|
|
174
|
-
const pairs = assumed.get(a);
|
|
175
|
-
if (pairs?.has(b)) return true;
|
|
176
|
-
if (pairs) pairs.add(b);
|
|
177
|
-
else assumed.set(a, new Set([b]));
|
|
178
|
-
|
|
179
|
-
if (a instanceof Date) return Object.is(a.getTime(), (b as Date).getTime());
|
|
180
|
-
if (a instanceof RegExp) return String(a) === String(b);
|
|
181
|
-
if (a instanceof Map || a instanceof Set) {
|
|
182
|
-
const bc = b as Map<unknown, unknown> | Set<unknown>;
|
|
183
|
-
if (a.size !== bc.size) return false;
|
|
184
|
-
const ai = a.entries();
|
|
185
|
-
const bi = bc.entries();
|
|
186
|
-
for (
|
|
187
|
-
let x = ai.next(), y = bi.next();
|
|
188
|
-
!x.done;
|
|
189
|
-
x = ai.next(), y = bi.next()
|
|
190
|
-
) {
|
|
191
|
-
if (!equal(x.value, y.value, assumed)) return false;
|
|
192
|
-
}
|
|
193
|
-
return true;
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
if (Array.isArray(a)) {
|
|
197
|
-
const bc = b as unknown[];
|
|
198
|
-
if (a.length !== bc.length) return false;
|
|
199
|
-
for (let i = 0; i < a.length; i++) {
|
|
200
|
-
if (!equal(a[i], bc[i], assumed)) return false;
|
|
201
|
-
}
|
|
202
|
-
return true;
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
const ao = a as Record<string, unknown>;
|
|
206
|
-
const bo = b as Record<string, unknown>;
|
|
207
|
-
const keys = Object.keys(ao);
|
|
208
|
-
if (keys.length !== Object.keys(bo).length) return false;
|
|
209
|
-
for (const key of keys) {
|
|
210
|
-
if (!hasOwn(bo, key) || !equal(ao[key], bo[key], assumed)) return false;
|
|
211
|
-
}
|
|
212
|
-
return true;
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
const hasOwn = (obj: object, key: string): boolean =>
|
|
216
|
-
Object.prototype.hasOwnProperty.call(obj, key);
|
|
217
|
-
|
|
218
42
|
// --- Serialize ---
|
|
219
43
|
|
|
220
44
|
const CLASS_TAG = '__spindle_class__';
|
|
@@ -5,12 +5,20 @@ import { defineMacro } from '../../define-macro';
|
|
|
5
5
|
import { checkVariableName } from '../../utils/namespace';
|
|
6
6
|
import { MacroError } from './MacroError';
|
|
7
7
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
8
|
+
/**
|
|
9
|
+
* Read the arguments of a {widget} definition: its name (quotes dropped)
|
|
10
|
+
* and its parameters, the words after it that start with one of the
|
|
11
|
+
* characters of `sigils`.
|
|
12
|
+
*/
|
|
13
|
+
export function parseWidgetDef(
|
|
14
|
+
rawArgs: string,
|
|
15
|
+
sigils = '@',
|
|
16
|
+
): { name: string; params: string[] } {
|
|
17
|
+
const [first, ...rest] = rawArgs.trim().split(/\s+/);
|
|
18
|
+
return {
|
|
19
|
+
name: first!.replace(/["']/g, ''),
|
|
20
|
+
params: rest.filter((word) => sigils.includes(word[0]!)),
|
|
21
|
+
};
|
|
14
22
|
}
|
|
15
23
|
|
|
16
24
|
defineMacro({
|
|
@@ -20,7 +28,10 @@ defineMacro({
|
|
|
20
28
|
let parsed: ReturnType<typeof parseWidgetDef> | undefined;
|
|
21
29
|
let error: unknown;
|
|
22
30
|
try {
|
|
23
|
-
|
|
31
|
+
// Refuse `@` parameters that no namespace can hold
|
|
32
|
+
const def = parseWidgetDef(rawArgs);
|
|
33
|
+
for (const param of def.params) checkVariableName(param.slice(1), param);
|
|
34
|
+
parsed = def;
|
|
24
35
|
} catch (err) {
|
|
25
36
|
error = err;
|
|
26
37
|
}
|
package/src/execute-mutation.ts
CHANGED
|
@@ -9,9 +9,18 @@ import {
|
|
|
9
9
|
import { useStoryStore } from './store';
|
|
10
10
|
import type { StoryState, VariableNamespaces } from './store';
|
|
11
11
|
import { execute } from './expression';
|
|
12
|
-
import {
|
|
13
|
-
|
|
14
|
-
|
|
12
|
+
import {
|
|
13
|
+
applyChange,
|
|
14
|
+
deepClone,
|
|
15
|
+
deepEqual,
|
|
16
|
+
diffPaths,
|
|
17
|
+
isApplied,
|
|
18
|
+
isMergeable,
|
|
19
|
+
mergeKeys,
|
|
20
|
+
type PathChange,
|
|
21
|
+
} from './structural';
|
|
22
|
+
import { getByPath } from './utils/object-path';
|
|
23
|
+
import { asNamespace, hasOwn } from './utils/namespace';
|
|
15
24
|
|
|
16
25
|
type NamespaceName = keyof VariableNamespaces;
|
|
17
26
|
|
|
@@ -21,11 +30,6 @@ const NAMESPACES: readonly NamespaceName[] = [
|
|
|
21
30
|
'transient',
|
|
22
31
|
];
|
|
23
32
|
|
|
24
|
-
/** A write to one property path of a namespace, or its deletion. */
|
|
25
|
-
type PathChange =
|
|
26
|
-
| { path: string[]; deleted: false; value: unknown }
|
|
27
|
-
| { path: string[]; deleted: true };
|
|
28
|
-
|
|
29
33
|
/**
|
|
30
34
|
* The state of one executing mutation. The code reads and writes `work`.
|
|
31
35
|
* `base` is what `work` started as, with every write made elsewhere during
|
|
@@ -76,80 +80,6 @@ export function frozenCopy<T>(value: T): T {
|
|
|
76
80
|
return freeze(cloneValue(value), true);
|
|
77
81
|
}
|
|
78
82
|
|
|
79
|
-
/**
|
|
80
|
-
* Objects merged property by property: plain objects and (registered)
|
|
81
|
-
* class instances. Arrays, Map, Set, Date and RegExp are values that are
|
|
82
|
-
* replaced as a whole, since their elements have no stable identity to
|
|
83
|
-
* merge by (a shift moves every index).
|
|
84
|
-
*/
|
|
85
|
-
export function isMergeable(value: unknown): value is Record<string, unknown> {
|
|
86
|
-
return (
|
|
87
|
-
typeof value === 'object' &&
|
|
88
|
-
value !== null &&
|
|
89
|
-
!Array.isArray(value) &&
|
|
90
|
-
!(value instanceof Map) &&
|
|
91
|
-
!(value instanceof Set) &&
|
|
92
|
-
!(value instanceof Date) &&
|
|
93
|
-
!(value instanceof RegExp)
|
|
94
|
-
);
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
const hasOwn = (obj: object, key: string): boolean =>
|
|
98
|
-
Object.prototype.hasOwnProperty.call(obj, key);
|
|
99
|
-
|
|
100
|
-
/** Collect the property paths where `work` differs from `base`. */
|
|
101
|
-
function diff(
|
|
102
|
-
base: Record<string, unknown>,
|
|
103
|
-
work: Record<string, unknown>,
|
|
104
|
-
path: string[],
|
|
105
|
-
changes: PathChange[],
|
|
106
|
-
ancestors: Set<object>,
|
|
107
|
-
): void {
|
|
108
|
-
// Stop at cycles; shared (non-cyclic) references are visited per path.
|
|
109
|
-
if (ancestors.has(work)) return;
|
|
110
|
-
ancestors.add(work);
|
|
111
|
-
for (const key of Object.keys(work)) {
|
|
112
|
-
const b = base[key];
|
|
113
|
-
const w = work[key];
|
|
114
|
-
if (!hasOwn(base, key)) {
|
|
115
|
-
changes.push({ path: [...path, key], deleted: false, value: w });
|
|
116
|
-
} else if (Object.is(b, w)) {
|
|
117
|
-
// The same value (an untouched subtree of an Immer update)
|
|
118
|
-
continue;
|
|
119
|
-
} else if (
|
|
120
|
-
isMergeable(b) &&
|
|
121
|
-
isMergeable(w) &&
|
|
122
|
-
Object.getPrototypeOf(b) === Object.getPrototypeOf(w)
|
|
123
|
-
) {
|
|
124
|
-
diff(b, w, [...path, key], changes, ancestors);
|
|
125
|
-
} else if (!deepEqual(b, w)) {
|
|
126
|
-
changes.push({ path: [...path, key], deleted: false, value: w });
|
|
127
|
-
}
|
|
128
|
-
}
|
|
129
|
-
for (const key of Object.keys(base)) {
|
|
130
|
-
if (!hasOwn(work, key))
|
|
131
|
-
changes.push({ path: [...path, key], deleted: true });
|
|
132
|
-
}
|
|
133
|
-
ancestors.delete(work);
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
/** Whether `ns` already holds what `change` would write. */
|
|
137
|
-
function isApplied(ns: Record<string, unknown>, change: PathChange): boolean {
|
|
138
|
-
const parent = getByPath(ns, change.path.slice(0, -1));
|
|
139
|
-
if (parent === null || typeof parent !== 'object') return change.deleted;
|
|
140
|
-
const key = change.path[change.path.length - 1]!;
|
|
141
|
-
if (!hasOwn(parent, key)) return change.deleted;
|
|
142
|
-
return (
|
|
143
|
-
!change.deleted &&
|
|
144
|
-
deepEqual((parent as Record<string, unknown>)[key], change.value)
|
|
145
|
-
);
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
function applyChange(ns: Record<string, unknown>, change: PathChange): void {
|
|
149
|
-
if (change.deleted) deleteByPath(ns, change.path);
|
|
150
|
-
else setByPath(ns, change.path, change.value);
|
|
151
|
-
}
|
|
152
|
-
|
|
153
83
|
/**
|
|
154
84
|
* Apply a write that reached the store while mutations execute to their
|
|
155
85
|
* `work` and `base` copies (see MutationScope), so the code sees it and
|
|
@@ -381,11 +311,9 @@ function commitScopes(commits: readonly Commit[]): void {
|
|
|
381
311
|
const all = commits.map(({ scope, keepRunning }) => ({
|
|
382
312
|
scope,
|
|
383
313
|
keepRunning,
|
|
384
|
-
changes: NAMESPACES.map(
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
return [ns, list] as const;
|
|
388
|
-
}),
|
|
314
|
+
changes: NAMESPACES.map(
|
|
315
|
+
(ns) => [ns, diffPaths(scope.base[ns], scope.work[ns])] as const,
|
|
316
|
+
),
|
|
389
317
|
}));
|
|
390
318
|
const changed = all.filter(({ changes }) =>
|
|
391
319
|
changes.some(([, list]) => list.length > 0),
|
|
@@ -450,16 +378,8 @@ const running = (): Commit[] =>
|
|
|
450
378
|
function resync(scope: MutationScope): void {
|
|
451
379
|
const state = useStoryStore.getState();
|
|
452
380
|
for (const ns of NAMESPACES) {
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
for (const key of Object.keys(work)) {
|
|
456
|
-
if (!hasOwn(stored, key)) delete work[key];
|
|
457
|
-
}
|
|
458
|
-
for (const key of Object.keys(stored)) {
|
|
459
|
-
if (!hasOwn(work, key) || !deepEqual(work[key], stored[key])) {
|
|
460
|
-
work[key] = deepClone(stored[key]);
|
|
461
|
-
}
|
|
462
|
-
}
|
|
381
|
+
// Root by root: a root that differs is replaced with a copy of the store's
|
|
382
|
+
mergeKeys(scope.work[ns], scope.work[ns], state[ns]);
|
|
463
383
|
}
|
|
464
384
|
scope.base = cloneNamespaces(state);
|
|
465
385
|
}
|
package/src/index.tsx
CHANGED
|
@@ -21,6 +21,7 @@ import { tokenize } from './markup/tokenizer';
|
|
|
21
21
|
import { buildAST, registerBlockMacro } from './markup/ast';
|
|
22
22
|
import { registerWidget } from './widgets/widget-registry';
|
|
23
23
|
import { astContainsChildren } from './widgets/ast-scanner';
|
|
24
|
+
import { parseWidgetDef } from './components/macros/Widget';
|
|
24
25
|
import { errorMessage } from './utils/error-message';
|
|
25
26
|
import type { ASTNode } from './markup/ast';
|
|
26
27
|
import './macros/register-builtins';
|
|
@@ -173,14 +174,10 @@ export function boot() {
|
|
|
173
174
|
const widgetAST = buildAST(widgetTokens);
|
|
174
175
|
for (const node of widgetAST) {
|
|
175
176
|
if (node.type === 'macro' && node.name === 'widget' && node.rawArgs) {
|
|
176
|
-
const
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
.filter(
|
|
181
|
-
(t) =>
|
|
182
|
-
t.startsWith('$') || t.startsWith('_') || t.startsWith('@'),
|
|
183
|
-
);
|
|
177
|
+
const { name: widgetName, params } = parseWidgetDef(
|
|
178
|
+
node.rawArgs,
|
|
179
|
+
'$_@',
|
|
180
|
+
);
|
|
184
181
|
const children = node.children as ASTNode[];
|
|
185
182
|
const isBlock = astContainsChildren(children);
|
|
186
183
|
try {
|
package/src/interpolation.ts
CHANGED
|
@@ -14,7 +14,13 @@
|
|
|
14
14
|
* string, so it is reported instead of being run or shown as source.
|
|
15
15
|
*/
|
|
16
16
|
import { evaluate } from './expression';
|
|
17
|
-
import {
|
|
17
|
+
import {
|
|
18
|
+
SCOPE_SIGILS,
|
|
19
|
+
SIGIL_SCOPES,
|
|
20
|
+
tokenize,
|
|
21
|
+
type VariableScope,
|
|
22
|
+
} from './markup/tokenizer';
|
|
23
|
+
import type { Sigil } from './js-lexer';
|
|
18
24
|
import { buildAST } from './markup/ast';
|
|
19
25
|
import type { ASTNode, MacroNode, TextNode } from './markup/ast';
|
|
20
26
|
import { getMacro, getMacroText, isSubMacro } from './registry';
|
|
@@ -116,15 +122,6 @@ function display(value: unknown): string {
|
|
|
116
122
|
return value == null ? '' : String(value);
|
|
117
123
|
}
|
|
118
124
|
|
|
119
|
-
type VariableKind = 'variable' | 'temporary' | 'local' | 'transient';
|
|
120
|
-
|
|
121
|
-
const KIND_SIGILS = {
|
|
122
|
-
variable: '$',
|
|
123
|
-
temporary: '_',
|
|
124
|
-
local: '@',
|
|
125
|
-
transient: '%',
|
|
126
|
-
} as const;
|
|
127
|
-
|
|
128
125
|
/**
|
|
129
126
|
* The value of a variable reference (`name` without its sigil, with an
|
|
130
127
|
* optional dot path), as `{$name}` shows it in passage text (see
|
|
@@ -133,12 +130,12 @@ const KIND_SIGILS = {
|
|
|
133
130
|
*/
|
|
134
131
|
function resolveVariable(
|
|
135
132
|
scope: TextScope,
|
|
136
|
-
kind:
|
|
133
|
+
kind: VariableScope,
|
|
137
134
|
name: string,
|
|
138
135
|
): unknown {
|
|
139
136
|
const parts = name.split('.');
|
|
140
137
|
const root = parts[0]!;
|
|
141
|
-
checkVariableName(root,
|
|
138
|
+
checkVariableName(root, SCOPE_SIGILS[kind] + root);
|
|
142
139
|
const store =
|
|
143
140
|
kind === 'variable'
|
|
144
141
|
? scope.variables
|
|
@@ -210,12 +207,16 @@ class TextEvaluator {
|
|
|
210
207
|
}
|
|
211
208
|
|
|
212
209
|
/** A variable reference, or '' and an error if it can't be one. */
|
|
213
|
-
private variable(
|
|
210
|
+
private variable(
|
|
211
|
+
kind: VariableScope,
|
|
212
|
+
name: string,
|
|
213
|
+
scope: TextScope,
|
|
214
|
+
): string {
|
|
214
215
|
try {
|
|
215
216
|
return display(resolveVariable(scope, kind, name));
|
|
216
217
|
} catch (error) {
|
|
217
218
|
// Reported as passage text reports it: `{$__proto__ error: …}`
|
|
218
|
-
this.errors.push({ macro:
|
|
219
|
+
this.errors.push({ macro: SCOPE_SIGILS[kind] + name, error });
|
|
219
220
|
return '';
|
|
220
221
|
}
|
|
221
222
|
}
|
|
@@ -341,13 +342,6 @@ export function interpolateText(
|
|
|
341
342
|
return renderText(parsed.nodes, scope);
|
|
342
343
|
}
|
|
343
344
|
|
|
344
|
-
const SIGIL_KINDS = {
|
|
345
|
-
$: 'variable',
|
|
346
|
-
_: 'temporary',
|
|
347
|
-
'@': 'local',
|
|
348
|
-
'%': 'transient',
|
|
349
|
-
} as const;
|
|
350
|
-
|
|
351
345
|
/**
|
|
352
346
|
* Resolve the sigil references in the value of a code attribute (see
|
|
353
347
|
* isCodeAttribute): `{$x}`, `{_x.y}`, `{$n + 1}`. Everything else, other
|
|
@@ -367,7 +361,7 @@ export function interpolateCode(
|
|
|
367
361
|
} else if ('verbatim' in part) {
|
|
368
362
|
text += part.verbatim;
|
|
369
363
|
} else if (/^[$_@%][\w.]+$/.test(part.expr)) {
|
|
370
|
-
const kind =
|
|
364
|
+
const kind = SIGIL_SCOPES[part.expr[0] as Sigil];
|
|
371
365
|
try {
|
|
372
366
|
text += display(resolveVariable(scope, kind, part.expr.slice(1)));
|
|
373
367
|
} catch (error) {
|
package/src/js-lexer.ts
CHANGED
|
@@ -145,15 +145,14 @@ function scanRegex(
|
|
|
145
145
|
};
|
|
146
146
|
let i = start + 1;
|
|
147
147
|
while (i < src.length) {
|
|
148
|
+
// Unterminated at the end of the line: leave the rest to the parser
|
|
149
|
+
const lineEnd = regexLineEnd(src, i);
|
|
150
|
+
if (lineEnd !== -1) return done(lineEnd, false);
|
|
148
151
|
const c = src.charAt(i);
|
|
149
152
|
if (c === '\\') {
|
|
150
|
-
// An escaped line break ends the line, and the regex, too
|
|
151
|
-
if (LINE_TERMINATOR_RE.test(src.charAt(i + 1))) return done(i + 1, false);
|
|
152
153
|
i += 2;
|
|
153
154
|
continue;
|
|
154
155
|
}
|
|
155
|
-
// Unterminated at the end of the line: leave the rest to the parser
|
|
156
|
-
if (LINE_TERMINATOR_RE.test(c)) return done(i, false);
|
|
157
156
|
if (c === '[') {
|
|
158
157
|
// A class, where `/` does not close
|
|
159
158
|
i = scanRegexClass(src, i, cache);
|
|
@@ -191,16 +190,17 @@ function scanRegexClass(
|
|
|
191
190
|
const opens = [open];
|
|
192
191
|
let i = open + 1;
|
|
193
192
|
while (i < src.length) {
|
|
193
|
+
const lineEnd = regexLineEnd(src, i);
|
|
194
|
+
if (lineEnd !== -1) {
|
|
195
|
+
i = lineEnd;
|
|
196
|
+
break;
|
|
197
|
+
}
|
|
194
198
|
const c = src.charAt(i);
|
|
195
199
|
if (c === '\\') {
|
|
196
|
-
if (LINE_TERMINATOR_RE.test(src.charAt(i + 1))) {
|
|
197
|
-
i++;
|
|
198
|
-
break;
|
|
199
|
-
}
|
|
200
200
|
i += 2;
|
|
201
201
|
continue;
|
|
202
202
|
}
|
|
203
|
-
if (c === ']'
|
|
203
|
+
if (c === ']') break;
|
|
204
204
|
if (c === '[' && cache) opens.push(i);
|
|
205
205
|
i++;
|
|
206
206
|
}
|
|
@@ -209,6 +209,17 @@ function scanRegexClass(
|
|
|
209
209
|
return i;
|
|
210
210
|
}
|
|
211
211
|
|
|
212
|
+
/**
|
|
213
|
+
* Index of the line break at `i`, escaped (`\` then a line break) or not,
|
|
214
|
+
* where a regex literal or class ends unterminated; -1 if there is none.
|
|
215
|
+
*/
|
|
216
|
+
function regexLineEnd(src: string, i: number): number {
|
|
217
|
+
const c = src.charAt(i);
|
|
218
|
+
if (LINE_TERMINATOR_RE.test(c)) return i;
|
|
219
|
+
if (c === '\\' && LINE_TERMINATOR_RE.test(src.charAt(i + 1))) return i + 1;
|
|
220
|
+
return -1;
|
|
221
|
+
}
|
|
222
|
+
|
|
212
223
|
/**
|
|
213
224
|
* The next index from `from` on where `find` matches (-1 for none), for
|
|
214
225
|
* searches from increasing positions: a search from within the stretch the
|