squiffy-runtime 6.0.0-alpha.10 → 6.0.0-alpha.12
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 +2 -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 +17 -243
- 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.12",
|
|
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,9 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
persist?: boolean,
|
|
6
|
-
onSet?: (attribute: string, value: any) => void,
|
|
7
|
-
}
|
|
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';
|
|
8
5
|
|
|
9
|
-
export
|
|
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
|
-
}
|
|
6
|
+
export type { SquiffyApi } from "./types.js"
|
|
62
7
|
|
|
63
8
|
export const init = (options: SquiffyInitOptions): SquiffyApi => {
|
|
64
9
|
let story: Story;
|
|
@@ -69,6 +14,8 @@ export const init = (options: SquiffyInitOptions): SquiffyApi => {
|
|
|
69
14
|
let outputElement: HTMLElement;
|
|
70
15
|
let settings: SquiffySettings;
|
|
71
16
|
let storageFallback: Record<string, string> = {};
|
|
17
|
+
let textProcessor: TextProcessor;
|
|
18
|
+
const emitter = new Emitter<SquiffyEventMap>();
|
|
72
19
|
|
|
73
20
|
function set(attribute: string, value: any) {
|
|
74
21
|
if (typeof value === 'undefined') value = true;
|
|
@@ -122,6 +69,7 @@ export const init = (options: SquiffyInitOptions): SquiffyApi => {
|
|
|
122
69
|
}
|
|
123
70
|
}
|
|
124
71
|
|
|
72
|
+
emitter.emit('linkClick', { linkType: 'passage' });
|
|
125
73
|
return true;
|
|
126
74
|
}
|
|
127
75
|
|
|
@@ -131,6 +79,7 @@ export const init = (options: SquiffyInitOptions): SquiffyApi => {
|
|
|
131
79
|
go(section);
|
|
132
80
|
}
|
|
133
81
|
|
|
82
|
+
emitter.emit('linkClick', { linkType: 'section' });
|
|
134
83
|
return true;
|
|
135
84
|
}
|
|
136
85
|
|
|
@@ -147,7 +96,8 @@ export const init = (options: SquiffyInitOptions): SquiffyApi => {
|
|
|
147
96
|
set(attribute, result[0]);
|
|
148
97
|
}
|
|
149
98
|
save();
|
|
150
|
-
|
|
99
|
+
|
|
100
|
+
emitter.emit('linkClick', { linkType: rotateAttr ? 'rotate' : 'sequence' });
|
|
151
101
|
return true;
|
|
152
102
|
}
|
|
153
103
|
|
|
@@ -493,197 +443,16 @@ export const init = (options: SquiffyInitOptions): SquiffyApi => {
|
|
|
493
443
|
}
|
|
494
444
|
},
|
|
495
445
|
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
446
|
const data = {
|
|
663
447
|
fulltext: text
|
|
664
448
|
};
|
|
665
|
-
return process(text, data);
|
|
449
|
+
return textProcessor.process(text, data);
|
|
666
450
|
},
|
|
667
451
|
transition: function (f: any) {
|
|
668
452
|
set('_transition', f.toString());
|
|
669
453
|
f();
|
|
670
454
|
},
|
|
671
455
|
};
|
|
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
456
|
|
|
688
457
|
function safeQuerySelector(name: string) {
|
|
689
458
|
return name.replace(/'/g, "\\'");
|
|
@@ -784,6 +553,8 @@ export const init = (options: SquiffyInitOptions): SquiffyApi => {
|
|
|
784
553
|
if (event.key !== "Enter") return;
|
|
785
554
|
handleClick(event);
|
|
786
555
|
});
|
|
556
|
+
|
|
557
|
+
textProcessor = new TextProcessor(get, set, story, ui, seen, processAttributes);
|
|
787
558
|
|
|
788
559
|
begin();
|
|
789
560
|
|
|
@@ -793,5 +564,8 @@ export const init = (options: SquiffyInitOptions): SquiffyApi => {
|
|
|
793
564
|
set: set,
|
|
794
565
|
clickLink: handleLink,
|
|
795
566
|
update: update,
|
|
567
|
+
on: (e, h) => emitter.on(e, h),
|
|
568
|
+
off: (e, h) => emitter.off(e, h),
|
|
569
|
+
once: (e, h) => emitter.once(e, h),
|
|
796
570
|
};
|
|
797
571
|
};
|
|
@@ -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
|
+
}
|