@rohal12/spindle 0.52.1 → 0.52.3

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.
@@ -4,6 +4,7 @@ import { useSyncExternalStore } from "preact/compat";
4
4
  import { createStore } from "zustand/vanilla";
5
5
  import { immer } from "zustand/middleware/immer";
6
6
  import { Immer, applyPatches, current, enableMapSet, enablePatches, freeze, isDraft, produceWithPatches } from "immer";
7
+ import { shallow } from "zustand/vanilla/shallow";
7
8
  import { Fragment as Fragment$1, jsx, jsxs } from "preact/jsx-runtime";
8
9
  import { micromark } from "micromark";
9
10
  import { gfmTable, gfmTableHtml } from "micromark-extension-gfm-table";
@@ -2804,15 +2805,28 @@ function buildAST(tokens) {
2804
2805
  return root;
2805
2806
  }
2806
2807
  //#endregion
2808
+ //#region src/hooks/use-story-fields.ts
2809
+ /**
2810
+ * Story state fields `keys`, re-rendering when any of them changes (as one
2811
+ * useStoryStore() selector per field would). The result keeps its identity
2812
+ * while the fields don't change, as the store's snapshot must.
2813
+ */
2814
+ function useStoryFields(...keys) {
2815
+ const prev = useRef();
2816
+ return useStoryStore((s) => {
2817
+ const next = {};
2818
+ for (const key of keys) next[key] = s[key];
2819
+ return shallow(prev.current, next) ? prev.current : prev.current = next;
2820
+ });
2821
+ }
2822
+ //#endregion
2807
2823
  //#region src/hooks/use-merged-locals.ts
2808
2824
  /**
2809
2825
  * Return store variables, temporary, locals from context, and transient.
2810
2826
  * All four dicts use unprefixed keys suitable for evaluate/execute.
2811
2827
  */
