@rohal12/spindle 0.10.0 → 0.12.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
@@ -37,13 +37,117 @@ const VAR_RE = /\$(\w+)/g;
37
37
  const TEMP_RE = /\b_(\w+)/g;
38
38
  const LOCAL_RE = /@(\w+)/g;
39
39
 
40
- function transform(expr: string): string {
41
- return expr
40
+ function transformSegment(segment: string): string {
41
+ return segment
42
42
  .replace(VAR_RE, 'variables["$1"]')
43
43
  .replace(TEMP_RE, 'temporary["$1"]')
44
44
  .replace(LOCAL_RE, 'locals["$1"]');
45
45
  }
46
46
 
47
+ /**
48
+ * String-aware expression transformer. Walks the expression character by
49
+ * character so that variable sigils ($, _, @) inside string literals are
50
+ * left untouched while code — including expressions inside template-literal
51
+ * `${…}` interpolations — is transformed.
52
+ */
53
+ function transform(expr: string): string {
54
+ let result = '';
55
+ let code = ''; // accumulates code characters to be transformed
56
+ let i = 0;
57
+
58
+ function flushCode() {
59
+ if (code) {
60
+ result += transformSegment(code);
61
+ code = '';
62
+ }
63
+ }
64
+
65
+ while (i < expr.length) {
66
+ const ch = expr.charAt(i);
67
+
68
+ // Single or double quoted string — skip entirely
69
+ if (ch === '"' || ch === "'") {
70
+ flushCode();
71
+ const quote = ch;
72
+ let str = quote;
73
+ i++;
74
+ while (i < expr.length) {
75
+ const c = expr.charAt(i);
76
+ if (c === '\\' && i + 1 < expr.length) {
77
+ str += c + expr.charAt(i + 1);
78
+ i += 2;
79
+ } else if (c === quote) {
80
+ str += quote;
81
+ i++;
82
+ break;
83
+ } else {
84
+ str += c;
85
+ i++;
86
+ }
87
+ }
88
+ result += str;
89
+ continue;
90
+ }
91
+
92
+ // Template literal — preserve literal parts, transform interpolations
93
+ if (ch === '`') {
94
+ flushCode();
95
+ result += '`';
96
+ i++;
97
+ while (i < expr.length) {
98
+ const c = expr.charAt(i);
99
+ if (c === '\\' && i + 1 < expr.length) {
100
+ result += c + expr.charAt(i + 1);
101
+ i += 2;
102
+ } else if (c === '$' && expr.charAt(i + 1) === '{') {
103
+ // Template interpolation — collect the inner expression and
104
+ // recursively transform it
105
+ result += '${';
106
+ i += 2;
107
+ let depth = 1;
108
+ let inner = '';
109
+ while (i < expr.length && depth > 0) {
110
+ const ic = expr.charAt(i);
111
+ if (ic === '{') {
112
+ depth++;
113
+ inner += ic;
114
+ } else if (ic === '}') {
115
+ depth--;
116
+ if (depth === 0) break;
117
+ inner += ic;
118
+ } else if (ic === '\\' && i + 1 < expr.length) {
119
+ inner += ic + expr.charAt(i + 1);
120
+ i++;
121
+ } else {
122
+ inner += ic;
123
+ }
124
+ i++;
125
+ }
126
+ result += transform(inner); // recursive transform
127
+ if (i < expr.length && expr.charAt(i) === '}') {
128
+ result += '}';
129
+ i++;
130
+ }
131
+ } else if (c === '`') {
132
+ result += '`';
133
+ i++;
134
+ break;
135
+ } else {
136
+ result += c;
137
+ i++;
138
+ }
139
+ }
140
+ continue;
141
+ }
142
+
143
+ // Regular code character
144
+ code += ch;
145
+ i++;
146
+ }
147
+ flushCode();
148
+ return result;
149
+ }
150
+
47
151
  const preamble =
48
152
  'const {currentPassage,previousPassage,visited,hasVisited,hasVisitedAny,hasVisitedAll,rendered,hasRendered,hasRenderedAny,hasRenderedAll,random,randomInt}=__fns;';
49
153
 
@@ -0,0 +1,17 @@
1
+ import { useCallback } from 'preact/hooks';
2
+ import { useMergedLocals } from './use-merged-locals';
3
+ import { hasInterpolation, interpolate } from '../interpolation';
4
+
5
+ export function useInterpolate(): (
6
+ s: string | undefined,
7
+ ) => string | undefined {
8
+ const [variables, temporary, locals] = useMergedLocals();
9
+
10
+ return useCallback(
11
+ (s: string | undefined): string | undefined => {
12
+ if (s === undefined || !hasInterpolation(s)) return s;
13
+ return interpolate(s, variables, temporary, locals);
14
+ },
15
+ [variables, temporary, locals],
16
+ );
17
+ }
package/src/index.tsx CHANGED
@@ -99,7 +99,10 @@ function boot() {
99
99
  const widgetName = tokens2[0]!.replace(/["']/g, '');
100
100
  const params = tokens2
101
101
  .slice(1)
102
- .filter((t) => t.startsWith('$') || t.startsWith('_'));
102
+ .filter(
103
+ (t) =>
104
+ t.startsWith('$') || t.startsWith('_') || t.startsWith('@'),
105
+ );
103
106
  registerWidget(widgetName, node.children as ASTNode[], params);
104
107
  }
105
108
  }
@@ -0,0 +1,44 @@
1
+ const INTERP_RE = /\{(\$[\w.]+|_[\w.]+|@[\w.]+)\}/g;
2
+ const INTERP_TEST = /\{[\$_@][\w.]+\}/;
3
+
4
+ export function hasInterpolation(s: string): boolean {
5
+ return INTERP_TEST.test(s);
6
+ }
7
+
8
+ function resolveDotPath(root: unknown, parts: string[]): unknown {
9
+ let value = root;
10
+ for (let i = 1; i < parts.length; i++) {
11
+ if (value == null || typeof value !== 'object') return undefined;
12
+ value = (value as Record<string, unknown>)[parts[i]!];
13
+ }
14
+ return value;
15
+ }
16
+
17
+ export function interpolate(
18
+ template: string,
19
+ variables: Record<string, unknown>,
20
+ temporary: Record<string, unknown>,
21
+ locals: Record<string, unknown>,
22
+ ): string {
23
+ return template.replace(INTERP_RE, (_match, ref: string) => {
24
+ const prefix = ref[0]!;
25
+ const path = ref.slice(1);
26
+ const parts = path.split('.');
27
+ const root = parts[0]!;
28
+
29
+ let value: unknown;
30
+ if (prefix === '$') {
31
+ value = variables[root];
32
+ } else if (prefix === '_') {
33
+ value = temporary[root];
34
+ } else {
35
+ value = locals[root];
36
+ }
37
+
38
+ if (parts.length > 1) {
39
+ value = resolveDotPath(value, parts);
40
+ }
41
+
42
+ return value == null ? '' : String(value);
43
+ });
44
+ }
@@ -40,7 +40,9 @@ import { getWidget } from '../widgets/widget-registry';
40
40
  import { getMacro } from '../registry';
41
41
  import { markdownToHtml } from './markdown';
42
42
  import { h } from 'preact';
43
- import type { ASTNode, Branch, MacroNode } from './ast';
43
+ import type { ASTNode, Branch, HtmlNode, MacroNode, VariableNode } from './ast';
44
+ import { useStoryStore } from '../store';
45
+ import { useInterpolate } from '../hooks/use-interpolate';
44
46
 
45
47
  export interface LocalsScope {
46
48
  values: Record<string, unknown>;
@@ -106,6 +108,19 @@ function convertDomNode(
106
108
  return null;
107
109
  }
108
110
 
111
+ function HtmlNodeRenderer({ node }: { node: HtmlNode }) {
112
+ const resolve = useInterpolate();
113
+ const attrs: Record<string, unknown> = {};
114
+ for (const [k, v] of Object.entries(node.attributes)) {
115
+ attrs[k] = resolve(v) ?? v;
116
+ }
117
+ return h(
118
+ node.tag,
119
+ attrs,
120
+ node.children.length > 0 ? renderNodes(node.children) : undefined,
121
+ );
122
+ }
123
+
109
124
  function renderMacro(node: MacroNode, key: number) {
110
125
  switch (node.name) {
111
126
  case 'set':
@@ -381,17 +396,19 @@ function renderMacro(node: MacroNode, key: number) {
381
396
  />
382
397
  );
383
398
 
384
- case 'timed':
399
+ case 'timed': {
400
+ const firstBranch = node.branches?.[0];
385
401
  return (
386
402
  <Timed
387
403
  key={key}
388
404
  rawArgs={node.rawArgs}
389
405
  children={node.children}
390
406
  branches={node.branches ?? EMPTY_BRANCHES}
391
- className={node.className}
392
- id={node.id}
407
+ className={node.className ?? firstBranch?.className}
408
+ id={node.id ?? firstBranch?.id}
393
409
  />
394
410
  );
411
+ }
395
412
 
396
413
  case 'repeat':
397
414
  return (
@@ -513,10 +530,11 @@ function renderSingleNode(
513
530
  return renderMacro(node, key);
514
531
 
515
532
  case 'html':
516
- return h(
517
- node.tag,
518
- { key, ...node.attributes },
519
- node.children.length > 0 ? renderNodes(node.children) : undefined,
533
+ return (
534
+ <HtmlNodeRenderer
535
+ key={key}
536
+ node={node}
537
+ />
520
538
  );
521
539
 
522
540
  default: {
@@ -536,6 +554,31 @@ export function renderInlineNodes(nodes: ASTNode[]): preact.ComponentChildren {
536
554
  return nodes.map((node, i) => renderSingleNode(node, i));
537
555
  }
538
556
 
557
+ function hasUnclosedBacktick(s: string): boolean {
558
+ let count = 0;
559
+ for (let i = 0; i < s.length; i++) {
560
+ if (s[i] === '`') count++;
561
+ }
562
+ return count % 2 === 1;
563
+ }
564
+
565
+ function getVariableTextValue(node: VariableNode): string {
566
+ const state = useStoryStore.getState();
567
+ const parts = node.name.split('.');
568
+ const root = parts[0]!;
569
+
570
+ let value: unknown;
571
+ if (node.scope === 'variable') value = state.variables[root];
572
+ else if (node.scope === 'temporary') value = state.temporary[root];
573
+
574
+ for (let i = 1; i < parts.length; i++) {
575
+ if (value == null || typeof value !== 'object') return '';
576
+ value = (value as Record<string, unknown>)[parts[i]!];
577
+ }
578
+
579
+ return value == null ? '' : String(value);
580
+ }
581
+
539
582
  /**
540
583
  * Render AST nodes with full CommonMark markdown support.
541
584
  *
@@ -563,7 +606,10 @@ export function renderNodes(nodes: ASTNode[]): preact.ComponentChildren {
563
606
  for (let i = 0; i < nodes.length; i++) {
564
607
  const node = nodes[i]!;
565
608
  if (node.type === 'text') {
566
- combined += node.value;
609
+ combined += node.value.replace(/^[ \t]{4,}/gm, ' ');
610
+ } else if (node.type === 'variable' && hasUnclosedBacktick(combined)) {
611
+ // Inline variable value to avoid placeholder inside code span
612
+ combined += getVariableTextValue(node);
567
613
  } else {
568
614
  const phIdx = components.length;
569
615
  components.push(renderSingleNode(node, i));
@@ -198,10 +198,32 @@ function parseSelectors(
198
198
  while (i < input.length && (input[i] === '.' || input[i] === '#')) {
199
199
  const prefix = input[i]!;
200
200
  i++; // skip the . or #
201
- const nameStart = i;
202
- while (i < input.length && /[a-zA-Z0-9_-]/.test(input[i]!)) i++;
203
- if (i > nameStart) {
204
- const name = input.slice(nameStart, i);
201
+ let name = '';
202
+ while (i < input.length) {
203
+ if (/[a-zA-Z0-9_-]/.test(input[i]!)) {
204
+ name += input[i];
205
+ i++;
206
+ } else if (
207
+ input[i] === '{' &&
208
+ (input[i + 1] === '$' || input[i + 1] === '_' || input[i + 1] === '@')
209
+ ) {
210
+ // Consume interpolation: {$var}, {_var}, {@var} (with optional dot paths)
211
+ const braceStart = i;
212
+ i += 2; // skip { and prefix
213
+ while (i < input.length && /[\w.]/.test(input[i]!)) i++;
214
+ if (i < input.length && input[i] === '}') {
215
+ i++; // skip }
216
+ name += input.slice(braceStart, i);
217
+ } else {
218
+ // Not a valid interpolation — stop
219
+ i = braceStart;
220
+ break;
221
+ }
222
+ } else {
223
+ break;
224
+ }
225
+ }
226
+ if (name) {
205
227
  if (prefix === '.') {
206
228
  classes.push(name);
207
229
  } else {
@@ -286,6 +308,15 @@ export function tokenize(input: string): Token[] {
286
308
  }
287
309
 
288
310
  while (i < input.length) {
311
+ // Handle escaped braces: \{ and \}
312
+ if (input[i] === '\\' && (input[i + 1] === '{' || input[i + 1] === '}')) {
313
+ flushText(i);
314
+ tokens.push({ type: 'text', value: input[i + 1]!, start: i, end: i + 2 });
315
+ i += 2;
316
+ textStart = i;
317
+ continue;
318
+ }
319
+
289
320
  // Check for [[ link
290
321
  if (input[i] === '[' && input[i + 1] === '[') {
291
322
  flushText(i);