@rohal12/spindle 0.9.0 → 0.11.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/dist/pkg/format.js +1 -1
- package/package.json +1 -1
- package/src/components/Passage.tsx +3 -2
- package/src/components/PassageLink.tsx +4 -0
- package/src/components/macros/Button.tsx +9 -1
- package/src/components/macros/Computed.tsx +57 -22
- package/src/components/macros/Do.tsx +2 -1
- package/src/components/macros/For.tsx +8 -3
- package/src/components/macros/If.tsx +9 -4
- package/src/components/macros/Include.tsx +6 -1
- package/src/components/macros/MacroLink.tsx +13 -2
- package/src/components/macros/Meter.tsx +4 -0
- package/src/components/macros/PassageDisplay.tsx +4 -0
- package/src/components/macros/Print.tsx +6 -1
- package/src/components/macros/Repeat.tsx +19 -3
- package/src/components/macros/Set.tsx +11 -5
- package/src/components/macros/Switch.tsx +3 -2
- package/src/components/macros/Timed.tsx +4 -0
- package/src/components/macros/Type.tsx +4 -0
- package/src/components/macros/Unset.tsx +6 -3
- package/src/components/macros/VarDisplay.tsx +4 -0
- package/src/components/macros/option-utils.ts +4 -1
- package/src/expression.ts +106 -2
- package/src/hooks/use-interpolate.ts +17 -0
- package/src/index.tsx +4 -1
- package/src/interpolation.ts +44 -0
- package/src/markup/render.tsx +55 -9
- package/src/markup/tokenizer.ts +35 -4
- package/src/utils/source-location.ts +19 -0
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
|
|
41
|
-
return
|
|
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(
|
|
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
|
+
}
|
package/src/markup/render.tsx
CHANGED
|
@@ -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
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
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));
|
package/src/markup/tokenizer.ts
CHANGED
|
@@ -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
|
-
|
|
202
|
-
while (i < input.length
|
|
203
|
-
|
|
204
|
-
|
|
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);
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { Passage } from '../parser';
|
|
2
|
+
import { useStoryStore } from '../store';
|
|
3
|
+
|
|
4
|
+
/** Returns e.g. " (story.twee:5)" or "" if not available. */
|
|
5
|
+
export function sourceLocationOf(passage: Passage): string {
|
|
6
|
+
const file = passage.metadata['data-source-file'];
|
|
7
|
+
const line = passage.metadata['data-source-line'];
|
|
8
|
+
if (!file) return '';
|
|
9
|
+
return line ? ` (${file}:${line})` : ` (${file})`;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Look up current passage from store and return its source location. */
|
|
13
|
+
export function currentSourceLocation(): string {
|
|
14
|
+
const state = useStoryStore.getState();
|
|
15
|
+
if (!state.storyData) return '';
|
|
16
|
+
const passage = state.storyData.passages.get(state.currentPassage);
|
|
17
|
+
if (!passage) return '';
|
|
18
|
+
return sourceLocationOf(passage);
|
|
19
|
+
}
|