@rohal12/spindle 0.1.0 → 0.2.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 +3 -2
- package/src/class-registry.ts +189 -0
- package/src/components/StoryInterface.tsx +2 -1
- package/src/components/macros/Button.tsx +3 -2
- package/src/components/macros/Cycle.tsx +1 -1
- package/src/components/macros/Do.tsx +3 -2
- package/src/components/macros/Listbox.tsx +1 -3
- package/src/components/macros/MacroLink.tsx +3 -2
- package/src/components/macros/Meter.tsx +130 -0
- package/src/components/macros/Numberbox.tsx +1 -3
- package/src/components/macros/Set.tsx +3 -2
- package/src/components/macros/Textarea.tsx +1 -3
- package/src/components/macros/Textbox.tsx +1 -3
- package/src/components/macros/Type.tsx +2 -9
- package/src/index.tsx +1 -5
- package/src/markup/ast.ts +3 -6
- package/src/markup/markdown.ts +1 -4
- package/src/markup/render.tsx +13 -6
- package/src/markup/tokenizer.ts +66 -8
- package/src/saves/save-manager.ts +24 -4
- package/src/store.ts +17 -13
- package/src/story-api.ts +6 -0
- package/src/story-init.ts +3 -2
- package/src/styles.css +31 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rohal12/spindle",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "A Preact-based story format for Twine 2.",
|
|
6
6
|
"license": "Unlicense",
|
|
@@ -40,6 +40,7 @@
|
|
|
40
40
|
"test": "vitest run",
|
|
41
41
|
"test:watch": "vitest",
|
|
42
42
|
"test:e2e": "vitest run --config vitest.e2e.config.ts",
|
|
43
|
+
"typecheck": "tsc --noEmit",
|
|
43
44
|
"format": "prettier --write .",
|
|
44
45
|
"format:check": "prettier --check .",
|
|
45
46
|
"docs:dev": "vitepress dev docs",
|
|
@@ -61,7 +62,7 @@
|
|
|
61
62
|
"happy-dom": "^20.7.0",
|
|
62
63
|
"playwright": "^1.58.2",
|
|
63
64
|
"prettier": "^3.8.1",
|
|
64
|
-
"
|
|
65
|
+
"@rohal12/twee-ts": "^1.0.0",
|
|
65
66
|
"typescript": "^5.7.0",
|
|
66
67
|
"vite": "^6.0.0",
|
|
67
68
|
"vite-plugin-singlefile": "^2.0.0",
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
// Class registry for preserving class instances across clone/save/load cycles.
|
|
2
|
+
|
|
3
|
+
type Constructor = new (...args: any[]) => any;
|
|
4
|
+
|
|
5
|
+
const registry = new Map<string, Constructor>();
|
|
6
|
+
const ctorToName = new Map<Constructor, string>();
|
|
7
|
+
|
|
8
|
+
export function registerClass(name: string, ctor: Constructor): void {
|
|
9
|
+
registry.set(name, ctor);
|
|
10
|
+
ctorToName.set(ctor, name);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function getClassName(ctor: Constructor): string | undefined {
|
|
14
|
+
return ctorToName.get(ctor);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function clearRegistry(): void {
|
|
18
|
+
registry.clear();
|
|
19
|
+
ctorToName.clear();
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// --- Deep Clone ---
|
|
23
|
+
|
|
24
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
25
|
+
if (typeof value !== 'object' || value === null) return false;
|
|
26
|
+
const proto = Object.getPrototypeOf(value);
|
|
27
|
+
return proto === Object.prototype || proto === null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function deepClone<T>(value: T): T {
|
|
31
|
+
const seen = new Map<object, object>();
|
|
32
|
+
|
|
33
|
+
function clone(val: unknown): unknown {
|
|
34
|
+
if (val === null || typeof val !== 'object') return val;
|
|
35
|
+
|
|
36
|
+
const obj = val as object;
|
|
37
|
+
if (seen.has(obj)) return seen.get(obj);
|
|
38
|
+
|
|
39
|
+
if (val instanceof Date) return new Date(val.getTime()) as unknown;
|
|
40
|
+
if (val instanceof RegExp)
|
|
41
|
+
return new RegExp(val.source, val.flags) as unknown;
|
|
42
|
+
|
|
43
|
+
if (Array.isArray(val)) {
|
|
44
|
+
const arr: unknown[] = [];
|
|
45
|
+
seen.set(obj, arr);
|
|
46
|
+
for (let i = 0; i < val.length; i++) {
|
|
47
|
+
arr[i] = clone(val[i]);
|
|
48
|
+
}
|
|
49
|
+
return arr;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Registered class instance
|
|
53
|
+
const ctor = obj.constructor as Constructor;
|
|
54
|
+
const name = ctorToName.get(ctor);
|
|
55
|
+
if (name !== undefined) {
|
|
56
|
+
const copy = Object.create(Object.getPrototypeOf(obj)) as Record<
|
|
57
|
+
string,
|
|
58
|
+
unknown
|
|
59
|
+
>;
|
|
60
|
+
seen.set(obj, copy);
|
|
61
|
+
for (const key of Object.keys(obj)) {
|
|
62
|
+
copy[key] = clone((obj as Record<string, unknown>)[key]);
|
|
63
|
+
}
|
|
64
|
+
return copy;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Plain object (or unregistered class — treat as plain)
|
|
68
|
+
if (isPlainObject(val) || typeof val === 'object') {
|
|
69
|
+
const copy: Record<string, unknown> = {};
|
|
70
|
+
seen.set(obj, copy);
|
|
71
|
+
for (const key of Object.keys(obj)) {
|
|
72
|
+
copy[key] = clone((obj as Record<string, unknown>)[key]);
|
|
73
|
+
}
|
|
74
|
+
return copy;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return val;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return clone(value) as T;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// --- Serialize ---
|
|
84
|
+
|
|
85
|
+
const CLASS_TAG = '__spindle_class__';
|
|
86
|
+
const DATA_TAG = '__spindle_data__';
|
|
87
|
+
|
|
88
|
+
export function serialize<T>(value: T): T {
|
|
89
|
+
const seen = new Set<object>();
|
|
90
|
+
|
|
91
|
+
function ser(val: unknown): unknown {
|
|
92
|
+
if (val === null || typeof val !== 'object') return val;
|
|
93
|
+
|
|
94
|
+
const obj = val as object;
|
|
95
|
+
if (seen.has(obj)) {
|
|
96
|
+
throw new Error('spindle: Cannot serialize circular references');
|
|
97
|
+
}
|
|
98
|
+
seen.add(obj);
|
|
99
|
+
|
|
100
|
+
if (val instanceof Date) {
|
|
101
|
+
seen.delete(obj);
|
|
102
|
+
return val.toISOString();
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (val instanceof RegExp) {
|
|
106
|
+
seen.delete(obj);
|
|
107
|
+
return val.toString();
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (Array.isArray(val)) {
|
|
111
|
+
const result = val.map((item) => ser(item));
|
|
112
|
+
seen.delete(obj);
|
|
113
|
+
return result;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Registered class instance
|
|
117
|
+
const ctor = obj.constructor as Constructor;
|
|
118
|
+
const name = ctorToName.get(ctor);
|
|
119
|
+
if (name !== undefined) {
|
|
120
|
+
const data: Record<string, unknown> = {};
|
|
121
|
+
for (const key of Object.keys(obj)) {
|
|
122
|
+
data[key] = ser((obj as Record<string, unknown>)[key]);
|
|
123
|
+
}
|
|
124
|
+
seen.delete(obj);
|
|
125
|
+
return { [CLASS_TAG]: name, [DATA_TAG]: data };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Plain object
|
|
129
|
+
const result: Record<string, unknown> = {};
|
|
130
|
+
for (const key of Object.keys(obj)) {
|
|
131
|
+
result[key] = ser((obj as Record<string, unknown>)[key]);
|
|
132
|
+
}
|
|
133
|
+
seen.delete(obj);
|
|
134
|
+
return result;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return ser(value) as T;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// --- Deserialize ---
|
|
141
|
+
|
|
142
|
+
export function deserialize<T>(value: T): T {
|
|
143
|
+
function deser(val: unknown): unknown {
|
|
144
|
+
if (val === null || typeof val !== 'object') return val;
|
|
145
|
+
|
|
146
|
+
if (Array.isArray(val)) {
|
|
147
|
+
return val.map((item) => deser(item));
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const obj = val as Record<string, unknown>;
|
|
151
|
+
|
|
152
|
+
// Tagged class instance (from serialized data)
|
|
153
|
+
if (CLASS_TAG in obj && DATA_TAG in obj) {
|
|
154
|
+
const name = obj[CLASS_TAG] as string;
|
|
155
|
+
const data = obj[DATA_TAG] as Record<string, unknown>;
|
|
156
|
+
const ctor = registry.get(name);
|
|
157
|
+
if (!ctor) {
|
|
158
|
+
console.warn(
|
|
159
|
+
`spindle: Class "${name}" not registered. Falling back to plain object.`,
|
|
160
|
+
);
|
|
161
|
+
const plain: Record<string, unknown> = {};
|
|
162
|
+
for (const key of Object.keys(data)) {
|
|
163
|
+
plain[key] = deser(data[key]);
|
|
164
|
+
}
|
|
165
|
+
return plain;
|
|
166
|
+
}
|
|
167
|
+
const instance = Object.create(ctor.prototype) as Record<string, unknown>;
|
|
168
|
+
for (const key of Object.keys(data)) {
|
|
169
|
+
instance[key] = deser(data[key]);
|
|
170
|
+
}
|
|
171
|
+
return instance;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Already-live registered class instance — pass through as-is
|
|
175
|
+
const ctor = (obj as object).constructor as Constructor;
|
|
176
|
+
if (ctorToName.has(ctor)) {
|
|
177
|
+
return val;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Plain object
|
|
181
|
+
const result: Record<string, unknown> = {};
|
|
182
|
+
for (const key of Object.keys(obj)) {
|
|
183
|
+
result[key] = deser(obj[key]);
|
|
184
|
+
}
|
|
185
|
+
return result;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
return deser(value) as T;
|
|
189
|
+
}
|
|
@@ -4,7 +4,8 @@ import { tokenize } from '../markup/tokenizer';
|
|
|
4
4
|
import { buildAST } from '../markup/ast';
|
|
5
5
|
import { renderNodes } from '../markup/render';
|
|
6
6
|
|
|
7
|
-
const DEFAULT_MARKUP =
|
|
7
|
+
const DEFAULT_MARKUP =
|
|
8
|
+
'{story-title}{back}{forward}{restart}{quicksave}{quickload}{saves}{settings}';
|
|
8
9
|
|
|
9
10
|
export function StoryInterface() {
|
|
10
11
|
const storyData = useStoryStore((s) => s.storyData);
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { useStoryStore } from '../../store';
|
|
2
2
|
import { execute } from '../../expression';
|
|
3
3
|
import { renderInlineNodes } from '../../markup/render';
|
|
4
|
+
import { deepClone } from '../../class-registry';
|
|
4
5
|
import type { ASTNode } from '../../markup/ast';
|
|
5
6
|
|
|
6
7
|
interface ButtonProps {
|
|
@@ -13,8 +14,8 @@ interface ButtonProps {
|
|
|
13
14
|
export function Button({ rawArgs, children, className, id }: ButtonProps) {
|
|
14
15
|
const handleClick = () => {
|
|
15
16
|
const state = useStoryStore.getState();
|
|
16
|
-
const vars =
|
|
17
|
-
const temps =
|
|
17
|
+
const vars = deepClone(state.variables);
|
|
18
|
+
const temps = deepClone(state.temporary);
|
|
18
19
|
|
|
19
20
|
try {
|
|
20
21
|
execute(rawArgs, vars, temps);
|
|
@@ -2,6 +2,7 @@ import { useLayoutEffect } from 'preact/hooks';
|
|
|
2
2
|
import { useStoryStore } from '../../store';
|
|
3
3
|
import { execute } from '../../expression';
|
|
4
4
|
import type { ASTNode } from '../../markup/ast';
|
|
5
|
+
import { deepClone } from '../../class-registry';
|
|
5
6
|
|
|
6
7
|
interface DoProps {
|
|
7
8
|
children: ASTNode[];
|
|
@@ -19,8 +20,8 @@ export function Do({ children }: DoProps) {
|
|
|
19
20
|
|
|
20
21
|
useLayoutEffect(() => {
|
|
21
22
|
const state = useStoryStore.getState();
|
|
22
|
-
const vars =
|
|
23
|
-
const temps =
|
|
23
|
+
const vars = deepClone(state.variables);
|
|
24
|
+
const temps = deepClone(state.temporary);
|
|
24
25
|
|
|
25
26
|
try {
|
|
26
27
|
execute(code, vars, temps);
|
|
@@ -25,9 +25,7 @@ export function Listbox({ rawArgs, children, className, id }: ListboxProps) {
|
|
|
25
25
|
id={id}
|
|
26
26
|
class={cls}
|
|
27
27
|
value={value == null ? '' : String(value)}
|
|
28
|
-
onChange={(e) =>
|
|
29
|
-
setVariable(name, (e.target as HTMLSelectElement).value)
|
|
30
|
-
}
|
|
28
|
+
onChange={(e) => setVariable(name, (e.target as HTMLSelectElement).value)}
|
|
31
29
|
>
|
|
32
30
|
{options.map((opt) => (
|
|
33
31
|
<option
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { useStoryStore } from '../../store';
|
|
2
2
|
import { execute } from '../../expression';
|
|
3
3
|
import type { ASTNode } from '../../markup/ast';
|
|
4
|
+
import { deepClone } from '../../class-registry';
|
|
4
5
|
|
|
5
6
|
interface MacroLinkProps {
|
|
6
7
|
rawArgs: string;
|
|
@@ -42,8 +43,8 @@ function collectText(nodes: ASTNode[]): string {
|
|
|
42
43
|
*/
|
|
43
44
|
function executeChildren(children: ASTNode[]) {
|
|
44
45
|
const state = useStoryStore.getState();
|
|
45
|
-
const vars =
|
|
46
|
-
const temps =
|
|
46
|
+
const vars = deepClone(state.variables);
|
|
47
|
+
const temps = deepClone(state.temporary);
|
|
47
48
|
|
|
48
49
|
for (const node of children) {
|
|
49
50
|
if (node.type !== 'macro') continue;
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { useStoryStore } from '../../store';
|
|
2
|
+
import { useContext } from 'preact/hooks';
|
|
3
|
+
import { evaluate } from '../../expression';
|
|
4
|
+
import { LocalsContext } from '../../markup/render';
|
|
5
|
+
|
|
6
|
+
interface MeterProps {
|
|
7
|
+
rawArgs: string;
|
|
8
|
+
className?: string;
|
|
9
|
+
id?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Parse rawArgs into currentExpr, maxExpr, and optional labelMode.
|
|
14
|
+
* Supports: {meter $current $max}, {meter $current $max "%"}, etc.
|
|
15
|
+
*/
|
|
16
|
+
function parseArgs(rawArgs: string): {
|
|
17
|
+
currentExpr: string;
|
|
18
|
+
maxExpr: string;
|
|
19
|
+
labelMode: string;
|
|
20
|
+
} {
|
|
21
|
+
const trimmed = rawArgs.trim();
|
|
22
|
+
|
|
23
|
+
// Extract quoted label mode from the end if present
|
|
24
|
+
let labelMode = '';
|
|
25
|
+
let rest = trimmed;
|
|
26
|
+
const quoteMatch = rest.match(/\s+(?:"([^"]*)"|'([^']*)')$/);
|
|
27
|
+
if (quoteMatch) {
|
|
28
|
+
labelMode = quoteMatch[1] ?? quoteMatch[2];
|
|
29
|
+
rest = rest.slice(0, quoteMatch.index!).trim();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Split remaining into two expressions.
|
|
33
|
+
// Expressions can contain dots, brackets, parens — we need to find the split point.
|
|
34
|
+
// Strategy: walk tokens, splitting on whitespace that isn't inside parens/brackets.
|
|
35
|
+
const exprs = splitExpressions(rest);
|
|
36
|
+
if (exprs.length < 2) {
|
|
37
|
+
throw new Error(
|
|
38
|
+
'meter requires two arguments: {meter currentExpr maxExpr}',
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return {
|
|
43
|
+
currentExpr: exprs[0],
|
|
44
|
+
maxExpr: exprs.slice(1).join(' '),
|
|
45
|
+
labelMode,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function splitExpressions(input: string): string[] {
|
|
50
|
+
const result: string[] = [];
|
|
51
|
+
let current = '';
|
|
52
|
+
let depth = 0;
|
|
53
|
+
|
|
54
|
+
for (let i = 0; i < input.length; i++) {
|
|
55
|
+
const ch = input[i];
|
|
56
|
+
if (ch === '(' || ch === '[') {
|
|
57
|
+
depth++;
|
|
58
|
+
current += ch;
|
|
59
|
+
} else if (ch === ')' || ch === ']') {
|
|
60
|
+
depth--;
|
|
61
|
+
current += ch;
|
|
62
|
+
} else if (/\s/.test(ch) && depth === 0 && current.length > 0) {
|
|
63
|
+
result.push(current);
|
|
64
|
+
current = '';
|
|
65
|
+
// Skip additional whitespace
|
|
66
|
+
while (i + 1 < input.length && /\s/.test(input[i + 1])) i++;
|
|
67
|
+
} else {
|
|
68
|
+
current += ch;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
if (current.length > 0) result.push(current);
|
|
72
|
+
return result;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function formatLabel(
|
|
76
|
+
current: number,
|
|
77
|
+
max: number,
|
|
78
|
+
labelMode: string,
|
|
79
|
+
): string | null {
|
|
80
|
+
if (labelMode === 'none') return null;
|
|
81
|
+
if (labelMode === '%') return `${Math.round((current / max) * 100)}%`;
|
|
82
|
+
if (labelMode) return `${current} ${labelMode} / ${max} ${labelMode}`;
|
|
83
|
+
return `${current} / ${max}`;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function Meter({ rawArgs, className, id }: MeterProps) {
|
|
87
|
+
const variables = useStoryStore((s) => s.variables);
|
|
88
|
+
const temporary = useStoryStore((s) => s.temporary);
|
|
89
|
+
const locals = useContext(LocalsContext);
|
|
90
|
+
|
|
91
|
+
// Merge locals into variables for expression evaluation
|
|
92
|
+
const mergedVars = { ...variables };
|
|
93
|
+
const mergedTemps = { ...temporary };
|
|
94
|
+
for (const [key, val] of Object.entries(locals)) {
|
|
95
|
+
if (key.startsWith('$')) mergedVars[key.slice(1)] = val;
|
|
96
|
+
else if (key.startsWith('_')) mergedTemps[key.slice(1)] = val;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
try {
|
|
100
|
+
const { currentExpr, maxExpr, labelMode } = parseArgs(rawArgs);
|
|
101
|
+
const current = Number(evaluate(currentExpr, mergedVars, mergedTemps));
|
|
102
|
+
const max = Number(evaluate(maxExpr, mergedVars, mergedTemps));
|
|
103
|
+
const pct = Math.max(0, Math.min(100, (current / max) * 100));
|
|
104
|
+
const label = formatLabel(current, max, labelMode);
|
|
105
|
+
|
|
106
|
+
const classes = ['macro-meter', className].filter(Boolean).join(' ');
|
|
107
|
+
|
|
108
|
+
return (
|
|
109
|
+
<div
|
|
110
|
+
class={classes}
|
|
111
|
+
id={id}
|
|
112
|
+
>
|
|
113
|
+
<div
|
|
114
|
+
class="macro-meter-fill"
|
|
115
|
+
style={`width: ${pct}%`}
|
|
116
|
+
/>
|
|
117
|
+
{label != null && <span class="macro-meter-label">{label}</span>}
|
|
118
|
+
</div>
|
|
119
|
+
);
|
|
120
|
+
} catch (err) {
|
|
121
|
+
return (
|
|
122
|
+
<span
|
|
123
|
+
class="error"
|
|
124
|
+
title={String(err)}
|
|
125
|
+
>
|
|
126
|
+
{`{meter error: ${(err as Error).message}}`}
|
|
127
|
+
</span>
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
@@ -7,9 +7,7 @@ interface NumberboxProps {
|
|
|
7
7
|
}
|
|
8
8
|
|
|
9
9
|
function parseArgs(rawArgs: string): { varName: string; placeholder: string } {
|
|
10
|
-
const match = rawArgs.match(
|
|
11
|
-
/^\s*(["']?\$\w+["']?)\s*(?:["'](.*)["'])?\s*$/,
|
|
12
|
-
);
|
|
10
|
+
const match = rawArgs.match(/^\s*(["']?\$\w+["']?)\s*(?:["'](.*)["'])?\s*$/);
|
|
13
11
|
if (!match) {
|
|
14
12
|
return { varName: rawArgs.trim(), placeholder: '' };
|
|
15
13
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { useLayoutEffect } from 'preact/hooks';
|
|
2
2
|
import { useStoryStore } from '../../store';
|
|
3
3
|
import { execute } from '../../expression';
|
|
4
|
+
import { deepClone } from '../../class-registry';
|
|
4
5
|
|
|
5
6
|
interface SetProps {
|
|
6
7
|
rawArgs: string;
|
|
@@ -9,8 +10,8 @@ interface SetProps {
|
|
|
9
10
|
export function Set({ rawArgs }: SetProps) {
|
|
10
11
|
useLayoutEffect(() => {
|
|
11
12
|
const state = useStoryStore.getState();
|
|
12
|
-
const vars =
|
|
13
|
-
const temps =
|
|
13
|
+
const vars = deepClone(state.variables);
|
|
14
|
+
const temps = deepClone(state.temporary);
|
|
14
15
|
|
|
15
16
|
try {
|
|
16
17
|
execute(rawArgs, vars, temps);
|
|
@@ -7,9 +7,7 @@ interface TextareaProps {
|
|
|
7
7
|
}
|
|
8
8
|
|
|
9
9
|
function parseArgs(rawArgs: string): { varName: string; placeholder: string } {
|
|
10
|
-
const match = rawArgs.match(
|
|
11
|
-
/^\s*(["']?\$\w+["']?)\s*(?:["'](.*)["'])?\s*$/,
|
|
12
|
-
);
|
|
10
|
+
const match = rawArgs.match(/^\s*(["']?\$\w+["']?)\s*(?:["'](.*)["'])?\s*$/);
|
|
13
11
|
if (!match) {
|
|
14
12
|
return { varName: rawArgs.trim(), placeholder: '' };
|
|
15
13
|
}
|
|
@@ -7,9 +7,7 @@ interface TextboxProps {
|
|
|
7
7
|
}
|
|
8
8
|
|
|
9
9
|
function parseArgs(rawArgs: string): { varName: string; placeholder: string } {
|
|
10
|
-
const match = rawArgs.match(
|
|
11
|
-
/^\s*(["']?\$\w+["']?)\s*(?:["'](.*)["'])?\s*$/,
|
|
12
|
-
);
|
|
10
|
+
const match = rawArgs.match(/^\s*(["']?\$\w+["']?)\s*(?:["'](.*)["'])?\s*$/);
|
|
13
11
|
if (!match) {
|
|
14
12
|
return { varName: rawArgs.trim(), placeholder: '' };
|
|
15
13
|
}
|
|
@@ -44,11 +44,7 @@ export function Type({ rawArgs, children, className, id }: TypeProps) {
|
|
|
44
44
|
|
|
45
45
|
const done = visibleChars >= totalChars && totalChars > 0;
|
|
46
46
|
|
|
47
|
-
const cls = [
|
|
48
|
-
'macro-type',
|
|
49
|
-
done ? 'macro-type-done' : '',
|
|
50
|
-
className || '',
|
|
51
|
-
]
|
|
47
|
+
const cls = ['macro-type', done ? 'macro-type-done' : '', className || '']
|
|
52
48
|
.filter(Boolean)
|
|
53
49
|
.join(' ');
|
|
54
50
|
|
|
@@ -58,10 +54,7 @@ export function Type({ rawArgs, children, className, id }: TypeProps) {
|
|
|
58
54
|
class={cls}
|
|
59
55
|
ref={containerRef}
|
|
60
56
|
style={{
|
|
61
|
-
clipPath:
|
|
62
|
-
totalChars > 0 && !done
|
|
63
|
-
? undefined
|
|
64
|
-
: undefined,
|
|
57
|
+
clipPath: totalChars > 0 && !done ? undefined : undefined,
|
|
65
58
|
}}
|
|
66
59
|
>
|
|
67
60
|
<span
|
package/src/index.tsx
CHANGED
|
@@ -93,11 +93,7 @@ function boot() {
|
|
|
93
93
|
const widgetTokens = tokenize(passage.content);
|
|
94
94
|
const widgetAST = buildAST(widgetTokens);
|
|
95
95
|
for (const node of widgetAST) {
|
|
96
|
-
if (
|
|
97
|
-
node.type === 'macro' &&
|
|
98
|
-
node.name === 'widget' &&
|
|
99
|
-
node.rawArgs
|
|
100
|
-
) {
|
|
96
|
+
if (node.type === 'macro' && node.name === 'widget' && node.rawArgs) {
|
|
101
97
|
const widgetName = node.rawArgs.trim().replace(/["']/g, '');
|
|
102
98
|
registerWidget(widgetName, node.children as ASTNode[]);
|
|
103
99
|
}
|
package/src/markup/ast.ts
CHANGED
|
@@ -89,11 +89,7 @@ export function buildAST(tokens: Token[]): ASTNode[] {
|
|
|
89
89
|
if (stack.length === 0) return root;
|
|
90
90
|
const top = stack[stack.length - 1].node;
|
|
91
91
|
// For if-blocks, append to the last branch's children
|
|
92
|
-
if (
|
|
93
|
-
top.type === 'macro' &&
|
|
94
|
-
top.branches &&
|
|
95
|
-
top.branches.length > 0
|
|
96
|
-
) {
|
|
92
|
+
if (top.type === 'macro' && top.branches && top.branches.length > 0) {
|
|
97
93
|
return top.branches[top.branches.length - 1].children;
|
|
98
94
|
}
|
|
99
95
|
return top.children;
|
|
@@ -204,7 +200,8 @@ export function buildAST(tokens: Token[]): ASTNode[] {
|
|
|
204
200
|
// Handle branch macros (elseif/else, case/default, next)
|
|
205
201
|
if (BRANCH_PARENT[token.name]) {
|
|
206
202
|
const expectedParent = BRANCH_PARENT[token.name];
|
|
207
|
-
const topNode =
|
|
203
|
+
const topNode =
|
|
204
|
+
stack.length > 0 ? stack[stack.length - 1].node : null;
|
|
208
205
|
if (
|
|
209
206
|
!topNode ||
|
|
210
207
|
topNode.type !== 'macro' ||
|
package/src/markup/markdown.ts
CHANGED
package/src/markup/render.tsx
CHANGED
|
@@ -33,6 +33,7 @@ import { Stop } from '../components/macros/Stop';
|
|
|
33
33
|
import { Type } from '../components/macros/Type';
|
|
34
34
|
import { Widget } from '../components/macros/Widget';
|
|
35
35
|
import { Computed } from '../components/macros/Computed';
|
|
36
|
+
import { Meter } from '../components/macros/Meter';
|
|
36
37
|
import { getWidget } from '../widgets/widget-registry';
|
|
37
38
|
import { getMacro } from '../registry';
|
|
38
39
|
import { markdownToHtml } from './markdown';
|
|
@@ -119,6 +120,16 @@ function renderMacro(node: MacroNode, key: number) {
|
|
|
119
120
|
/>
|
|
120
121
|
);
|
|
121
122
|
|
|
123
|
+
case 'meter':
|
|
124
|
+
return (
|
|
125
|
+
<Meter
|
|
126
|
+
key={key}
|
|
127
|
+
rawArgs={node.rawArgs}
|
|
128
|
+
className={node.className}
|
|
129
|
+
id={node.id}
|
|
130
|
+
/>
|
|
131
|
+
);
|
|
132
|
+
|
|
122
133
|
case 'if':
|
|
123
134
|
return (
|
|
124
135
|
<If
|
|
@@ -371,9 +382,7 @@ function renderMacro(node: MacroNode, key: number) {
|
|
|
371
382
|
);
|
|
372
383
|
|
|
373
384
|
case 'stop':
|
|
374
|
-
return
|
|
375
|
-
<Stop key={key} />
|
|
376
|
-
);
|
|
385
|
+
return <Stop key={key} />;
|
|
377
386
|
|
|
378
387
|
case 'type':
|
|
379
388
|
return (
|
|
@@ -487,9 +496,7 @@ function renderSingleNode(
|
|
|
487
496
|
* Used for inline containers (button labels, link text) where block-level
|
|
488
497
|
* markdown (lists, headers) would misinterpret content like "-" or "+".
|
|
489
498
|
*/
|
|
490
|
-
export function renderInlineNodes(
|
|
491
|
-
nodes: ASTNode[],
|
|
492
|
-
): preact.ComponentChildren {
|
|
499
|
+
export function renderInlineNodes(nodes: ASTNode[]): preact.ComponentChildren {
|
|
493
500
|
if (nodes.length === 0) return null;
|
|
494
501
|
return nodes.map((node, i) => renderSingleNode(node, i));
|
|
495
502
|
}
|