@rohal12/spindle 0.51.0 → 0.51.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.
@@ -768,6 +768,12 @@ var VALID_VAR_TYPES = new Set([
768
768
  "string",
769
769
  "boolean"
770
770
  ]);
771
+ /** Boxed sample values whose members a primitive of each type can access. */
772
+ var PRIMITIVE_SAMPLES = {
773
+ string: Object(""),
774
+ number: Object(0),
775
+ boolean: Object(false)
776
+ };
771
777
  function inferSchema(value) {
772
778
  if (Array.isArray(value)) return { type: "array" };
773
779
  if (value !== null && typeof value === "object") {
@@ -845,6 +851,13 @@ function validateRef(ref, schema, forLocals) {
845
851
  for (let i = 1; i < parts.length; i++) {
846
852
  const part = parts[i];
847
853
  if (current.type === "array") return null;
854
+ const sample = PRIMITIVE_SAMPLES[current.type];
855
+ if (sample && part in sample) {
856
+ const memberType = typeof sample[part];
857
+ if (!VALID_VAR_TYPES.has(memberType)) return null;
858
+ current = { type: memberType };
859
+ continue;
860
+ }
848
861
  if (current.type !== "object" || !current.fields) return `Cannot access field "${part}" on $${parts.slice(0, i).join(".")} (type: ${current.type})`;
849
862
  const fieldSchema = current.fields.get(part);
850
863
  if (!fieldSchema) return null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rohal12/spindle",
3
- "version": "0.51.0",
3
+ "version": "0.51.1",
4
4
  "type": "module",
5
5
  "description": "A Preact-based story format for Twine 2.",
6
6
  "license": "Unlicense",
@@ -27,7 +27,16 @@ function isPlainObject(value: unknown): value is Record<string, unknown> {
27
27
  return proto === Object.prototype || proto === null;
28
28
  }
29
29
 
30
- export function deepClone<T>(value: T): T {
30
+ export interface DeepCloneOptions {
31
+ /**
32
+ * Return instances of unregistered classes (DOM nodes, promises, other
33
+ * library objects) by reference instead of copying their own keys into a
34
+ * plain object, which would lose their prototype and identity.
35
+ */
36
+ keepUnregistered?: boolean;
37
+ }
38
+
39
+ export function deepClone<T>(value: T, options: DeepCloneOptions = {}): T {
31
40
  const seen = new Map<object, object>();
32
41
 
33
42
  function clone(val: unknown): unknown {
@@ -83,6 +92,7 @@ export function deepClone<T>(value: T): T {
83
92
  }
84
93
 
85
94
  // Plain object (or unregistered class — treat as plain)
95
+ if (!isPlainObject(val) && options.keepUnregistered) return val;
86
96
  if (isPlainObject(val) || typeof val === 'object') {
87
97
  const copy: Record<string, unknown> = {};
88
98
  seen.set(obj, copy);
@@ -98,6 +108,58 @@ export function deepClone<T>(value: T): T {
98
108
  return clone(value) as T;
99
109
  }
100
110
 
111
+ // --- Deep Equal ---
112
+
113
+ /**
114
+ * Structural equality over the value types deepClone() supports: primitives,
115
+ * arrays, plain objects, class instances, Date, RegExp, Map and Set (nested
116
+ * at any depth). Map and Set entries are compared in insertion order.
117
+ */
118
+ export function deepEqual(
119
+ a: unknown,
120
+ b: unknown,
121
+ seen: Map<object, object> = new Map(),
122
+ ): boolean {
123
+ if (Object.is(a, b)) return true;
124
+ if (
125
+ a === null ||
126
+ b === null ||
127
+ typeof a !== 'object' ||
128
+ typeof b !== 'object'
129
+ ) {
130
+ return false;
131
+ }
132
+ if (Object.getPrototypeOf(a) !== Object.getPrototypeOf(b)) return false;
133
+ if (seen.get(a) === b) return true;
134
+ seen.set(a, b);
135
+
136
+ if (a instanceof Date) return Object.is(a.getTime(), (b as Date).getTime());
137
+ if (a instanceof RegExp) return String(a) === String(b);
138
+ if (a instanceof Map || a instanceof Set) {
139
+ const bc = b as Map<unknown, unknown> | Set<unknown>;
140
+ if (a.size !== bc.size) return false;
141
+ const ai = a.entries();
142
+ const bi = bc.entries();
143
+ for (
144
+ let x = ai.next(), y = bi.next();
145
+ !x.done;
146
+ x = ai.next(), y = bi.next()
147
+ ) {
148
+ if (!deepEqual(x.value, y.value, seen)) return false;
149
+ }
150
+ return true;
151
+ }
152
+
153
+ const ao = a as Record<string, unknown>;
154
+ const bo = b as Record<string, unknown>;
155
+ const keys = Object.keys(ao);
156
+ if (keys.length !== Object.keys(bo).length) return false;
157
+ for (const key of keys) {
158
+ if (!(key in bo) || !deepEqual(ao[key], bo[key], seen)) return false;
159
+ }
160
+ return true;
161
+ }
162
+
101
163
  // --- Serialize ---
102
164
 
103
165
  const CLASS_TAG = '__spindle_class__';
@@ -1,5 +1,6 @@
1
1
  import { useStoryStore } from '../../store';
2
2
  import { evaluate } from '../../expression';
3
+ import { deepEqual } from '../../class-registry';
3
4
  import { currentSourceLocation } from '../../utils/source-location';
4
5
  import { defineMacro } from '../../define-macro';
5
6
  import { MacroError } from './MacroError';
@@ -37,23 +38,6 @@ function parseComputedArgs(rawArgs: string): { target: string; expr: string } {
37
38
  );
38
39
  }
39
40
 
40
- function valuesEqual(a: unknown, b: unknown): boolean {
41
- if (Object.is(a, b)) return true;
42
- if (
43
- typeof a === 'object' &&
44
- a !== null &&
45
- typeof b === 'object' &&
46
- b !== null
47
- ) {
48
- try {
49
- return JSON.stringify(a) === JSON.stringify(b);
50
- } catch {
51
- return false;
52
- }
53
- }
54
- return false;
55
- }
56
-
57
41
  function computeAndApply(
58
42
  expr: string,
59
43
  name: string,
@@ -78,7 +62,7 @@ function computeAndApply(
78
62
  return;
79
63
  }
80
64
 
81
- if (!valuesEqual(prevRef.current, newValue)) {
65
+ if (!deepEqual(prevRef.current, newValue)) {
82
66
  prevRef.current = newValue;
83
67
  if (isLocal) {
84
68
  try {
@@ -4,6 +4,62 @@ import { buildAST } from '../../markup/ast';
4
4
  import { NobrContext } from '../../markup/render';
5
5
  import { defineMacro } from '../../define-macro';
6
6
 
7
+ const FLAG = 'inline';
8
+
9
+ /**
10
+ * Split a standalone `inline` flag off the start or end of the include
11
+ * arguments. The flag only counts outside quotes, brackets, parentheses and
12
+ * braces, separated by whitespace from the target expression, so passage
13
+ * names and expressions containing the word stay intact (#201).
14
+ */
15
+ function parseIncludeArgs(rawArgs: string): {
16
+ nameExpr: string;
17
+ inline: boolean;
18
+ } {
19
+ const trimmed = rawArgs.trim();
20
+
21
+ // First and last whitespace runs at depth 0, as [start, end) offsets.
22
+ let first: [number, number] | null = null;
23
+ let last: [number, number] | null = null;
24
+ let depth = 0;
25
+ let inString: string | null = null;
26
+ for (let i = 0; i < trimmed.length; i++) {
27
+ const ch = trimmed[i]!;
28
+ if (inString) {
29
+ if (ch === '\\') i++;
30
+ else if (ch === inString) inString = null;
31
+ continue;
32
+ }
33
+ if (ch === '"' || ch === "'" || ch === '`') inString = ch;
34
+ else if (ch === '(' || ch === '[' || ch === '{') depth++;
35
+ else if (ch === ')' || ch === ']' || ch === '}') depth--;
36
+ else if (depth === 0 && /\s/.test(ch)) {
37
+ let end = i + 1;
38
+ while (end < trimmed.length && /\s/.test(trimmed[end]!)) end++;
39
+ last = [i, end];
40
+ first ??= last;
41
+ i = end - 1;
42
+ }
43
+ }
44
+
45
+ // Next to a binary operator the word is an operand (`"a" + inline`).
46
+ if (
47
+ last &&
48
+ last[1] === trimmed.length - FLAG.length &&
49
+ trimmed.endsWith(FLAG)
50
+ ) {
51
+ const rest = trimmed.slice(0, last[0]);
52
+ if (!/[-+*/%&|^!=<>?:,.]$/.test(rest))
53
+ return { nameExpr: rest, inline: true };
54
+ }
55
+ if (first && first[0] === FLAG.length && trimmed.startsWith(FLAG)) {
56
+ const rest = trimmed.slice(first[1]);
57
+ if (!/^[-+*/%&|^=<>?:,.]/.test(rest))
58
+ return { nameExpr: rest, inline: true };
59
+ }
60
+ return { nameExpr: trimmed, inline: false };
61
+ }
62
+
7
63
  defineMacro({
8
64
  name: 'include',
9
65
  interpolate: true,
@@ -11,8 +67,7 @@ defineMacro({
11
67
  render({ rawArgs }, ctx) {
12
68
  const storyData = useStoryStore((s) => s.storyData);
13
69
 
14
- const inline = /\binline\b/.test(rawArgs);
15
- const nameExpr = rawArgs.replace(/\binline\b/, '').trim();
70
+ const { nameExpr, inline } = parseIncludeArgs(rawArgs);
16
71
 
17
72
  let passageName: string;
18
73
  try {
@@ -14,12 +14,13 @@ function parseArgs(rawArgs: string): {
14
14
  display: string;
15
15
  passage: string | null;
16
16
  } {
17
- // {link "text" "Passage"} or {link "text"}
17
+ // {link "text" "Passage"} or {link "text"}. A backslash escapes a quote
18
+ // or another backslash inside a quoted argument (#200).
18
19
  const parts: string[] = [];
19
- const re = /(["'])(.*?)\1/g;
20
+ const re = /(["'])((?:\\[^]|(?!\1)[^\\])*)\1/g;
20
21
  let m;
21
22
  while ((m = re.exec(rawArgs)) !== null) {
22
- parts.push(m[2]!);
23
+ parts.push(m[2]!.replace(/\\(["'\\])/g, '$1'));
23
24
  }
24
25
  if (parts.length >= 2) {
25
26
  return { display: parts[0]!, passage: parts[1]! };
@@ -12,6 +12,7 @@ import {
12
12
  createSave,
13
13
  overwriteSave,
14
14
  deleteSaveById,
15
+ populateKnownSaves,
15
16
  renameSave,
16
17
  exportSave,
17
18
  importSave,
@@ -151,6 +152,12 @@ export function SaveManagerContent() {
151
152
  if (!confirm('Delete this save?')) return;
152
153
  try {
153
154
  await deleteSaveById(saveId);
155
+ // The save may have been held by a slot; hasSave() and QuickLoad
156
+ // read the store's cache
157
+ const known = await populateKnownSaves(ifid);
158
+ useStoryStore.setState((state) => {
159
+ state.knownSaves = known;
160
+ });
154
161
  showStatus('Save deleted');
155
162
  await refresh();
156
163
  } catch {
@@ -34,9 +34,10 @@ export function VarDisplay({ name, scope, className, id }: VarDisplayProps) {
34
34
  value = storeValue;
35
35
  }
36
36
 
37
- // Resolve dot path (e.g. "character.name" → character['name'])
37
+ // Resolve dot path (e.g. "character.name" → character['name']). Primitives
38
+ // box on access, so built-ins like "name.length" resolve too.
38
39
  for (let i = 1; i < parts.length; i++) {
39
- if (value == null || typeof value !== 'object') {
40
+ if (value == null) {
40
41
  value = undefined;
41
42
  break;
42
43
  }
@@ -1,4 +1,6 @@
1
1
  import { registerWidget } from '../../widgets/widget-registry';
2
+ import { astContainsChildren } from '../../widgets/ast-scanner';
3
+ import { registerBlockMacro } from '../../markup/ast';
2
4
  import { defineMacro } from '../../define-macro';
3
5
 
4
6
  function parseWidgetDef(rawArgs: string): { name: string; params: string[] } {
@@ -18,7 +20,11 @@ defineMacro({
18
20
  const paramsKey = params.join(',');
19
21
 
20
22
  ctx.hooks.useLayoutEffect(() => {
21
- registerWidget(name, children, params);
23
+ // Widgets whose body renders {@children} take a closing tag; register
24
+ // them as block macros so passages parsed later nest their content.
25
+ const isBlock = astContainsChildren(children);
26
+ registerWidget(name, children, params, isBlock);
27
+ if (isBlock) registerBlockMacro(name);
22
28
  }, [name, childrenKey, paramsKey]);
23
29
 
24
30
  return null;
@@ -230,7 +230,9 @@ export function WidgetInvocation({
230
230
 
231
231
  const childrenValue = invocationChildren?.length ? invocationChildren : null;
232
232
 
233
- if (params.length === 0 || !rawArgs) {
233
+ // Parameterized widgets always get their own local scope, even when invoked
234
+ // without arguments: missing parameters shadow outer locals as undefined.
235
+ if (params.length === 0) {
234
236
  return (
235
237
  <WidgetChildrenContext.Provider value={childrenValue}>
236
238
  {renderNodes(body, { nobr, locals: parentValues })}
@@ -238,7 +240,7 @@ export function WidgetInvocation({
238
240
  );
239
241
  }
240
242
 
241
- const argExprs = splitArgs(rawArgs);
243
+ const argExprs = rawArgs ? splitArgs(rawArgs) : [];
242
244
  const values: unknown[] = [];
243
245
 
244
246
  for (let i = 0; i < params.length; i++) {
@@ -1,57 +1,6 @@
1
1
  import { useStoryStore } from './store';
2
2
  import { execute } from './expression';
3
- import { deepClone } from './class-registry';
4
-
5
- /**
6
- * Structural equality for a deepClone() copy against its source. Lets
7
- * executeMutation write back only the variables the code actually changed,
8
- * so untouched clones don't overwrite updates made during execution
9
- * (e.g. Story.set called from inside {do}).
10
- */
11
- function deepEqual(
12
- a: unknown,
13
- b: unknown,
14
- seen: Map<object, object> = new Map(),
15
- ): boolean {
16
- if (Object.is(a, b)) return true;
17
- if (
18
- a === null ||
19
- b === null ||
20
- typeof a !== 'object' ||
21
- typeof b !== 'object'
22
- ) {
23
- return false;
24
- }
25
- if (Object.getPrototypeOf(a) !== Object.getPrototypeOf(b)) return false;
26
- if (seen.get(a) === b) return true;
27
- seen.set(a, b);
28
-
29
- if (a instanceof Date) return a.getTime() === (b as Date).getTime();
30
- if (a instanceof RegExp) return String(a) === String(b);
31
- if (a instanceof Map || a instanceof Set) {
32
- const bc = b as Map<unknown, unknown> | Set<unknown>;
33
- if (a.size !== bc.size) return false;
34
- const ai = a.entries();
35
- const bi = bc.entries();
36
- for (
37
- let x = ai.next(), y = bi.next();
38
- !x.done;
39
- x = ai.next(), y = bi.next()
40
- ) {
41
- if (!deepEqual(x.value, y.value, seen)) return false;
42
- }
43
- return true;
44
- }
45
-
46
- const ao = a as Record<string, unknown>;
47
- const bo = b as Record<string, unknown>;
48
- const keys = Object.keys(ao);
49
- if (keys.length !== Object.keys(bo).length) return false;
50
- for (const key of keys) {
51
- if (!(key in bo) || !deepEqual(ao[key], bo[key], seen)) return false;
52
- }
53
- return true;
54
- }
3
+ import { deepClone, deepEqual } from './class-registry';
55
4
 
56
5
  /**
57
6
  * Write the keys `code` changed (`clone` vs the pre-execution `before`) into
@@ -84,7 +33,12 @@ export function executeMutation(
84
33
  const vars = deepClone(state.variables);
85
34
  const temps = deepClone(state.temporary);
86
35
  const trans = deepClone(state.transient);
87
- const localsClone = { ...mergedLocals };
36
+ // Locals are deep-cloned like the store namespaces: a loop item or widget
37
+ // argument taken from story state is Immer-frozen, and an unfrozen object
38
+ // mutated in place would keep its reference, so a nested assignment
39
+ // (`@item.name = "x"`) would be lost or never reach the scope updater.
40
+ // Unregistered class instances (DOM nodes etc.) stay shared by reference.
41
+ const localsClone = deepClone(mergedLocals, { keepUnregistered: true });
88
42
 
89
43
  execute(code, vars, temps, localsClone, trans);
90
44
 
@@ -98,7 +52,7 @@ export function executeMutation(
98
52
  });
99
53
 
100
54
  for (const key of Object.keys(localsClone)) {
101
- if (localsClone[key] !== mergedLocals[key]) {
55
+ if (!deepEqual(localsClone[key], mergedLocals[key])) {
102
56
  scopeUpdate(key, localsClone[key]);
103
57
  }
104
58
  }
package/src/expression.ts CHANGED
@@ -31,21 +31,45 @@ const fnCache = new Map<string, CompiledExpression>();
31
31
 
32
32
  /**
33
33
  * Transform expression: $var → variables["var"], _var → temporary["var"],
34
- * @var → locals["var"], %var → transient["var"]
34
+ * @var → locals["var"]
35
35
  * Only transforms when sigils appear as a word boundary (not inside strings naively,
36
36
  * but authors already have full JS access so this is acceptable).
37
+ * `%var` → transient["var"] is handled by `transform` itself, because `%` is
38
+ * also the modulo operator and only an operand position makes it a sigil.
37
39
  */
38
40
  const VAR_RE = /\$(\w+)/g;
39
41
  const TEMP_RE = /(?<![.\w])_(\w+)/g;
40
42
  const LOCAL_RE = /@(\w+)/g;
41
- const TRANS_RE = /(?<!\w)%(\w+)/g;
43
+ /** Transient name after `%`: an identifier, so `%3` is never a reference. */
44
+ const TRANS_NAME_RE = /[A-Za-z_]\w*/y;
45
+ /** Characters of identifiers, numbers and sigil variable references. */
46
+ const WORD_CHAR_RE = /[\w$@]/;
47
+ const SPACE_RE = /\s/;
48
+ /** Keywords followed by an operand rather than an operator. */
49
+ const OPERAND_KEYWORDS = new Set([
50
+ 'await',
51
+ 'case',
52
+ 'delete',
53
+ 'do',
54
+ 'else',
55
+ 'in',
56
+ 'instanceof',
57
+ 'new',
58
+ 'of',
59
+ 'return',
60
+ 'throw',
61
+ 'typeof',
62
+ 'void',
63
+ 'yield',
64
+ ]);
65
+ /** Keywords whose parenthesised header is followed by a statement. */
66
+ const HEADER_KEYWORDS = new Set(['for', 'if', 'while', 'with']);
42
67
 
43
68
  function transformSegment(segment: string): string {
44
69
  return segment
45
70
  .replace(VAR_RE, 'variables["$1"]')
46
71
  .replace(TEMP_RE, 'temporary["$1"]')
47
- .replace(LOCAL_RE, 'locals["$1"]')
48
- .replace(TRANS_RE, 'transient["$1"]');
72
+ .replace(LOCAL_RE, 'locals["$1"]');
49
73
  }
50
74
 
51
75
  /**
@@ -53,12 +77,23 @@ function transformSegment(segment: string): string {
53
77
  * character so that variable sigils ($, _, @, %) inside string literals are
54
78
  * left untouched while code — including expressions inside template-literal
55
79
  * `${…}` interpolations — is transformed.
80
+ *
81
+ * It also tracks whether the next token is an operand or an operator, so that
82
+ * `%name` in operand position is a transient reference while `%` after an
83
+ * operand — `($n)%3`, `$a[i] %2`, `$a%$b` — stays the modulo operator.
56
84
  */
57
85
  function transform(expr: string): string {
58
86
  let result = '';
59
87
  let code = ''; // accumulates code characters to be transformed
60
88
  let i = 0;
61
89
 
90
+ let operandNext = true; // an operand (not an operator) comes next
91
+ let word = ''; // identifier/number currently being read
92
+ let afterDot = false; // `word` is a property name, never a keyword
93
+ let afterHeaderKeyword = false; // last token was if/while/for/with
94
+ let lastPunct = '';
95
+ const parenIsHeader: boolean[] = []; // per open `(`: closes a header?
96
+
62
97
  function flushCode() {
63
98
  if (code) {
64
99
  result += transformSegment(code);
@@ -66,6 +101,49 @@ function transform(expr: string): string {
66
101
  }
67
102
  }
68
103
 
104
+ function endWord() {
105
+ if (!word) return;
106
+ operandNext = !afterDot && OPERAND_KEYWORDS.has(word);
107
+ afterHeaderKeyword = !afterDot && HEADER_KEYWORDS.has(word);
108
+ afterDot = false;
109
+ lastPunct = '';
110
+ word = '';
111
+ }
112
+
113
+ /** A string or template literal, or a transient reference, just ended. */
114
+ function endOperand() {
115
+ endWord();
116
+ operandNext = false;
117
+ afterHeaderKeyword = false;
118
+ afterDot = false;
119
+ lastPunct = '';
120
+ }
121
+
122
+ function trackCode(c: string) {
123
+ if (WORD_CHAR_RE.test(c)) {
124
+ word += c;
125
+ return;
126
+ }
127
+ endWord();
128
+ // A line may start a new statement such as `%x = 1`.
129
+ if (c === '\n') operandNext = true;
130
+ if (SPACE_RE.test(c)) return;
131
+ if (c === '(') parenIsHeader.push(afterHeaderKeyword);
132
+ if (c === ')') {
133
+ // `if (…) %x = 1` vs `($n)%3`
134
+ operandNext = parenIsHeader.pop() ?? false;
135
+ } else if (c === '.') {
136
+ // Property access, unless it is the spread `...`
137
+ operandNext = lastPunct === '.';
138
+ } else {
139
+ // `]` ends an operand; `}` closes a block, so a statement may follow.
140
+ operandNext = c !== ']';
141
+ }
142
+ afterDot = c === '.';
143
+ afterHeaderKeyword = false;
144
+ lastPunct = c;
145
+ }
146
+
69
147
  while (i < expr.length) {
70
148
  const ch = expr.charAt(i);
71
149
 
@@ -90,6 +168,7 @@ function transform(expr: string): string {
90
168
  }
91
169
  }
92
170
  result += str;
171
+ endOperand();
93
172
  continue;
94
173
  }
95
174
 
@@ -192,10 +271,26 @@ function transform(expr: string): string {
192
271
  i++;
193
272
  }
194
273
  }
274
+ endOperand();
195
275
  continue;
196
276
  }
197
277
 
278
+ // Transient reference — only where an operand is expected
279
+ if (ch === '%') {
280
+ endWord();
281
+ TRANS_NAME_RE.lastIndex = i + 1;
282
+ const name = operandNext ? TRANS_NAME_RE.exec(expr)?.[0] : undefined;
283
+ if (name) {
284
+ flushCode();
285
+ result += `transient["${name}"]`;
286
+ i += 1 + name.length;
287
+ endOperand();
288
+ continue;
289
+ }
290
+ }
291
+
198
292
  // Regular code character
293
+ trackCode(ch);
199
294
  code += ch;
200
295
  i++;
201
296
  }
package/src/index.tsx CHANGED
@@ -2,14 +2,13 @@ import { render } from 'preact';
2
2
  import { App } from './components/App';
3
3
  import { parseStoryData } from './parser';
4
4
  import { useStoryStore, enterRuntimePhase } from './store';
5
- import { emit } from './event-emitter';
6
5
  import {
7
6
  installStoryAPI,
8
7
  getReadyPromise,
9
8
  setDeclaredVariables,
10
9
  } from './story-api';
11
10
  import { resetIdCounters } from './action-registry';
12
- import { executeStoryInit } from './story-init';
11
+ import { initializeStory } from './story-init';
13
12
  import { connectTriggersToStore } from './triggers';
14
13
  import { loadSession } from './saves/save-manager';
15
14
  import {
@@ -141,26 +140,15 @@ export function boot() {
141
140
  // Enter runtime phase — handlers registered from here on are cleaned on restart
142
141
  enterRuntimePhase();
143
142
 
144
- // Execute StoryInit passage if it exists
145
- executeStoryInit();
146
-
147
- // Restore session state if the page was refreshed
148
- const sessionPayload = loadSession(storyData.ifid);
149
- if (sessionPayload) {
150
- useStoryStore.getState().loadFromPayload(sessionPayload);
151
- }
152
-
153
- // Fire storyinit after all state is settled (defaults + StoryInit + session)
154
- emit('storyinit');
155
-
156
- // Pass 1: Pre-scan all widget passages to discover block widgets.
157
- // Register them as block macros BEFORE any tokenize/buildAST calls,
158
- // so that widget bodies using other block widgets parse correctly
159
- // regardless of passage order.
143
+ // Pass 1: Pre-scan StoryInit and all widget passages to discover block
144
+ // widgets. Register them as block macros BEFORE any tokenize/buildAST
145
+ // calls (StoryInit's included), so that passages invoking block widgets
146
+ // and widget bodies using other block widgets parse correctly regardless
147
+ // of passage or definition order.
160
148
  const blockWidgetPattern =
161
149
  /\{widget\s+["']?(\w+)["']?[^}]*\}([\s\S]*?)\{\/widget\}/g;
162
- for (const [, passage] of storyData.passages) {
163
- if (passage.tags.includes('widget')) {
150
+ for (const [passageName, passage] of storyData.passages) {
151
+ if (passageName === 'StoryInit' || passage.tags.includes('widget')) {
164
152
  let match;
165
153
  while ((match = blockWidgetPattern.exec(passage.content)) !== null) {
166
154
  const name = match[1]!;
@@ -173,6 +161,10 @@ export function boot() {
173
161
  }
174
162
  }
175
163
 
164
+ // Run StoryInit, restore the session if the page was refreshed, and fire
165
+ // storyinit after all state is settled (defaults + StoryInit + session)
166
+ initializeStory(loadSession(storyData.ifid));
167
+
176
168
  // Pass 2: Full parse and register widgets from passages tagged "widget"
177
169
  for (const [, passage] of storyData.passages) {
178
170
  if (passage.tags.includes('widget')) {
@@ -11,7 +11,8 @@ export function hasInterpolation(s: string): boolean {
11
11
  function resolveDotPath(root: unknown, parts: string[]): unknown {
12
12
  let value = root;
13
13
  for (let i = 1; i < parts.length; i++) {
14
- if (value == null || typeof value !== 'object') return undefined;
14
+ // Primitives box on access, so `{$name.length}` works too.
15
+ if (value == null) return undefined;
15
16
  value = (value as Record<string, unknown>)[parts[i]!];
16
17
  }
17
18
  return value;
package/src/markup/ast.ts CHANGED
@@ -91,6 +91,14 @@ const BRANCH_PARENT: Record<string, string> = {
91
91
  /** Block macros that use the branches[] array */
92
92
  const BRANCHING_BLOCK_MACROS = new Set(['if', 'switch', 'timed']);
93
93
 
94
+ /**
95
+ * Quote a bracket-link value as a {link} argument, escaping backslashes and
96
+ * double quotes so MacroLink reads the value back unchanged (#200).
97
+ */
98
+ function quoteArg(value: string): string {
99
+ return `"${value.replace(/[\\"]/g, '\\$&')}"`;
100
+ }
101
+
94
102
  /**
95
103
  * Build an AST from a token array. Block macros are nested into trees
96
104
  * using a stack. Throws on unclosed or mismatched macros.
@@ -118,7 +126,7 @@ export function buildAST(tokens: Token[]): ASTNode[] {
118
126
  break;
119
127
 
120
128
  case 'link': {
121
- const rawArgs = `"${token.display}" "${token.target}"`;
129
+ const rawArgs = `${quoteArg(token.display)} ${quoteArg(token.target)}`;
122
130
  const macroNode: MacroNode = {
123
131
  type: 'macro',
124
132
  name: 'link',
@@ -339,7 +339,8 @@ function getVariableTextValue(
339
339
  else value = locals[root];
340
340
 
341
341
  for (let i = 1; i < parts.length; i++) {
342
- if (value == null || typeof value !== 'object') return '';
342
+ // Primitives box on access, so `{$name.length}` works too.
343
+ if (value == null) return '';
343
344
  value = (value as Record<string, unknown>)[parts[i]!];
344
345
  }
345
346