@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rohal12/spindle",
3
- "version": "0.52.3",
3
+ "version": "0.52.5",
4
4
  "type": "module",
5
5
  "description": "A Preact-based story format for Twine 2.",
6
6
  "license": "Unlicense",
@@ -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
- function parseWidgetDef(rawArgs: string): { name: string; params: string[] } {
9
- const tokens = rawArgs.trim().split(/\s+/);
10
- const name = tokens[0]!.replace(/["']/g, '');
11
- const params = tokens.slice(1).filter((t) => t.startsWith('@'));
12
- for (const param of params) checkVariableName(param.slice(1), param);
13
- return { name, params };
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
- parsed = parseWidgetDef(rawArgs);
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
  }
@@ -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 { deepClone, deepEqual } from './class-registry';
13
- import { deleteByPath, getByPath, setByPath } from './utils/object-path';
14
- import { asNamespace } from './utils/namespace';
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((ns) => {
385
- const list: PathChange[] = [];
386
- diff(scope.base[ns], scope.work[ns], [], list, new Set());
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
- const work = scope.work[ns];
454
- const stored = state[ns];
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 tokens2 = node.rawArgs.trim().split(/\s+/);
177
- const widgetName = tokens2[0]!.replace(/["']/g, '');
178
- const params = tokens2
179
- .slice(1)
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 {
@@ -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 { tokenize } from './markup/tokenizer';
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: VariableKind,
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, KIND_SIGILS[kind] + 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(kind: VariableKind, name: string, scope: TextScope): string {
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: KIND_SIGILS[kind] + name, error });
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 = SIGIL_KINDS[part.expr[0] as keyof typeof SIGIL_KINDS];
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 === ']' || LINE_TERMINATOR_RE.test(c)) break;
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