@rohal12/spindle 0.52.2 → 0.52.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/settings.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { useStoryStore } from './store';
2
- import { createNamespace, ownValue } from './utils/namespace';
2
+ import { createNamespace, hasOwn, ownValue } from './utils/namespace';
3
3
 
4
4
  export interface ToggleConfig {
5
5
  label: string;
@@ -76,29 +76,30 @@ function loadFromStorage(): void {
76
76
  }
77
77
  }
78
78
 
79
+ /** Register a setting, starting from its default unless it has a value. */
80
+ function define(name: string, def: SettingDef): void {
81
+ definitions.set(name, def);
82
+ if (!hasOwn(values, name)) values[name] = def.config.default;
83
+ loadFromStorage();
84
+ }
85
+
86
+ /** The value of setting `name` if it has the type of `fallback`, else that. */
87
+ function valueOf<T>(name: string, fallback: T): T {
88
+ const v = ownValue(values, name);
89
+ return typeof v === typeof fallback ? (v as T) : fallback;
90
+ }
91
+
79
92
  export const settings = {
80
93
  addToggle(name: string, config: ToggleConfig): void {
81
- definitions.set(name, { type: 'toggle', config });
82
- if (!Object.prototype.hasOwnProperty.call(values, name)) {
83
- values[name] = config.default;
84
- }
85
- loadFromStorage();
94
+ define(name, { type: 'toggle', config });
86
95
  },
87
96
 
88
97
  addList(name: string, config: ListConfig): void {
89
- definitions.set(name, { type: 'list', config });
90
- if (!Object.prototype.hasOwnProperty.call(values, name)) {
91
- values[name] = config.default;
92
- }
93
- loadFromStorage();
98
+ define(name, { type: 'list', config });
94
99
  },
95
100
 
96
101
  addRange(name: string, config: RangeConfig): void {
97
- definitions.set(name, { type: 'range', config });
98
- if (!Object.prototype.hasOwnProperty.call(values, name)) {
99
- values[name] = config.default;
100
- }
101
- loadFromStorage();
102
+ define(name, { type: 'range', config });
102
103
  },
103
104
 
104
105
  get(name: string): unknown {
@@ -106,18 +107,15 @@ export const settings = {
106
107
  },
107
108
 
108
109
  getToggle(name: string): boolean {
109
- const v = ownValue(values, name);
110
- return typeof v === 'boolean' ? v : false;
110
+ return valueOf(name, false);
111
111
  },
112
112
 
113
113
  getList(name: string): string {
114
- const v = ownValue(values, name);
115
- return typeof v === 'string' ? v : '';
114
+ return valueOf(name, '');
116
115
  },
117
116
 
118
117
  getRange(name: string): number {
119
- const v = ownValue(values, name);
120
- return typeof v === 'number' ? v : 0;
118
+ return valueOf(name, 0);
121
119
  },
122
120
 
123
121
  set(name: string, value: unknown): void {
package/src/store.ts CHANGED
@@ -62,11 +62,12 @@ import {
62
62
  } from './execute-mutation';
63
63
  import {
64
64
  checkVariableName,
65
+ createCounts,
65
66
  createNamespace,
66
67
  isNamespace,
68
+ type Counts,
67
69
  type Namespace,
68
70
  } from './utils/namespace';
69
- import { createCounts, type Counts } from './utils/counts';
70
71
 
71
72
  enablePatches();
72
73
  // Story state holds Map and Set values: Immer must be able to draft them
package/src/story-api.ts CHANGED
@@ -23,8 +23,8 @@ import { getBackendType } from './saves/storage';
23
23
  import { registerClass } from './class-registry';
24
24
  import { frozenCopy, getActiveMutationScope } from './execute-mutation';
25
25
  import { getByPath, setByPath } from './utils/object-path';
26
- import { checkVariableName } from './utils/namespace';
27
- import { countOf } from './utils/counts';
26
+ import { changedNames, checkVariableName, ownValue } from './utils/namespace';
27
+ import { countQueries, currentPassage, previousPassage } from './expression';
28
28
  import { defineMacro } from './define-macro';
29
29
  import type { MacroDefinition } from './define-macro';
30
30
  import { getMacroRegistry as _getMacroRegistry } from './registry';
@@ -83,35 +83,24 @@ function ensureVariableChangedSubscription(): void {
83
83
  let prevTrans = useStoryStore.getState().transient;
84
84
  useStoryStore.subscribe((state) => {
85
85
  const changed: Record<string, { from: unknown; to: unknown }> = {};
86
- let hasChanges = false;
87
-
88
- // Check $variables
89
- const allVarKeys = new Set([
90
- ...Object.keys(prevVars),
91
- ...Object.keys(state.variables),
92
- ]);
93
- for (const key of allVarKeys) {
94
- if (state.variables[key] !== prevVars[key]) {
95
- changed[key] = { from: prevVars[key], to: state.variables[key] };
96
- hasChanges = true;
86
+ const collect = (
87
+ prev: Record<string, unknown>,
88
+ next: Record<string, unknown>,
89
+ prefix: string,
90
+ ) => {
91
+ for (const key of changedNames(prev, next)) {
92
+ changed[prefix + key] = {
93
+ from: ownValue(prev, key),
94
+ to: ownValue(next, key),
95
+ };
97
96
  }
98
- }
99
-
100
- // Check %transient
101
- const allTransKeys = new Set([
102
- ...Object.keys(prevTrans),
103
- ...Object.keys(state.transient),
104
- ]);
105
- for (const key of allTransKeys) {
106
- if (state.transient[key] !== prevTrans[key]) {
107
- changed[`%${key}`] = { from: prevTrans[key], to: state.transient[key] };
108
- hasChanges = true;
109
- }
110
- }
97
+ };
98
+ collect(prevVars, state.variables, '');
99
+ collect(prevTrans, state.transient, '%');
111
100
 
112
101
  prevVars = state.variables;
113
102
  prevTrans = state.transient;
114
- if (hasChanges) {
103
+ if (Object.keys(changed).length > 0) {
115
104
  emit('variableChanged', changed);
116
105
  }
117
106
  });
@@ -271,6 +260,8 @@ function setOne(draft: VariableNamespaces, name: string, value: unknown): void {
271
260
  }
272
261
 
273
262
  function createStoryAPI(): StoryAPI {
263
+ const visits = countQueries(() => useStoryStore.getState().visitCounts);
264
+ const renders = countQueries(() => useStoryStore.getState().renderCounts);
274
265
  return {
275
266
  get(name: string): unknown {
276
267
  const { isTransient, key } = parseName(name);
@@ -284,7 +275,7 @@ function createStoryAPI(): StoryAPI {
284
275
  const namespace = isTransient ? source.transient : source.variables;
285
276
  const value = key.includes('.')
286
277
  ? getByPath(namespace, key.split('.'))
287
- : namespace[key];
278
+ : ownValue(namespace, key);
288
279
  return scope ? frozenCopy(value) : value;
289
280
  },
290
281
 
@@ -357,55 +348,16 @@ function createStoryAPI(): StoryAPI {
357
348
  return useStoryStore.getState().importSave(data, slot);
358
349
  },
359
350
 
360
- visited(name?: string): number {
361
- const state = useStoryStore.getState();
362
- return countOf(state.visitCounts, name ?? state.currentPassage);
363
- },
364
-
365
- hasVisited(name?: string): boolean {
366
- return this.visited(name) > 0;
367
- },
368
-
369
- hasVisitedAny(...names: string[]): boolean {
370
- const { visitCounts } = useStoryStore.getState();
371
- return names.some((n) => countOf(visitCounts, n) > 0);
372
- },
373
-
374
- hasVisitedAll(...names: string[]): boolean {
375
- const { visitCounts } = useStoryStore.getState();
376
- return names.every((n) => countOf(visitCounts, n) > 0);
377
- },
378
-
379
- rendered(name?: string): number {
380
- const state = useStoryStore.getState();
381
- return countOf(state.renderCounts, name ?? state.currentPassage);
382
- },
383
-
384
- hasRendered(name?: string): boolean {
385
- return this.rendered(name) > 0;
386
- },
387
-
388
- hasRenderedAny(...names: string[]): boolean {
389
- const { renderCounts } = useStoryStore.getState();
390
- return names.some((n) => countOf(renderCounts, n) > 0);
391
- },
392
-
393
- hasRenderedAll(...names: string[]): boolean {
394
- const { renderCounts } = useStoryStore.getState();
395
- return names.every((n) => countOf(renderCounts, n) > 0);
396
- },
397
-
398
- currentPassage(): Passage | undefined {
399
- const state = useStoryStore.getState();
400
- return state.storyData?.passages.get(state.currentPassage);
401
- },
402
-
403
- previousPassage(): Passage | undefined {
404
- const state = useStoryStore.getState();
405
- if (state.historyIndex <= 0) return undefined;
406
- const prevName = state.history[state.historyIndex - 1]?.passage;
407
- return prevName ? state.storyData?.passages.get(prevName) : undefined;
408
- },
351
+ visited: visits.count,
352
+ hasVisited: visits.has,
353
+ hasVisitedAny: visits.any,
354
+ hasVisitedAll: visits.all,
355
+ rendered: renders.count,
356
+ hasRendered: renders.has,
357
+ hasRenderedAny: renders.any,
358
+ hasRenderedAll: renders.all,
359
+ currentPassage,
360
+ previousPassage,
409
361
 
410
362
  get title(): string {
411
363
  return useStoryStore.getState().storyData?.name || '';
@@ -3,7 +3,7 @@ import { tokenize, type Token } from './markup/tokenizer';
3
3
  import { isCodeAttribute, splitSigilTemplate } from './markup/code-attributes';
4
4
  import { errorMessage } from './utils/error-message';
5
5
  import { lexJs, scanStringLiteral, type JsGoal } from './js-lexer';
6
- import { createNamespace, RESERVED_NAME } from './utils/namespace';
6
+ import { createNamespace, variableNameError } from './utils/namespace';
7
7
 
8
8
  export type VarType = 'number' | 'string' | 'boolean' | 'array' | 'object';
9
9
 
@@ -80,11 +80,8 @@ export function parseStoryVariables(
80
80
  }
81
81
 
82
82
  const [, name, expr] = match as [string, string, string];
83
- if (name === RESERVED_NAME) {
84
- throw new Error(
85
- `${passageName}: "${sigil}${name}" cannot be used as a variable name (${RESERVED_NAME} is reserved)`,
86
- );
87
- }
83
+ const nameError = variableNameError(name, sigil + name);
84
+ if (nameError) throw new Error(`${passageName}: ${nameError}`);
88
85
  let value: unknown;
89
86
  try {
90
87
  value = new Function('return (' + expr + ')')();
@@ -134,9 +131,8 @@ function validateRef(
134
131
  const parts = ref.split('.');
135
132
  const rootName = parts[0]!;
136
133
 
137
- if (rootName === RESERVED_NAME) {
138
- return `"$${rootName}" cannot be used as a variable name (${RESERVED_NAME} is reserved)`;
139
- }
134
+ const nameError = variableNameError(rootName, '$' + rootName);
135
+ if (nameError) return nameError;
140
136
 
141
137
  // Skip for-loop locals
142
138
  if (forLocals.has(rootName)) return null;
@@ -1,4 +1,4 @@
1
- import { createNamespace, ownValue } from './namespace';
1
+ import { createNamespace, hasOwn, ownValue } from './namespace';
2
2
 
3
3
  /**
4
4
  * Read-only view of a locals scope that always reflects its current values.
@@ -16,9 +16,7 @@ export function liveLocalsView(
16
16
  return new Proxy(createNamespace(), {
17
17
  get: (_, key) =>
18
18
  typeof key === 'string' ? ownValue(getValues(), key) : undefined,
19
- has: (_, key) =>
20
- typeof key === 'string' &&
21
- Object.prototype.hasOwnProperty.call(getValues(), key),
19
+ has: (_, key) => typeof key === 'string' && hasOwn(getValues(), key),
22
20
  ownKeys: () => Reflect.ownKeys(getValues()),
23
21
  getOwnPropertyDescriptor: (_, key) => {
24
22
  const desc = Object.getOwnPropertyDescriptor(getValues(), key);
@@ -1,14 +1,17 @@
1
1
  /**
2
- * Variable namespaces: the records holding $story variables, _temporary
3
- * variables, %transient variables and @locals, keyed by variable name.
2
+ * Name-keyed records: the variable namespaces ($story variables, _temporary
3
+ * variables, %transient variables and @locals), the passage counters and
4
+ * the other records keyed by names the author chooses.
4
5
  *
5
6
  * They have no prototype, so every name is plain storage: a variable may be
6
7
  * called `constructor`, `toString` or `hasOwnProperty`, and reading one
7
8
  * that was never set gives undefined rather than an Object.prototype
8
- * member. The one name they cannot hold is `__proto__`: story state (and so
9
- * saves) never holds a property by that name, and on an ordinary object
10
- * writing it would replace the prototype instead of storing a value. It is
11
- * refused wherever a variable name enters (checkVariableName).
9
+ * member. Records that may have a prototype (a loaded save, a caller's
10
+ * object) are read through ownValue(), which only sees their own entries.
11
+ * The one variable name a namespace cannot hold is `__proto__`: story state
12
+ * (and so saves) never holds a property by that name, and on an ordinary
13
+ * object writing it would replace the prototype instead of storing a value.
14
+ * It is refused wherever a variable name enters (checkVariableName).
12
15
  */
13
16
 
14
17
  export type Namespace = Record<string, unknown>;
@@ -17,31 +20,58 @@ export type Namespace = Record<string, unknown>;
17
20
  export const RESERVED_NAME = '__proto__';
18
21
 
19
22
  /**
20
- * Throw a TypeError if `name` (without its sigil) cannot be a variable
21
- * name. `label` is the name as the author wrote it (e.g. `$__proto__`).
23
+ * Why `name` (without its sigil) cannot be a variable name, or undefined if
24
+ * it can. `label` is the name as the author wrote it (e.g. `$__proto__`).
22
25
  */
26
+ export function variableNameError(
27
+ name: string,
28
+ label = name,
29
+ ): string | undefined {
30
+ return name === RESERVED_NAME
31
+ ? `"${label}" cannot be used as a variable name (${RESERVED_NAME} is reserved)`
32
+ : undefined;
33
+ }
34
+
35
+ /** Throw a TypeError if `name` cannot be a variable name. */
23
36
  export function checkVariableName(name: string, label = name): void {
24
- if (name === RESERVED_NAME) {
25
- throw new TypeError(
26
- `spindle: "${label}" cannot be used as a variable name (${RESERVED_NAME} is reserved)`,
27
- );
28
- }
37
+ const error = variableNameError(name, label);
38
+ if (error) throw new TypeError(`spindle: ${error}`);
29
39
  }
30
40
 
31
- /** A namespace with no prototype holding the own entries of `sources`. */
32
- export function createNamespace(
33
- ...sources: readonly (object | null | undefined)[]
34
- ): Namespace {
35
- const ns = Object.create(null) as Namespace;
41
+ /** Whether `obj` holds `key` as an own property. */
42
+ export const hasOwn = (obj: object, key: PropertyKey): boolean =>
43
+ Object.prototype.hasOwnProperty.call(obj, key);
44
+
45
+ /** `ns[key]` if `ns` holds it as an own property, else undefined. */
46
+ export const ownValue = (
47
+ ns: object | null | undefined,
48
+ key: string,
49
+ ): unknown => (ns && hasOwn(ns, key) ? (ns as Namespace)[key] : undefined);
50
+
51
+ /**
52
+ * A record with no prototype holding the own entries of `sources`, or only
53
+ * those whose value passes `keep`.
54
+ */
55
+ function ownEntries<T>(
56
+ sources: readonly (object | null | undefined)[],
57
+ keep: (value: unknown) => boolean = () => true,
58
+ ): Record<string, T> {
59
+ const record = Object.create(null) as Record<string, T>;
36
60
  for (const source of sources) {
37
61
  if (!source) continue;
38
62
  for (const key of Object.keys(source)) {
39
- ns[key] = (source as Namespace)[key];
63
+ const value = (source as Namespace)[key];
64
+ if (keep(value)) record[key] = value as T;
40
65
  }
41
66
  }
42
- return ns;
67
+ return record;
43
68
  }
44
69
 
70
+ /** A namespace with no prototype holding the own entries of `sources`. */
71
+ export const createNamespace = (
72
+ ...sources: readonly (object | null | undefined)[]
73
+ ): Namespace => ownEntries(sources);
74
+
45
75
  /** Whether `value` is an object with no prototype. */
46
76
  export const isNamespace = (value: object): boolean =>
47
77
  Object.getPrototypeOf(value) === null;
@@ -64,8 +94,42 @@ export function withEntry(
64
94
  /** An empty namespace that cannot change. */
65
95
  export const EMPTY_NAMESPACE: Namespace = Object.freeze(createNamespace());
66
96
 
67
- /** `ns[key]` if `ns` holds it as an own property, else undefined. */
68
- export const ownValue = (ns: object, key: string): unknown =>
69
- Object.prototype.hasOwnProperty.call(ns, key)
70
- ? (ns as Namespace)[key]
71
- : undefined;
97
+ /** The names whose own entries differ between `a` and `b`, `a`'s first. */
98
+ export function changedNames(a: object, b: object): string[] {
99
+ const names = new Set([...Object.keys(a), ...Object.keys(b)]);
100
+ return [...names].filter((name) => ownValue(a, name) !== ownValue(b, name));
101
+ }
102
+
103
+ /**
104
+ * Passage counters: the visit and render counts of the story state, keyed
105
+ * by passage name. A passage may have any name, `__proto__` included, so
106
+ * counting one stores an entry instead of replacing the prototype, and a
107
+ * passage that was never counted counts 0. Counters from outside the store
108
+ * (a loaded save) go through createCounts(), which keeps only their own
109
+ * entries that are counts.
110
+ */
111
+ export type Counts = Record<string, number>;
112
+
113
+ const isCount = (value: unknown): value is number =>
114
+ typeof value === 'number' && Number.isInteger(value) && value >= 0;
115
+
116
+ /** The count of `name` in `counts`: its own entry, or 0. */
117
+ export function countOf(
118
+ counts: object | null | undefined,
119
+ name: string,
120
+ ): number {
121
+ const value = ownValue(counts, name);
122
+ return isCount(value) ? value : 0;
123
+ }
124
+
125
+ /**
126
+ * Counters with no prototype holding the own entries of `source` that are
127
+ * counts, with `name` (if given) counted once more. Immer copies the
128
+ * counters on every write anyway, so counting into a fresh copy costs no
129
+ * more than counting in place.
130
+ */
131
+ export function createCounts(source?: object | null, name?: string): Counts {
132
+ const counts = ownEntries<number>([source], isCount);
133
+ if (name !== undefined) counts[name] = countOf(source, name) + 1;
134
+ return counts;
135
+ }
@@ -1,7 +1,5 @@
1
1
  import { isDraft } from 'immer';
2
-
3
- const hasOwn = (obj: object, key: string): boolean =>
4
- Object.prototype.hasOwnProperty.call(obj, key);
2
+ import { hasOwn } from './namespace';
5
3
 
6
4
  /**
7
5
  * Traverse dot-path segments on an object and return the nested value.
@@ -1,45 +0,0 @@
1
- /**
2
- * Passage counters: the visit and render counts of the story state, keyed
3
- * by passage name.
4
- *
5
- * A passage may have any name, including `constructor`, `toString` or
6
- * `__proto__`, so the counters only ever read their own entries: a passage
7
- * that was never counted counts 0, never an Object.prototype member. The
8
- * store keeps them without a prototype, so writing the count of a passage
9
- * named `__proto__` stores an entry instead of replacing the prototype.
10
- * Counters from outside the store (a loaded save) go through
11
- * createCounts(), which keeps only their own entries that are counts.
12
- */
13
-
14
- export type Counts = Record<string, number>;
15
-
16
- const isCount = (value: unknown): value is number =>
17
- typeof value === 'number' && Number.isInteger(value) && value >= 0;
18
-
19
- /** The count of `name` in `counts`: its own entry, or 0. */
20
- export function countOf(
21
- counts: object | null | undefined,
22
- name: string,
23
- ): number {
24
- if (!counts || !Object.prototype.hasOwnProperty.call(counts, name)) return 0;
25
- const value = (counts as Record<string, unknown>)[name];
26
- return isCount(value) ? value : 0;
27
- }
28
-
29
- /**
30
- * Counters with no prototype holding the own entries of `source` that are
31
- * counts, with `name` (if given) counted once more. Immer copies the
32
- * counters on every write anyway, so counting into a fresh copy costs no
33
- * more than counting in place.
34
- */
35
- export function createCounts(source?: object | null, name?: string): Counts {
36
- const counts = Object.create(null) as Counts;
37
- if (source) {
38
- for (const key of Object.keys(source)) {
39
- const value = (source as Record<string, unknown>)[key];
40
- if (isCount(value)) counts[key] = value;
41
- }
42
- }
43
- if (name !== undefined) counts[name] = countOf(source, name) + 1;
44
- return counts;
45
- }