@rohal12/spindle 0.28.4 → 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.28.4",
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
  },
@@ -9,6 +9,7 @@ import {
9
9
  LocalsValuesContext,
10
10
  LocalsUpdateContext,
11
11
  NobrContext,
12
+ WidgetChildrenContext,
12
13
  renderNodes,
13
14
  } from '../../markup/render';
14
15
  import { useMergedLocals } from '../../hooks/use-merged-locals';
@@ -19,6 +20,7 @@ interface WidgetInvocationProps {
19
20
  body: ASTNode[];
20
21
  params: string[];
21
22
  rawArgs?: string;
23
+ invocationChildren?: ASTNode[];
22
24
  }
23
25
 
24
26
  /**
@@ -110,13 +112,20 @@ export function WidgetInvocation({
110
112
  body,
111
113
  params,
112
114
  rawArgs,
115
+ invocationChildren,
113
116
  }: WidgetInvocationProps) {
114
117
  const parentValues = useContext(LocalsValuesContext);
115
118
  const nobr = useContext(NobrContext);
116
119
  const [mergedVars, mergedTemps, mergedLocals] = useMergedLocals();
117
120
 
121
+ const childrenValue = invocationChildren?.length ? invocationChildren : null;
122
+
118
123
  if (params.length === 0 || !rawArgs) {
119
- return <>{renderNodes(body, { nobr, locals: parentValues })}</>;
124
+ return (
125
+ <WidgetChildrenContext.Provider value={childrenValue}>
126
+ {renderNodes(body, { nobr, locals: parentValues })}
127
+ </WidgetChildrenContext.Provider>
128
+ );
120
129
  }
121
130
 
122
131
  const argExprs = splitArgs(rawArgs);
@@ -137,10 +146,12 @@ export function WidgetInvocation({
137
146
  }
138
147
 
139
148
  return (
140
- <WidgetBody
141
- body={body}
142
- parentValues={parentValues}
143
- ownKeys={ownKeys}
144
- />
149
+ <WidgetChildrenContext.Provider value={childrenValue}>
150
+ <WidgetBody
151
+ body={body}
152
+ parentValues={parentValues}
153
+ ownKeys={ownKeys}
154
+ />
155
+ </WidgetChildrenContext.Provider>
145
156
  );
146
157
  }
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
  }
package/src/index.tsx CHANGED
@@ -13,8 +13,9 @@ import {
13
13
  extractDefaults,
14
14
  } from './story-variables';
15
15
  import { tokenize } from './markup/tokenizer';
16
- import { buildAST } from './markup/ast';
16
+ import { buildAST, registerBlockMacro } from './markup/ast';
17
17
  import { registerWidget } from './widgets/widget-registry';
18
+ import { astContainsChildren } from './widgets/ast-scanner';
18
19
  import type { ASTNode } from './markup/ast';
19
20
  import './macros/register-builtins';
20
21
  import builtinCSS from './styles.css?inline';
@@ -122,7 +123,12 @@ function boot() {
122
123
  (t) =>
123
124
  t.startsWith('$') || t.startsWith('_') || t.startsWith('@'),
124
125
  );
125
- registerWidget(widgetName, node.children as ASTNode[], params);
126
+ const children = node.children as ASTNode[];
127
+ const isBlock = astContainsChildren(children);
128
+ registerWidget(widgetName, children, params, isBlock);
129
+ if (isBlock) {
130
+ registerBlockMacro(widgetName);
131
+ }
126
132
  }
127
133
  }
128
134
  }
@@ -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
  }
@@ -23,6 +23,7 @@ const defaultUpdater: LocalsUpdater = {
23
23
  export const LocalsValuesContext = createContext<Record<string, unknown>>({});
24
24
  export const LocalsUpdateContext = createContext<LocalsUpdater>(defaultUpdater);
25
25
  export const NobrContext = createContext(false);
26
+ export const WidgetChildrenContext = createContext<ASTNode[] | null>(null);
26
27
 
27
28
  /**
28
29
  * Convert an HTML string (from micromark) to Preact VNodes,
@@ -97,6 +98,14 @@ function HtmlNodeRenderer({ node }: { node: HtmlNode }) {
97
98
  );
98
99
  }
99
100
 
101
+ function ChildrenSlot() {
102
+ const childrenAST = useContext(WidgetChildrenContext);
103
+ const nobr = useContext(NobrContext);
104
+ const locals = useContext(LocalsValuesContext);
105
+ if (!childrenAST || childrenAST.length === 0) return null;
106
+ return <>{renderNodes(childrenAST, { nobr, locals })}</>;
107
+ }
108
+
100
109
  function renderMacro(node: MacroNode, key: number) {
101
110
  if (isSubMacro(node.name)) return null;
102
111
 
@@ -108,6 +117,7 @@ function renderMacro(node: MacroNode, key: number) {
108
117
  body={widget.body}
109
118
  params={widget.params}
110
119
  rawArgs={node.rawArgs}
120
+ invocationChildren={node.children}
111
121
  />
112
122
  );
113
123
  }
@@ -148,6 +158,9 @@ function renderSingleNode(
148
158
  return node.value;
149
159
 
150
160
  case 'variable':
161
+ if (node.scope === 'local' && node.name === 'children') {
162
+ return <ChildrenSlot key={key} />;
163
+ }
151
164
  return (
152
165
  <VarDisplay
153
166
  key={key}
@@ -246,7 +259,7 @@ export function renderNodes(
246
259
  for (let i = 0; i < nodes.length; i++) {
247
260
  const node = nodes[i]!;
248
261
  if (node.type === 'text') {
249
- combined += node.value.replace(/^[ \t]{4,}/gm, ' ');
262
+ combined += node.value;
250
263
  } else if (node.type === 'variable' && hasUnclosedBacktick(combined)) {
251
264
  // Inline variable value to avoid placeholder inside code span
252
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
@@ -0,0 +1,29 @@
1
+ import type { ASTNode } from '../markup/ast';
2
+
3
+ /**
4
+ * Recursively scan an AST node array for a VariableNode
5
+ * with scope 'local' and name 'children' (@children).
6
+ */
7
+ export function astContainsChildren(nodes: ASTNode[]): boolean {
8
+ for (const node of nodes) {
9
+ if (
10
+ node.type === 'variable' &&
11
+ node.scope === 'local' &&
12
+ node.name === 'children'
13
+ ) {
14
+ return true;
15
+ }
16
+ if (node.type === 'html' && astContainsChildren(node.children)) {
17
+ return true;
18
+ }
19
+ if (node.type === 'macro') {
20
+ if (astContainsChildren(node.children)) return true;
21
+ if (node.branches) {
22
+ for (const branch of node.branches) {
23
+ if (astContainsChildren(branch.children)) return true;
24
+ }
25
+ }
26
+ }
27
+ }
28
+ return false;
29
+ }
@@ -3,6 +3,7 @@ import type { ASTNode } from '../markup/ast';
3
3
  interface WidgetEntry {
4
4
  body: ASTNode[];
5
5
  params: string[];
6
+ isBlock: boolean;
6
7
  }
7
8
 
8
9
  const widgets = new Map<string, WidgetEntry>();
@@ -11,14 +12,24 @@ export function registerWidget(
11
12
  name: string,
12
13
  bodyAST: ASTNode[],
13
14
  params: string[],
15
+ isBlock = false,
14
16
  ): void {
15
- widgets.set(name.toLowerCase(), { body: bodyAST, params });
17
+ const filteredParams = params.filter((p) => p !== '@children');
18
+ widgets.set(name.toLowerCase(), {
19
+ body: bodyAST,
20
+ params: filteredParams,
21
+ isBlock,
22
+ });
16
23
  }
17
24
 
18
25
  export function getWidget(name: string): WidgetEntry | undefined {
19
26
  return widgets.get(name.toLowerCase());
20
27
  }
21
28
 
29
+ export function isBlockWidget(name: string): boolean {
30
+ return widgets.get(name.toLowerCase())?.isBlock ?? false;
31
+ }
32
+
22
33
  export function clearWidgets(): void {
23
34
  widgets.clear();
24
35
  }