@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.
Files changed (65) hide show
  1. package/README.md +66 -0
  2. package/dist/pkg/format.js +1 -0
  3. package/dist/pkg/index.js +12 -0
  4. package/dist/pkg/types/globals.d.ts +18 -0
  5. package/dist/pkg/types/index.d.ts +158 -0
  6. package/package.json +71 -0
  7. package/src/components/App.tsx +53 -0
  8. package/src/components/Passage.tsx +36 -0
  9. package/src/components/PassageLink.tsx +35 -0
  10. package/src/components/SaveLoadDialog.tsx +403 -0
  11. package/src/components/SettingsDialog.tsx +106 -0
  12. package/src/components/StoryInterface.tsx +31 -0
  13. package/src/components/macros/Back.tsx +23 -0
  14. package/src/components/macros/Button.tsx +49 -0
  15. package/src/components/macros/Checkbox.tsx +41 -0
  16. package/src/components/macros/Computed.tsx +100 -0
  17. package/src/components/macros/Cycle.tsx +39 -0
  18. package/src/components/macros/Do.tsx +46 -0
  19. package/src/components/macros/For.tsx +113 -0
  20. package/src/components/macros/Forward.tsx +25 -0
  21. package/src/components/macros/Goto.tsx +23 -0
  22. package/src/components/macros/If.tsx +63 -0
  23. package/src/components/macros/Include.tsx +52 -0
  24. package/src/components/macros/Listbox.tsx +42 -0
  25. package/src/components/macros/MacroLink.tsx +107 -0
  26. package/src/components/macros/Numberbox.tsx +43 -0
  27. package/src/components/macros/Print.tsx +48 -0
  28. package/src/components/macros/QuickLoad.tsx +33 -0
  29. package/src/components/macros/QuickSave.tsx +22 -0
  30. package/src/components/macros/Radiobutton.tsx +59 -0
  31. package/src/components/macros/Repeat.tsx +53 -0
  32. package/src/components/macros/Restart.tsx +27 -0
  33. package/src/components/macros/Saves.tsx +25 -0
  34. package/src/components/macros/Set.tsx +36 -0
  35. package/src/components/macros/SettingsButton.tsx +29 -0
  36. package/src/components/macros/Stop.tsx +12 -0
  37. package/src/components/macros/StoryTitle.tsx +20 -0
  38. package/src/components/macros/Switch.tsx +69 -0
  39. package/src/components/macros/Textarea.tsx +41 -0
  40. package/src/components/macros/Textbox.tsx +40 -0
  41. package/src/components/macros/Timed.tsx +63 -0
  42. package/src/components/macros/Type.tsx +83 -0
  43. package/src/components/macros/Unset.tsx +25 -0
  44. package/src/components/macros/VarDisplay.tsx +44 -0
  45. package/src/components/macros/Widget.tsx +18 -0
  46. package/src/components/macros/option-utils.ts +14 -0
  47. package/src/expression.ts +93 -0
  48. package/src/index.tsx +120 -0
  49. package/src/markup/ast.ts +284 -0
  50. package/src/markup/markdown.ts +21 -0
  51. package/src/markup/render.tsx +537 -0
  52. package/src/markup/tokenizer.ts +581 -0
  53. package/src/parser.ts +72 -0
  54. package/src/registry.ts +21 -0
  55. package/src/saves/idb.ts +165 -0
  56. package/src/saves/save-manager.ts +317 -0
  57. package/src/saves/types.ts +40 -0
  58. package/src/settings.ts +96 -0
  59. package/src/store.ts +317 -0
  60. package/src/story-api.ts +129 -0
  61. package/src/story-init.ts +67 -0
  62. package/src/story-variables.ts +166 -0
  63. package/src/styles.css +780 -0
  64. package/src/utils/parse-delay.ts +14 -0
  65. 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
+ }
@@ -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
+ }