@rohal12/spindle 0.7.0 → 0.9.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.
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/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
  }
@@ -42,7 +42,17 @@ import { markdownToHtml } from './markdown';
42
42
  import { h } from 'preact';
43
43
  import type { ASTNode, Branch, MacroNode } from './ast';
44
44
 
45
- 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);
46
56
 
47
57
  const EMPTY_BRANCHES: Branch[] = [];
48
58
 
@@ -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
  }