@rohal12/spindle 0.1.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/README.md +66 -0
- package/dist/pkg/format.js +1 -0
- package/dist/pkg/index.js +12 -0
- package/dist/pkg/types/globals.d.ts +18 -0
- package/dist/pkg/types/index.d.ts +158 -0
- package/package.json +71 -0
- package/src/components/App.tsx +53 -0
- package/src/components/Passage.tsx +36 -0
- package/src/components/PassageLink.tsx +35 -0
- package/src/components/SaveLoadDialog.tsx +403 -0
- package/src/components/SettingsDialog.tsx +106 -0
- package/src/components/StoryInterface.tsx +31 -0
- package/src/components/macros/Back.tsx +23 -0
- package/src/components/macros/Button.tsx +49 -0
- package/src/components/macros/Checkbox.tsx +41 -0
- package/src/components/macros/Computed.tsx +100 -0
- package/src/components/macros/Cycle.tsx +39 -0
- package/src/components/macros/Do.tsx +46 -0
- package/src/components/macros/For.tsx +113 -0
- package/src/components/macros/Forward.tsx +25 -0
- package/src/components/macros/Goto.tsx +23 -0
- package/src/components/macros/If.tsx +63 -0
- package/src/components/macros/Include.tsx +52 -0
- package/src/components/macros/Listbox.tsx +42 -0
- package/src/components/macros/MacroLink.tsx +107 -0
- package/src/components/macros/Numberbox.tsx +43 -0
- package/src/components/macros/Print.tsx +48 -0
- package/src/components/macros/QuickLoad.tsx +33 -0
- package/src/components/macros/QuickSave.tsx +22 -0
- package/src/components/macros/Radiobutton.tsx +59 -0
- package/src/components/macros/Repeat.tsx +53 -0
- package/src/components/macros/Restart.tsx +27 -0
- package/src/components/macros/Saves.tsx +25 -0
- package/src/components/macros/Set.tsx +36 -0
- package/src/components/macros/SettingsButton.tsx +29 -0
- package/src/components/macros/Stop.tsx +12 -0
- package/src/components/macros/StoryTitle.tsx +20 -0
- package/src/components/macros/Switch.tsx +69 -0
- package/src/components/macros/Textarea.tsx +41 -0
- package/src/components/macros/Textbox.tsx +40 -0
- package/src/components/macros/Timed.tsx +63 -0
- package/src/components/macros/Type.tsx +83 -0
- package/src/components/macros/Unset.tsx +25 -0
- package/src/components/macros/VarDisplay.tsx +44 -0
- package/src/components/macros/Widget.tsx +18 -0
- package/src/components/macros/option-utils.ts +14 -0
- package/src/expression.ts +93 -0
- package/src/index.tsx +120 -0
- package/src/markup/ast.ts +284 -0
- package/src/markup/markdown.ts +21 -0
- package/src/markup/render.tsx +537 -0
- package/src/markup/tokenizer.ts +581 -0
- package/src/parser.ts +72 -0
- package/src/registry.ts +21 -0
- package/src/saves/idb.ts +165 -0
- package/src/saves/save-manager.ts +317 -0
- package/src/saves/types.ts +40 -0
- package/src/settings.ts +96 -0
- package/src/store.ts +317 -0
- package/src/story-api.ts +129 -0
- package/src/story-init.ts +67 -0
- package/src/story-variables.ts +166 -0
- package/src/styles.css +780 -0
- package/src/utils/parse-delay.ts +14 -0
- package/src/widgets/widget-registry.ts +15 -0
|
@@ -0,0 +1,581 @@
|
|
|
1
|
+
export interface TextToken {
|
|
2
|
+
type: 'text';
|
|
3
|
+
value: string;
|
|
4
|
+
start: number;
|
|
5
|
+
end: number;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface LinkToken {
|
|
9
|
+
type: 'link';
|
|
10
|
+
display: string;
|
|
11
|
+
target: string;
|
|
12
|
+
className?: string;
|
|
13
|
+
id?: string;
|
|
14
|
+
start: number;
|
|
15
|
+
end: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface MacroToken {
|
|
19
|
+
type: 'macro';
|
|
20
|
+
name: string;
|
|
21
|
+
rawArgs: string;
|
|
22
|
+
isClose: boolean;
|
|
23
|
+
className?: string;
|
|
24
|
+
id?: string;
|
|
25
|
+
start: number;
|
|
26
|
+
end: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface VariableToken {
|
|
30
|
+
type: 'variable';
|
|
31
|
+
name: string;
|
|
32
|
+
scope: 'variable' | 'temporary';
|
|
33
|
+
className?: string;
|
|
34
|
+
id?: string;
|
|
35
|
+
start: number;
|
|
36
|
+
end: number;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface HtmlToken {
|
|
40
|
+
type: 'html';
|
|
41
|
+
tag: string;
|
|
42
|
+
attributes: Record<string, string>;
|
|
43
|
+
isClose: boolean;
|
|
44
|
+
isSelfClose: boolean;
|
|
45
|
+
start: number;
|
|
46
|
+
end: number;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export type Token = TextToken | LinkToken | MacroToken | VariableToken | HtmlToken;
|
|
50
|
+
|
|
51
|
+
const HTML_TAGS = new Set([
|
|
52
|
+
'a', 'article', 'aside', 'b', 'blockquote', 'br', 'caption', 'code',
|
|
53
|
+
'col', 'colgroup', 'dd', 'del', 'details', 'dfn', 'div', 'dl', 'dt',
|
|
54
|
+
'em', 'figcaption', 'figure', 'footer', 'h1', 'h2', 'h3', 'h4', 'h5',
|
|
55
|
+
'h6', 'header', 'hr', 'i', 'img', 'ins', 'kbd', 'li', 'main', 'mark',
|
|
56
|
+
'nav', 'ol', 'p', 'pre', 'q', 's', 'samp', 'section', 'small', 'span',
|
|
57
|
+
'strong', 'sub', 'summary', 'sup', 'table', 'tbody', 'td', 'tfoot', 'th',
|
|
58
|
+
'thead', 'tr', 'u', 'ul', 'wbr',
|
|
59
|
+
]);
|
|
60
|
+
|
|
61
|
+
const HTML_VOID_TAGS = new Set(['br', 'col', 'hr', 'img', 'wbr']);
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Parse a Twine link interior into display and target.
|
|
65
|
+
* Supports: display|target, display->target, target<-display, plain
|
|
66
|
+
*/
|
|
67
|
+
function parseLink(inner: string): { display: string; target: string } {
|
|
68
|
+
// Pipe syntax: display|target
|
|
69
|
+
const pipeIdx = inner.indexOf('|');
|
|
70
|
+
if (pipeIdx !== -1) {
|
|
71
|
+
return {
|
|
72
|
+
display: inner.slice(0, pipeIdx).trim(),
|
|
73
|
+
target: inner.slice(pipeIdx + 1).trim(),
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Arrow syntax: display->target
|
|
78
|
+
const arrowIdx = inner.indexOf('->');
|
|
79
|
+
if (arrowIdx !== -1) {
|
|
80
|
+
return {
|
|
81
|
+
display: inner.slice(0, arrowIdx).trim(),
|
|
82
|
+
target: inner.slice(arrowIdx + 2).trim(),
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Reverse arrow: target<-display
|
|
87
|
+
const revIdx = inner.indexOf('<-');
|
|
88
|
+
if (revIdx !== -1) {
|
|
89
|
+
return {
|
|
90
|
+
target: inner.slice(0, revIdx).trim(),
|
|
91
|
+
display: inner.slice(revIdx + 2).trim(),
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Plain: [[passage]]
|
|
96
|
+
const trimmed = inner.trim();
|
|
97
|
+
return { display: trimmed, target: trimmed };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Parse a macro opening: extract name and rawArgs.
|
|
102
|
+
* e.g. "set $x = 5" → { name: "set", rawArgs: "$x = 5" }
|
|
103
|
+
* e.g. "/if" → { name: "if", rawArgs: "", isClose: true }
|
|
104
|
+
* e.g. "elseif $x > 3" → { name: "elseif", rawArgs: "$x > 3" }
|
|
105
|
+
*/
|
|
106
|
+
function parseMacroContent(content: string): {
|
|
107
|
+
name: string;
|
|
108
|
+
rawArgs: string;
|
|
109
|
+
isClose: boolean;
|
|
110
|
+
} {
|
|
111
|
+
const trimmed = content.trim();
|
|
112
|
+
const isClose = trimmed.startsWith('/');
|
|
113
|
+
const rest = isClose ? trimmed.slice(1) : trimmed;
|
|
114
|
+
|
|
115
|
+
const spaceIdx = rest.search(/\s/);
|
|
116
|
+
if (spaceIdx === -1) {
|
|
117
|
+
return { name: rest, rawArgs: '', isClose };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return {
|
|
121
|
+
name: rest.slice(0, spaceIdx),
|
|
122
|
+
rawArgs: rest.slice(spaceIdx + 1).trim(),
|
|
123
|
+
isClose,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Parse CSS selectors: .foo.bar#baz → { className: "foo bar", id: "baz" }
|
|
129
|
+
* Scans .[a-zA-Z0-9_-]+ and #[a-zA-Z0-9_-]+ segments in any order.
|
|
130
|
+
* Returns space-joined class string, last id wins, and position after last segment.
|
|
131
|
+
*/
|
|
132
|
+
function parseSelectors(
|
|
133
|
+
input: string,
|
|
134
|
+
startIdx: number,
|
|
135
|
+
): { className: string; id: string; endIdx: number } {
|
|
136
|
+
const classes: string[] = [];
|
|
137
|
+
let id = '';
|
|
138
|
+
let i = startIdx;
|
|
139
|
+
|
|
140
|
+
while (i < input.length && (input[i] === '.' || input[i] === '#')) {
|
|
141
|
+
const prefix = input[i];
|
|
142
|
+
i++; // skip the . or #
|
|
143
|
+
const nameStart = i;
|
|
144
|
+
while (i < input.length && /[a-zA-Z0-9_-]/.test(input[i])) i++;
|
|
145
|
+
if (i > nameStart) {
|
|
146
|
+
const name = input.slice(nameStart, i);
|
|
147
|
+
if (prefix === '.') {
|
|
148
|
+
classes.push(name);
|
|
149
|
+
} else {
|
|
150
|
+
id = name;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return { className: classes.join(' '), id, endIdx: i };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Parse HTML attributes from a string starting at position j.
|
|
160
|
+
* Returns the attributes and the position after the last attribute.
|
|
161
|
+
*/
|
|
162
|
+
function parseHtmlAttributes(
|
|
163
|
+
input: string,
|
|
164
|
+
j: number,
|
|
165
|
+
): { attributes: Record<string, string>; endIdx: number } {
|
|
166
|
+
const attributes: Record<string, string> = {};
|
|
167
|
+
|
|
168
|
+
while (j < input.length) {
|
|
169
|
+
// Skip whitespace
|
|
170
|
+
while (j < input.length && /\s/.test(input[j])) j++;
|
|
171
|
+
// End of tag?
|
|
172
|
+
if (
|
|
173
|
+
j >= input.length ||
|
|
174
|
+
input[j] === '>' ||
|
|
175
|
+
(input[j] === '/' && input[j + 1] === '>')
|
|
176
|
+
)
|
|
177
|
+
break;
|
|
178
|
+
|
|
179
|
+
// Read attribute name
|
|
180
|
+
const attrStart = j;
|
|
181
|
+
while (j < input.length && /[a-zA-Z0-9_-]/.test(input[j])) j++;
|
|
182
|
+
const attrName = input.slice(attrStart, j);
|
|
183
|
+
if (!attrName) break;
|
|
184
|
+
|
|
185
|
+
// Check for = value
|
|
186
|
+
if (input[j] === '=') {
|
|
187
|
+
j++; // skip =
|
|
188
|
+
if (input[j] === '"' || input[j] === "'") {
|
|
189
|
+
const quote = input[j];
|
|
190
|
+
j++; // skip opening quote
|
|
191
|
+
const valStart = j;
|
|
192
|
+
while (j < input.length && input[j] !== quote) j++;
|
|
193
|
+
attributes[attrName] = input.slice(valStart, j);
|
|
194
|
+
if (j < input.length) j++; // skip closing quote
|
|
195
|
+
} else {
|
|
196
|
+
// Unquoted value
|
|
197
|
+
const valStart = j;
|
|
198
|
+
while (j < input.length && /[^\s>]/.test(input[j])) j++;
|
|
199
|
+
attributes[attrName] = input.slice(valStart, j);
|
|
200
|
+
}
|
|
201
|
+
} else {
|
|
202
|
+
// Boolean attribute
|
|
203
|
+
attributes[attrName] = '';
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
return { attributes, endIdx: j };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Single-pass tokenizer for Twine passage content.
|
|
212
|
+
* Recognizes: [[links]], {$variable}, {_temporary}, {macroName args}
|
|
213
|
+
*/
|
|
214
|
+
export function tokenize(input: string): Token[] {
|
|
215
|
+
const tokens: Token[] = [];
|
|
216
|
+
let i = 0;
|
|
217
|
+
let textStart = 0;
|
|
218
|
+
|
|
219
|
+
function flushText(end: number) {
|
|
220
|
+
if (end > textStart) {
|
|
221
|
+
tokens.push({
|
|
222
|
+
type: 'text',
|
|
223
|
+
value: input.slice(textStart, end),
|
|
224
|
+
start: textStart,
|
|
225
|
+
end,
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
while (i < input.length) {
|
|
231
|
+
// Check for [[ link
|
|
232
|
+
if (input[i] === '[' && input[i + 1] === '[') {
|
|
233
|
+
flushText(i);
|
|
234
|
+
const start = i;
|
|
235
|
+
i += 2;
|
|
236
|
+
|
|
237
|
+
// Check for .class or #id syntax after [[
|
|
238
|
+
let className: string | undefined;
|
|
239
|
+
let id: string | undefined;
|
|
240
|
+
if (input[i] === '.' || input[i] === '#') {
|
|
241
|
+
const parsed = parseSelectors(input, i);
|
|
242
|
+
className = parsed.className || undefined;
|
|
243
|
+
id = parsed.id || undefined;
|
|
244
|
+
i = parsed.endIdx;
|
|
245
|
+
// Consume trailing space after selectors
|
|
246
|
+
if (input[i] === ' ') i++;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Find closing ]]
|
|
250
|
+
let depth = 1;
|
|
251
|
+
const innerStart = i;
|
|
252
|
+
while (i < input.length && depth > 0) {
|
|
253
|
+
if (input[i] === '[' && input[i + 1] === '[') {
|
|
254
|
+
depth++;
|
|
255
|
+
i += 2;
|
|
256
|
+
} else if (input[i] === ']' && input[i + 1] === ']') {
|
|
257
|
+
depth--;
|
|
258
|
+
if (depth === 0) break;
|
|
259
|
+
i += 2;
|
|
260
|
+
} else {
|
|
261
|
+
i++;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
if (depth !== 0) {
|
|
266
|
+
// Unclosed link — treat as text
|
|
267
|
+
i = start + 2;
|
|
268
|
+
textStart = start;
|
|
269
|
+
continue;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const inner = input.slice(innerStart, i);
|
|
273
|
+
i += 2; // skip ]]
|
|
274
|
+
|
|
275
|
+
const { display, target } = parseLink(inner);
|
|
276
|
+
const linkToken: LinkToken = {
|
|
277
|
+
type: 'link',
|
|
278
|
+
display,
|
|
279
|
+
target,
|
|
280
|
+
start,
|
|
281
|
+
end: i,
|
|
282
|
+
};
|
|
283
|
+
if (className) linkToken.className = className;
|
|
284
|
+
if (id) linkToken.id = id;
|
|
285
|
+
tokens.push(linkToken);
|
|
286
|
+
textStart = i;
|
|
287
|
+
continue;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// Check for { — macro or variable (with optional .class prefix)
|
|
291
|
+
if (input[i] === '{') {
|
|
292
|
+
const start = i;
|
|
293
|
+
let nextChar = input[i + 1];
|
|
294
|
+
|
|
295
|
+
// Check for .class/#id prefix: {.foo#bar $var} or {#id.foo macroName ...}
|
|
296
|
+
let className: string | undefined;
|
|
297
|
+
let id: string | undefined;
|
|
298
|
+
if (nextChar === '.' || nextChar === '#') {
|
|
299
|
+
flushText(i);
|
|
300
|
+
const parsed = parseSelectors(input, i + 1);
|
|
301
|
+
className = parsed.className || undefined;
|
|
302
|
+
id = parsed.id || undefined;
|
|
303
|
+
// After selectors, check what follows (space then $ or _ or letter)
|
|
304
|
+
let afterSelectors = parsed.endIdx;
|
|
305
|
+
if (input[afterSelectors] === ' ') afterSelectors++;
|
|
306
|
+
const charAfter = input[afterSelectors];
|
|
307
|
+
|
|
308
|
+
if (charAfter === '$') {
|
|
309
|
+
// {.class#id $variable.field}
|
|
310
|
+
i = afterSelectors + 1;
|
|
311
|
+
const nameStart = i;
|
|
312
|
+
while (i < input.length && /[\w.]/.test(input[i])) i++;
|
|
313
|
+
const name = input.slice(nameStart, i);
|
|
314
|
+
|
|
315
|
+
if (input[i] === '}') {
|
|
316
|
+
i++; // skip }
|
|
317
|
+
const token: VariableToken = {
|
|
318
|
+
type: 'variable',
|
|
319
|
+
name,
|
|
320
|
+
scope: 'variable',
|
|
321
|
+
start,
|
|
322
|
+
end: i,
|
|
323
|
+
};
|
|
324
|
+
if (className) token.className = className;
|
|
325
|
+
if (id) token.id = id;
|
|
326
|
+
tokens.push(token);
|
|
327
|
+
textStart = i;
|
|
328
|
+
continue;
|
|
329
|
+
}
|
|
330
|
+
// Not valid — treat as text
|
|
331
|
+
i = start + 1;
|
|
332
|
+
textStart = start;
|
|
333
|
+
continue;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
if (charAfter === '_') {
|
|
337
|
+
// {.class#id _temporary.field}
|
|
338
|
+
i = afterSelectors + 1;
|
|
339
|
+
const nameStart = i;
|
|
340
|
+
while (i < input.length && /[\w.]/.test(input[i])) i++;
|
|
341
|
+
const name = input.slice(nameStart, i);
|
|
342
|
+
|
|
343
|
+
if (input[i] === '}') {
|
|
344
|
+
i++; // skip }
|
|
345
|
+
const token: VariableToken = {
|
|
346
|
+
type: 'variable',
|
|
347
|
+
name,
|
|
348
|
+
scope: 'temporary',
|
|
349
|
+
start,
|
|
350
|
+
end: i,
|
|
351
|
+
};
|
|
352
|
+
if (className) token.className = className;
|
|
353
|
+
if (id) token.id = id;
|
|
354
|
+
tokens.push(token);
|
|
355
|
+
textStart = i;
|
|
356
|
+
continue;
|
|
357
|
+
}
|
|
358
|
+
// Not valid — treat as text
|
|
359
|
+
i = start + 1;
|
|
360
|
+
textStart = start;
|
|
361
|
+
continue;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
if (charAfter !== undefined && /[a-zA-Z]/.test(charAfter)) {
|
|
365
|
+
// {.class#id macroName args}
|
|
366
|
+
i = afterSelectors;
|
|
367
|
+
|
|
368
|
+
// Scan to closing }, tracking brace nesting
|
|
369
|
+
let depth = 1;
|
|
370
|
+
const contentStart = i;
|
|
371
|
+
while (i < input.length && depth > 0) {
|
|
372
|
+
if (input[i] === '{') depth++;
|
|
373
|
+
else if (input[i] === '}') depth--;
|
|
374
|
+
if (depth > 0) i++;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
if (depth !== 0) {
|
|
378
|
+
i = start + 1;
|
|
379
|
+
textStart = start;
|
|
380
|
+
continue;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
const content = input.slice(contentStart, i);
|
|
384
|
+
i++; // skip closing }
|
|
385
|
+
|
|
386
|
+
const { name, rawArgs, isClose } = parseMacroContent(content);
|
|
387
|
+
const token: MacroToken = {
|
|
388
|
+
type: 'macro',
|
|
389
|
+
name,
|
|
390
|
+
rawArgs,
|
|
391
|
+
isClose,
|
|
392
|
+
start,
|
|
393
|
+
end: i,
|
|
394
|
+
};
|
|
395
|
+
if (className) token.className = className;
|
|
396
|
+
if (id) token.id = id;
|
|
397
|
+
tokens.push(token);
|
|
398
|
+
textStart = i;
|
|
399
|
+
continue;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// Selector prefix after { but nothing valid follows — treat as text
|
|
403
|
+
i = start + 1;
|
|
404
|
+
textStart = start;
|
|
405
|
+
continue;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// {$variable} or {$variable.field.subfield}
|
|
409
|
+
if (nextChar === '$') {
|
|
410
|
+
flushText(i);
|
|
411
|
+
i += 2;
|
|
412
|
+
const nameStart = i;
|
|
413
|
+
while (i < input.length && /[\w.]/.test(input[i])) i++;
|
|
414
|
+
const name = input.slice(nameStart, i);
|
|
415
|
+
|
|
416
|
+
if (input[i] === '}') {
|
|
417
|
+
i++; // skip }
|
|
418
|
+
tokens.push({
|
|
419
|
+
type: 'variable',
|
|
420
|
+
name,
|
|
421
|
+
scope: 'variable',
|
|
422
|
+
start,
|
|
423
|
+
end: i,
|
|
424
|
+
});
|
|
425
|
+
textStart = i;
|
|
426
|
+
continue;
|
|
427
|
+
}
|
|
428
|
+
// Not a valid variable token — treat as text
|
|
429
|
+
i = start + 1;
|
|
430
|
+
textStart = start;
|
|
431
|
+
continue;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// {_temporary.field}
|
|
435
|
+
if (nextChar === '_') {
|
|
436
|
+
flushText(i);
|
|
437
|
+
i += 2;
|
|
438
|
+
const nameStart = i;
|
|
439
|
+
while (i < input.length && /[\w.]/.test(input[i])) i++;
|
|
440
|
+
const name = input.slice(nameStart, i);
|
|
441
|
+
|
|
442
|
+
if (input[i] === '}') {
|
|
443
|
+
i++; // skip }
|
|
444
|
+
tokens.push({
|
|
445
|
+
type: 'variable',
|
|
446
|
+
name,
|
|
447
|
+
scope: 'temporary',
|
|
448
|
+
start,
|
|
449
|
+
end: i,
|
|
450
|
+
});
|
|
451
|
+
textStart = i;
|
|
452
|
+
continue;
|
|
453
|
+
}
|
|
454
|
+
// Not a valid temporary token — treat as text
|
|
455
|
+
i = start + 1;
|
|
456
|
+
textStart = start;
|
|
457
|
+
continue;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
// {macro ...} or {/macro} — but not bare { that's just text
|
|
461
|
+
// Must start with a letter or /
|
|
462
|
+
if (
|
|
463
|
+
nextChar !== undefined &&
|
|
464
|
+
(nextChar === '/' || /[a-zA-Z]/.test(nextChar))
|
|
465
|
+
) {
|
|
466
|
+
flushText(i);
|
|
467
|
+
i++; // skip {
|
|
468
|
+
|
|
469
|
+
// Scan to closing }, tracking brace nesting for object literals
|
|
470
|
+
let depth = 1;
|
|
471
|
+
const contentStart = i;
|
|
472
|
+
while (i < input.length && depth > 0) {
|
|
473
|
+
if (input[i] === '{') depth++;
|
|
474
|
+
else if (input[i] === '}') depth--;
|
|
475
|
+
if (depth > 0) i++;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
if (depth !== 0) {
|
|
479
|
+
// Unclosed macro — treat as text
|
|
480
|
+
i = start + 1;
|
|
481
|
+
textStart = start;
|
|
482
|
+
continue;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
const content = input.slice(contentStart, i);
|
|
486
|
+
i++; // skip closing }
|
|
487
|
+
|
|
488
|
+
const { name, rawArgs, isClose } = parseMacroContent(content);
|
|
489
|
+
tokens.push({
|
|
490
|
+
type: 'macro',
|
|
491
|
+
name,
|
|
492
|
+
rawArgs,
|
|
493
|
+
isClose,
|
|
494
|
+
start,
|
|
495
|
+
end: i,
|
|
496
|
+
});
|
|
497
|
+
textStart = i;
|
|
498
|
+
continue;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
// Just a bare { — treat as regular text
|
|
502
|
+
i++;
|
|
503
|
+
continue;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
// Check for < — HTML tag
|
|
507
|
+
if (input[i] === '<') {
|
|
508
|
+
const start = i;
|
|
509
|
+
let j = i + 1;
|
|
510
|
+
|
|
511
|
+
// Closing tag?
|
|
512
|
+
const isClose = input[j] === '/';
|
|
513
|
+
if (isClose) j++;
|
|
514
|
+
|
|
515
|
+
// Read tag name
|
|
516
|
+
const tagStart = j;
|
|
517
|
+
while (j < input.length && /[a-zA-Z0-9]/.test(input[j])) j++;
|
|
518
|
+
const tag = input.slice(tagStart, j).toLowerCase();
|
|
519
|
+
|
|
520
|
+
// Only handle known HTML tags
|
|
521
|
+
if (tag && HTML_TAGS.has(tag)) {
|
|
522
|
+
if (isClose) {
|
|
523
|
+
// Closing tag: skip whitespace, expect >
|
|
524
|
+
while (j < input.length && /\s/.test(input[j])) j++;
|
|
525
|
+
if (input[j] === '>') {
|
|
526
|
+
j++;
|
|
527
|
+
flushText(start);
|
|
528
|
+
tokens.push({
|
|
529
|
+
type: 'html',
|
|
530
|
+
tag,
|
|
531
|
+
attributes: {},
|
|
532
|
+
isClose: true,
|
|
533
|
+
isSelfClose: false,
|
|
534
|
+
start,
|
|
535
|
+
end: j,
|
|
536
|
+
});
|
|
537
|
+
textStart = j;
|
|
538
|
+
i = j;
|
|
539
|
+
continue;
|
|
540
|
+
}
|
|
541
|
+
} else {
|
|
542
|
+
// Opening or self-closing tag: parse attributes
|
|
543
|
+
const parsed = parseHtmlAttributes(input, j);
|
|
544
|
+
j = parsed.endIdx;
|
|
545
|
+
|
|
546
|
+
let isSelfClose = HTML_VOID_TAGS.has(tag);
|
|
547
|
+
if (input[j] === '/') {
|
|
548
|
+
isSelfClose = true;
|
|
549
|
+
j++;
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
if (input[j] === '>') {
|
|
553
|
+
j++;
|
|
554
|
+
flushText(start);
|
|
555
|
+
tokens.push({
|
|
556
|
+
type: 'html',
|
|
557
|
+
tag,
|
|
558
|
+
attributes: parsed.attributes,
|
|
559
|
+
isClose: false,
|
|
560
|
+
isSelfClose,
|
|
561
|
+
start,
|
|
562
|
+
end: j,
|
|
563
|
+
});
|
|
564
|
+
textStart = j;
|
|
565
|
+
i = j;
|
|
566
|
+
continue;
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
// Not a valid HTML tag — treat as text
|
|
572
|
+
i++;
|
|
573
|
+
continue;
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
i++;
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
flushText(input.length);
|
|
580
|
+
return tokens;
|
|
581
|
+
}
|
package/src/parser.ts
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
export interface Passage {
|
|
2
|
+
pid: number;
|
|
3
|
+
name: string;
|
|
4
|
+
tags: string[];
|
|
5
|
+
content: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface StoryData {
|
|
9
|
+
name: string;
|
|
10
|
+
startNode: number;
|
|
11
|
+
ifid: string;
|
|
12
|
+
format: string;
|
|
13
|
+
formatVersion: string;
|
|
14
|
+
passages: Map<string, Passage>;
|
|
15
|
+
passagesById: Map<number, Passage>;
|
|
16
|
+
userCSS: string;
|
|
17
|
+
userScript: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Parse <tw-storydata> and all <tw-passagedata> elements from the DOM.
|
|
22
|
+
* The browser auto-decodes HTML entities when building the DOM, so
|
|
23
|
+
* textContent gives us the original passage text.
|
|
24
|
+
*/
|
|
25
|
+
export function parseStoryData(): StoryData {
|
|
26
|
+
const storyEl = document.querySelector('tw-storydata');
|
|
27
|
+
if (!storyEl) {
|
|
28
|
+
throw new Error(
|
|
29
|
+
'spindle: No <tw-storydata> element found in the document.',
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const name = storyEl.getAttribute('name') || 'Untitled';
|
|
34
|
+
const startNode = parseInt(storyEl.getAttribute('startnode') || '1', 10);
|
|
35
|
+
const ifid = storyEl.getAttribute('ifid') || '';
|
|
36
|
+
const format = storyEl.getAttribute('format') || '';
|
|
37
|
+
const formatVersion = storyEl.getAttribute('format-version') || '';
|
|
38
|
+
|
|
39
|
+
const cssEl = storyEl.querySelector('[type="text/twine-css"]');
|
|
40
|
+
const userCSS = cssEl?.textContent || '';
|
|
41
|
+
|
|
42
|
+
const jsEl = storyEl.querySelector('[type="text/twine-javascript"]');
|
|
43
|
+
const userScript = jsEl?.textContent || '';
|
|
44
|
+
|
|
45
|
+
const passages = new Map<string, Passage>();
|
|
46
|
+
const passagesById = new Map<number, Passage>();
|
|
47
|
+
|
|
48
|
+
for (const el of storyEl.querySelectorAll('tw-passagedata')) {
|
|
49
|
+
const pid = parseInt(el.getAttribute('pid') || '0', 10);
|
|
50
|
+
const passageName = el.getAttribute('name') || '';
|
|
51
|
+
const tags = (el.getAttribute('tags') || '')
|
|
52
|
+
.split(/\s+/)
|
|
53
|
+
.filter((t) => t.length > 0);
|
|
54
|
+
const content = el.textContent || '';
|
|
55
|
+
|
|
56
|
+
const passage: Passage = { pid, name: passageName, tags, content };
|
|
57
|
+
passages.set(passageName, passage);
|
|
58
|
+
passagesById.set(pid, passage);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return {
|
|
62
|
+
name,
|
|
63
|
+
startNode,
|
|
64
|
+
ifid,
|
|
65
|
+
format,
|
|
66
|
+
formatVersion,
|
|
67
|
+
passages,
|
|
68
|
+
passagesById,
|
|
69
|
+
userCSS,
|
|
70
|
+
userScript,
|
|
71
|
+
};
|
|
72
|
+
}
|
package/src/registry.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { ComponentType } from 'preact';
|
|
2
|
+
|
|
3
|
+
export interface MacroProps {
|
|
4
|
+
rawArgs: string;
|
|
5
|
+
className?: string;
|
|
6
|
+
id?: string;
|
|
7
|
+
children: preact.ComponentChildren;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const registry = new Map<string, ComponentType<MacroProps>>();
|
|
11
|
+
|
|
12
|
+
export function registerMacro(
|
|
13
|
+
name: string,
|
|
14
|
+
component: ComponentType<MacroProps>,
|
|
15
|
+
): void {
|
|
16
|
+
registry.set(name.toLowerCase(), component);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function getMacro(name: string): ComponentType<MacroProps> | undefined {
|
|
20
|
+
return registry.get(name.toLowerCase());
|
|
21
|
+
}
|