@rohal12/spindle 0.29.0 → 0.29.1

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.29.0",
3
+ "version": "0.29.1",
4
4
  "type": "module",
5
5
  "description": "A Preact-based story format for Twine 2.",
6
6
  "license": "Unlicense",
@@ -99,12 +99,18 @@ export function serialize<T>(value: T): T {
99
99
 
100
100
  if (val instanceof Date) {
101
101
  seen.delete(obj);
102
- return val.toISOString();
102
+ return {
103
+ [CLASS_TAG]: '__Date__',
104
+ [DATA_TAG]: { iso: val.toISOString() },
105
+ };
103
106
  }
104
107
 
105
108
  if (val instanceof RegExp) {
106
109
  seen.delete(obj);
107
- return val.toString();
110
+ return {
111
+ [CLASS_TAG]: '__RegExp__',
112
+ [DATA_TAG]: { source: val.source, flags: val.flags },
113
+ };
108
114
  }
109
115
 
110
116
  if (Array.isArray(val)) {
@@ -153,6 +159,15 @@ export function deserialize<T>(value: T): T {
153
159
  if (CLASS_TAG in obj && DATA_TAG in obj) {
154
160
  const name = obj[CLASS_TAG] as string;
155
161
  const data = obj[DATA_TAG] as Record<string, unknown>;
162
+
163
+ // Built-in types
164
+ if (name === '__Date__') {
165
+ return new Date(data.iso as string);
166
+ }
167
+ if (name === '__RegExp__') {
168
+ return new RegExp(data.source as string, data.flags as string);
169
+ }
170
+
156
171
  const ctor = registry.get(name);
157
172
  if (!ctor) {
158
173
  console.warn(
@@ -37,7 +37,8 @@ export function Passage({ passage, dataTransition }: PassageProps) {
37
37
  return (
38
38
  <div class="error">
39
39
  Error parsing passage &ldquo;{passage.name}&rdquo;
40
- {sourceLocationOf(passage)}: {(err as Error).message}
40
+ {sourceLocationOf(passage)}:{' '}
41
+ {err instanceof Error ? err.message : String(err)}
41
42
  </div>
42
43
  );
43
44
  }
@@ -38,7 +38,11 @@ export function PassageDialog({
38
38
  const ast = buildAST(tokens);
39
39
  return renderNodes(ast);
40
40
  } catch (err) {
41
- return <div class="error">Error in dialog: {(err as Error).message}</div>;
41
+ return (
42
+ <div class="error">
43
+ Error in dialog: {err instanceof Error ? err.message : String(err)}
44
+ </div>
45
+ );
42
46
  }
43
47
  }, [markup]);
44
48
 
@@ -1,9 +1,11 @@
1
1
  import { h, render } from 'preact';
2
+ import { useContext } from 'preact/hooks';
2
3
  import { defineMacro } from '../../define-macro';
3
4
  import {
4
5
  renderNodes,
5
6
  LocalsUpdateContext,
6
7
  LocalsValuesContext,
8
+ NobrContext,
7
9
  } from '../../markup/render';
8
10
 
9
11
  defineMacro({
@@ -11,6 +13,7 @@ defineMacro({
11
13
  interpolate: true,
12
14
  render({ rawArgs, children = [] }, ctx) {
13
15
  const label = ctx.resolve?.(rawArgs.replace(/^["']|["']$/g, '')) ?? rawArgs;
16
+ const nobr = useContext(NobrContext);
14
17
 
15
18
  const handleClick = () => {
16
19
  // Render children into a detached DOM node — all macro side effects
@@ -18,12 +21,16 @@ defineMacro({
18
21
  // Wrap with locals context so @local variables from for-loops are available.
19
22
  const container = document.createElement('div');
20
23
  const vnode = h(
21
- LocalsUpdateContext.Provider,
22
- { value: { update: ctx.update, getValues: ctx.getValues } },
24
+ NobrContext.Provider,
25
+ { value: nobr },
23
26
  h(
24
- LocalsValuesContext.Provider,
25
- { value: ctx.getValues() },
26
- renderNodes(children),
27
+ LocalsUpdateContext.Provider,
28
+ { value: { update: ctx.update, getValues: ctx.getValues } },
29
+ h(
30
+ LocalsValuesContext.Provider,
31
+ { value: ctx.getValues() },
32
+ renderNodes(children),
33
+ ),
27
34
  ),
28
35
  );
29
36
  render(vnode, container);
@@ -63,7 +63,6 @@ function computeAndApply(
63
63
  locals: Record<string, unknown>,
64
64
  rawArgs: string,
65
65
  ): void {
66
- const state = useStoryStore.getState();
67
66
  let newValue: unknown;
68
67
  try {
69
68
  newValue = evaluate(expr, variables, temporary, locals);
@@ -75,8 +74,9 @@ function computeAndApply(
75
74
  return;
76
75
  }
77
76
 
78
- const current = isTemp ? state.temporary[name] : state.variables[name];
77
+ const current = isTemp ? temporary[name] : variables[name];
79
78
  if (!valuesEqual(current, newValue)) {
79
+ const state = useStoryStore.getState();
80
80
  if (isTemp) state.setTemporary(name, newValue);
81
81
  else state.setVariable(name, newValue);
82
82
  }
@@ -106,13 +106,12 @@ defineMacro({
106
106
  const ran = ctx.hooks.useRef(false);
107
107
  if (!ran.current) {
108
108
  ran.current = true;
109
- const state = useStoryStore.getState();
110
109
  computeAndApply(
111
110
  expr,
112
111
  name,
113
112
  isTemp,
114
- state.variables,
115
- state.temporary,
113
+ mergedVars,
114
+ mergedTemps,
116
115
  mergedLocals,
117
116
  rawArgs,
118
117
  );
@@ -62,18 +62,19 @@ function ForIteration({
62
62
  initialValues: Record<string, unknown>;
63
63
  children: ASTNode[];
64
64
  }) {
65
- const [localState, setLocalState] = useState<Record<string, unknown>>(() => ({
66
- ...parentValues,
67
- ...ownKeys,
68
- ...initialValues,
69
- }));
65
+ const [localMutations, setLocalMutations] = useState<Record<string, unknown>>(
66
+ () => ({ ...initialValues }),
67
+ );
68
+
69
+ // Recomputed every render — picks up new parentValues/ownKeys from parent
70
+ const localState = { ...parentValues, ...ownKeys, ...localMutations };
70
71
 
71
72
  const valuesRef = useRef(localState);
72
73
  valuesRef.current = localState;
73
74
 
74
75
  const getValues = useCallback(() => valuesRef.current, []);
75
76
  const update = useCallback((key: string, value: unknown) => {
76
- setLocalState((prev) => ({ ...prev, [key]: value }));
77
+ setLocalMutations((prev) => ({ ...prev, [key]: value }));
77
78
  }, []);
78
79
  const updater = useMemo(() => ({ update, getValues }), [update, getValues]);
79
80
 
@@ -1,9 +1,11 @@
1
1
  import { h, render } from 'preact';
2
+ import { useContext } from 'preact/hooks';
2
3
  import { useStoryStore } from '../../store';
3
4
  import {
4
5
  renderNodes,
5
6
  LocalsUpdateContext,
6
7
  LocalsValuesContext,
8
+ NobrContext,
7
9
  } from '../../markup/render';
8
10
  import { defineMacro } from '../../define-macro';
9
11
 
@@ -32,15 +34,20 @@ function renderChildrenDetached(
32
34
  children: import('../../markup/ast').ASTNode[],
33
35
  getValues: () => Record<string, unknown>,
34
36
  update: (key: string, value: unknown) => void,
37
+ nobr: boolean,
35
38
  ) {
36
39
  const container = document.createElement('div');
37
40
  const vnode = h(
38
- LocalsUpdateContext.Provider,
39
- { value: { update, getValues } },
41
+ NobrContext.Provider,
42
+ { value: nobr },
40
43
  h(
41
- LocalsValuesContext.Provider,
42
- { value: getValues() },
43
- renderNodes(children),
44
+ LocalsUpdateContext.Provider,
45
+ { value: { update, getValues } },
46
+ h(
47
+ LocalsValuesContext.Provider,
48
+ { value: getValues() },
49
+ renderNodes(children),
50
+ ),
44
51
  ),
45
52
  );
46
53
  render(vnode, container);
@@ -52,10 +59,11 @@ defineMacro({
52
59
  interpolate: true,
53
60
  render({ rawArgs, children = [] }, ctx) {
54
61
  const { display, passage } = parseArgs(rawArgs);
62
+ const nobr = useContext(NobrContext);
55
63
 
56
64
  const handleClick = (e: Event) => {
57
65
  e.preventDefault();
58
- renderChildrenDetached(children, ctx.getValues, ctx.update);
66
+ renderChildrenDetached(children, ctx.getValues, ctx.update, nobr);
59
67
  if (passage) {
60
68
  useStoryStore.getState().navigate(passage);
61
69
  }
@@ -68,7 +76,7 @@ defineMacro({
68
76
  label: display,
69
77
  target: passage ?? undefined,
70
78
  perform: () => {
71
- renderChildrenDetached(children, ctx.getValues, ctx.update);
79
+ renderChildrenDetached(children, ctx.getValues, ctx.update, nobr);
72
80
  if (passage) {
73
81
  useStoryStore.getState().navigate(passage);
74
82
  }
@@ -88,6 +88,10 @@ export function SaveManagerContent() {
88
88
  statusTimer.current = window.setTimeout(() => setStatus(null), 3000);
89
89
  };
90
90
 
91
+ useEffect(() => {
92
+ return () => clearTimeout(statusTimer.current);
93
+ }, []);
94
+
91
95
  const toggleCollapse = (id: string) => {
92
96
  setCollapsed((prev) => {
93
97
  const next = new Set(prev);
@@ -10,6 +10,8 @@ defineMacro({
10
10
  const containerRef = useRef<HTMLSpanElement>(null);
11
11
  const [totalChars, setTotalChars] = useState(0);
12
12
  const [visibleChars, setVisibleChars] = useState(0);
13
+ const visibleCharsRef = useRef(0);
14
+ visibleCharsRef.current = visibleChars;
13
15
 
14
16
  useEffect(() => {
15
17
  if (containerRef.current) {
@@ -21,7 +23,7 @@ defineMacro({
21
23
  // Typewriter interval
22
24
  useEffect(() => {
23
25
  if (totalChars === 0) return;
24
- if (visibleChars >= totalChars) return;
26
+ if (visibleCharsRef.current >= totalChars) return;
25
27
 
26
28
  const timer = setInterval(() => {
27
29
  setVisibleChars((c) => {
@@ -13,9 +13,12 @@ defineMacro({
13
13
  render({ rawArgs, children = [] }, ctx) {
14
14
  const { name, params } = parseWidgetDef(rawArgs);
15
15
 
16
+ const childrenKey = JSON.stringify(children);
17
+ const paramsKey = params.join(',');
18
+
16
19
  ctx.hooks.useLayoutEffect(() => {
17
20
  registerWidget(name, children, params);
18
- }, []);
21
+ }, [name, childrenKey, paramsKey]);
19
22
 
20
23
  return null;
21
24
  },
package/src/expression.ts CHANGED
@@ -118,6 +118,57 @@ function transform(expr: string): string {
118
118
  } else if (ic === '\\' && i + 1 < expr.length) {
119
119
  inner += ic + expr.charAt(i + 1);
120
120
  i++;
121
+ } else if (ic === '"' || ic === "'") {
122
+ // Skip quoted strings — braces inside are not depth-relevant
123
+ inner += ic;
124
+ i++;
125
+ while (i < expr.length) {
126
+ const sc = expr.charAt(i);
127
+ if (sc === '\\' && i + 1 < expr.length) {
128
+ inner += sc + expr.charAt(i + 1);
129
+ i += 2;
130
+ } else if (sc === ic) {
131
+ inner += sc;
132
+ i++;
133
+ break;
134
+ } else {
135
+ inner += sc;
136
+ i++;
137
+ }
138
+ }
139
+ continue; // skip the i++ at the end
140
+ } else if (ic === '`') {
141
+ // Nested template literal — consume entirely
142
+ inner += ic;
143
+ i++;
144
+ while (i < expr.length) {
145
+ const tc = expr.charAt(i);
146
+ if (tc === '\\' && i + 1 < expr.length) {
147
+ inner += tc + expr.charAt(i + 1);
148
+ i += 2;
149
+ } else if (tc === '$' && expr.charAt(i + 1) === '{') {
150
+ // Nested interpolation — track brace depth
151
+ inner += '${';
152
+ i += 2;
153
+ let nestedDepth = 1;
154
+ while (i < expr.length && nestedDepth > 0) {
155
+ const nc = expr.charAt(i);
156
+ if (nc === '{') nestedDepth++;
157
+ else if (nc === '}') nestedDepth--;
158
+ if (nestedDepth > 0) inner += nc;
159
+ i++;
160
+ }
161
+ inner += '}';
162
+ } else if (tc === '`') {
163
+ inner += tc;
164
+ i++;
165
+ break;
166
+ } else {
167
+ inner += tc;
168
+ i++;
169
+ }
170
+ }
171
+ continue; // skip the i++ at the end
121
172
  } else {
122
173
  inner += ic;
123
174
  }
@@ -12,7 +12,11 @@ import {
12
12
  export function markdownToHtml(text: string): string {
13
13
  return micromark(text, {
14
14
  allowDangerousHtml: true,
15
- extensions: [gfmTable(), gfmStrikethrough()],
15
+ extensions: [
16
+ gfmTable(),
17
+ gfmStrikethrough(),
18
+ { disable: { null: ['codeIndented'] } },
19
+ ],
16
20
  htmlExtensions: [gfmTableHtml(), gfmStrikethroughHtml()],
17
21
  });
18
22
  }
@@ -259,7 +259,7 @@ export function renderNodes(
259
259
  for (let i = 0; i < nodes.length; i++) {
260
260
  const node = nodes[i]!;
261
261
  if (node.type === 'text') {
262
- combined += node.value.replace(/^[ \t]{4,}/gm, ' ');
262
+ combined += node.value;
263
263
  } else if (node.type === 'variable' && hasUnclosedBacktick(combined)) {
264
264
  // Inline variable value to avoid placeholder inside code span
265
265
  combined += getVariableTextValue(node, locals);
@@ -69,7 +69,8 @@ export function isSaveExport(value: unknown): value is SaveExport {
69
69
 
70
70
  const payload = save.payload as Record<string, unknown>;
71
71
  if (typeof payload.passage !== 'string') return false;
72
- if (!Array.isArray(payload.history)) return false;
72
+ if (!Array.isArray(payload.history) || payload.history.length === 0)
73
+ return false;
73
74
  if (typeof payload.historyIndex !== 'number') return false;
74
75
  if (typeof payload.variables !== 'object' || payload.variables === null)
75
76
  return false;
@@ -82,7 +83,7 @@ export function isSavePayload(value: unknown): value is SavePayload {
82
83
  const obj = value as Record<string, unknown>;
83
84
  if (typeof obj.passage !== 'string') return false;
84
85
  if (typeof obj.variables !== 'object' || obj.variables === null) return false;
85
- if (!Array.isArray(obj.history)) return false;
86
+ if (!Array.isArray(obj.history) || obj.history.length === 0) return false;
86
87
  if (typeof obj.historyIndex !== 'number') return false;
87
88
  return true;
88
89
  }
package/src/store.ts CHANGED
@@ -576,6 +576,10 @@ export const useStoryStore = create<StoryState>()(
576
576
  },
577
577
 
578
578
  loadFromPayload: (payload: SavePayload) => {
579
+ if (payload.history.length === 0) {
580
+ console.warn('loadFromPayload: rejecting payload with empty history');
581
+ return;
582
+ }
579
583
  // Convert full snapshots to patch entries
580
584
  const base = deserialize(payload.history[0]?.variables ?? {}) as Record<
581
585
  string,
package/src/story-init.ts CHANGED
@@ -19,12 +19,16 @@ export function executeStoryInit() {
19
19
  const tokens = tokenize(storyInit.content);
20
20
  const ast = buildAST(tokens);
21
21
 
22
+ // Mount into a persistent hidden container. We intentionally do NOT
23
+ // unmount — this lets async effects (useEffect, setTimeout, etc.)
24
+ // inside StoryInit macros fire through the normal Preact pipeline.
22
25
  const container = document.createElement('div');
26
+ container.style.display = 'none';
27
+ document.body.appendChild(container);
23
28
  render(
24
29
  h(() => renderNodes(ast) as any, null),
25
30
  container,
26
31
  );
27
- render(null, container);
28
32
  }
29
33
 
30
34
  // Register SaveTitle passage if it exists