squiffy-runtime 6.0.0-alpha.1 → 6.0.0-alpha.3

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.
@@ -0,0 +1,45 @@
1
+ interface SquiffyInitOptions {
2
+ element: HTMLElement;
3
+ story: Story;
4
+ scroll?: string;
5
+ persist?: boolean;
6
+ onSet?: (attribute: string, value: any) => void;
7
+ }
8
+ interface SquiffyApi {
9
+ restart: () => void;
10
+ get: (attribute: string) => any;
11
+ set: (attribute: string, value: any) => void;
12
+ clickLink: (link: HTMLElement) => void;
13
+ }
14
+ interface SquiffyJsFunctionApi {
15
+ get: (attribute: string) => any;
16
+ set: (attribute: string, value: any) => void;
17
+ ui: {
18
+ transition: (f: any) => void;
19
+ };
20
+ story: {
21
+ go: (section: string) => void;
22
+ };
23
+ }
24
+ interface Story {
25
+ js: ((squiffy: SquiffyJsFunctionApi, get: (attribute: string) => any, set: (attribute: string, value: any) => void) => void)[];
26
+ start: string;
27
+ id?: string | null;
28
+ sections: Record<string, Section>;
29
+ }
30
+ interface Section {
31
+ text?: string;
32
+ clear?: boolean;
33
+ attributes?: string[];
34
+ jsIndex?: number;
35
+ passages?: Record<string, Passage>;
36
+ passageCount?: number;
37
+ }
38
+ interface Passage {
39
+ text?: string;
40
+ clear?: boolean;
41
+ attributes?: string[];
42
+ jsIndex?: number;
43
+ }
44
+ export declare const init: (options: SquiffyInitOptions) => SquiffyApi;
45
+ export {};
@@ -0,0 +1,603 @@
1
+ export const init = (options) => {
2
+ let story;
3
+ let currentSection;
4
+ let currentSectionElement;
5
+ let scrollPosition = 0;
6
+ let outputElement;
7
+ let settings;
8
+ let storageFallback = {};
9
+ function set(attribute, value) {
10
+ if (typeof value === 'undefined')
11
+ value = true;
12
+ if (settings.persist && window.localStorage) {
13
+ localStorage[story.id + '-' + attribute] = JSON.stringify(value);
14
+ }
15
+ else {
16
+ storageFallback[attribute] = JSON.stringify(value);
17
+ }
18
+ settings.onSet(attribute, value);
19
+ }
20
+ function get(attribute) {
21
+ let result;
22
+ if (settings.persist && window.localStorage) {
23
+ result = localStorage[story.id + '-' + attribute];
24
+ }
25
+ else {
26
+ result = storageFallback[attribute];
27
+ }
28
+ if (!result)
29
+ return null;
30
+ return JSON.parse(result);
31
+ }
32
+ function handleLink(link) {
33
+ if (link.classList.contains('disabled'))
34
+ return;
35
+ let passage = link.getAttribute('data-passage');
36
+ let section = link.getAttribute('data-section');
37
+ const rotateAttr = link.getAttribute('data-rotate');
38
+ const sequenceAttr = link.getAttribute('data-sequence');
39
+ const rotateOrSequenceAttr = rotateAttr || sequenceAttr;
40
+ if (passage) {
41
+ disableLink(link);
42
+ set('_turncount', get('_turncount') + 1);
43
+ passage = processLink(passage);
44
+ if (passage) {
45
+ currentSectionElement?.appendChild(document.createElement('hr'));
46
+ showPassage(passage);
47
+ }
48
+ const turnPassage = '@' + get('_turncount');
49
+ if (currentSection.passages) {
50
+ if (turnPassage in currentSection.passages) {
51
+ showPassage(turnPassage);
52
+ }
53
+ if ('@last' in currentSection.passages && get('_turncount') >= (currentSection.passageCount || 0)) {
54
+ showPassage('@last');
55
+ }
56
+ }
57
+ }
58
+ else if (section) {
59
+ currentSectionElement?.appendChild(document.createElement('hr'));
60
+ disableLink(link);
61
+ section = processLink(section);
62
+ if (section) {
63
+ go(section);
64
+ }
65
+ }
66
+ else if (rotateOrSequenceAttr) {
67
+ const result = rotate(rotateOrSequenceAttr, rotateAttr ? link.innerText : '');
68
+ link.innerHTML = result[0].replace(/&quot;/g, '"').replace(/&#39;/g, '\'');
69
+ const dataAttribute = rotateAttr ? 'data-rotate' : 'data-sequence';
70
+ link.setAttribute(dataAttribute, result[1] || '');
71
+ if (!result[1]) {
72
+ disableLink(link);
73
+ }
74
+ const attribute = link.getAttribute('data-attribute');
75
+ if (attribute) {
76
+ set(attribute, result[0]);
77
+ }
78
+ save();
79
+ }
80
+ }
81
+ function handleClick(event) {
82
+ const target = event.target;
83
+ if (target.classList.contains('squiffy-link')) {
84
+ handleLink(target);
85
+ }
86
+ }
87
+ function disableLink(link) {
88
+ link.classList.add('disabled');
89
+ link.setAttribute('tabindex', '-1');
90
+ }
91
+ function disableLinks(links) {
92
+ links.forEach(disableLink);
93
+ }
94
+ function begin() {
95
+ if (!load()) {
96
+ go(story.start);
97
+ }
98
+ }
99
+ function processLink(link) {
100
+ const sections = link.split(',');
101
+ let first = true;
102
+ let target = null;
103
+ sections.forEach(function (section) {
104
+ section = section.trim();
105
+ if (startsWith(section, '@replace ')) {
106
+ replaceLabel(section.substring(9));
107
+ }
108
+ else {
109
+ if (first) {
110
+ target = section;
111
+ }
112
+ else {
113
+ setAttribute(section);
114
+ }
115
+ }
116
+ first = false;
117
+ });
118
+ return target;
119
+ }
120
+ function setAttribute(expr) {
121
+ expr = expr.replace(/^(\w*\s*):=(.*)$/, (_, name, value) => (name + "=" + ui.processText(value)));
122
+ const setRegex = /^([\w]*)\s*=\s*(.*)$/;
123
+ const setMatch = setRegex.exec(expr);
124
+ if (setMatch) {
125
+ const lhs = setMatch[1];
126
+ let rhs = setMatch[2];
127
+ if (isNaN(rhs)) {
128
+ if (startsWith(rhs, "@"))
129
+ rhs = get(rhs.substring(1));
130
+ set(lhs, rhs);
131
+ }
132
+ else {
133
+ set(lhs, parseFloat(rhs));
134
+ }
135
+ }
136
+ else {
137
+ const incDecRegex = /^([\w]*)\s*([\+\-\*\/])=\s*(.*)$/;
138
+ const incDecMatch = incDecRegex.exec(expr);
139
+ if (incDecMatch) {
140
+ const lhs = incDecMatch[1];
141
+ const op = incDecMatch[2];
142
+ let rhs = incDecMatch[3];
143
+ if (startsWith(rhs, "@"))
144
+ rhs = get(rhs.substring(1));
145
+ const rhsNumeric = parseFloat(rhs);
146
+ let value = get(lhs);
147
+ if (value === null)
148
+ value = 0;
149
+ if (op == '+') {
150
+ value += rhsNumeric;
151
+ }
152
+ if (op == '-') {
153
+ value -= rhsNumeric;
154
+ }
155
+ if (op == '*') {
156
+ value *= rhsNumeric;
157
+ }
158
+ if (op == '/') {
159
+ value /= rhsNumeric;
160
+ }
161
+ set(lhs, value);
162
+ }
163
+ else {
164
+ let value = true;
165
+ if (startsWith(expr, 'not ')) {
166
+ expr = expr.substring(4);
167
+ value = false;
168
+ }
169
+ set(expr, value);
170
+ }
171
+ }
172
+ }
173
+ function replaceLabel(expr) {
174
+ const regex = /^([\w]*)\s*=\s*(.*)$/;
175
+ const match = regex.exec(expr);
176
+ if (!match)
177
+ return;
178
+ const label = match[1];
179
+ let text = match[2];
180
+ if (currentSection.passages && text in currentSection.passages) {
181
+ text = currentSection.passages[text].text || '';
182
+ }
183
+ else if (text in story.sections) {
184
+ text = story.sections[text].text || '';
185
+ }
186
+ const stripParags = /^<p>(.*)<\/p>$/;
187
+ const stripParagsMatch = stripParags.exec(text);
188
+ if (stripParagsMatch) {
189
+ text = stripParagsMatch[1];
190
+ }
191
+ const labelElement = outputElement.querySelector('.squiffy-label-' + label);
192
+ if (!labelElement)
193
+ return;
194
+ labelElement.addEventListener('transitionend', function () {
195
+ labelElement.innerHTML = ui.processText(text);
196
+ labelElement.addEventListener('transitionend', function () {
197
+ save();
198
+ }, { once: true });
199
+ labelElement.classList.remove('fade-out');
200
+ labelElement.classList.add('fade-in');
201
+ }, { once: true });
202
+ labelElement.classList.add('fade-out');
203
+ }
204
+ function go(section) {
205
+ set('_transition', null);
206
+ newSection();
207
+ currentSection = story.sections[section];
208
+ if (!currentSection)
209
+ return;
210
+ set('_section', section);
211
+ setSeen(section);
212
+ const master = story.sections[''];
213
+ if (master) {
214
+ run(master);
215
+ ui.write(master.text || '');
216
+ }
217
+ run(currentSection);
218
+ // The JS might have changed which section we're in
219
+ if (get('_section') == section) {
220
+ set('_turncount', 0);
221
+ ui.write(currentSection.text || '');
222
+ save();
223
+ }
224
+ }
225
+ function run(section) {
226
+ if (section.clear) {
227
+ ui.clearScreen();
228
+ }
229
+ if (section.attributes) {
230
+ processAttributes(section.attributes.map(line => line.replace(/^random\s*:\s*(\w+)\s*=\s*(.+)/i, (line, attr, options) => (options = options.split("|")) ? attr + " = " + options[Math.floor(Math.random() * options.length)] : line)));
231
+ }
232
+ if (section.jsIndex !== undefined) {
233
+ const squiffy = {
234
+ get: get,
235
+ set: set,
236
+ ui: {
237
+ transition: ui.transition,
238
+ },
239
+ story: {
240
+ go: go,
241
+ },
242
+ };
243
+ story.js[section.jsIndex](squiffy, get, set);
244
+ }
245
+ }
246
+ function showPassage(passageName) {
247
+ let passage = currentSection.passages && currentSection.passages[passageName];
248
+ const masterSection = story.sections[''];
249
+ if (!passage && masterSection && masterSection.passages)
250
+ passage = masterSection.passages[passageName];
251
+ if (!passage)
252
+ return;
253
+ setSeen(passageName);
254
+ if (masterSection && masterSection.passages) {
255
+ const masterPassage = masterSection.passages[''];
256
+ if (masterPassage) {
257
+ run(masterPassage);
258
+ ui.write(masterPassage.text || '');
259
+ }
260
+ }
261
+ const master = currentSection.passages && currentSection.passages[''];
262
+ if (master) {
263
+ run(master);
264
+ ui.write(master.text || '');
265
+ }
266
+ run(passage);
267
+ ui.write(passage.text || '');
268
+ save();
269
+ }
270
+ function processAttributes(attributes) {
271
+ attributes.forEach(function (attribute) {
272
+ if (startsWith(attribute, '@replace ')) {
273
+ replaceLabel(attribute.substring(9));
274
+ }
275
+ else {
276
+ setAttribute(attribute);
277
+ }
278
+ });
279
+ }
280
+ function restart() {
281
+ if (settings.persist && window.localStorage && story.id) {
282
+ const keys = Object.keys(localStorage);
283
+ for (const key of keys) {
284
+ if (startsWith(key, story.id)) {
285
+ localStorage.removeItem(key);
286
+ }
287
+ }
288
+ }
289
+ else {
290
+ storageFallback = {};
291
+ }
292
+ if (settings.scroll === 'element') {
293
+ outputElement.innerHTML = '';
294
+ begin();
295
+ }
296
+ else {
297
+ location.reload();
298
+ }
299
+ }
300
+ function save() {
301
+ set('_output', outputElement.innerHTML);
302
+ }
303
+ function load() {
304
+ const output = get('_output');
305
+ if (!output)
306
+ return false;
307
+ outputElement.innerHTML = output;
308
+ const element = document.getElementById(get('_output-section'));
309
+ if (!element)
310
+ return false;
311
+ currentSectionElement = element;
312
+ currentSection = story.sections[get('_section')];
313
+ const transition = get('_transition');
314
+ if (transition) {
315
+ eval('(' + transition + ')()');
316
+ }
317
+ return true;
318
+ }
319
+ function setSeen(sectionName) {
320
+ let seenSections = get('_seen_sections');
321
+ if (!seenSections)
322
+ seenSections = [];
323
+ if (seenSections.indexOf(sectionName) == -1) {
324
+ seenSections.push(sectionName);
325
+ set('_seen_sections', seenSections);
326
+ }
327
+ }
328
+ function seen(sectionName) {
329
+ const seenSections = get('_seen_sections');
330
+ if (!seenSections)
331
+ return false;
332
+ return (seenSections.indexOf(sectionName) > -1);
333
+ }
334
+ function newSection() {
335
+ if (currentSectionElement) {
336
+ disableLinks(currentSectionElement.querySelectorAll('.squiffy-link'));
337
+ currentSectionElement.querySelectorAll('input').forEach(el => {
338
+ const attribute = el.getAttribute('data-attribute') || el.id;
339
+ if (attribute)
340
+ set(attribute, el.value);
341
+ el.disabled = true;
342
+ });
343
+ currentSectionElement.querySelectorAll("[contenteditable]").forEach(el => {
344
+ const attribute = el.getAttribute('data-attribute') || el.id;
345
+ if (attribute)
346
+ set(attribute, el.innerHTML);
347
+ el.contentEditable = 'false';
348
+ });
349
+ currentSectionElement.querySelectorAll('textarea').forEach(el => {
350
+ const attribute = el.getAttribute('data-attribute') || el.id;
351
+ if (attribute)
352
+ set(attribute, el.value);
353
+ el.disabled = true;
354
+ });
355
+ }
356
+ const sectionCount = get('_section-count') + 1;
357
+ set('_section-count', sectionCount);
358
+ const id = 'squiffy-section-' + sectionCount;
359
+ currentSectionElement = document.createElement('div');
360
+ currentSectionElement.id = id;
361
+ outputElement.appendChild(currentSectionElement);
362
+ set('_output-section', id);
363
+ }
364
+ const ui = {
365
+ write: (text) => {
366
+ if (!currentSectionElement)
367
+ return;
368
+ scrollPosition = outputElement.scrollHeight;
369
+ const div = document.createElement('div');
370
+ currentSectionElement.appendChild(div);
371
+ div.innerHTML = ui.processText(text);
372
+ ui.scrollToEnd();
373
+ },
374
+ clearScreen: () => {
375
+ outputElement.innerHTML = '';
376
+ newSection();
377
+ },
378
+ scrollToEnd: () => {
379
+ if (settings.scroll === 'element') {
380
+ const scrollTo = outputElement.scrollHeight - outputElement.clientHeight;
381
+ const currentScrollTop = outputElement.scrollTop;
382
+ if (scrollTo > (currentScrollTop || 0)) {
383
+ outputElement.scrollTo({ top: scrollTo, behavior: 'smooth' });
384
+ }
385
+ }
386
+ else {
387
+ let scrollTo = scrollPosition;
388
+ const currentScrollTop = Math.max(document.body.scrollTop, document.documentElement.scrollTop);
389
+ if (scrollTo > currentScrollTop) {
390
+ const maxScrollTop = document.documentElement.scrollHeight - window.innerHeight;
391
+ if (scrollTo > maxScrollTop)
392
+ scrollTo = maxScrollTop;
393
+ window.scrollTo({ top: scrollTo, behavior: 'smooth' });
394
+ }
395
+ }
396
+ },
397
+ processText: (text) => {
398
+ function process(text, data) {
399
+ let containsUnprocessedSection = false;
400
+ const open = text.indexOf('{');
401
+ let close;
402
+ if (open > -1) {
403
+ let nestCount = 1;
404
+ let searchStart = open + 1;
405
+ let finished = false;
406
+ while (!finished) {
407
+ const nextOpen = text.indexOf('{', searchStart);
408
+ const nextClose = text.indexOf('}', searchStart);
409
+ if (nextClose > -1) {
410
+ if (nextOpen > -1 && nextOpen < nextClose) {
411
+ nestCount++;
412
+ searchStart = nextOpen + 1;
413
+ }
414
+ else {
415
+ nestCount--;
416
+ searchStart = nextClose + 1;
417
+ if (nestCount === 0) {
418
+ close = nextClose;
419
+ containsUnprocessedSection = true;
420
+ finished = true;
421
+ }
422
+ }
423
+ }
424
+ else {
425
+ finished = true;
426
+ }
427
+ }
428
+ }
429
+ if (containsUnprocessedSection) {
430
+ const section = text.substring(open + 1, close);
431
+ const value = processTextCommand(section, data);
432
+ text = text.substring(0, open) + value + process(text.substring(close + 1), data);
433
+ }
434
+ return (text);
435
+ }
436
+ function processTextCommand(text, data) {
437
+ if (startsWith(text, 'if ')) {
438
+ return processTextCommand_If(text, data);
439
+ }
440
+ else if (startsWith(text, 'else:')) {
441
+ return processTextCommand_Else(text, data);
442
+ }
443
+ else if (startsWith(text, 'label:')) {
444
+ return processTextCommand_Label(text, data);
445
+ }
446
+ else if (/^rotate[: ]/.test(text)) {
447
+ return processTextCommand_Rotate('rotate', text);
448
+ }
449
+ else if (/^sequence[: ]/.test(text)) {
450
+ return processTextCommand_Rotate('sequence', text);
451
+ }
452
+ else if (currentSection.passages && text in currentSection.passages) {
453
+ return process(currentSection.passages[text].text || '', data);
454
+ }
455
+ else if (text in story.sections) {
456
+ return process(story.sections[text].text || '', data);
457
+ }
458
+ else if (startsWith(text, '@') && !startsWith(text, '@replace')) {
459
+ processAttributes(text.substring(1).split(","));
460
+ return "";
461
+ }
462
+ return get(text);
463
+ }
464
+ function processTextCommand_If(section, data) {
465
+ const command = section.substring(3);
466
+ const colon = command.indexOf(':');
467
+ if (colon == -1) {
468
+ return ('{if ' + command + '}');
469
+ }
470
+ const text = command.substring(colon + 1);
471
+ let condition = command.substring(0, colon);
472
+ condition = condition.replace("<", "&lt;");
473
+ const operatorRegex = /([\w ]*)(=|&lt;=|&gt;=|&lt;&gt;|&lt;|&gt;)(.*)/;
474
+ const match = operatorRegex.exec(condition);
475
+ let result = false;
476
+ if (match) {
477
+ const lhs = get(match[1]);
478
+ const op = match[2];
479
+ let rhs = match[3];
480
+ if (startsWith(rhs, '@'))
481
+ rhs = get(rhs.substring(1));
482
+ if (op == '=' && lhs == rhs)
483
+ result = true;
484
+ if (op == '&lt;&gt;' && lhs != rhs)
485
+ result = true;
486
+ if (op == '&gt;' && lhs > rhs)
487
+ result = true;
488
+ if (op == '&lt;' && lhs < rhs)
489
+ result = true;
490
+ if (op == '&gt;=' && lhs >= rhs)
491
+ result = true;
492
+ if (op == '&lt;=' && lhs <= rhs)
493
+ result = true;
494
+ }
495
+ else {
496
+ let checkValue = true;
497
+ if (startsWith(condition, 'not ')) {
498
+ condition = condition.substring(4);
499
+ checkValue = false;
500
+ }
501
+ if (startsWith(condition, 'seen ')) {
502
+ result = (seen(condition.substring(5)) == checkValue);
503
+ }
504
+ else {
505
+ let value = get(condition);
506
+ if (value === null)
507
+ value = false;
508
+ result = (value == checkValue);
509
+ }
510
+ }
511
+ const textResult = result ? process(text, data) : '';
512
+ data.lastIf = result;
513
+ return textResult;
514
+ }
515
+ function processTextCommand_Else(section, data) {
516
+ if (!('lastIf' in data) || data.lastIf)
517
+ return '';
518
+ const text = section.substring(5);
519
+ return process(text, data);
520
+ }
521
+ function processTextCommand_Label(section, data) {
522
+ const command = section.substring(6);
523
+ const eq = command.indexOf('=');
524
+ if (eq == -1) {
525
+ return ('{label:' + command + '}');
526
+ }
527
+ const text = command.substring(eq + 1);
528
+ const label = command.substring(0, eq);
529
+ return '<span class="squiffy-label-' + label + '">' + process(text, data) + '</span>';
530
+ }
531
+ function processTextCommand_Rotate(type, section) {
532
+ let options;
533
+ let attribute = '';
534
+ if (section.substring(type.length, type.length + 1) == ' ') {
535
+ const colon = section.indexOf(':');
536
+ if (colon == -1) {
537
+ return '{' + section + '}';
538
+ }
539
+ options = section.substring(colon + 1);
540
+ attribute = section.substring(type.length + 1, colon);
541
+ }
542
+ else {
543
+ options = section.substring(type.length + 1);
544
+ }
545
+ // TODO: Check - previously there was no second parameter here
546
+ const rotation = rotate(options.replace(/"/g, '&quot;').replace(/'/g, '&#39;'), null);
547
+ if (attribute) {
548
+ set(attribute, rotation[0]);
549
+ }
550
+ return '<a class="squiffy-link" data-' + type + '="' + rotation[1] + '" data-attribute="' + attribute + '" role="link">' + rotation[0] + '</a>';
551
+ }
552
+ const data = {
553
+ fulltext: text
554
+ };
555
+ return process(text, data);
556
+ },
557
+ transition: function (f) {
558
+ set('_transition', f.toString());
559
+ f();
560
+ },
561
+ };
562
+ function startsWith(string, prefix) {
563
+ return string.substring(0, prefix.length) === prefix;
564
+ }
565
+ function rotate(options, current) {
566
+ const colon = options.indexOf(':');
567
+ if (colon == -1) {
568
+ return [options, current];
569
+ }
570
+ const next = options.substring(0, colon);
571
+ let remaining = options.substring(colon + 1);
572
+ if (current)
573
+ remaining += ':' + current;
574
+ return [next, remaining];
575
+ }
576
+ outputElement = options.element;
577
+ story = options.story;
578
+ settings = {
579
+ scroll: options.scroll || 'body',
580
+ persist: (options.persist === undefined) ? true : options.persist,
581
+ onSet: options.onSet || (() => { })
582
+ };
583
+ if (options.persist === true && !story.id) {
584
+ console.warn("Persist is set to true in Squiffy runtime options, but no story id has been set. Persist will be disabled.");
585
+ settings.persist = false;
586
+ }
587
+ if (settings.scroll === 'element') {
588
+ outputElement.style.overflowY = 'auto';
589
+ }
590
+ document.addEventListener('click', handleClick);
591
+ document.addEventListener('keypress', function (event) {
592
+ if (event.key !== "Enter")
593
+ return;
594
+ handleClick(event);
595
+ });
596
+ begin();
597
+ return {
598
+ restart: restart,
599
+ get: get,
600
+ set: set,
601
+ clickLink: handleLink
602
+ };
603
+ };
package/package.json CHANGED
@@ -1,15 +1,26 @@
1
1
  {
2
2
  "name": "squiffy-runtime",
3
- "version": "6.0.0-alpha.1",
4
- "main": "squiffy.runtime.js",
3
+ "version": "6.0.0-alpha.3",
4
+ "main": "dist/squiffy.runtime.js",
5
+ "types": "dist/squiffy.runtime.d.ts",
5
6
  "scripts": {
6
- "test": "echo \"Error: no test specified\" && exit 1",
7
+ "test": "vitest",
7
8
  "build": "tsc"
8
9
  },
9
10
  "author": "Alex Warren",
11
+ "contributors": [
12
+ "CrisisSDK",
13
+ "mrangel",
14
+ "Luis Felipe Morales"
15
+ ],
10
16
  "license": "MIT",
11
17
  "description": "",
12
- "dependencies": {
13
- "typescript": "^5.6.2"
18
+ "devDependencies": {
19
+ "@types/jsdom": "^21.1.7",
20
+ "global-jsdom": "^25.0.0",
21
+ "jsdom": "^25.0.0",
22
+ "squiffy-compiler": "^6.0.0-alpha.2",
23
+ "typescript": "^5.6.2",
24
+ "vitest": "^2.1.1"
14
25
  }
15
26
  }
@@ -0,0 +1,54 @@
1
+ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
2
+
3
+ exports[`"Hello world" script should run 1`] = `
4
+ "
5
+ <div id="squiffy-section-1"><div><p>Hello world</p></div></div>"
6
+ `;
7
+
8
+ exports[`Click a passage link 1`] = `
9
+ "
10
+ <div id="squiffy-section-1"><div><h1>Squiffy Test</h1>
11
+ <p>This is <a href="http://textadventures.co.uk">a website link</a>.</p>
12
+ <p>This should be <a class="squiffy-link link-passage" data-passage="passage" role="link" tabindex="0">a link to a passage</a>. Here's <a class="squiffy-link link-passage" data-passage="passage2" role="link" tabindex="0">another one</a>.</p>
13
+ <p>You don't need to specify a name - for example, this <a class="squiffy-link link-passage" data-passage="link" role="link" tabindex="0">link</a> and this <a class="squiffy-link link-section" data-section="section" role="link" tabindex="0">section</a>.</p>
14
+ <p>And this goes to the <a class="squiffy-link link-section" data-section="section2" role="link" tabindex="0">next section</a>.</p>
15
+ <p>This line has links to <a class="squiffy-link link-section" data-section="section 3" role="link" tabindex="0">section 3</a> and <a class="squiffy-link link-section" data-section="section four" role="link" tabindex="0">section 4</a>.</p>
16
+ <p>This line has links to <a class="squiffy-link link-passage" data-passage="passage 3" role="link" tabindex="0">passage 3</a> and <a class="squiffy-link link-passage" data-passage="passage four" role="link" tabindex="0">passage 4</a>.</p>
17
+ <p>Oh look - <a class="squiffy-link link-passage" data-passage="it's a passage with an apostrophe" role="link" tabindex="0">it's a passage with an apostrophe</a>.</p></div></div>"
18
+ `;
19
+
20
+ exports[`Click a passage link 2`] = `
21
+ "
22
+ <div id="squiffy-section-1"><div><h1>Squiffy Test</h1>
23
+ <p>This is <a href="http://textadventures.co.uk">a website link</a>.</p>
24
+ <p>This should be <a class="squiffy-link link-passage disabled" data-passage="passage" role="link" tabindex="-1">a link to a passage</a>. Here's <a class="squiffy-link link-passage" data-passage="passage2" role="link" tabindex="0">another one</a>.</p>
25
+ <p>You don't need to specify a name - for example, this <a class="squiffy-link link-passage" data-passage="link" role="link" tabindex="0">link</a> and this <a class="squiffy-link link-section" data-section="section" role="link" tabindex="0">section</a>.</p>
26
+ <p>And this goes to the <a class="squiffy-link link-section" data-section="section2" role="link" tabindex="0">next section</a>.</p>
27
+ <p>This line has links to <a class="squiffy-link link-section" data-section="section 3" role="link" tabindex="0">section 3</a> and <a class="squiffy-link link-section" data-section="section four" role="link" tabindex="0">section 4</a>.</p>
28
+ <p>This line has links to <a class="squiffy-link link-passage" data-passage="passage 3" role="link" tabindex="0">passage 3</a> and <a class="squiffy-link link-passage" data-passage="passage four" role="link" tabindex="0">passage 4</a>.</p>
29
+ <p>Oh look - <a class="squiffy-link link-passage" data-passage="it's a passage with an apostrophe" role="link" tabindex="0">it's a passage with an apostrophe</a>.</p></div><hr><div><p>Here's some text for the passage.</p></div></div>"
30
+ `;
31
+
32
+ exports[`Click a section link 1`] = `
33
+ "
34
+ <div id="squiffy-section-1"><div><h1>Squiffy Test</h1>
35
+ <p>This is <a href="http://textadventures.co.uk">a website link</a>.</p>
36
+ <p>This should be <a class="squiffy-link link-passage" data-passage="passage" role="link" tabindex="0">a link to a passage</a>. Here's <a class="squiffy-link link-passage" data-passage="passage2" role="link" tabindex="0">another one</a>.</p>
37
+ <p>You don't need to specify a name - for example, this <a class="squiffy-link link-passage" data-passage="link" role="link" tabindex="0">link</a> and this <a class="squiffy-link link-section" data-section="section" role="link" tabindex="0">section</a>.</p>
38
+ <p>And this goes to the <a class="squiffy-link link-section" data-section="section2" role="link" tabindex="0">next section</a>.</p>
39
+ <p>This line has links to <a class="squiffy-link link-section" data-section="section 3" role="link" tabindex="0">section 3</a> and <a class="squiffy-link link-section" data-section="section four" role="link" tabindex="0">section 4</a>.</p>
40
+ <p>This line has links to <a class="squiffy-link link-passage" data-passage="passage 3" role="link" tabindex="0">passage 3</a> and <a class="squiffy-link link-passage" data-passage="passage four" role="link" tabindex="0">passage 4</a>.</p>
41
+ <p>Oh look - <a class="squiffy-link link-passage" data-passage="it's a passage with an apostrophe" role="link" tabindex="0">it's a passage with an apostrophe</a>.</p></div></div>"
42
+ `;
43
+
44
+ exports[`Click a section link 2`] = `
45
+ "
46
+ <div id="squiffy-section-1"><div><h1>Squiffy Test</h1>
47
+ <p>This is <a href="http://textadventures.co.uk">a website link</a>.</p>
48
+ <p>This should be <a class="squiffy-link link-passage disabled" data-passage="passage" role="link" tabindex="-1">a link to a passage</a>. Here's <a class="squiffy-link link-passage disabled" data-passage="passage2" role="link" tabindex="-1">another one</a>.</p>
49
+ <p>You don't need to specify a name - for example, this <a class="squiffy-link link-passage disabled" data-passage="link" role="link" tabindex="-1">link</a> and this <a class="squiffy-link link-section disabled" data-section="section" role="link" tabindex="-1">section</a>.</p>
50
+ <p>And this goes to the <a class="squiffy-link link-section disabled" data-section="section2" role="link" tabindex="-1">next section</a>.</p>
51
+ <p>This line has links to <a class="squiffy-link link-section disabled" data-section="section 3" role="link" tabindex="-1">section 3</a> and <a class="squiffy-link link-section disabled" data-section="section four" role="link" tabindex="-1">section 4</a>.</p>
52
+ <p>This line has links to <a class="squiffy-link link-passage disabled" data-passage="passage 3" role="link" tabindex="-1">passage 3</a> and <a class="squiffy-link link-passage disabled" data-passage="passage four" role="link" tabindex="-1">passage 4</a>.</p>
53
+ <p>Oh look - <a class="squiffy-link link-passage disabled" data-passage="it's a passage with an apostrophe" role="link" tabindex="-1">it's a passage with an apostrophe</a>.</p></div><hr></div><div id="squiffy-section-2"><div><p>Another section is here.</p></div></div>"
54
+ `;
@@ -0,0 +1,173 @@
1
+ import { expect, test, beforeEach, afterEach } from 'vitest';
2
+ import fs from 'fs/promises';
3
+ import globalJsdom from 'global-jsdom';
4
+ import { init } from './squiffy.runtime.js';
5
+ import { compile } from 'squiffy-compiler';
6
+
7
+ const html = `
8
+ <!DOCTYPE html>
9
+ <html>
10
+ <head>
11
+ </head>
12
+ <body>
13
+ <div id="squiffy">
14
+ </div>
15
+ <div id="test">
16
+ </div>
17
+ </body>
18
+ </html>
19
+ `;
20
+
21
+ const initScript = async (script: string) => {
22
+ globalJsdom(html);
23
+ const element = document.getElementById('squiffy');
24
+
25
+ if (!element) {
26
+ throw new Error('Element not found');
27
+ }
28
+
29
+ const compileResult = await compile({
30
+ scriptBaseFilename: "filename.squiffy", // TODO: This shouldn't be required
31
+ script: script,
32
+ });
33
+
34
+ if (!compileResult.success) {
35
+ throw new Error('Compile failed');
36
+ }
37
+
38
+ const story = compileResult.output.story;
39
+ const js = compileResult.output.js.map(jsLines => new Function('squiffy', 'get', 'set', jsLines.join('\n')));
40
+
41
+ const squiffyApi = init({
42
+ element: element,
43
+ story: {
44
+ js: js as any,
45
+ ...story,
46
+ },
47
+ });
48
+
49
+ return {
50
+ squiffyApi,
51
+ element
52
+ }
53
+ };
54
+
55
+ const findLink = (element: HTMLElement, linkType: string, linkText: string, onlyEnabled: boolean = false) => {
56
+ const links = element.querySelectorAll(`a.squiffy-link.link-${linkType}`);
57
+ return Array.from(links).find(link => link.textContent === linkText && (onlyEnabled ? !link.classList.contains("disabled") : true)) as HTMLElement;
58
+ };
59
+
60
+ const getTestOutput = () => {
61
+ const testElement = document.getElementById('test');
62
+ if (!testElement) {
63
+ throw new Error('Test element not found');
64
+ }
65
+ return testElement.innerText;
66
+ }
67
+
68
+ let cleanup: { (): void };
69
+
70
+ beforeEach(() => {
71
+ cleanup = globalJsdom();
72
+ });
73
+
74
+ afterEach(() => {
75
+ cleanup();
76
+ });
77
+
78
+ test('"Hello world" script should run', async () => {
79
+ const { element } = await initScript("Hello world");
80
+ expect(element.innerHTML).toMatchSnapshot();
81
+ });
82
+
83
+ test('Click a section link', async () => {
84
+ const script = await fs.readFile('../examples/test/example.squiffy', 'utf-8');
85
+ const { squiffyApi, element } = await initScript(script);
86
+ expect(element.innerHTML).toMatchSnapshot();
87
+
88
+ expect(element.querySelectorAll('a.squiffy-link').length).toBe(10);
89
+ const linkToPassage = findLink(element, 'passage', 'a link to a passage');
90
+ const section3Link = findLink(element, 'section', 'section 3');
91
+
92
+ expect(linkToPassage).toBeDefined();
93
+ expect(section3Link).toBeDefined();
94
+ expect(linkToPassage.classList).not.toContain('disabled');
95
+ expect(section3Link.classList).not.toContain('disabled');
96
+
97
+ squiffyApi.clickLink(section3Link);
98
+
99
+ expect(linkToPassage.classList).toContain('disabled');
100
+ expect(section3Link.classList).toContain('disabled');
101
+ expect(element.innerHTML).toMatchSnapshot();
102
+ });
103
+
104
+ test('Click a passage link', async () => {
105
+ const script = await fs.readFile('../examples/test/example.squiffy', 'utf-8');
106
+ const { squiffyApi, element } = await initScript(script);
107
+ expect(element.innerHTML).toMatchSnapshot();
108
+
109
+ expect(element.querySelectorAll('a.squiffy-link').length).toBe(10);
110
+ const linkToPassage = findLink(element, 'passage', 'a link to a passage');
111
+ const section3Link = findLink(element, 'section', 'section 3');
112
+
113
+ expect(linkToPassage).toBeDefined();
114
+ expect(section3Link).toBeDefined();
115
+ expect(linkToPassage.classList).not.toContain('disabled');
116
+ expect(section3Link.classList).not.toContain('disabled');
117
+
118
+ squiffyApi.clickLink(linkToPassage);
119
+
120
+ expect(linkToPassage.classList).toContain('disabled');
121
+ expect(section3Link.classList).not.toContain('disabled');
122
+ expect(element.innerHTML).toMatchSnapshot();
123
+ });
124
+
125
+ test('Run JavaScript functions', async () => {
126
+ const script = `
127
+ document.getElementById('test').innerText = 'Initial JavaScript';
128
+ @set some_string = some_value
129
+ @set some_number = 5
130
+
131
+ +++Continue...
132
+ document.getElementById('test').innerText = "Value: " + get("some_number");
133
+ +++Continue...
134
+ document.getElementById('test').innerText = "Value: " + get("some_string");
135
+ set("some_number", 10);
136
+ +++Continue...
137
+ document.getElementById('test').innerText = "Value: " + get("some_number");
138
+ +++Continue...
139
+ @inc some_number
140
+ +++Continue...
141
+ document.getElementById('test').innerText = "Value: " + get("some_number");
142
+ +++Continue...
143
+ squiffy.story.go("other section");
144
+ [[other section]]:
145
+ document.getElementById('test').innerText = "In other section";
146
+ `;
147
+
148
+ const clickContinue = () => {
149
+ const continueLink = findLink(element, 'section', 'Continue...', true);
150
+ expect(continueLink).toBeDefined();
151
+ squiffyApi.clickLink(continueLink);
152
+ };
153
+
154
+ const { squiffyApi, element } = await initScript(script);
155
+
156
+ expect(getTestOutput()).toBe('Initial JavaScript');
157
+ clickContinue();
158
+
159
+ expect(getTestOutput()).toBe('Value: 5');
160
+ clickContinue();
161
+
162
+ expect(getTestOutput()).toBe('Value: some_value');
163
+ clickContinue();
164
+
165
+ expect(getTestOutput()).toBe('Value: 10');
166
+
167
+ clickContinue();
168
+ clickContinue();
169
+ expect(getTestOutput()).toBe('Value: 11');
170
+
171
+ clickContinue();
172
+ expect(getTestOutput()).toBe('In other section');
173
+ });
@@ -16,10 +16,28 @@ interface SquiffyApi {
16
16
  restart: () => void;
17
17
  get: (attribute: string) => any;
18
18
  set: (attribute: string, value: any) => void;
19
+ clickLink: (link: HTMLElement) => void;
20
+ }
21
+
22
+ // Previous versions of Squiffy had "squiffy", "get" and "set" as globals - we now pass these directly into JS functions.
23
+ // We may tidy up this API at some point, though that would be a breaking change.
24
+ interface SquiffyJsFunctionApi {
25
+ get: (attribute: string) => any;
26
+ set: (attribute: string, value: any) => void;
27
+ ui: {
28
+ transition: (f: any) => void;
29
+ };
30
+ story: {
31
+ go: (section: string) => void;
32
+ };
19
33
  }
20
34
 
21
35
  interface Story {
22
- js: (() => void)[];
36
+ js: ((
37
+ squiffy: SquiffyJsFunctionApi,
38
+ get: (attribute: string) => any,
39
+ set: (attribute: string, value: any) => void
40
+ ) => void)[];
23
41
  start: string;
24
42
  id?: string | null;
25
43
  sections: Record<string, Section>;
@@ -73,68 +91,60 @@ export const init = (options: SquiffyInitOptions): SquiffyApi => {
73
91
  return JSON.parse(result);
74
92
  }
75
93
 
76
- function initLinkHandler() {
77
- function handleLink(link: HTMLElement) {
78
- if (link.classList.contains('disabled')) return;
79
- let passage = link.getAttribute('data-passage');
80
- let section = link.getAttribute('data-section');
81
- const rotateAttr = link.getAttribute('data-rotate');
82
- const sequenceAttr = link.getAttribute('data-sequence');
83
- const rotateOrSequenceAttr = rotateAttr || sequenceAttr;
94
+ function handleLink(link: HTMLElement) {
95
+ if (link.classList.contains('disabled')) return;
96
+ let passage = link.getAttribute('data-passage');
97
+ let section = link.getAttribute('data-section');
98
+ const rotateAttr = link.getAttribute('data-rotate');
99
+ const sequenceAttr = link.getAttribute('data-sequence');
100
+ const rotateOrSequenceAttr = rotateAttr || sequenceAttr;
101
+ if (passage) {
102
+ disableLink(link);
103
+ set('_turncount', get('_turncount') + 1);
104
+ passage = processLink(passage);
84
105
  if (passage) {
85
- disableLink(link);
86
- set('_turncount', get('_turncount') + 1);
87
- passage = processLink(passage);
88
- if (passage) {
89
- currentSectionElement?.appendChild(document.createElement('hr'));
90
- showPassage(passage);
91
- }
92
- const turnPassage = '@' + get('_turncount');
93
- if (currentSection.passages) {
94
- if (turnPassage in currentSection.passages) {
95
- showPassage(turnPassage);
96
- }
97
- if ('@last' in currentSection.passages && get('_turncount') >= (currentSection.passageCount || 0)) {
98
- showPassage('@last');
99
- }
100
- }
101
- }
102
- else if (section) {
103
106
  currentSectionElement?.appendChild(document.createElement('hr'));
104
- disableLink(link);
105
- section = processLink(section);
106
- if (section) {
107
- go(section);
108
- }
107
+ showPassage(passage);
109
108
  }
110
- else if (rotateOrSequenceAttr) {
111
- const result = rotate(rotateOrSequenceAttr, rotateAttr ? link.innerText : '');
112
- link.innerHTML = result[0]!.replace(/&quot;/g, '"').replace(/&#39;/g, '\'');
113
- const dataAttribute = rotateAttr ? 'data-rotate' : 'data-sequence';
114
- link.setAttribute(dataAttribute, result[1] || '');
115
- if (!result[1]) {
116
- disableLink(link);
109
+ const turnPassage = '@' + get('_turncount');
110
+ if (currentSection.passages) {
111
+ if (turnPassage in currentSection.passages) {
112
+ showPassage(turnPassage);
117
113
  }
118
- const attribute = link.getAttribute('data-attribute');
119
- if (attribute) {
120
- set(attribute, result[0]);
114
+ if ('@last' in currentSection.passages && get('_turncount') >= (currentSection.passageCount || 0)) {
115
+ showPassage('@last');
121
116
  }
122
- save();
123
117
  }
124
118
  }
125
-
126
- function handleClick(event: Event) {
127
- const target = event.target as HTMLElement;
128
- if (target.classList.contains('squiffy-link')) {
129
- handleLink(target);
119
+ else if (section) {
120
+ currentSectionElement?.appendChild(document.createElement('hr'));
121
+ disableLink(link);
122
+ section = processLink(section);
123
+ if (section) {
124
+ go(section);
130
125
  }
131
126
  }
132
-
133
- document.addEventListener('click', handleClick);
134
- document.addEventListener('keypress', function (event) {
135
- if (event.key !== "Enter") return;
136
- handleClick(event);
137
- });
127
+ else if (rotateOrSequenceAttr) {
128
+ const result = rotate(rotateOrSequenceAttr, rotateAttr ? link.innerText : '');
129
+ link.innerHTML = result[0]!.replace(/&quot;/g, '"').replace(/&#39;/g, '\'');
130
+ const dataAttribute = rotateAttr ? 'data-rotate' : 'data-sequence';
131
+ link.setAttribute(dataAttribute, result[1] || '');
132
+ if (!result[1]) {
133
+ disableLink(link);
134
+ }
135
+ const attribute = link.getAttribute('data-attribute');
136
+ if (attribute) {
137
+ set(attribute, result[0]);
138
+ }
139
+ save();
140
+ }
141
+ }
142
+
143
+ function handleClick(event: Event) {
144
+ const target = event.target as HTMLElement;
145
+ if (target.classList.contains('squiffy-link')) {
146
+ handleLink(target);
147
+ }
138
148
  }
139
149
 
140
150
  function disableLink(link: Element) {
@@ -289,7 +299,17 @@ export const init = (options: SquiffyInitOptions): SquiffyApi => {
289
299
  processAttributes(section.attributes.map(line => line.replace(/^random\s*:\s*(\w+)\s*=\s*(.+)/i, (line, attr, options) => (options = options.split("|")) ? attr + " = " + options[Math.floor(Math.random() * options.length)] : line)));
290
300
  }
291
301
  if (section.jsIndex !== undefined) {
292
- story.js[section.jsIndex]();
302
+ const squiffy = {
303
+ get: get,
304
+ set: set,
305
+ ui: {
306
+ transition: ui.transition,
307
+ },
308
+ story: {
309
+ go: go,
310
+ },
311
+ };
312
+ story.js[section.jsIndex](squiffy, get, set);
293
313
  }
294
314
  }
295
315
 
@@ -659,12 +679,18 @@ export const init = (options: SquiffyInitOptions): SquiffyApi => {
659
679
  outputElement.style.overflowY = 'auto';
660
680
  }
661
681
 
662
- initLinkHandler();
682
+ document.addEventListener('click', handleClick);
683
+ document.addEventListener('keypress', function (event) {
684
+ if (event.key !== "Enter") return;
685
+ handleClick(event);
686
+ });
687
+
663
688
  begin();
664
689
 
665
690
  return {
666
691
  restart: restart,
667
692
  get: get,
668
693
  set: set,
694
+ clickLink: handleLink
669
695
  };
670
696
  };
package/tsconfig.json CHANGED
@@ -7,6 +7,7 @@
7
7
  "noFallthroughCasesInSwitch": true,
8
8
  "skipLibCheck": true,
9
9
  "outDir": "dist",
10
+ "declaration": true,
10
11
  },
11
12
  "include": ["src/squiffy.runtime.ts"]
12
13
  }