2812
2828
  function useMergedLocals() {
2813
- const variables = useStoryStore((s) => s.variables);
2814
- const temporary = useStoryStore((s) => s.temporary);
2815
- const transient = useStoryStore((s) => s.transient);
2829
+ const { variables, temporary, transient } = useStoryFields("variables", "temporary", "transient");
2816
2830
  const localsValues = useContext(LocalsValuesContext);
2817
2831
  return useMemo(() => {
2818
2832
  return [
@@ -2957,21 +2971,38 @@ function getPRNGPull() {
2957
2971
  /** The variable name no namespace can hold. */
2958
2972
  var RESERVED_NAME = "__proto__";
2959
2973
  /**
2960
- * Throw a TypeError if `name` (without its sigil) cannot be a variable
2961
- * name. `label` is the name as the author wrote it (e.g. `$__proto__`).
2974
+ * Why `name` (without its sigil) cannot be a variable name, or undefined if
2975
+ * it can. `label` is the name as the author wrote it (e.g. `$__proto__`).
2962
2976
  */
2977
+ function variableNameError(name, label = name) {
2978
+ return name === "__proto__" ? `"${label}" cannot be used as a variable name (${RESERVED_NAME} is reserved)` : void 0;
2979
+ }
2980
+ /** Throw a TypeError if `name` cannot be a variable name. */
2963
2981
  function checkVariableName(name, label = name) {
2964
- if (name === "__proto__") throw new TypeError(`spindle: "${label}" cannot be used as a variable name (${RESERVED_NAME} is reserved)`);
2982
+ const error = variableNameError(name, label);
2983
+ if (error) throw new TypeError(`spindle: ${error}`);
2965
2984
  }
2966
- /** A namespace with no prototype holding the own entries of `sources`. */
2967
- function createNamespace(...sources) {
2968
- const ns = Object.create(null);
2985
+ /** Whether `obj` holds `key` as an own property. */
2986
+ var hasOwn$2 = (obj, key) => Object.prototype.hasOwnProperty.call(obj, key);
2987
+ /** `ns[key]` if `ns` holds it as an own property, else undefined. */
2988
+ var ownValue = (ns, key) => ns && hasOwn$2(ns, key) ? ns[key] : void 0;
2989
+ /**
2990
+ * A record with no prototype holding the own entries of `sources`, or only
2991
+ * those whose value passes `keep`.
2992
+ */
2993
+ function ownEntries(sources, keep = () => true) {
2994
+ const record = Object.create(null);
2969
2995
  for (const source of sources) {
2970
2996
  if (!source) continue;
2971
- for (const key of Object.keys(source)) ns[key] = source[key];
2997
+ for (const key of Object.keys(source)) {
2998
+ const value = source[key];
2999
+ if (keep(value)) record[key] = value;
3000
+ }
2972
3001
  }
2973
- return ns;
3002
+ return record;
2974
3003
  }
3004
+ /** A namespace with no prototype holding the own entries of `sources`. */
3005
+ var createNamespace = (...sources) => ownEntries(sources);
2975
3006
  /** Whether `value` is an object with no prototype. */
2976
3007
  var isNamespace = (value) => Object.getPrototypeOf(value) === null;
2977
3008
  /** `ns` itself if it has no prototype, else a namespace copy of it. */
@@ -2984,15 +3015,14 @@ function withEntry(ns, key, value) {
2984
3015
  }
2985
3016
  /** An empty namespace that cannot change. */
2986
3017
  var EMPTY_NAMESPACE = Object.freeze(createNamespace());
2987
- /** `ns[key]` if `ns` holds it as an own property, else undefined. */
2988
- var ownValue = (ns, key) => Object.prototype.hasOwnProperty.call(ns, key) ? ns[key] : void 0;
2989
- //#endregion
2990
- //#region src/utils/counts.ts
3018
+ /** The names whose own entries differ between `a` and `b`, `a`'s first. */
3019
+ function changedNames(a, b) {
3020
+ return [...new Set([...Object.keys(a), ...Object.keys(b)])].filter((name) => ownValue(a, name) !== ownValue(b, name));
3021
+ }
2991
3022
  var isCount = (value) => typeof value === "number" && Number.isInteger(value) && value >= 0;
2992
3023
  /** The count of `name` in `counts`: its own entry, or 0. */
2993
3024
  function countOf(counts, name) {
2994
- if (!counts || !Object.prototype.hasOwnProperty.call(counts, name)) return 0;
2995
- const value = counts[name];
3025
+ const value = ownValue(counts, name);
2996
3026
  return isCount(value) ? value : 0;
2997
3027
  }
2998
3028
  /**
@@ -3002,11 +3032,7 @@ function countOf(counts, name) {
3002
3032
  * more than counting in place.
3003
3033
  */
3004
3034
  function createCounts(source, name) {
3005
- const counts = Object.create(null);
3006
- if (source) for (const key of Object.keys(source)) {
3007
- const value = source[key];
3008
- if (isCount(value)) counts[key] = value;
3009
- }
3035
+ const counts = ownEntries([source], isCount);
3010
3036
  if (name !== void 0) counts[name] = countOf(source, name) + 1;
3011
3037
  return counts;
3012
3038
  }
@@ -3045,7 +3071,8 @@ function transform(expr, goal = "expression") {
3045
3071
  result += text;
3046
3072
  },
3047
3073
  variable(sigil, name) {
3048
- if (name === "__proto__") throw new SyntaxError(`spindle: "${sigil}${name}" cannot be used as a variable name (${RESERVED_NAME} is reserved)`);
3074
+ const error = variableNameError(name, sigil + name);
3075
+ if (error) throw new SyntaxError(`spindle: ${error}`);
3049
3076
  if (IDENT_END_RE.test(result.slice(-2))) result += " ";
3050
3077
  result += `${NAMESPACES$1[sigil]}["${name}"]`;
3051
3078
  }
@@ -3068,41 +3095,50 @@ function getOrCompile(key, body) {
3068
3095
  }
3069
3096
  return fn;
3070
3097
  }
3098
+ /** The passage being shown. */
3099
+ function currentPassage() {
3100
+ const s = useStoryStore.getState();
3101
+ return s.storyData?.passages.get(s.currentPassage);
3102
+ }
3103
+ /** The passage shown before the current one in history. */
3104
+ function previousPassage() {
3105
+ const s = useStoryStore.getState();
3106
+ if (s.historyIndex <= 0) return void 0;
3107
+ const prevName = s.history[s.historyIndex - 1]?.passage;
3108
+ return prevName ? s.storyData?.passages.get(prevName) : void 0;
3109
+ }
3110
+ /**
3111
+ * visited()/rendered() and their has-forms, counting in the counters
3112
+ * `counts()` returns. A missing name means the current passage.
3113
+ */
3114
+ function countQueries(counts) {
3115
+ const count = (name) => countOf(counts(), name ?? useStoryStore.getState().currentPassage);
3116
+ return {
3117
+ count,
3118
+ has: (name) => count(name) > 0,
3119
+ any: (...names) => names.some((n) => count(n) > 0),
3120
+ all: (...names) => names.every((n) => count(n) > 0)
3121
+ };
3122
+ }
3071
3123
  var cachedFns = null;
3072
3124
  var cachedVisitCounts = null;
3073
3125
  var cachedRenderCounts = null;
3074
3126
  function buildExpressionFns() {
3075
3127
  const { visitCounts, renderCounts } = useStoryStore.getState();
3076
3128
  if (cachedFns && cachedVisitCounts === visitCounts && cachedRenderCounts === renderCounts) return cachedFns;
3077
- const visited = (name) => countOf(visitCounts, name ?? useStoryStore.getState().currentPassage);
3078
- const hasVisited = (name) => visited(name) > 0;
3079
- const hasVisitedAny = (...names) => names.some((n) => visited(n) > 0);
3080
- const hasVisitedAll = (...names) => names.every((n) => visited(n) > 0);
3081
- const rendered = (name) => countOf(renderCounts, name ?? useStoryStore.getState().currentPassage);
3082
- const hasRendered = (name) => rendered(name) > 0;
3083
- const hasRenderedAny = (...names) => names.some((n) => rendered(n) > 0);
3084
- const hasRenderedAll = (...names) => names.every((n) => rendered(n) > 0);
3085
- const currentPassage = () => {
3086
- const s = useStoryStore.getState();
3087
- return s.storyData?.passages.get(s.currentPassage);
3088
- };
3089
- const previousPassage = () => {
3090
- const s = useStoryStore.getState();
3091
- if (s.historyIndex <= 0) return void 0;
3092
- const prevName = s.history[s.historyIndex - 1]?.passage;
3093
- return prevName ? s.storyData?.passages.get(prevName) : void 0;
3094
- };
3129
+ const visits = countQueries(() => visitCounts);
3130
+ const renders = countQueries(() => renderCounts);
3095
3131
  cachedFns = {
3096
3132
  currentPassage,
3097
3133
  previousPassage,
3098
- visited,
3099
- hasVisited,
3100
- hasVisitedAny,
3101
- hasVisitedAll,
3102
- rendered,
3103
- hasRendered,
3104
- hasRenderedAny,
3105
- hasRenderedAll,
3134
+ visited: visits.count,
3135
+ hasVisited: visits.has,
3136
+ hasVisitedAny: visits.any,
3137
+ hasVisitedAll: visits.all,
3138
+ rendered: renders.count,
3139
+ hasRendered: renders.has,
3140
+ hasRenderedAny: renders.any,
3141
+ hasRenderedAll: renders.all,
3106
3142
  random,
3107
3143
  randomInt
3108
3144
  };
@@ -3700,6 +3736,11 @@ var SIGILS = {
3700
3736
  local: "@",
3701
3737
  transient: "%"
3702
3738
  };
3739
+ var STORE_NAMESPACES = {
3740
+ variable: "variables",
3741
+ temporary: "temporary",
3742
+ transient: "transient"
3743
+ };
3703
3744
  function VarDisplay({ name, scope, className, id }) {
3704
3745
  const resolve = useInterpolate();
3705
3746
  className = resolve(className);
@@ -3707,10 +3748,11 @@ function VarDisplay({ name, scope, className, id }) {
3707
3748
  const localsValues = useContext(LocalsValuesContext);
3708
3749
  const parts = name.split(".");
3709
3750
  const root = parts[0];
3710
- const storeValue = useStoryStore((s) => scope === "variable" ? ownValue(s.variables, root) : scope === "temporary" ? ownValue(s.temporary, root) : scope === "transient" ? ownValue(s.transient, root) : void 0);
3711
- if (root === "__proto__") return /* @__PURE__ */ jsx("span", {
3751
+ const storeValue = useStoryStore((s) => scope === "local" ? void 0 : ownValue(s[STORE_NAMESPACES[scope]], root));
3752
+ const nameError = variableNameError(root, SIGILS[scope] + root);
3753
+ if (nameError) return /* @__PURE__ */ jsx("span", {
3712
3754
  class: "error",
3713
- children: `{${SIGILS[scope]}${name} error: "${SIGILS[scope]}${root}" cannot be used as a variable name (${RESERVED_NAME} is reserved)}`
3755
+ children: `{${SIGILS[scope]}${name} error: ${nameError}}`
3714
3756
  });
3715
3757
  let value;
3716
3758
  if (scope === "local") value = ownValue(localsValues, root);
@@ -3737,10 +3779,7 @@ function ExprDisplay({ expression, className, id }) {
3737
3779
  const resolve = useInterpolate();
3738
3780
  className = resolve(className);
3739
3781
  id = resolve(id);
3740
- const localsValues = useContext(LocalsValuesContext);
3741
- const variables = useStoryStore((s) => s.variables);
3742
- const temporary = useStoryStore((s) => s.temporary);
3743
- const transient = useStoryStore((s) => s.transient);
3782
+ const [variables, temporary, localsValues, transient] = useMergedLocals();
3744
3783
  let display;
3745
3784
  try {
3746
3785
  const value = evaluate(expression, variables, temporary, localsValues, transient);
@@ -5049,10 +5088,14 @@ function on(event, cb) {
5049
5088
  set.delete(cb);
5050
5089
  };
5051
5090
  }
5052
- function emit(event, ...args) {
5091
+ /** Call `call` with each listener of `event`. */
5092
+ function forEachListener(event, call) {
5053
5093
  const set = listeners.get(event);
5054
5094
  if (!set) return;
5055
- for (const cb of [...set]) cb(...args);
5095
+ for (const cb of [...set]) call(cb);
5096
+ }
5097
+ function emit(event, ...args) {
5098
+ forEachListener(event, (cb) => cb(...args));
5056
5099
  }
5057
5100
  /**
5058
5101
  * Emit an event from inside a render commit (layout effect). A throwing
@@ -5060,13 +5103,13 @@ function emit(event, ...args) {
5060
5103
  * render; the remaining handlers still run.
5061
5104
  */
5062
5105
  function emitFromRender(event, ...args) {
5063
- const set = listeners.get(event);
5064
- if (!set) return;
5065
- for (const cb of [...set]) try {
5066
- cb(...args);
5067
- } catch (err) {
5068
- console.error(`spindle: Error in ${event} handler:`, err);
5069
- }
5106
+ forEachListener(event, (cb) => {
5107
+ try {
5108
+ cb(...args);
5109
+ } catch (err) {
5110
+ console.error(`spindle: Error in ${event} handler:`, err);
5111
+ }
5112
+ });
5070
5113
  }
5071
5114
  //#endregion
5072
5115
  //#region src/saves/save-manager.ts
@@ -5784,7 +5827,6 @@ function initializeStory(session) {
5784
5827
  }
5785
5828
  //#endregion
5786
5829
  //#region src/utils/object-path.ts
5787
- var hasOwn$2 = (obj, key) => Object.prototype.hasOwnProperty.call(obj, key);
5788
5830
  /**
5789
5831
  * Traverse dot-path segments on an object and return the nested value.
5790
5832
  * Members of Object.prototype (`constructor`, `toString`, `__proto__`, ...)
@@ -7638,9 +7680,7 @@ function TriggerDialogHost() {
7638
7680
  //#endregion
7639
7681
  //#region src/components/App.tsx
7640
7682
  function App() {
7641
- const storyData = useStoryStore((s) => s.storyData);
7642
- const currentPassage = useStoryStore((s) => s.currentPassage);
7643
- const nobr = useStoryStore((s) => s.nobr);
7683
+ const { storyData, currentPassage, nobr } = useStoryFields("storyData", "currentPassage", "nobr");
7644
7684
  useEffect(() => {
7645
7685
  const onKeyDown = (e) => {
7646
7686
  const { quickSaveKey, quickLoadKey, save, load } = useStoryStore.getState();
@@ -7759,45 +7799,47 @@ function loadFromStorage() {
7759
7799
  }
7760
7800
  } catch {}
7761
7801
  }
7802
+ /** Register a setting, starting from its default unless it has a value. */
7803
+ function define(name, def) {
7804
+ definitions.set(name, def);
7805
+ if (!hasOwn$2(values, name)) values[name] = def.config.default;
7806
+ loadFromStorage();
7807
+ }
7808
+ /** The value of setting `name` if it has the type of `fallback`, else that. */
7809
+ function valueOf(name, fallback) {
7810
+ const v = ownValue(values, name);
7811
+ return typeof v === typeof fallback ? v : fallback;
7812
+ }
7762
7813
  var settings = {
7763
7814
  addToggle(name, config) {
7764
- definitions.set(name, {
7815
+ define(name, {
7765
7816
  type: "toggle",
7766
7817
  config
7767
7818
  });
7768
- if (!Object.prototype.hasOwnProperty.call(values, name)) values[name] = config.default;
7769
- loadFromStorage();
7770
7819
  },
7771
7820
  addList(name, config) {
7772
- definitions.set(name, {
7821
+ define(name, {
7773
7822
  type: "list",
7774
7823
  config
7775
7824
  });
7776
- if (!Object.prototype.hasOwnProperty.call(values, name)) values[name] = config.default;
7777
- loadFromStorage();
7778
7825
  },
7779
7826
  addRange(name, config) {
7780
- definitions.set(name, {
7827
+ define(name, {
7781
7828
  type: "range",
7782
7829
  config
7783
7830
  });
7784
- if (!Object.prototype.hasOwnProperty.call(values, name)) values[name] = config.default;
7785
- loadFromStorage();
7786
7831
  },
7787
7832
  get(name) {
7788
7833
  return ownValue(values, name);
7789
7834
  },
7790
7835
  getToggle(name) {
7791
- const v = ownValue(values, name);
7792
- return typeof v === "boolean" ? v : false;
7836
+ return valueOf(name, false);
7793
7837
  },
7794
7838
  getList(name) {
7795
- const v = ownValue(values, name);
7796
- return typeof v === "string" ? v : "";
7839
+ return valueOf(name, "");
7797
7840
  },
7798
7841
  getRange(name) {
7799
- const v = ownValue(values, name);
7800
- return typeof v === "number" ? v : 0;
7842
+ return valueOf(name, 0);
7801
7843
  },
7802
7844
  set(name, value) {
7803
7845
  values[name] = value;
@@ -8096,26 +8138,17 @@ function ensureVariableChangedSubscription() {
8096
8138
  let prevTrans = useStoryStore.getState().transient;
8097
8139
  useStoryStore.subscribe((state) => {
8098
8140
  const changed = {};
8099
- let hasChanges = false;
8100
- const allVarKeys = new Set([...Object.keys(prevVars), ...Object.keys(state.variables)]);
8101
- for (const key of allVarKeys) if (state.variables[key] !== prevVars[key]) {
8102
- changed[key] = {
8103
- from: prevVars[key],
8104
- to: state.variables[key]
8105
- };
8106
- hasChanges = true;
8107
- }
8108
- const allTransKeys = new Set([...Object.keys(prevTrans), ...Object.keys(state.transient)]);
8109
- for (const key of allTransKeys) if (state.transient[key] !== prevTrans[key]) {
8110
- changed[`%${key}`] = {
8111
- from: prevTrans[key],
8112
- to: state.transient[key]
8141
+ const collect = (prev, next, prefix) => {
8142
+ for (const key of changedNames(prev, next)) changed[prefix + key] = {
8143
+ from: ownValue(prev, key),
8144
+ to: ownValue(next, key)
8113
8145
  };
8114
- hasChanges = true;
8115
- }
8146
+ };
8147
+ collect(prevVars, state.variables, "");
8148
+ collect(prevTrans, state.transient, "%");
8116
8149
  prevVars = state.variables;
8117
8150
  prevTrans = state.transient;
8118
- if (hasChanges) emit("variableChanged", changed);
8151
+ if (Object.keys(changed).length > 0) emit("variableChanged", changed);
8119
8152
  });
8120
8153
  }
8121
8154
  var declaredVariables = null;
@@ -8160,13 +8193,15 @@ function setOne(draft, name, value) {
8160
8193
  else namespace[key] = value;
8161
8194
  }
8162
8195
  function createStoryAPI() {
8196
+ const visits = countQueries(() => useStoryStore.getState().visitCounts);
8197
+ const renders = countQueries(() => useStoryStore.getState().renderCounts);
8163
8198
  return {
8164
8199
  get(name) {
8165
8200
  const { isTransient, key } = parseName(name);
8166
8201
  const scope = getActiveMutationScope();
8167
8202
  const source = scope ?? useStoryStore.getState();
8168
8203
  const namespace = isTransient ? source.transient : source.variables;
8169
- const value = key.includes(".") ? getByPath(namespace, key.split(".")) : namespace[key];
8204
+ const value = key.includes(".") ? getByPath(namespace, key.split(".")) : ownValue(namespace, key);
8170
8205
  return scope ? frozenCopy(value) : value;
8171
8206
  },
8172
8207
  set(nameOrVars, value) {
@@ -8215,46 +8250,16 @@ function createStoryAPI() {
8215
8250
  importSave(data, slot) {
8216
8251
  return useStoryStore.getState().importSave(data, slot);
8217
8252
  },
8218
- visited(name) {
8219
- const state = useStoryStore.getState();
8220
- return countOf(state.visitCounts, name ?? state.currentPassage);
8221
- },
8222
- hasVisited(name) {
8223
- return this.visited(name) > 0;
8224
- },
8225
- hasVisitedAny(...names) {
8226
- const { visitCounts } = useStoryStore.getState();
8227
- return names.some((n) => countOf(visitCounts, n) > 0);
8228
- },
8229
- hasVisitedAll(...names) {
8230
- const { visitCounts } = useStoryStore.getState();
8231
- return names.every((n) => countOf(visitCounts, n) > 0);
8232
- },
8233
- rendered(name) {
8234
- const state = useStoryStore.getState();
8235
- return countOf(state.renderCounts, name ?? state.currentPassage);
8236
- },
8237
- hasRendered(name) {
8238
- return this.rendered(name) > 0;
8239
- },
8240
- hasRenderedAny(...names) {
8241
- const { renderCounts } = useStoryStore.getState();
8242
- return names.some((n) => countOf(renderCounts, n) > 0);
8243
- },
8244
- hasRenderedAll(...names) {
8245
- const { renderCounts } = useStoryStore.getState();
8246
- return names.every((n) => countOf(renderCounts, n) > 0);
8247
- },
8248
- currentPassage() {
8249
- const state = useStoryStore.getState();
8250
- return state.storyData?.passages.get(state.currentPassage);
8251
- },
8252
- previousPassage() {
8253
- const state = useStoryStore.getState();
8254
- if (state.historyIndex <= 0) return void 0;
8255
- const prevName = state.history[state.historyIndex - 1]?.passage;
8256
- return prevName ? state.storyData?.passages.get(prevName) : void 0;
8257
- },
8253
+ visited: visits.count,
8254
+ hasVisited: visits.has,
8255
+ hasVisitedAny: visits.any,
8256
+ hasVisitedAll: visits.all,
8257
+ rendered: renders.count,
8258
+ hasRendered: renders.has,
8259
+ hasRenderedAny: renders.any,
8260
+ hasRenderedAll: renders.all,
8261
+ currentPassage,
8262
+ previousPassage,
8258
8263
  get title() {
8259
8264
  return useStoryStore.getState().storyData?.name || "";
8260
8265
  },
@@ -8492,7 +8497,8 @@ function parseStoryVariables(content, sigil = "$") {
8492
8497
  const match = line.match(DECLARATION_RE);
8493
8498
  if (!match) throw new Error(`${passageName}: Invalid declaration: "${line}". Expected: ${sigil}name = value`);
8494
8499
  const [, name, expr] = match;
8495
- if (name === "__proto__") throw new Error(`${passageName}: "${sigil}${name}" cannot be used as a variable name (${RESERVED_NAME} is reserved)`);
8500
+ const nameError = variableNameError(name, sigil + name);
8501
+ if (nameError) throw new Error(`${passageName}: ${nameError}`);
8496
8502
  let value;
8497
8503
  try {
8498
8504
  value = new Function("return (" + expr + ")")();
@@ -8535,7 +8541,8 @@ function extractForLocals(content) {
8535
8541
  function validateRef(ref, schema, forLocals) {
8536
8542
  const parts = ref.split(".");
8537
8543
  const rootName = parts[0];
8538
- if (rootName === "__proto__") return `"$${rootName}" cannot be used as a variable name (${RESERVED_NAME} is reserved)`;
8544
+ const nameError = variableNameError(rootName, "$" + rootName);
8545
+ if (nameError) return nameError;
8539
8546
  if (forLocals.has(rootName)) return null;
8540
8547
  const rootSchema = schema.get(rootName);
8541
8548
  if (!rootSchema) return `Undeclared variable: $${ref}`;
@@ -9159,7 +9166,7 @@ defineMacro({
9159
9166
  function liveLocalsView(getValues) {
9160
9167
  return new Proxy(createNamespace(), {
9161
9168
  get: (_, key) => typeof key === "string" ? ownValue(getValues(), key) : void 0,
9162
- has: (_, key) => typeof key === "string" && Object.prototype.hasOwnProperty.call(getValues(), key),
9169
+ has: (_, key) => typeof key === "string" && hasOwn$2(getValues(), key),
9163
9170
  ownKeys: () => Reflect.ownKeys(getValues()),
9164
9171
  getOwnPropertyDescriptor: (_, key) => {
9165
9172
  const desc = Object.getOwnPropertyDescriptor(getValues(), key);
@@ -9631,10 +9638,13 @@ defineMenubarAction({
9631
9638
  name: "quicksave",
9632
9639
  label: "QuickSave",
9633
9640
  actionType: "save",
9634
- setup: () => ({
9635
- perform: useStoryStore((s) => s.save),
9636
- title: hotkeyTitle("Quick Save", useStoryStore((s) => s.quickSaveKey))
9637
- })
9641
+ setup: () => {
9642
+ const { save, quickSaveKey } = useStoryFields("save", "quickSaveKey");
9643
+ return {
9644
+ perform: save,
9645
+ title: hotkeyTitle("Quick Save", quickSaveKey)
9646
+ };
9647
+ }
9638
9648
  });
9639
9649
  defineMenubarAction({
9640
9650
  name: "quickload",
@@ -9642,14 +9652,11 @@ defineMenubarAction({
9642
9652
  actionType: "load",
9643
9653
  confirm: "Load saved game? Current progress will be lost.",
9644
9654
  setup: () => {
9645
- const load = useStoryStore((s) => s.load);
9646
- const hasSave = useStoryStore((s) => s.hasSave);
9647
- const key = useStoryStore((s) => s.quickLoadKey);
9648
- useStoryStore((s) => s.knownSaves);
9655
+ const { load, hasSave, quickLoadKey } = useStoryFields("load", "hasSave", "quickLoadKey", "knownSaves");
9649
9656
  return {
9650
9657
  perform: () => load(),
9651
9658
  disabled: !hasSave(),
9652
- title: hotkeyTitle("Quick Load", key)
9659
+ title: hotkeyTitle("Quick Load", quickLoadKey)
9653
9660
  };
9654
9661
  }
9655
9662
  });
@@ -9737,10 +9744,7 @@ function SaveManagerContent() {
9737
9744
  const renameInputRef = useRef(null);
9738
9745
  const fileInputRef = useRef(null);
9739
9746
  const closeDialog = useContext(DialogCloseContext);
9740
- const storyData = useStoryStore((s) => s.storyData);
9741
- const playthroughId = useStoryStore((s) => s.playthroughId);
9742
- const beginSave = useStoryStore((s) => s.beginSave);
9743
- const loadFromPayload = useStoryStore((s) => s.loadFromPayload);
9747
+ const { storyData, playthroughId, beginSave, loadFromPayload } = useStoryFields("storyData", "playthroughId", "beginSave", "loadFromPayload");
9744
9748
  const ifid = storyData?.ifid ?? "";
9745
9749
  const refresh = useCallback(async () => {
9746
9750
  if (!ifid) return;
@@ -10680,10 +10684,7 @@ defineMacro({
10680
10684
  name: "passage",
10681
10685
  interpolate: true,
10682
10686
  render(_props, ctx) {
10683
- const currentPassage = useStoryStore((s) => s.currentPassage);
10684
- const navigationId = useStoryStore((s) => s.navigationId);
10685
- const storyData = useStoryStore((s) => s.storyData);
10686
- const renderDeferred = useStoryStore((s) => s.renderDeferred);
10687
+ const { currentPassage, navigationId, storyData, renderDeferred } = useStoryFields("currentPassage", "navigationId", "storyData", "renderDeferred");
10687
10688
  const [displayed, setDisplayed] = useState({
10688
10689
  passage: currentPassage,
10689
10690
  id: navigationId
@@ -2234,15 +2234,30 @@ function errorMessage(err, fallback) {
2234
2234
  //#region src/utils/namespace.ts
2235
2235
  /** The variable name no namespace can hold. */
2236
2236
  var RESERVED_NAME = "__proto__";
2237
- /** A namespace with no prototype holding the own entries of `sources`. */
2238
- function createNamespace(...sources) {
2239
- const ns = Object.create(null);
2237
+ /**
2238
+ * Why `name` (without its sigil) cannot be a variable name, or undefined if
2239
+ * it can. `label` is the name as the author wrote it (e.g. `$__proto__`).
2240
+ */
2241
+ function variableNameError(name, label = name) {
2242
+ return name === "__proto__" ? `"${label}" cannot be used as a variable name (${RESERVED_NAME} is reserved)` : void 0;
2243
+ }
2244
+ /**
2245
+ * A record with no prototype holding the own entries of `sources`, or only
2246
+ * those whose value passes `keep`.
2247
+ */
2248
+ function ownEntries(sources, keep = () => true) {
2249
+ const record = Object.create(null);
2240
2250
  for (const source of sources) {
2241
2251
  if (!source) continue;
2242
- for (const key of Object.keys(source)) ns[key] = source[key];
2252
+ for (const key of Object.keys(source)) {
2253
+ const value = source[key];
2254
+ if (keep(value)) record[key] = value;
2255
+ }
2243
2256
  }
2244
- return ns;
2257
+ return record;
2245
2258
  }
2259
+ /** A namespace with no prototype holding the own entries of `sources`. */
2260
+ var createNamespace = (...sources) => ownEntries(sources);
2246
2261
  Object.freeze(createNamespace());
2247
2262
  //#endregion
2248
2263
  //#region src/story-variables.ts
@@ -2294,7 +2309,8 @@ function parseStoryVariables(content, sigil = "$") {
2294
2309
  const match = line.match(DECLARATION_RE);
2295
2310
  if (!match) throw new Error(`${passageName}: Invalid declaration: "${line}". Expected: ${sigil}name = value`);
2296
2311
  const [, name, expr] = match;
2297
- if (name === "__proto__") throw new Error(`${passageName}: "${sigil}${name}" cannot be used as a variable name (${RESERVED_NAME} is reserved)`);
2312
+ const nameError = variableNameError(name, sigil + name);
2313
+ if (nameError) throw new Error(`${passageName}: ${nameError}`);
2298
2314
  let value;
2299
2315
  try {
2300
2316
  value = new Function("return (" + expr + ")")();
@@ -2337,7 +2353,8 @@ function extractForLocals(content) {
2337
2353
  function validateRef(ref, schema, forLocals) {
2338
2354
  const parts = ref.split(".");
2339
2355
  const rootName = parts[0];
2340
- if (rootName === "__proto__") return `"$${rootName}" cannot be used as a variable name (${RESERVED_NAME} is reserved)`;
2356
+ const nameError = variableNameError(rootName, "$" + rootName);
2357
+ if (nameError) return nameError;
2341
2358
  if (forLocals.has(rootName)) return null;
2342
2359
  const rootSchema = schema.get(rootName);
2343
2360
  if (!rootSchema) return `Undeclared variable: $${ref}`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rohal12/spindle",
3
- "version": "0.52.1",
3
+ "version": "0.52.3",
4
4
  "type": "module",
5
5
  "description": "A Preact-based story format for Twine 2.",
6
6
  "license": "Unlicense",
@@ -56,6 +56,7 @@
56
56
  "typecheck": "tsc --noEmit && tsc --noEmit -p test",
57
57
  "format": "prettier --write .",
58
58
  "format:check": "prettier --check .",
59
+ "duplication": "bun scripts/duplication.ts jscpd && bun scripts/duplication.ts cpd && bun scripts/duplication.ts fallow",
59
60
  "docs:dev": "vitepress dev docs",
60
61
  "docs:build": "vitepress build docs",
61
62
  "docs:preview": "vitepress preview docs",
@@ -82,10 +83,12 @@
82
83
  "@types/react": "^19.2.14",
83
84
  "@vitest/coverage-v8": "^4.1.2",
84
85
  "fake-indexeddb": "^6.2.5",
86
+ "fallow": "3.31.0",
85
87
  "fast-check": "^4.10.2",
86
88
  "happy-dom": "^20.8.9",
87
89
  "husky": "^9.1.7",
88
90
  "js-yaml": "^4.1.1",
91
+ "jscpd": "5.4.0",
89
92
  "lint-staged": "^16.4.0",
90
93
  "playwright": "^1.58.2",
91
94
  "prettier": "^3.8.1",
@@ -1,13 +1,16 @@
1
1
  import { useEffect } from 'preact/hooks';
2
2
  import { useStoryStore } from '../store';
3
+ import { useStoryFields } from '../hooks/use-story-fields';
3
4
  import { NobrContext } from '../markup/render';
4
5
  import { StoryInterface } from './StoryInterface';
5
6
  import { TriggerDialogHost } from './TriggerDialogHost';
6
7
 
7
8
  export function App() {
8
- const storyData = useStoryStore((s) => s.storyData);
9
- const currentPassage = useStoryStore((s) => s.currentPassage);
10
- const nobr = useStoryStore((s) => s.nobr);
9
+ const { storyData, currentPassage, nobr } = useStoryFields(
10
+ 'storyData',
11
+ 'currentPassage',
12
+ 'nobr',
13
+ );
11
14
 
12
15
  useEffect(() => {
13
16
  const onKeyDown = (e: KeyboardEvent) => {