squiffy-runtime 6.0.0-alpha.10 → 6.0.0-alpha.11
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/events.d.ts +15 -0
- package/dist/events.js +35 -0
- package/dist/squiffy.runtime.d.ts +1 -50
- package/dist/squiffy.runtime.js +13 -169
- package/dist/squiffy.runtime.test.js +16 -4
- package/dist/textProcessor.d.ts +15 -0
- package/dist/textProcessor.js +165 -0
- package/dist/types.d.ts +54 -0
- package/dist/types.js +1 -0
- package/dist/utils.d.ts +2 -0
- package/dist/utils.js +14 -0
- package/package.json +2 -2
- package/src/__snapshots__/squiffy.runtime.test.ts.snap +0 -10
- package/src/events.ts +42 -0
- package/src/squiffy.runtime.test.ts +26 -4
- package/src/squiffy.runtime.ts +16 -244
- package/src/textProcessor.ts +177 -0
- package/src/types.ts +78 -0
- package/src/utils.ts +14 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "squiffy-runtime",
|
|
3
|
-
"version": "6.0.0-alpha.
|
|
3
|
+
"version": "6.0.0-alpha.11",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "dist/squiffy.runtime.js",
|
|
6
6
|
"types": "dist/squiffy.runtime.d.ts",
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
"@types/jsdom": "^21.1.7",
|
|
23
23
|
"global-jsdom": "^25.0.0",
|
|
24
24
|
"jsdom": "^25.0.0",
|
|
25
|
-
"squiffy-compiler": "^6.0.0-alpha.
|
|
25
|
+
"squiffy-compiler": "^6.0.0-alpha.3",
|
|
26
26
|
"typescript": "^5.6.2",
|
|
27
27
|
"vitest": "^2.1.1"
|
|
28
28
|
}
|
|
@@ -102,13 +102,3 @@ exports[`Update passage output - passage name "a'1" 2`] = `
|
|
|
102
102
|
"
|
|
103
103
|
<div class="squiffy-output-section" id="squiffy-section-1" data-section="_default"><div class="squiffy-output-block"><div data-source="[[_default]]"><p>Click this: <a class="squiffy-link link-passage disabled" data-passage="a'1" role="link" tabindex="-1">a'1</a></p></div></div><div class="squiffy-output-block"><div data-source="[[_default]][a'1]"><p>Updated passage content</p></div></div></div>"
|
|
104
104
|
`;
|
|
105
|
-
|
|
106
|
-
exports[`Update passage output 1`] = `
|
|
107
|
-
"
|
|
108
|
-
<div class="squiffy-output-section" id="squiffy-section-1" data-section="_default"><div class="squiffy-output-block"><div data-source="[[_default]]"><p>Click this: <a class="squiffy-link link-passage disabled" data-passage="a" role="link" tabindex="-1">a</a></p></div></div><div class="squiffy-output-block"><div data-source="[[_default]][a]"><p>Passage a content</p></div></div></div>"
|
|
109
|
-
`;
|
|
110
|
-
|
|
111
|
-
exports[`Update passage output 2`] = `
|
|
112
|
-
"
|
|
113
|
-
<div class="squiffy-output-section" id="squiffy-section-1" data-section="_default"><div class="squiffy-output-block"><div data-source="[[_default]]"><p>Click this: <a class="squiffy-link link-passage disabled" data-passage="a" role="link" tabindex="-1">a</a></p></div></div><div class="squiffy-output-block"><div data-source="[[_default]][a]"><p>Updated passage content</p></div></div></div>"
|
|
114
|
-
`;
|
package/src/events.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
type LinkType = 'section' | 'passage' | 'rotate' | 'sequence';
|
|
2
|
+
|
|
3
|
+
export type SquiffyEventMap = {
|
|
4
|
+
linkClick: { linkType: LinkType }; // a story link was clicked
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
export type SquiffyEventHandler<E extends keyof SquiffyEventMap> =
|
|
8
|
+
(payload: SquiffyEventMap[E]) => void;
|
|
9
|
+
|
|
10
|
+
export class Emitter<Events extends Record<string, any>> {
|
|
11
|
+
private listeners = new Map<keyof Events, Set<Function>>();
|
|
12
|
+
|
|
13
|
+
on<E extends keyof Events>(event: E, handler: (p: Events[E]) => void) {
|
|
14
|
+
if (!this.listeners.has(event)) this.listeners.set(event, new Set());
|
|
15
|
+
this.listeners.get(event)!.add(handler);
|
|
16
|
+
return () => this.off(event, handler);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
off<E extends keyof Events>(event: E, handler: (p: Events[E]) => void) {
|
|
20
|
+
this.listeners.get(event)?.delete(handler);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
once<E extends keyof Events>(event: E, handler: (p: Events[E]) => void) {
|
|
24
|
+
const off = this.on(event, (payload) => {
|
|
25
|
+
off();
|
|
26
|
+
handler(payload);
|
|
27
|
+
});
|
|
28
|
+
return off;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
emit<E extends keyof Events>(event: E, payload: Events[E]) {
|
|
32
|
+
// Fire handlers asynchronously so the runtime isn't blocked by user code.
|
|
33
|
+
queueMicrotask(() => {
|
|
34
|
+
this.listeners.get(event)?.forEach(h => {
|
|
35
|
+
try { (h as any)(payload); } catch (err) {
|
|
36
|
+
// Swallow so a bad handler doesn't break the game; optionally log.
|
|
37
|
+
console.error(`[Squiffy] handler for "${String(event)}" failed`, err);
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { expect, test, beforeEach, afterEach } from 'vitest';
|
|
1
|
+
import { expect, test, beforeEach, afterEach, vi } from 'vitest';
|
|
2
2
|
import fs from 'fs/promises';
|
|
3
3
|
import globalJsdom from 'global-jsdom';
|
|
4
4
|
import { init } from './squiffy.runtime.js';
|
|
@@ -20,7 +20,6 @@ const html = `
|
|
|
20
20
|
|
|
21
21
|
const compile = async (script: string) => {
|
|
22
22
|
const compileResult = await squiffyCompile({
|
|
23
|
-
scriptBaseFilename: "filename.squiffy", // TODO: This shouldn't be required
|
|
24
23
|
script: script,
|
|
25
24
|
});
|
|
26
25
|
|
|
@@ -40,7 +39,6 @@ const compile = async (script: string) => {
|
|
|
40
39
|
}
|
|
41
40
|
|
|
42
41
|
const initScript = async (script: string) => {
|
|
43
|
-
globalJsdom(html);
|
|
44
42
|
const element = document.getElementById('squiffy');
|
|
45
43
|
|
|
46
44
|
if (!element) {
|
|
@@ -78,7 +76,7 @@ const getTestOutput = () => {
|
|
|
78
76
|
let cleanup: { (): void };
|
|
79
77
|
|
|
80
78
|
beforeEach(() => {
|
|
81
|
-
cleanup = globalJsdom();
|
|
79
|
+
cleanup = globalJsdom(html);
|
|
82
80
|
});
|
|
83
81
|
|
|
84
82
|
afterEach(() => {
|
|
@@ -103,6 +101,9 @@ test('Click a section link', async () => {
|
|
|
103
101
|
expect(section3Link).not.toBeNull();
|
|
104
102
|
expect(linkToPassage.classList).not.toContain('disabled');
|
|
105
103
|
|
|
104
|
+
const handler = vi.fn();
|
|
105
|
+
const off = squiffyApi.on('linkClick', handler);
|
|
106
|
+
|
|
106
107
|
const handled = squiffyApi.clickLink(section3Link);
|
|
107
108
|
expect(handled).toBe(true);
|
|
108
109
|
|
|
@@ -110,6 +111,15 @@ test('Click a section link', async () => {
|
|
|
110
111
|
|
|
111
112
|
// passage link is from the previous section, so should be unclickable
|
|
112
113
|
expect(squiffyApi.clickLink(linkToPassage)).toBe(false);
|
|
114
|
+
|
|
115
|
+
// await for the event to be processed
|
|
116
|
+
await Promise.resolve();
|
|
117
|
+
|
|
118
|
+
expect(handler).toHaveBeenCalledTimes(1);
|
|
119
|
+
expect(handler).toHaveBeenCalledWith(
|
|
120
|
+
expect.objectContaining({ linkType: 'section' })
|
|
121
|
+
);
|
|
122
|
+
off();
|
|
113
123
|
});
|
|
114
124
|
|
|
115
125
|
test('Click a passage link', async () => {
|
|
@@ -125,6 +135,9 @@ test('Click a passage link', async () => {
|
|
|
125
135
|
expect(section3Link).not.toBeNull();
|
|
126
136
|
expect(linkToPassage.classList).not.toContain('disabled');
|
|
127
137
|
|
|
138
|
+
const handler = vi.fn();
|
|
139
|
+
const off = squiffyApi.on('linkClick', handler);
|
|
140
|
+
|
|
128
141
|
const handled = squiffyApi.clickLink(linkToPassage);
|
|
129
142
|
expect(handled).toBe(true);
|
|
130
143
|
|
|
@@ -133,6 +146,15 @@ test('Click a passage link', async () => {
|
|
|
133
146
|
|
|
134
147
|
// shouldn't be able to click it again
|
|
135
148
|
expect(squiffyApi.clickLink(linkToPassage)).toBe(false);
|
|
149
|
+
|
|
150
|
+
// await for the event to be processed
|
|
151
|
+
await Promise.resolve();
|
|
152
|
+
|
|
153
|
+
expect(handler).toHaveBeenCalledTimes(1);
|
|
154
|
+
expect(handler).toHaveBeenCalledWith(
|
|
155
|
+
expect.objectContaining({ linkType: 'passage' })
|
|
156
|
+
);
|
|
157
|
+
off();
|
|
136
158
|
});
|
|
137
159
|
|
|
138
160
|
test('Run JavaScript functions', async () => {
|
package/src/squiffy.runtime.ts
CHANGED
|
@@ -1,64 +1,7 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
persist?: boolean,
|
|
6
|
-
onSet?: (attribute: string, value: any) => void,
|
|
7
|
-
}
|
|
8
|
-
|
|
9
|
-
export interface SquiffySettings {
|
|
10
|
-
scroll: string,
|
|
11
|
-
persist: boolean,
|
|
12
|
-
onSet: (attribute: string, value: any) => void,
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
export interface SquiffyApi {
|
|
16
|
-
restart: () => void;
|
|
17
|
-
get: (attribute: string) => any;
|
|
18
|
-
set: (attribute: string, value: any) => void;
|
|
19
|
-
clickLink: (link: HTMLElement) => boolean;
|
|
20
|
-
update: (story: Story) => void;
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
// Previous versions of Squiffy had "squiffy", "get" and "set" as globals - we now pass these directly into JS functions.
|
|
24
|
-
// We may tidy up this API at some point, though that would be a breaking change.
|
|
25
|
-
interface SquiffyJsFunctionApi {
|
|
26
|
-
get: (attribute: string) => any;
|
|
27
|
-
set: (attribute: string, value: any) => void;
|
|
28
|
-
ui: {
|
|
29
|
-
transition: (f: any) => void;
|
|
30
|
-
};
|
|
31
|
-
story: {
|
|
32
|
-
go: (section: string) => void;
|
|
33
|
-
};
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
export interface Story {
|
|
37
|
-
js: ((
|
|
38
|
-
squiffy: SquiffyJsFunctionApi,
|
|
39
|
-
get: (attribute: string) => any,
|
|
40
|
-
set: (attribute: string, value: any) => void
|
|
41
|
-
) => void)[];
|
|
42
|
-
start: string;
|
|
43
|
-
id?: string | null;
|
|
44
|
-
sections: Record<string, Section>;
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
export interface Section {
|
|
48
|
-
text?: string;
|
|
49
|
-
clear?: boolean;
|
|
50
|
-
attributes?: string[],
|
|
51
|
-
jsIndex?: number;
|
|
52
|
-
passages?: Record<string, Passage>;
|
|
53
|
-
passageCount?: number;
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
export interface Passage {
|
|
57
|
-
text?: string;
|
|
58
|
-
clear?: boolean;
|
|
59
|
-
attributes?: string[];
|
|
60
|
-
jsIndex?: number;
|
|
61
|
-
}
|
|
1
|
+
import { SquiffyApi, SquiffyInitOptions, SquiffySettings, Story, Section } from './types.js';
|
|
2
|
+
import { startsWith, rotate } from "./utils.js";
|
|
3
|
+
import { TextProcessor } from './textProcessor.js';
|
|
4
|
+
import { Emitter, SquiffyEventMap } from './events.js';
|
|
62
5
|
|
|
63
6
|
export const init = (options: SquiffyInitOptions): SquiffyApi => {
|
|
64
7
|
let story: Story;
|
|
@@ -69,6 +12,8 @@ export const init = (options: SquiffyInitOptions): SquiffyApi => {
|
|
|
69
12
|
let outputElement: HTMLElement;
|
|
70
13
|
let settings: SquiffySettings;
|
|
71
14
|
let storageFallback: Record<string, string> = {};
|
|
15
|
+
let textProcessor: TextProcessor;
|
|
16
|
+
const emitter = new Emitter<SquiffyEventMap>();
|
|
72
17
|
|
|
73
18
|
function set(attribute: string, value: any) {
|
|
74
19
|
if (typeof value === 'undefined') value = true;
|
|
@@ -122,6 +67,7 @@ export const init = (options: SquiffyInitOptions): SquiffyApi => {
|
|
|
122
67
|
}
|
|
123
68
|
}
|
|
124
69
|
|
|
70
|
+
emitter.emit('linkClick', { linkType: 'passage' });
|
|
125
71
|
return true;
|
|
126
72
|
}
|
|
127
73
|
|
|
@@ -131,6 +77,7 @@ export const init = (options: SquiffyInitOptions): SquiffyApi => {
|
|
|
131
77
|
go(section);
|
|
132
78
|
}
|
|
133
79
|
|
|
80
|
+
emitter.emit('linkClick', { linkType: 'section' });
|
|
134
81
|
return true;
|
|
135
82
|
}
|
|
136
83
|
|
|
@@ -147,7 +94,8 @@ export const init = (options: SquiffyInitOptions): SquiffyApi => {
|
|
|
147
94
|
set(attribute, result[0]);
|
|
148
95
|
}
|
|
149
96
|
save();
|
|
150
|
-
|
|
97
|
+
|
|
98
|
+
emitter.emit('linkClick', { linkType: rotateAttr ? 'rotate' : 'sequence' });
|
|
151
99
|
return true;
|
|
152
100
|
}
|
|
153
101
|
|
|
@@ -493,197 +441,16 @@ export const init = (options: SquiffyInitOptions): SquiffyApi => {
|
|
|
493
441
|
}
|
|
494
442
|
},
|
|
495
443
|
processText: (text: string) => {
|
|
496
|
-
function process(text: string, data: any) {
|
|
497
|
-
let containsUnprocessedSection = false;
|
|
498
|
-
const open = text.indexOf('{');
|
|
499
|
-
let close;
|
|
500
|
-
|
|
501
|
-
if (open > -1) {
|
|
502
|
-
let nestCount = 1;
|
|
503
|
-
let searchStart = open + 1;
|
|
504
|
-
let finished = false;
|
|
505
|
-
|
|
506
|
-
while (!finished) {
|
|
507
|
-
const nextOpen = text.indexOf('{', searchStart);
|
|
508
|
-
const nextClose = text.indexOf('}', searchStart);
|
|
509
|
-
|
|
510
|
-
if (nextClose > -1) {
|
|
511
|
-
if (nextOpen > -1 && nextOpen < nextClose) {
|
|
512
|
-
nestCount++;
|
|
513
|
-
searchStart = nextOpen + 1;
|
|
514
|
-
}
|
|
515
|
-
else {
|
|
516
|
-
nestCount--;
|
|
517
|
-
searchStart = nextClose + 1;
|
|
518
|
-
if (nestCount === 0) {
|
|
519
|
-
close = nextClose;
|
|
520
|
-
containsUnprocessedSection = true;
|
|
521
|
-
finished = true;
|
|
522
|
-
}
|
|
523
|
-
}
|
|
524
|
-
}
|
|
525
|
-
else {
|
|
526
|
-
finished = true;
|
|
527
|
-
}
|
|
528
|
-
}
|
|
529
|
-
}
|
|
530
|
-
|
|
531
|
-
if (containsUnprocessedSection) {
|
|
532
|
-
const section = text.substring(open + 1, close);
|
|
533
|
-
const value = processTextCommand(section, data);
|
|
534
|
-
text = text.substring(0, open) + value + process(text.substring(close! + 1), data);
|
|
535
|
-
}
|
|
536
|
-
|
|
537
|
-
return (text);
|
|
538
|
-
}
|
|
539
|
-
|
|
540
|
-
function processTextCommand(text: string, data: any) {
|
|
541
|
-
if (startsWith(text, 'if ')) {
|
|
542
|
-
return processTextCommand_If(text, data);
|
|
543
|
-
}
|
|
544
|
-
else if (startsWith(text, 'else:')) {
|
|
545
|
-
return processTextCommand_Else(text, data);
|
|
546
|
-
}
|
|
547
|
-
else if (startsWith(text, 'label:')) {
|
|
548
|
-
return processTextCommand_Label(text, data);
|
|
549
|
-
}
|
|
550
|
-
else if (/^rotate[: ]/.test(text)) {
|
|
551
|
-
return processTextCommand_Rotate('rotate', text);
|
|
552
|
-
}
|
|
553
|
-
else if (/^sequence[: ]/.test(text)) {
|
|
554
|
-
return processTextCommand_Rotate('sequence', text);
|
|
555
|
-
}
|
|
556
|
-
else if (currentSection.passages && text in currentSection.passages) {
|
|
557
|
-
return process(currentSection.passages[text].text || '', data);
|
|
558
|
-
}
|
|
559
|
-
else if (text in story.sections) {
|
|
560
|
-
return process(story.sections[text].text || '', data);
|
|
561
|
-
}
|
|
562
|
-
else if (startsWith(text, '@') && !startsWith(text, '@replace')) {
|
|
563
|
-
processAttributes(text.substring(1).split(","));
|
|
564
|
-
return "";
|
|
565
|
-
}
|
|
566
|
-
return get(text);
|
|
567
|
-
}
|
|
568
|
-
|
|
569
|
-
function processTextCommand_If(section: string, data: any) {
|
|
570
|
-
const command = section.substring(3);
|
|
571
|
-
const colon = command.indexOf(':');
|
|
572
|
-
if (colon == -1) {
|
|
573
|
-
return ('{if ' + command + '}');
|
|
574
|
-
}
|
|
575
|
-
|
|
576
|
-
const text = command.substring(colon + 1);
|
|
577
|
-
let condition = command.substring(0, colon);
|
|
578
|
-
condition = condition.replace("<", "<");
|
|
579
|
-
const operatorRegex = /([\w ]*)(=|<=|>=|<>|<|>)(.*)/;
|
|
580
|
-
const match = operatorRegex.exec(condition);
|
|
581
|
-
|
|
582
|
-
let result = false;
|
|
583
|
-
|
|
584
|
-
if (match) {
|
|
585
|
-
const lhs = get(match[1]);
|
|
586
|
-
const op = match[2];
|
|
587
|
-
let rhs = match[3];
|
|
588
|
-
|
|
589
|
-
if (startsWith(rhs, '@')) rhs = get(rhs.substring(1));
|
|
590
|
-
|
|
591
|
-
if (op == '=' && lhs == rhs) result = true;
|
|
592
|
-
if (op == '<>' && lhs != rhs) result = true;
|
|
593
|
-
if (op == '>' && lhs > rhs) result = true;
|
|
594
|
-
if (op == '<' && lhs < rhs) result = true;
|
|
595
|
-
if (op == '>=' && lhs >= rhs) result = true;
|
|
596
|
-
if (op == '<=' && lhs <= rhs) result = true;
|
|
597
|
-
}
|
|
598
|
-
else {
|
|
599
|
-
let checkValue = true;
|
|
600
|
-
if (startsWith(condition, 'not ')) {
|
|
601
|
-
condition = condition.substring(4);
|
|
602
|
-
checkValue = false;
|
|
603
|
-
}
|
|
604
|
-
|
|
605
|
-
if (startsWith(condition, 'seen ')) {
|
|
606
|
-
result = (seen(condition.substring(5)) == checkValue);
|
|
607
|
-
}
|
|
608
|
-
else {
|
|
609
|
-
let value = get(condition);
|
|
610
|
-
if (value === null) value = false;
|
|
611
|
-
result = (value == checkValue);
|
|
612
|
-
}
|
|
613
|
-
}
|
|
614
|
-
|
|
615
|
-
const textResult = result ? process(text, data) : '';
|
|
616
|
-
|
|
617
|
-
data.lastIf = result;
|
|
618
|
-
return textResult;
|
|
619
|
-
}
|
|
620
|
-
|
|
621
|
-
function processTextCommand_Else(section: string, data: any) {
|
|
622
|
-
if (!('lastIf' in data) || data.lastIf) return '';
|
|
623
|
-
const text = section.substring(5);
|
|
624
|
-
return process(text, data);
|
|
625
|
-
}
|
|
626
|
-
|
|
627
|
-
function processTextCommand_Label(section: string, data: any) {
|
|
628
|
-
const command = section.substring(6);
|
|
629
|
-
const eq = command.indexOf('=');
|
|
630
|
-
if (eq == -1) {
|
|
631
|
-
return ('{label:' + command + '}');
|
|
632
|
-
}
|
|
633
|
-
|
|
634
|
-
const text = command.substring(eq + 1);
|
|
635
|
-
const label = command.substring(0, eq);
|
|
636
|
-
|
|
637
|
-
return '<span class="squiffy-label-' + label + '">' + process(text, data) + '</span>';
|
|
638
|
-
}
|
|
639
|
-
|
|
640
|
-
function processTextCommand_Rotate(type: string, section: string) {
|
|
641
|
-
let options;
|
|
642
|
-
let attribute = '';
|
|
643
|
-
if (section.substring(type.length, type.length + 1) == ' ') {
|
|
644
|
-
const colon = section.indexOf(':');
|
|
645
|
-
if (colon == -1) {
|
|
646
|
-
return '{' + section + '}';
|
|
647
|
-
}
|
|
648
|
-
options = section.substring(colon + 1);
|
|
649
|
-
attribute = section.substring(type.length + 1, colon);
|
|
650
|
-
}
|
|
651
|
-
else {
|
|
652
|
-
options = section.substring(type.length + 1);
|
|
653
|
-
}
|
|
654
|
-
// TODO: Check - previously there was no second parameter here
|
|
655
|
-
const rotation = rotate(options.replace(/"/g, '"').replace(/'/g, '''), null);
|
|
656
|
-
if (attribute) {
|
|
657
|
-
set(attribute, rotation[0]);
|
|
658
|
-
}
|
|
659
|
-
return '<a class="squiffy-link" data-' + type + '="' + rotation[1] + '" data-attribute="' + attribute + '" role="link">' + rotation[0] + '</a>';
|
|
660
|
-
}
|
|
661
|
-
|
|
662
444
|
const data = {
|
|
663
445
|
fulltext: text
|
|
664
446
|
};
|
|
665
|
-
return process(text, data);
|
|
447
|
+
return textProcessor.process(text, data);
|
|
666
448
|
},
|
|
667
449
|
transition: function (f: any) {
|
|
668
450
|
set('_transition', f.toString());
|
|
669
451
|
f();
|
|
670
452
|
},
|
|
671
453
|
};
|
|
672
|
-
|
|
673
|
-
function startsWith(string: string, prefix: string) {
|
|
674
|
-
return string.substring(0, prefix.length) === prefix;
|
|
675
|
-
}
|
|
676
|
-
|
|
677
|
-
function rotate(options: string, current: string | null) {
|
|
678
|
-
const colon = options.indexOf(':');
|
|
679
|
-
if (colon == -1) {
|
|
680
|
-
return [options, current];
|
|
681
|
-
}
|
|
682
|
-
const next = options.substring(0, colon);
|
|
683
|
-
let remaining = options.substring(colon + 1);
|
|
684
|
-
if (current) remaining += ':' + current;
|
|
685
|
-
return [next, remaining];
|
|
686
|
-
}
|
|
687
454
|
|
|
688
455
|
function safeQuerySelector(name: string) {
|
|
689
456
|
return name.replace(/'/g, "\\'");
|
|
@@ -784,6 +551,8 @@ export const init = (options: SquiffyInitOptions): SquiffyApi => {
|
|
|
784
551
|
if (event.key !== "Enter") return;
|
|
785
552
|
handleClick(event);
|
|
786
553
|
});
|
|
554
|
+
|
|
555
|
+
textProcessor = new TextProcessor(get, set, story, ui, seen, processAttributes);
|
|
787
556
|
|
|
788
557
|
begin();
|
|
789
558
|
|
|
@@ -793,5 +562,8 @@ export const init = (options: SquiffyInitOptions): SquiffyApi => {
|
|
|
793
562
|
set: set,
|
|
794
563
|
clickLink: handleLink,
|
|
795
564
|
update: update,
|
|
565
|
+
on: (e, h) => emitter.on(e, h),
|
|
566
|
+
off: (e, h) => emitter.off(e, h),
|
|
567
|
+
once: (e, h) => emitter.once(e, h),
|
|
796
568
|
};
|
|
797
569
|
};
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { startsWith, rotate } from "./utils.js";
|
|
2
|
+
|
|
3
|
+
export class TextProcessor {
|
|
4
|
+
get: (attribute: string) => any;
|
|
5
|
+
set: (attribute: string, value: any) => void;
|
|
6
|
+
story: any;
|
|
7
|
+
currentSection: any;
|
|
8
|
+
seen: (section: string) => boolean;
|
|
9
|
+
processAttributes: (attributes: string[]) => void;
|
|
10
|
+
|
|
11
|
+
constructor (get: (attribute: string) => any,
|
|
12
|
+
set: (attribute: string, value: any) => void,
|
|
13
|
+
story: any, currentSection: any,
|
|
14
|
+
seen: (section: string) => boolean,
|
|
15
|
+
processAttributes: (attributes: string[]) => void) {
|
|
16
|
+
this.get = get;
|
|
17
|
+
this.set = set;
|
|
18
|
+
this.story = story;
|
|
19
|
+
this.currentSection = currentSection;
|
|
20
|
+
this.seen = seen;
|
|
21
|
+
this.processAttributes = processAttributes;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
process(text: string, data: any) {
|
|
25
|
+
let containsUnprocessedSection = false;
|
|
26
|
+
const open = text.indexOf('{');
|
|
27
|
+
let close;
|
|
28
|
+
|
|
29
|
+
if (open > -1) {
|
|
30
|
+
let nestCount = 1;
|
|
31
|
+
let searchStart = open + 1;
|
|
32
|
+
let finished = false;
|
|
33
|
+
|
|
34
|
+
while (!finished) {
|
|
35
|
+
const nextOpen = text.indexOf('{', searchStart);
|
|
36
|
+
const nextClose = text.indexOf('}', searchStart);
|
|
37
|
+
|
|
38
|
+
if (nextClose > -1) {
|
|
39
|
+
if (nextOpen > -1 && nextOpen < nextClose) {
|
|
40
|
+
nestCount++;
|
|
41
|
+
searchStart = nextOpen + 1;
|
|
42
|
+
} else {
|
|
43
|
+
nestCount--;
|
|
44
|
+
searchStart = nextClose + 1;
|
|
45
|
+
if (nestCount === 0) {
|
|
46
|
+
close = nextClose;
|
|
47
|
+
containsUnprocessedSection = true;
|
|
48
|
+
finished = true;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
} else {
|
|
52
|
+
finished = true;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (containsUnprocessedSection) {
|
|
58
|
+
const section = text.substring(open + 1, close);
|
|
59
|
+
const value = this.processTextCommand(section, data);
|
|
60
|
+
text = text.substring(0, open) + value + this.process(text.substring(close! + 1), data);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return (text);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
processTextCommand(text: string, data: any) {
|
|
67
|
+
if (startsWith(text, 'if ')) {
|
|
68
|
+
return this.processTextCommand_If(text, data);
|
|
69
|
+
} else if (startsWith(text, 'else:')) {
|
|
70
|
+
return this.processTextCommand_Else(text, data);
|
|
71
|
+
} else if (startsWith(text, 'label:')) {
|
|
72
|
+
return this.processTextCommand_Label(text, data);
|
|
73
|
+
} else if (/^rotate[: ]/.test(text)) {
|
|
74
|
+
return this.processTextCommand_Rotate('rotate', text);
|
|
75
|
+
} else if (/^sequence[: ]/.test(text)) {
|
|
76
|
+
return this.processTextCommand_Rotate('sequence', text);
|
|
77
|
+
} else if (this.currentSection.passages && text in this.currentSection.passages) {
|
|
78
|
+
return this.process(this.currentSection.passages[text].text || '', data);
|
|
79
|
+
} else if (text in this.story.sections) {
|
|
80
|
+
return this.process(this.story.sections[text].text || '', data);
|
|
81
|
+
} else if (startsWith(text, '@') && !startsWith(text, '@replace')) {
|
|
82
|
+
this.processAttributes(text.substring(1).split(","));
|
|
83
|
+
return "";
|
|
84
|
+
}
|
|
85
|
+
return this.get(text);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
processTextCommand_If(section: string, data: any) {
|
|
89
|
+
const command = section.substring(3);
|
|
90
|
+
const colon = command.indexOf(':');
|
|
91
|
+
if (colon == -1) {
|
|
92
|
+
return ('{if ' + command + '}');
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const text = command.substring(colon + 1);
|
|
96
|
+
let condition = command.substring(0, colon);
|
|
97
|
+
condition = condition.replace("<", "<");
|
|
98
|
+
const operatorRegex = /([\w ]*)(=|<=|>=|<>|<|>)(.*)/;
|
|
99
|
+
const match = operatorRegex.exec(condition);
|
|
100
|
+
|
|
101
|
+
let result = false;
|
|
102
|
+
|
|
103
|
+
if (match) {
|
|
104
|
+
const lhs = this.get(match[1]);
|
|
105
|
+
const op = match[2];
|
|
106
|
+
let rhs = match[3];
|
|
107
|
+
|
|
108
|
+
if (startsWith(rhs, '@')) rhs = this.get(rhs.substring(1));
|
|
109
|
+
|
|
110
|
+
if (op == '=' && lhs == rhs) result = true;
|
|
111
|
+
if (op == '<>' && lhs != rhs) result = true;
|
|
112
|
+
if (op == '>' && lhs > rhs) result = true;
|
|
113
|
+
if (op == '<' && lhs < rhs) result = true;
|
|
114
|
+
if (op == '>=' && lhs >= rhs) result = true;
|
|
115
|
+
if (op == '<=' && lhs <= rhs) result = true;
|
|
116
|
+
} else {
|
|
117
|
+
let checkValue = true;
|
|
118
|
+
if (startsWith(condition, 'not ')) {
|
|
119
|
+
condition = condition.substring(4);
|
|
120
|
+
checkValue = false;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (startsWith(condition, 'seen ')) {
|
|
124
|
+
result = (this.seen(condition.substring(5)) == checkValue);
|
|
125
|
+
} else {
|
|
126
|
+
let value = this.get(condition);
|
|
127
|
+
if (value === null) value = false;
|
|
128
|
+
result = (value == checkValue);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const textResult = result ? this.process(text, data) : '';
|
|
133
|
+
|
|
134
|
+
data.lastIf = result;
|
|
135
|
+
return textResult;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
processTextCommand_Else(section: string, data: any) {
|
|
139
|
+
if (!('lastIf' in data) || data.lastIf) return '';
|
|
140
|
+
const text = section.substring(5);
|
|
141
|
+
return this.process(text, data);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
processTextCommand_Label(section: string, data: any) {
|
|
145
|
+
const command = section.substring(6);
|
|
146
|
+
const eq = command.indexOf('=');
|
|
147
|
+
if (eq == -1) {
|
|
148
|
+
return ('{label:' + command + '}');
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const text = command.substring(eq + 1);
|
|
152
|
+
const label = command.substring(0, eq);
|
|
153
|
+
|
|
154
|
+
return '<span class="squiffy-label-' + label + '">' + this.process(text, data) + '</span>';
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
processTextCommand_Rotate(type: string, section: string) {
|
|
158
|
+
let options;
|
|
159
|
+
let attribute = '';
|
|
160
|
+
if (section.substring(type.length, type.length + 1) == ' ') {
|
|
161
|
+
const colon = section.indexOf(':');
|
|
162
|
+
if (colon == -1) {
|
|
163
|
+
return '{' + section + '}';
|
|
164
|
+
}
|
|
165
|
+
options = section.substring(colon + 1);
|
|
166
|
+
attribute = section.substring(type.length + 1, colon);
|
|
167
|
+
} else {
|
|
168
|
+
options = section.substring(type.length + 1);
|
|
169
|
+
}
|
|
170
|
+
// TODO: Check - previously there was no second parameter here
|
|
171
|
+
const rotation = rotate(options.replace(/"/g, '"').replace(/'/g, '''), null);
|
|
172
|
+
if (attribute) {
|
|
173
|
+
this.set(attribute, rotation[0]);
|
|
174
|
+
}
|
|
175
|
+
return '<a class="squiffy-link" data-' + type + '="' + rotation[1] + '" data-attribute="' + attribute + '" role="link">' + rotation[0] + '</a>';
|
|
176
|
+
}
|
|
177
|
+
}
|