@rohal12/spindle 0.6.0 → 0.8.0

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.
@@ -0,0 +1,127 @@
1
+ import { useContext, useState, useCallback } from 'preact/hooks';
2
+ import { LocalsContext, renderNodes } from '../../markup/render';
3
+ import type { LocalsScope } from '../../markup/render';
4
+ import { useMergedLocals } from '../../hooks/use-merged-locals';
5
+ import { evaluate } from '../../expression';
6
+ import type { ASTNode } from '../../markup/ast';
7
+
8
+ interface WidgetInvocationProps {
9
+ body: ASTNode[];
10
+ params: string[];
11
+ rawArgs?: string;
12
+ }
13
+
14
+ /**
15
+ * Split rawArgs by commas, respecting parentheses, brackets, braces, and strings.
16
+ */
17
+ function splitArgs(raw: string): string[] {
18
+ const args: string[] = [];
19
+ let current = '';
20
+ let depth = 0;
21
+ let inString: string | null = null;
22
+
23
+ for (let i = 0; i < raw.length; i++) {
24
+ const ch = raw[i]!;
25
+
26
+ if (inString) {
27
+ current += ch;
28
+ if (ch === inString && raw[i - 1] !== '\\') inString = null;
29
+ continue;
30
+ }
31
+
32
+ if (ch === '"' || ch === "'" || ch === '`') {
33
+ inString = ch;
34
+ current += ch;
35
+ continue;
36
+ }
37
+
38
+ if (ch === '(' || ch === '[' || ch === '{') {
39
+ depth++;
40
+ current += ch;
41
+ continue;
42
+ }
43
+
44
+ if (ch === ')' || ch === ']' || ch === '}') {
45
+ depth--;
46
+ current += ch;
47
+ continue;
48
+ }
49
+
50
+ if (ch === ',' && depth === 0) {
51
+ args.push(current.trim());
52
+ current = '';
53
+ continue;
54
+ }
55
+
56
+ current += ch;
57
+ }
58
+
59
+ const last = current.trim();
60
+ if (last) args.push(last);
61
+ return args;
62
+ }
63
+
64
+ function WidgetBody({
65
+ body,
66
+ parentValues,
67
+ ownKeys,
68
+ }: {
69
+ body: ASTNode[];
70
+ parentValues: Record<string, unknown>;
71
+ ownKeys: Record<string, unknown>;
72
+ }) {
73
+ const [localState, setLocalState] = useState<Record<string, unknown>>(() => ({
74
+ ...parentValues,
75
+ ...ownKeys,
76
+ }));
77
+
78
+ const update = useCallback((key: string, value: unknown) => {
79
+ setLocalState((prev) => ({ ...prev, [key]: value }));
80
+ }, []);
81
+
82
+ const scope: LocalsScope = { values: localState, update };
83
+
84
+ return (
85
+ <LocalsContext.Provider value={scope}>
86
+ {renderNodes(body)}
87
+ </LocalsContext.Provider>
88
+ );
89
+ }
90
+
91
+ export function WidgetInvocation({
92
+ body,
93
+ params,
94
+ rawArgs,
95
+ }: WidgetInvocationProps) {
96
+ const parentScope = useContext(LocalsContext);
97
+ const [mergedVars, mergedTemps, mergedLocals] = useMergedLocals();
98
+
99
+ if (params.length === 0 || !rawArgs) {
100
+ return <>{renderNodes(body)}</>;
101
+ }
102
+
103
+ const argExprs = splitArgs(rawArgs);
104
+ const ownKeys: Record<string, unknown> = {};
105
+
106
+ for (let i = 0; i < params.length; i++) {
107
+ const param = params[i]!;
108
+ const expr = argExprs[i];
109
+ let value: unknown;
110
+ if (expr !== undefined) {
111
+ try {
112
+ value = evaluate(expr, mergedVars, mergedTemps, mergedLocals);
113
+ } catch {
114
+ value = undefined;
115
+ }
116
+ }
117
+ ownKeys[param] = value;
118
+ }
119
+
120
+ return (
121
+ <WidgetBody
122
+ body={body}
123
+ parentValues={parentScope.values}
124
+ ownKeys={ownKeys}
125
+ />
126
+ );
127
+ }
package/src/expression.ts CHANGED
@@ -21,6 +21,7 @@ interface ExpressionFns {
21
21
  type CompiledExpression = (
22
22
  variables: Record<string, unknown>,
23
23
  temporary: Record<string, unknown>,
24
+ locals: Record<string, unknown>,
24
25
  __fns: ExpressionFns,
25
26
  ) => unknown;
26
27
 
@@ -34,11 +35,13 @@ const fnCache = new Map<string, CompiledExpression>();
34
35
  */
35
36
  const VAR_RE = /\$(\w+)/g;
36
37
  const TEMP_RE = /\b_(\w+)/g;
38
+ const LOCAL_RE = /@(\w+)/g;
37
39
 
38
40
  function transform(expr: string): string {
39
41
  return expr
40
42
  .replace(VAR_RE, 'variables["$1"]')
41
- .replace(TEMP_RE, 'temporary["$1"]');
43
+ .replace(TEMP_RE, 'temporary["$1"]')
44
+ .replace(LOCAL_RE, 'locals["$1"]');
42
45
  }
43
46
 
44
47
  const preamble =
@@ -55,6 +58,7 @@ function getOrCompile(key: string, body: string): CompiledExpression {
55
58
  const fn = new Function(
56
59
  'variables',
57
60
  'temporary',
61
+ 'locals',
58
62
  '__fns',
59
63
  preamble + body,
60
64
  ) as CompiledExpression;
@@ -136,11 +140,12 @@ export function evaluate(
136
140
  expr: string,
137
141
  variables: Record<string, unknown>,
138
142
  temporary: Record<string, unknown>,
143
+ locals: Record<string, unknown> = {},
139
144
  ): unknown {
140
145
  const transformed = transform(expr);
141
146
  const body = `return (${transformed});`;
142
147
  const fn = getOrCompile(body, body);
143
- return fn(variables, temporary, buildExpressionFns());
148
+ return fn(variables, temporary, locals, buildExpressionFns());
144
149
  }
145
150
 
146
151
  /**
@@ -151,15 +156,16 @@ export function execute(
151
156
  code: string,
152
157
  variables: Record<string, unknown>,
153
158
  temporary: Record<string, unknown>,
159
+ locals: Record<string, unknown> = {},
154
160
  ): void {
155
161
  const transformed = transform(code);
156
162
  const fn = getOrCompile('exec:' + transformed, transformed);
157
- fn(variables, temporary, buildExpressionFns());
163
+ fn(variables, temporary, locals, buildExpressionFns());
158
164
  }
159
165
 
160
166
  /**
161
167
  * Convenience: evaluate using store state directly.
162
168
  */
163
169
  export function evaluateWithState(expr: string, state: StoryState): unknown {
164
- return evaluate(expr, state.variables, state.temporary);
170
+ return evaluate(expr, state.variables, state.temporary, {});
165
171
  }
@@ -3,24 +3,26 @@ import { useStoryStore } from '../store';
3
3
  import { LocalsContext } from '../markup/render';
4
4
 
5
5
  /**
6
- * Merge store variables/temporary with LocalsContext values.
7
- * Locals prefixed with `$` go into variables, `_` into temporary.
6
+ * Return store variables, temporary, and @-prefixed locals from context.
7
+ * Locals use `@` prefix keys internally; the returned locals dict has
8
+ * the prefix stripped so it can be passed directly to evaluate/execute.
8
9
  */
9
10
  export function useMergedLocals(): readonly [
10
11
  Record<string, unknown>,
11
12
  Record<string, unknown>,
13
+ Record<string, unknown>,
12
14
  ] {
13
15
  const variables = useStoryStore((s) => s.variables);
14
16
  const temporary = useStoryStore((s) => s.temporary);
15
- const locals = useContext(LocalsContext);
17
+ const scope = useContext(LocalsContext);
16
18
 
17
19
  return useMemo(() => {
18
- const vars = { ...variables };
19
- const temps = { ...temporary };
20
- for (const [key, val] of Object.entries(locals)) {
21
- if (key.startsWith('$')) vars[key.slice(1)] = val;
22
- else if (key.startsWith('_')) temps[key.slice(1)] = val;
20
+ const locals: Record<string, unknown> = {};
21
+ for (const [key, val] of Object.entries(scope.values)) {
22
+ if (key.startsWith('@')) {
23
+ locals[key.slice(1)] = val;
24
+ }
23
25
  }
24
- return [vars, temps] as const;
25
- }, [variables, temporary, locals]);
26
+ return [variables, temporary, locals] as const;
27
+ }, [variables, temporary, scope.values]);
26
28
  }
package/src/index.tsx CHANGED
@@ -95,8 +95,12 @@ function boot() {
95
95
  const widgetAST = buildAST(widgetTokens);
96
96
  for (const node of widgetAST) {
97
97
  if (node.type === 'macro' && node.name === 'widget' && node.rawArgs) {
98
- const widgetName = node.rawArgs.trim().replace(/["']/g, '');
99
- registerWidget(widgetName, node.children as ASTNode[]);
98
+ const tokens2 = node.rawArgs.trim().split(/\s+/);
99
+ const widgetName = tokens2[0]!.replace(/["']/g, '');
100
+ const params = tokens2
101
+ .slice(1)
102
+ .filter((t) => t.startsWith('$') || t.startsWith('_'));
103
+ registerWidget(widgetName, node.children as ASTNode[], params);
100
104
  }
101
105
  }
102
106
  }
@@ -111,6 +115,18 @@ function boot() {
111
115
  }
112
116
  });
113
117
 
118
+ // Warn if StoryInterface passage exists but doesn't contain {passage}
119
+ const storyInterfacePassage = storyData.passages.get('StoryInterface');
120
+ if (
121
+ storyInterfacePassage &&
122
+ !storyInterfacePassage.content.includes('{passage}')
123
+ ) {
124
+ console.warn(
125
+ 'spindle: StoryInterface passage does not contain {passage}. ' +
126
+ 'The current passage will not be displayed.',
127
+ );
128
+ }
129
+
114
130
  const root = document.getElementById('root');
115
131
  if (!root) {
116
132
  throw new Error('spindle: No <div id="root"> element found.');
package/src/markup/ast.ts CHANGED
@@ -16,7 +16,7 @@ export interface LinkNode {
16
16
  export interface VariableNode {
17
17
  type: 'variable';
18
18
  name: string;
19
- scope: 'variable' | 'temporary';
19
+ scope: 'variable' | 'temporary' | 'local';
20
20
  className?: string;
21
21
  id?: string;
22
22
  }
@@ -32,15 +32,27 @@ import { Repeat } from '../components/macros/Repeat';
32
32
  import { Stop } from '../components/macros/Stop';
33
33
  import { Type } from '../components/macros/Type';
34
34
  import { Widget } from '../components/macros/Widget';
35
+ import { WidgetInvocation } from '../components/macros/WidgetInvocation';
35
36
  import { Computed } from '../components/macros/Computed';
36
37
  import { Meter } from '../components/macros/Meter';
38
+ import { PassageDisplay } from '../components/macros/PassageDisplay';
37
39
  import { getWidget } from '../widgets/widget-registry';
38
40
  import { getMacro } from '../registry';
39
41
  import { markdownToHtml } from './markdown';
40
42
  import { h } from 'preact';
41
43
  import type { ASTNode, Branch, MacroNode } from './ast';
42
44
 
43
- export const LocalsContext = createContext<Record<string, unknown>>({});
45
+ export interface LocalsScope {
46
+ values: Record<string, unknown>;
47
+ update: (key: string, value: unknown) => void;
48
+ }
49
+
50
+ const defaultLocalsScope: LocalsScope = {
51
+ values: {},
52
+ update: () => {},
53
+ };
54
+
55
+ export const LocalsContext = createContext<LocalsScope>(defaultLocalsScope);
44
56
 
45
57
  const EMPTY_BRANCHES: Branch[] = [];
46
58
 
@@ -132,6 +144,15 @@ function renderMacro(node: MacroNode, key: number) {
132
144
  />
133
145
  );
134
146
 
147
+ case 'passage':
148
+ return (
149
+ <PassageDisplay
150
+ key={key}
151
+ className={node.className}
152
+ id={node.id}
153
+ />
154
+ );
155
+
135
156
  case 'if':
136
157
  return (
137
158
  <If
@@ -415,9 +436,16 @@ function renderMacro(node: MacroNode, key: number) {
415
436
 
416
437
  default: {
417
438
  // Check widget registry for user-defined widgets
418
- const widgetAST = getWidget(node.name);
419
- if (widgetAST) {
420
- return <>{renderNodes(widgetAST)}</>;
439
+ const widget = getWidget(node.name);
440
+ if (widget) {
441
+ return (
442
+ <WidgetInvocation
443
+ key={key}
444
+ body={widget.body}
445
+ params={widget.params}
446
+ rawArgs={node.rawArgs}
447
+ />
448
+ );
421
449
  }
422
450
 
423
451
  // Check component registry for custom macros
@@ -29,7 +29,7 @@ export interface MacroToken {
29
29
  export interface VariableToken {
30
30
  type: 'variable';
31
31
  name: string;
32
- scope: 'variable' | 'temporary';
32
+ scope: 'variable' | 'temporary' | 'local';
33
33
  className?: string;
34
34
  id?: string;
35
35
  start: number;
@@ -419,6 +419,34 @@ export function tokenize(input: string): Token[] {
419
419
  continue;
420
420
  }
421
421
 
422
+ if (charAfter === '@') {
423
+ // {.class#id @local.field}
424
+ i = afterSelectors + 1;
425
+ const nameStart = i;
426
+ while (i < input.length && /[\w.]/.test(input[i]!)) i++;
427
+ const name = input.slice(nameStart, i);
428
+
429
+ if (input[i] === '}') {
430
+ i++; // skip }
431
+ const token: VariableToken = {
432
+ type: 'variable',
433
+ name,
434
+ scope: 'local',
435
+ start,
436
+ end: i,
437
+ };
438
+ if (className) token.className = className;
439
+ if (id) token.id = id;
440
+ tokens.push(token);
441
+ textStart = i;
442
+ continue;
443
+ }
444
+ // Not valid — treat as text
445
+ i = start + 1;
446
+ textStart = start;
447
+ continue;
448
+ }
449
+
422
450
  if (charAfter !== undefined && /[a-zA-Z]/.test(charAfter)) {
423
451
  // {.class#id macroName args}
424
452
  i = afterSelectors;
@@ -515,6 +543,32 @@ export function tokenize(input: string): Token[] {
515
543
  continue;
516
544
  }
517
545
 
546
+ // {@local.field}
547
+ if (nextChar === '@') {
548
+ flushText(i);
549
+ i += 2;
550
+ const nameStart = i;
551
+ while (i < input.length && /[\w.]/.test(input[i]!)) i++;
552
+ const name = input.slice(nameStart, i);
553
+
554
+ if (input[i] === '}') {
555
+ i++; // skip }
556
+ tokens.push({
557
+ type: 'variable',
558
+ name,
559
+ scope: 'local',
560
+ start,
561
+ end: i,
562
+ });
563
+ textStart = i;
564
+ continue;
565
+ }
566
+ // Not a valid local token — treat as text
567
+ i = start + 1;
568
+ textStart = start;
569
+ continue;
570
+ }
571
+
518
572
  // {macro ...} or {/macro} — but not bare { that's just text
519
573
  // Must start with a letter or /
520
574
  if (
@@ -14,7 +14,7 @@ export interface VariableSchema extends FieldSchema {
14
14
 
15
15
  const DECLARATION_RE = /^\$(\w+)\s*=\s*(.+)$/;
16
16
  const VAR_REF_RE = /\$(\w+(?:\.\w+)*)/g;
17
- const FOR_LOCAL_RE = /\{for\s+(\$\w+)(?:\s*,\s*(\$\w+))?\s+of\b/g;
17
+ const FOR_LOCAL_RE = /\{for\s+@(\w+)(?:\s*,\s*@(\w+))?\s+of\b/g;
18
18
 
19
19
  const VALID_VAR_TYPES = new Set<string>(['number', 'string', 'boolean']);
20
20
 
@@ -77,16 +77,16 @@ export function parseStoryVariables(
77
77
 
78
78
  /**
79
79
  * Extract for-loop local variable names from passage content.
80
- * `{for $item of ...}` → "item"
81
- * `{for $index, $item of ...}` → "index", "item"
80
+ * `{for @item of ...}` → "item"
81
+ * `{for @index, @item of ...}` → "index", "item"
82
82
  */
83
83
  function extractForLocals(content: string): Set<string> {
84
84
  const locals = new Set<string>();
85
85
  let match: RegExpExecArray | null;
86
86
  FOR_LOCAL_RE.lastIndex = 0;
87
87
  while ((match = FOR_LOCAL_RE.exec(content)) !== null) {
88
- locals.add(match[1]!.slice(1)); // strip $
89
- if (match[2]) locals.add(match[2]!.slice(1));
88
+ locals.add(match[1]!);
89
+ if (match[2]) locals.add(match[2]!);
90
90
  }
91
91
  return locals;
92
92
  }
@@ -1,12 +1,21 @@
1
1
  import type { ASTNode } from '../markup/ast';
2
2
 
3
- const widgets = new Map<string, ASTNode[]>();
3
+ interface WidgetEntry {
4
+ body: ASTNode[];
5
+ params: string[];
6
+ }
7
+
8
+ const widgets = new Map<string, WidgetEntry>();
4
9
 
5
- export function registerWidget(name: string, bodyAST: ASTNode[]): void {
6
- widgets.set(name.toLowerCase(), bodyAST);
10
+ export function registerWidget(
11
+ name: string,
12
+ bodyAST: ASTNode[],
13
+ params: string[],
14
+ ): void {
15
+ widgets.set(name.toLowerCase(), { body: bodyAST, params });
7
16
  }
8
17
 
9
- export function getWidget(name: string): ASTNode[] | undefined {
18
+ export function getWidget(name: string): WidgetEntry | undefined {
10
19
  return widgets.get(name.toLowerCase());
11
20
  }
12
21