@rohal12/spindle 0.52.2 → 0.52.4
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/dist/pkg/headless.js +430 -786
- package/dist/pkg/story-variables.js +179 -476
- package/package.json +1 -1
- package/src/components/App.tsx +6 -3
- package/src/components/macros/ExprDisplay.tsx +2 -7
- package/src/components/macros/MenubarButtons.tsx +13 -12
- package/src/components/macros/PassageDisplay.tsx +8 -4
- package/src/components/macros/SaveManager.tsx +8 -4
- package/src/components/macros/VarDisplay.tsx +11 -12
- package/src/components/macros/Widget.tsx +18 -7
- package/src/event-emitter.ts +15 -11
- package/src/expression.ts +44 -42
- package/src/hooks/use-merged-locals.ts +6 -4
- package/src/hooks/use-story-fields.ts +19 -0
- package/src/index.tsx +5 -8
- package/src/interpolation.ts +16 -22
- package/src/js-lexer.ts +20 -9
- package/src/markup/ast.ts +108 -147
- package/src/markup/code-attributes.ts +4 -6
- package/src/markup/tokenizer.ts +218 -574
- package/src/settings.ts +20 -22
- package/src/store.ts +2 -1
- package/src/story-api.ts +29 -77
- package/src/story-variables.ts +5 -9
- package/src/utils/live-locals.ts +2 -4
- package/src/utils/namespace.ts +89 -25
- package/src/utils/object-path.ts +1 -3
- package/src/utils/counts.ts +0 -45
package/package.json
CHANGED
package/src/components/App.tsx
CHANGED
|
@@ -1,13 +1,16 @@
|
|
|
1
1
|
import { useEffect } from 'preact/hooks';
|
|
2
2
|
import { useStoryStore } from '../store';
|
|
3
|
+
import { useStoryFields } from '../hooks/use-story-fields';
|
|
3
4
|
import { NobrContext } from '../markup/render';
|
|
4
5
|
import { StoryInterface } from './StoryInterface';
|
|
5
6
|
import { TriggerDialogHost } from './TriggerDialogHost';
|
|
6
7
|
|
|
7
8
|
export function App() {
|
|
8
|
-
const storyData =
|
|
9
|
-
|
|
10
|
-
|
|
9
|
+
const { storyData, currentPassage, nobr } = useStoryFields(
|
|
10
|
+
'storyData',
|
|
11
|
+
'currentPassage',
|
|
12
|
+
'nobr',
|
|
13
|
+
);
|
|
11
14
|
|
|
12
15
|
useEffect(() => {
|
|
13
16
|
const onKeyDown = (e: KeyboardEvent) => {
|
|
@@ -1,8 +1,6 @@
|
|
|
1
|
-
import { useContext } from 'preact/hooks';
|
|
2
|
-
import { useStoryStore } from '../../store';
|
|
3
1
|
import { evaluate } from '../../expression';
|
|
4
|
-
import { LocalsValuesContext } from '../../markup/render';
|
|
5
2
|
import { useInterpolate } from '../../hooks/use-interpolate';
|
|
3
|
+
import { useMergedLocals } from '../../hooks/use-merged-locals';
|
|
6
4
|
|
|
7
5
|
interface ExprDisplayProps {
|
|
8
6
|
expression: string;
|
|
@@ -14,10 +12,7 @@ export function ExprDisplay({ expression, className, id }: ExprDisplayProps) {
|
|
|
14
12
|
const resolve = useInterpolate();
|
|
15
13
|
className = resolve(className);
|
|
16
14
|
id = resolve(id);
|
|
17
|
-
const localsValues =
|
|
18
|
-
const variables = useStoryStore((s) => s.variables);
|
|
19
|
-
const temporary = useStoryStore((s) => s.temporary);
|
|
20
|
-
const transient = useStoryStore((s) => s.transient);
|
|
15
|
+
const [variables, temporary, localsValues, transient] = useMergedLocals();
|
|
21
16
|
|
|
22
17
|
let display: string;
|
|
23
18
|
try {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { useState } from 'preact/hooks';
|
|
2
2
|
import { useStoryStore } from '../../store';
|
|
3
|
+
import { useStoryFields } from '../../hooks/use-story-fields';
|
|
3
4
|
import { settings } from '../../settings';
|
|
4
5
|
import { defineMacro } from '../../define-macro';
|
|
5
6
|
import { PassageDialog } from '../PassageDialog';
|
|
@@ -108,13 +109,10 @@ defineMenubarAction({
|
|
|
108
109
|
name: 'quicksave',
|
|
109
110
|
label: 'QuickSave',
|
|
110
111
|
actionType: 'save',
|
|
111
|
-
setup: () =>
|
|
112
|
-
|
|
113
|
-
title: hotkeyTitle(
|
|
114
|
-
|
|
115
|
-
useStoryStore((s) => s.quickSaveKey),
|
|
116
|
-
),
|
|
117
|
-
}),
|
|
112
|
+
setup: () => {
|
|
113
|
+
const { save, quickSaveKey } = useStoryFields('save', 'quickSaveKey');
|
|
114
|
+
return { perform: save, title: hotkeyTitle('Quick Save', quickSaveKey) };
|
|
115
|
+
},
|
|
118
116
|
});
|
|
119
117
|
|
|
120
118
|
defineMenubarAction({
|
|
@@ -123,14 +121,17 @@ defineMenubarAction({
|
|
|
123
121
|
actionType: 'load',
|
|
124
122
|
confirm: 'Load saved game? Current progress will be lost.',
|
|
125
123
|
setup: () => {
|
|
126
|
-
|
|
127
|
-
const hasSave =
|
|
128
|
-
|
|
129
|
-
|
|
124
|
+
// knownSaves: re-render when the saves change, as hasSave() reads them
|
|
125
|
+
const { load, hasSave, quickLoadKey } = useStoryFields(
|
|
126
|
+
'load',
|
|
127
|
+
'hasSave',
|
|
128
|
+
'quickLoadKey',
|
|
129
|
+
'knownSaves',
|
|
130
|
+
);
|
|
130
131
|
return {
|
|
131
132
|
perform: () => load(),
|
|
132
133
|
disabled: !hasSave(),
|
|
133
|
-
title: hotkeyTitle('Quick Load',
|
|
134
|
+
title: hotkeyTitle('Quick Load', quickLoadKey),
|
|
134
135
|
};
|
|
135
136
|
},
|
|
136
137
|
});
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { useRef, useEffect, useCallback, useState } from 'preact/hooks';
|
|
2
2
|
import { useStoryStore } from '../../store';
|
|
3
|
+
import { useStoryFields } from '../../hooks/use-story-fields';
|
|
3
4
|
import { Passage, renderPassageContent } from '../Passage';
|
|
4
5
|
import { defineMacro } from '../../define-macro';
|
|
5
6
|
import { resolveTransition, type ResolvedTransition } from '../../transition';
|
|
@@ -69,10 +70,13 @@ defineMacro({
|
|
|
69
70
|
name: 'passage',
|
|
70
71
|
interpolate: true,
|
|
71
72
|
render(_props, ctx) {
|
|
72
|
-
const currentPassage
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
73
|
+
const { currentPassage, navigationId, storyData, renderDeferred } =
|
|
74
|
+
useStoryFields(
|
|
75
|
+
'currentPassage',
|
|
76
|
+
'navigationId',
|
|
77
|
+
'storyData',
|
|
78
|
+
'renderDeferred',
|
|
79
|
+
);
|
|
76
80
|
|
|
77
81
|
// Render-gating: displayed controls which passage is visually shown.
|
|
78
82
|
// The store updates immediately, but the visual swap is deferred by the
|
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
useContext,
|
|
7
7
|
} from 'preact/hooks';
|
|
8
8
|
import { useStoryStore, resolvePlaythroughId } from '../../store';
|
|
9
|
+
import { useStoryFields } from '../../hooks/use-story-fields';
|
|
9
10
|
import { isSaveExport, type SaveRecord } from '../../saves/types';
|
|
10
11
|
import {
|
|
11
12
|
getSavesGrouped,
|
|
@@ -61,10 +62,13 @@ export function SaveManagerContent() {
|
|
|
61
62
|
|
|
62
63
|
const closeDialog = useContext(DialogCloseContext);
|
|
63
64
|
|
|
64
|
-
const storyData
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
65
|
+
const { storyData, playthroughId, beginSave, loadFromPayload } =
|
|
66
|
+
useStoryFields(
|
|
67
|
+
'storyData',
|
|
68
|
+
'playthroughId',
|
|
69
|
+
'beginSave',
|
|
70
|
+
'loadFromPayload',
|
|
71
|
+
);
|
|
68
72
|
const ifid = storyData?.ifid ?? '';
|
|
69
73
|
|
|
70
74
|
const refresh = useCallback(async () => {
|
|
@@ -2,7 +2,7 @@ import { useStoryStore } from '../../store';
|
|
|
2
2
|
import { useContext } from 'preact/hooks';
|
|
3
3
|
import { LocalsValuesContext } from '../../markup/render';
|
|
4
4
|
import { useInterpolate } from '../../hooks/use-interpolate';
|
|
5
|
-
import {
|
|
5
|
+
import { ownValue, variableNameError } from '../../utils/namespace';
|
|
6
6
|
|
|
7
7
|
const SIGILS = {
|
|
8
8
|
variable: '$',
|
|
@@ -11,6 +11,12 @@ const SIGILS = {
|
|
|
11
11
|
transient: '%',
|
|
12
12
|
} as const;
|
|
13
13
|
|
|
14
|
+
const STORE_NAMESPACES = {
|
|
15
|
+
variable: 'variables',
|
|
16
|
+
temporary: 'temporary',
|
|
17
|
+
transient: 'transient',
|
|
18
|
+
} as const;
|
|
19
|
+
|
|
14
20
|
interface VarDisplayProps {
|
|
15
21
|
name: string;
|
|
16
22
|
scope: 'variable' | 'temporary' | 'local' | 'transient';
|
|
@@ -27,20 +33,13 @@ export function VarDisplay({ name, scope, className, id }: VarDisplayProps) {
|
|
|
27
33
|
const root = parts[0]!;
|
|
28
34
|
// Own entries only, as the expression engine reads them
|
|
29
35
|
const storeValue = useStoryStore((s) =>
|
|
30
|
-
scope === '
|
|
31
|
-
? ownValue(s.variables, root)
|
|
32
|
-
: scope === 'temporary'
|
|
33
|
-
? ownValue(s.temporary, root)
|
|
34
|
-
: scope === 'transient'
|
|
35
|
-
? ownValue(s.transient, root)
|
|
36
|
-
: undefined,
|
|
36
|
+
scope === 'local' ? undefined : ownValue(s[STORE_NAMESPACES[scope]], root),
|
|
37
37
|
);
|
|
38
38
|
|
|
39
|
-
|
|
39
|
+
const nameError = variableNameError(root, SIGILS[scope] + root);
|
|
40
|
+
if (nameError) {
|
|
40
41
|
return (
|
|
41
|
-
<span class="error">
|
|
42
|
-
{`{${SIGILS[scope]}${name} error: "${SIGILS[scope]}${root}" cannot be used as a variable name (${RESERVED_NAME} is reserved)}`}
|
|
43
|
-
</span>
|
|
42
|
+
<span class="error">{`{${SIGILS[scope]}${name} error: ${nameError}}`}</span>
|
|
44
43
|
);
|
|
45
44
|
}
|
|
46
45
|
|
|
@@ -5,12 +5,20 @@ import { defineMacro } from '../../define-macro';
|
|
|
5
5
|
import { checkVariableName } from '../../utils/namespace';
|
|
6
6
|
import { MacroError } from './MacroError';
|
|
7
7
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
8
|
+
/**
|
|
9
|
+
* Read the arguments of a {widget} definition: its name (quotes dropped)
|
|
10
|
+
* and its parameters, the words after it that start with one of the
|
|
11
|
+
* characters of `sigils`.
|
|
12
|
+
*/
|
|
13
|
+
export function parseWidgetDef(
|
|
14
|
+
rawArgs: string,
|
|
15
|
+
sigils = '@',
|
|
16
|
+
): { name: string; params: string[] } {
|
|
17
|
+
const [first, ...rest] = rawArgs.trim().split(/\s+/);
|
|
18
|
+
return {
|
|
19
|
+
name: first!.replace(/["']/g, ''),
|
|
20
|
+
params: rest.filter((word) => sigils.includes(word[0]!)),
|
|
21
|
+
};
|
|
14
22
|
}
|
|
15
23
|
|
|
16
24
|
defineMacro({
|
|
@@ -20,7 +28,10 @@ defineMacro({
|
|
|
20
28
|
let parsed: ReturnType<typeof parseWidgetDef> | undefined;
|
|
21
29
|
let error: unknown;
|
|
22
30
|
try {
|
|
23
|
-
|
|
31
|
+
// Refuse `@` parameters that no namespace can hold
|
|
32
|
+
const def = parseWidgetDef(rawArgs);
|
|
33
|
+
for (const param of def.params) checkVariableName(param.slice(1), param);
|
|
34
|
+
parsed = def;
|
|
24
35
|
} catch (err) {
|
|
25
36
|
error = err;
|
|
26
37
|
}
|
package/src/event-emitter.ts
CHANGED
|
@@ -57,16 +57,22 @@ export function on<E extends StoryEvent>(
|
|
|
57
57
|
};
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
60
|
+
/** Call `call` with each listener of `event`. */
|
|
61
|
+
function forEachListener(
|
|
62
|
+
event: StoryEvent,
|
|
63
|
+
call: (cb: Function) => void,
|
|
63
64
|
): void {
|
|
64
65
|
const set = listeners.get(event);
|
|
65
66
|
if (!set) return;
|
|
66
67
|
// Snapshot to tolerate unsubscription during iteration
|
|
67
|
-
for (const cb of [...set])
|
|
68
|
-
|
|
69
|
-
|
|
68
|
+
for (const cb of [...set]) call(cb);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function emit<E extends StoryEvent>(
|
|
72
|
+
event: E,
|
|
73
|
+
...args: Parameters<EventMap[E]>
|
|
74
|
+
): void {
|
|
75
|
+
forEachListener(event, (cb) => cb(...args));
|
|
70
76
|
}
|
|
71
77
|
|
|
72
78
|
/**
|
|
@@ -78,15 +84,13 @@ export function emitFromRender<E extends StoryEvent>(
|
|
|
78
84
|
event: E,
|
|
79
85
|
...args: Parameters<EventMap[E]>
|
|
80
86
|
): void {
|
|
81
|
-
|
|
82
|
-
if (!set) return;
|
|
83
|
-
for (const cb of [...set]) {
|
|
87
|
+
forEachListener(event, (cb) => {
|
|
84
88
|
try {
|
|
85
|
-
|
|
89
|
+
cb(...args);
|
|
86
90
|
} catch (err) {
|
|
87
91
|
console.error(`spindle: Error in ${event} handler:`, err);
|
|
88
92
|
}
|
|
89
|
-
}
|
|
93
|
+
});
|
|
90
94
|
}
|
|
91
95
|
|
|
92
96
|
/** Test-only: clear all listeners. */
|
package/src/expression.ts
CHANGED
|
@@ -5,12 +5,13 @@ import { random, randomInt } from './prng';
|
|
|
5
5
|
import { lexJs, type JsGoal, type Sigil } from './js-lexer';
|
|
6
6
|
import {
|
|
7
7
|
EMPTY_NAMESPACE,
|
|
8
|
-
RESERVED_NAME,
|
|
9
8
|
asNamespace,
|
|
9
|
+
countOf,
|
|
10
10
|
isNamespace,
|
|
11
|
+
variableNameError,
|
|
12
|
+
type Counts,
|
|
11
13
|
type Namespace,
|
|
12
14
|
} from './utils/namespace';
|
|
13
|
-
import { countOf, type Counts } from './utils/counts';
|
|
14
15
|
|
|
15
16
|
interface ExpressionFns {
|
|
16
17
|
currentPassage: () => Passage | undefined;
|
|
@@ -73,11 +74,8 @@ export function transform(expr: string, goal: JsGoal = 'expression'): string {
|
|
|
73
74
|
result += text;
|
|
74
75
|
},
|
|
75
76
|
variable(sigil, name) {
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
`spindle: "${sigil}${name}" cannot be used as a variable name (${RESERVED_NAME} is reserved)`,
|
|
79
|
-
);
|
|
80
|
-
}
|
|
77
|
+
const error = variableNameError(name, sigil + name);
|
|
78
|
+
if (error) throw new SyntaxError(`spindle: ${error}`);
|
|
81
79
|
// `typeof%x` needs a space once `%x` turns into an identifier.
|
|
82
80
|
if (IDENT_END_RE.test(result.slice(-2))) result += ' ';
|
|
83
81
|
result += `${NAMESPACES[sigil]}["${name}"]`;
|
|
@@ -116,6 +114,35 @@ function getOrCompile(key: string, body: string): CompiledExpression {
|
|
|
116
114
|
return fn;
|
|
117
115
|
}
|
|
118
116
|
|
|
117
|
+
/** The passage being shown. */
|
|
118
|
+
export function currentPassage(): Passage | undefined {
|
|
119
|
+
const s = useStoryStore.getState();
|
|
120
|
+
return s.storyData?.passages.get(s.currentPassage);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** The passage shown before the current one in history. */
|
|
124
|
+
export function previousPassage(): Passage | undefined {
|
|
125
|
+
const s = useStoryStore.getState();
|
|
126
|
+
if (s.historyIndex <= 0) return undefined;
|
|
127
|
+
const prevName = s.history[s.historyIndex - 1]?.passage;
|
|
128
|
+
return prevName ? s.storyData?.passages.get(prevName) : undefined;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* visited()/rendered() and their has-forms, counting in the counters
|
|
133
|
+
* `counts()` returns. A missing name means the current passage.
|
|
134
|
+
*/
|
|
135
|
+
export function countQueries(counts: () => Counts) {
|
|
136
|
+
const count = (name?: string): number =>
|
|
137
|
+
countOf(counts(), name ?? useStoryStore.getState().currentPassage);
|
|
138
|
+
return {
|
|
139
|
+
count,
|
|
140
|
+
has: (name?: string): boolean => count(name) > 0,
|
|
141
|
+
any: (...names: string[]): boolean => names.some((n) => count(n) > 0),
|
|
142
|
+
all: (...names: string[]): boolean => names.every((n) => count(n) > 0),
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
119
146
|
let cachedFns: ExpressionFns | null = null;
|
|
120
147
|
let cachedVisitCounts: Counts | null = null;
|
|
121
148
|
let cachedRenderCounts: Counts | null = null;
|
|
@@ -132,44 +159,19 @@ export function buildExpressionFns() {
|
|
|
132
159
|
return cachedFns;
|
|
133
160
|
}
|
|
134
161
|
|
|
135
|
-
const
|
|
136
|
-
|
|
137
|
-
const hasVisited = (name?: string): boolean => visited(name) > 0;
|
|
138
|
-
const hasVisitedAny = (...names: string[]): boolean =>
|
|
139
|
-
names.some((n) => visited(n) > 0);
|
|
140
|
-
const hasVisitedAll = (...names: string[]): boolean =>
|
|
141
|
-
names.every((n) => visited(n) > 0);
|
|
142
|
-
|
|
143
|
-
const rendered = (name?: string): number =>
|
|
144
|
-
countOf(renderCounts, name ?? useStoryStore.getState().currentPassage);
|
|
145
|
-
const hasRendered = (name?: string): boolean => rendered(name) > 0;
|
|
146
|
-
const hasRenderedAny = (...names: string[]): boolean =>
|
|
147
|
-
names.some((n) => rendered(n) > 0);
|
|
148
|
-
const hasRenderedAll = (...names: string[]): boolean =>
|
|
149
|
-
names.every((n) => rendered(n) > 0);
|
|
150
|
-
|
|
151
|
-
const currentPassage = (): Passage | undefined => {
|
|
152
|
-
const s = useStoryStore.getState();
|
|
153
|
-
return s.storyData?.passages.get(s.currentPassage);
|
|
154
|
-
};
|
|
155
|
-
const previousPassage = (): Passage | undefined => {
|
|
156
|
-
const s = useStoryStore.getState();
|
|
157
|
-
if (s.historyIndex <= 0) return undefined;
|
|
158
|
-
const prevName = s.history[s.historyIndex - 1]?.passage;
|
|
159
|
-
return prevName ? s.storyData?.passages.get(prevName) : undefined;
|
|
160
|
-
};
|
|
161
|
-
|
|
162
|
+
const visits = countQueries(() => visitCounts);
|
|
163
|
+
const renders = countQueries(() => renderCounts);
|
|
162
164
|
cachedFns = {
|
|
163
165
|
currentPassage,
|
|
164
166
|
previousPassage,
|
|
165
|
-
visited,
|
|
166
|
-
hasVisited,
|
|
167
|
-
hasVisitedAny,
|
|
168
|
-
hasVisitedAll,
|
|
169
|
-
rendered,
|
|
170
|
-
hasRendered,
|
|
171
|
-
hasRenderedAny,
|
|
172
|
-
hasRenderedAll,
|
|
167
|
+
visited: visits.count,
|
|
168
|
+
hasVisited: visits.has,
|
|
169
|
+
hasVisitedAny: visits.any,
|
|
170
|
+
hasVisitedAll: visits.all,
|
|
171
|
+
rendered: renders.count,
|
|
172
|
+
hasRendered: renders.has,
|
|
173
|
+
hasRenderedAny: renders.any,
|
|
174
|
+
hasRenderedAll: renders.all,
|
|
173
175
|
random,
|
|
174
176
|
randomInt,
|
|
175
177
|
};
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { useContext, useMemo } from 'preact/hooks';
|
|
2
|
-
import {
|
|
2
|
+
import { useStoryFields } from './use-story-fields';
|
|
3
3
|
import { LocalsValuesContext } from '../markup/render';
|
|
4
4
|
|
|
5
5
|
/**
|
|
@@ -12,9 +12,11 @@ export function useMergedLocals(): readonly [
|
|
|
12
12
|
Record<string, unknown>,
|
|
13
13
|
Record<string, unknown>,
|
|
14
14
|
] {
|
|
15
|
-
const variables =
|
|
16
|
-
|
|
17
|
-
|
|
15
|
+
const { variables, temporary, transient } = useStoryFields(
|
|
16
|
+
'variables',
|
|
17
|
+
'temporary',
|
|
18
|
+
'transient',
|
|
19
|
+
);
|
|
18
20
|
const localsValues = useContext(LocalsValuesContext);
|
|
19
21
|
|
|
20
22
|
return useMemo(() => {
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { useRef } from 'preact/hooks';
|
|
2
|
+
import { shallow } from 'zustand/vanilla/shallow';
|
|
3
|
+
import { useStoryStore, type StoryState } from '../store';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Story state fields `keys`, re-rendering when any of them changes (as one
|
|
7
|
+
* useStoryStore() selector per field would). The result keeps its identity
|
|
8
|
+
* while the fields don't change, as the store's snapshot must.
|
|
9
|
+
*/
|
|
10
|
+
export function useStoryFields<K extends keyof StoryState>(
|
|
11
|
+
...keys: K[]
|
|
12
|
+
): Pick<StoryState, K> {
|
|
13
|
+
const prev = useRef<Pick<StoryState, K>>();
|
|
14
|
+
return useStoryStore((s) => {
|
|
15
|
+
const next = {} as Pick<StoryState, K>;
|
|
16
|
+
for (const key of keys) next[key] = s[key];
|
|
17
|
+
return shallow(prev.current, next) ? prev.current! : (prev.current = next);
|
|
18
|
+
});
|
|
19
|
+
}
|
package/src/index.tsx
CHANGED
|
@@ -21,6 +21,7 @@ import { tokenize } from './markup/tokenizer';
|
|
|
21
21
|
import { buildAST, registerBlockMacro } from './markup/ast';
|
|
22
22
|
import { registerWidget } from './widgets/widget-registry';
|
|
23
23
|
import { astContainsChildren } from './widgets/ast-scanner';
|
|
24
|
+
import { parseWidgetDef } from './components/macros/Widget';
|
|
24
25
|
import { errorMessage } from './utils/error-message';
|
|
25
26
|
import type { ASTNode } from './markup/ast';
|
|
26
27
|
import './macros/register-builtins';
|
|
@@ -173,14 +174,10 @@ export function boot() {
|
|
|
173
174
|
const widgetAST = buildAST(widgetTokens);
|
|
174
175
|
for (const node of widgetAST) {
|
|
175
176
|
if (node.type === 'macro' && node.name === 'widget' && node.rawArgs) {
|
|
176
|
-
const
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
.filter(
|
|
181
|
-
(t) =>
|
|
182
|
-
t.startsWith('$') || t.startsWith('_') || t.startsWith('@'),
|
|
183
|
-
);
|
|
177
|
+
const { name: widgetName, params } = parseWidgetDef(
|
|
178
|
+
node.rawArgs,
|
|
179
|
+
'$_@',
|
|
180
|
+
);
|
|
184
181
|
const children = node.children as ASTNode[];
|
|
185
182
|
const isBlock = astContainsChildren(children);
|
|
186
183
|
try {
|
package/src/interpolation.ts
CHANGED
|
@@ -14,7 +14,13 @@
|
|
|
14
14
|
* string, so it is reported instead of being run or shown as source.
|
|
15
15
|
*/
|
|
16
16
|
import { evaluate } from './expression';
|
|
17
|
-
import {
|
|
17
|
+
import {
|
|
18
|
+
SCOPE_SIGILS,
|
|
19
|
+
SIGIL_SCOPES,
|
|
20
|
+
tokenize,
|
|
21
|
+
type VariableScope,
|
|
22
|
+
} from './markup/tokenizer';
|
|
23
|
+
import type { Sigil } from './js-lexer';
|
|
18
24
|
import { buildAST } from './markup/ast';
|
|
19
25
|
import type { ASTNode, MacroNode, TextNode } from './markup/ast';
|
|
20
26
|
import { getMacro, getMacroText, isSubMacro } from './registry';
|
|
@@ -116,15 +122,6 @@ function display(value: unknown): string {
|
|
|
116
122
|
return value == null ? '' : String(value);
|
|
117
123
|
}
|
|
118
124
|
|
|
119
|
-
type VariableKind = 'variable' | 'temporary' | 'local' | 'transient';
|
|
120
|
-
|
|
121
|
-
const KIND_SIGILS = {
|
|
122
|
-
variable: '$',
|
|
123
|
-
temporary: '_',
|
|
124
|
-
local: '@',
|
|
125
|
-
transient: '%',
|
|
126
|
-
} as const;
|
|
127
|
-
|
|
128
125
|
/**
|
|
129
126
|
* The value of a variable reference (`name` without its sigil, with an
|
|
130
127
|
* optional dot path), as `{$name}` shows it in passage text (see
|
|
@@ -133,12 +130,12 @@ const KIND_SIGILS = {
|
|
|
133
130
|
*/
|
|
134
131
|
function resolveVariable(
|
|
135
132
|
scope: TextScope,
|
|
136
|
-
kind:
|
|
133
|
+
kind: VariableScope,
|
|
137
134
|
name: string,
|
|
138
135
|
): unknown {
|
|
139
136
|
const parts = name.split('.');
|
|
140
137
|
const root = parts[0]!;
|
|
141
|
-
checkVariableName(root,
|
|
138
|
+
checkVariableName(root, SCOPE_SIGILS[kind] + root);
|
|
142
139
|
const store =
|
|
143
140
|
kind === 'variable'
|
|
144
141
|
? scope.variables
|
|
@@ -210,12 +207,16 @@ class TextEvaluator {
|
|
|
210
207
|
}
|
|
211
208
|
|
|
212
209
|
/** A variable reference, or '' and an error if it can't be one. */
|
|
213
|
-
private variable(
|
|
210
|
+
private variable(
|
|
211
|
+
kind: VariableScope,
|
|
212
|
+
name: string,
|
|
213
|
+
scope: TextScope,
|
|
214
|
+
): string {
|
|
214
215
|
try {
|
|
215
216
|
return display(resolveVariable(scope, kind, name));
|
|
216
217
|
} catch (error) {
|
|
217
218
|
// Reported as passage text reports it: `{$__proto__ error: …}`
|
|
218
|
-
this.errors.push({ macro:
|
|
219
|
+
this.errors.push({ macro: SCOPE_SIGILS[kind] + name, error });
|
|
219
220
|
return '';
|
|
220
221
|
}
|
|
221
222
|
}
|
|
@@ -341,13 +342,6 @@ export function interpolateText(
|
|
|
341
342
|
return renderText(parsed.nodes, scope);
|
|
342
343
|
}
|
|
343
344
|
|
|
344
|
-
const SIGIL_KINDS = {
|
|
345
|
-
$: 'variable',
|
|
346
|
-
_: 'temporary',
|
|
347
|
-
'@': 'local',
|
|
348
|
-
'%': 'transient',
|
|
349
|
-
} as const;
|
|
350
|
-
|
|
351
345
|
/**
|
|
352
346
|
* Resolve the sigil references in the value of a code attribute (see
|
|
353
347
|
* isCodeAttribute): `{$x}`, `{_x.y}`, `{$n + 1}`. Everything else, other
|
|
@@ -367,7 +361,7 @@ export function interpolateCode(
|
|
|
367
361
|
} else if ('verbatim' in part) {
|
|
368
362
|
text += part.verbatim;
|
|
369
363
|
} else if (/^[$_@%][\w.]+$/.test(part.expr)) {
|
|
370
|
-
const kind =
|
|
364
|
+
const kind = SIGIL_SCOPES[part.expr[0] as Sigil];
|
|
371
365
|
try {
|
|
372
366
|
text += display(resolveVariable(scope, kind, part.expr.slice(1)));
|
|
373
367
|
} catch (error) {
|
package/src/js-lexer.ts
CHANGED
|
@@ -145,15 +145,14 @@ function scanRegex(
|
|
|
145
145
|
};
|
|
146
146
|
let i = start + 1;
|
|
147
147
|
while (i < src.length) {
|
|
148
|
+
// Unterminated at the end of the line: leave the rest to the parser
|
|
149
|
+
const lineEnd = regexLineEnd(src, i);
|
|
150
|
+
if (lineEnd !== -1) return done(lineEnd, false);
|
|
148
151
|
const c = src.charAt(i);
|
|
149
152
|
if (c === '\\') {
|
|
150
|
-
// An escaped line break ends the line, and the regex, too
|
|
151
|
-
if (LINE_TERMINATOR_RE.test(src.charAt(i + 1))) return done(i + 1, false);
|
|
152
153
|
i += 2;
|
|
153
154
|
continue;
|
|
154
155
|
}
|
|
155
|
-
// Unterminated at the end of the line: leave the rest to the parser
|
|
156
|
-
if (LINE_TERMINATOR_RE.test(c)) return done(i, false);
|
|
157
156
|
if (c === '[') {
|
|
158
157
|
// A class, where `/` does not close
|
|
159
158
|
i = scanRegexClass(src, i, cache);
|
|
@@ -191,16 +190,17 @@ function scanRegexClass(
|
|
|
191
190
|
const opens = [open];
|
|
192
191
|
let i = open + 1;
|
|
193
192
|
while (i < src.length) {
|
|
193
|
+
const lineEnd = regexLineEnd(src, i);
|
|
194
|
+
if (lineEnd !== -1) {
|
|
195
|
+
i = lineEnd;
|
|
196
|
+
break;
|
|
197
|
+
}
|
|
194
198
|
const c = src.charAt(i);
|
|
195
199
|
if (c === '\\') {
|
|
196
|
-
if (LINE_TERMINATOR_RE.test(src.charAt(i + 1))) {
|
|
197
|
-
i++;
|
|
198
|
-
break;
|
|
199
|
-
}
|
|
200
200
|
i += 2;
|
|
201
201
|
continue;
|
|
202
202
|
}
|
|
203
|
-
if (c === ']'
|
|
203
|
+
if (c === ']') break;
|
|
204
204
|
if (c === '[' && cache) opens.push(i);
|
|
205
205
|
i++;
|
|
206
206
|
}
|
|
@@ -209,6 +209,17 @@ function scanRegexClass(
|
|
|
209
209
|
return i;
|
|
210
210
|
}
|
|
211
211
|
|
|
212
|
+
/**
|
|
213
|
+
* Index of the line break at `i`, escaped (`\` then a line break) or not,
|
|
214
|
+
* where a regex literal or class ends unterminated; -1 if there is none.
|
|
215
|
+
*/
|
|
216
|
+
function regexLineEnd(src: string, i: number): number {
|
|
217
|
+
const c = src.charAt(i);
|
|
218
|
+
if (LINE_TERMINATOR_RE.test(c)) return i;
|
|
219
|
+
if (c === '\\' && LINE_TERMINATOR_RE.test(src.charAt(i + 1))) return i + 1;
|
|
220
|
+
return -1;
|
|
221
|
+
}
|
|
222
|
+
|
|
212
223
|
/**
|
|
213
224
|
* The next index from `from` on where `find` matches (-1 for none), for
|
|
214
225
|
* searches from increasing positions: a search from within the stretch the
|