squiffy-runtime 6.0.0-alpha.2 → 6.0.0-alpha.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/LICENSE +22 -0
  2. package/dist/animation.d.ts +11 -0
  3. package/dist/events.d.ts +20 -0
  4. package/dist/import.d.ts +4 -0
  5. package/dist/linkHandler.d.ts +8 -0
  6. package/dist/pluginManager.d.ts +23 -0
  7. package/dist/squiffy.runtime.d.ts +3 -34
  8. package/dist/squiffy.runtime.global.js +126 -0
  9. package/dist/squiffy.runtime.global.js.map +1 -0
  10. package/dist/squiffy.runtime.js +8779 -547
  11. package/dist/squiffy.runtime.js.map +1 -0
  12. package/dist/squiffy.runtime.test.d.ts +1 -0
  13. package/dist/state.d.ts +19 -0
  14. package/dist/textProcessor.d.ts +11 -0
  15. package/dist/types.d.ts +57 -0
  16. package/dist/types.plugins.d.ts +27 -0
  17. package/dist/updater.d.ts +2 -0
  18. package/dist/utils.d.ts +1 -0
  19. package/package.json +26 -5
  20. package/src/__snapshots__/squiffy.runtime.test.ts.snap +138 -0
  21. package/src/animation.ts +68 -0
  22. package/src/events.ts +41 -0
  23. package/src/import.ts +5 -0
  24. package/src/linkHandler.ts +18 -0
  25. package/src/pluginManager.ts +74 -0
  26. package/src/plugins/animate.ts +97 -0
  27. package/src/plugins/index.ts +13 -0
  28. package/src/plugins/live.ts +83 -0
  29. package/src/plugins/random.ts +22 -0
  30. package/src/plugins/replaceLabel.ts +22 -0
  31. package/src/plugins/rotateSequence.ts +61 -0
  32. package/src/squiffy.runtime.test.ts +677 -0
  33. package/src/squiffy.runtime.ts +528 -499
  34. package/src/state.ts +106 -0
  35. package/src/textProcessor.ts +76 -0
  36. package/src/types.plugins.ts +41 -0
  37. package/src/types.ts +81 -0
  38. package/src/updater.ts +77 -0
  39. package/src/utils.ts +17 -0
  40. package/tsconfig.json +4 -1
  41. package/vite.config.ts +36 -0
  42. package/vitest.config.ts +9 -0
  43. package/vitest.setup.ts +16 -0
package/src/state.ts ADDED
@@ -0,0 +1,106 @@
1
+ import { Emitter, SquiffyEventMap } from "./events.js";
2
+
3
+ export class State {
4
+ persist: boolean;
5
+ storyId: string;
6
+ onSet: (attribute: string, value: any) => void;
7
+ onSetInternal?: (attribute: string, oldValue: any, newValue: any) => void;
8
+ store: Record<string, any> = {};
9
+ emitter: Emitter<SquiffyEventMap>;
10
+
11
+ constructor(persist: boolean,
12
+ storyId: string,
13
+ onSet: (attribute: string, value: any) => void,
14
+ emitter: Emitter<SquiffyEventMap>,
15
+ onSetInternal?: (attribute: string, oldValue: any, newValue: any) => void) {
16
+ this.persist = persist;
17
+ this.storyId = storyId;
18
+ this.onSet = onSet;
19
+ this.emitter = emitter;
20
+ this.onSetInternal = onSetInternal;
21
+ }
22
+
23
+ private usePersistentStorage() {
24
+ return this.persist && window.localStorage && this.storyId;
25
+ }
26
+
27
+ set(attribute: string, value: any) {
28
+ this.setInternal(attribute, value, true);
29
+ }
30
+
31
+ setInternal(attribute: string, value: any, raiseEvents: boolean) {
32
+ if (typeof value === "undefined") value = true;
33
+
34
+ if (raiseEvents && this.onSetInternal) {
35
+ this.onSetInternal(attribute, this.get(attribute), structuredClone(value));
36
+ }
37
+
38
+ this.store[attribute] = structuredClone(value);
39
+
40
+ if (this.usePersistentStorage()) {
41
+ localStorage[this.storyId + "-" + attribute] = JSON.stringify(value);
42
+ }
43
+
44
+ if (raiseEvents) {
45
+ this.emitter.emit("set", { attribute, value });
46
+ }
47
+
48
+ this.onSet(attribute, value);
49
+ }
50
+
51
+ get(attribute: string): any {
52
+ if (attribute in this.store) {
53
+ return structuredClone(this.store[attribute]);
54
+ }
55
+
56
+ return null;
57
+ }
58
+
59
+ getStore() {
60
+ return structuredClone(this.store);
61
+ }
62
+
63
+ load() {
64
+ if (!this.usePersistentStorage()) {
65
+ return;
66
+ }
67
+
68
+ const keys = Object.keys(localStorage);
69
+ for (const key of keys) {
70
+ if (key.startsWith(this.storyId + "-")) {
71
+ const attribute = key.substring(this.storyId.length + 1);
72
+ this.store[attribute] = JSON.parse(localStorage[key]);
73
+ }
74
+ }
75
+ }
76
+
77
+ reset() {
78
+ this.store = {};
79
+
80
+ if (!this.usePersistentStorage()) {
81
+ return;
82
+ }
83
+
84
+ const keys = Object.keys(localStorage);
85
+ for (const key of keys) {
86
+ if (key.startsWith(this.storyId)) {
87
+ localStorage.removeItem(key);
88
+ }
89
+ }
90
+ }
91
+
92
+ setSeen(sectionName: string) {
93
+ let seenSections = this.get("_seen_sections");
94
+ if (!seenSections) seenSections = [];
95
+ if (seenSections.indexOf(sectionName) == -1) {
96
+ seenSections.push(sectionName);
97
+ this.set("_seen_sections", seenSections);
98
+ }
99
+ }
100
+
101
+ getSeen(sectionName: string) {
102
+ const seenSections = this.get("_seen_sections");
103
+ if (!seenSections) return false;
104
+ return (seenSections.indexOf(sectionName) > -1);
105
+ }
106
+ }
@@ -0,0 +1,76 @@
1
+ import * as marked from "marked";
2
+ import Handlebars from "handlebars";
3
+ import { Section, Story } from "./types.js";
4
+ import { State } from "./state.js";
5
+
6
+ export class TextProcessor {
7
+ story: Story;
8
+ state: State;
9
+ getCurrentSection: () => Section;
10
+ handlebars: typeof Handlebars;
11
+
12
+ constructor (story: Story,
13
+ state: State,
14
+ currentSection: () => Section) {
15
+ this.story = story;
16
+ this.state = state;
17
+ this.getCurrentSection = currentSection;
18
+ this.handlebars = Handlebars.create();
19
+
20
+ this.handlebars.registerHelper("embed", (name: string) => {
21
+ const currentSection = this.getCurrentSection();
22
+ if (currentSection.passages && name in currentSection.passages) {
23
+ return this.process(currentSection.passages[name].text || "", true);
24
+ } else if (name in this.story.sections) {
25
+ return this.process(this.story.sections[name].text || "", true);
26
+ }
27
+ });
28
+
29
+ this.handlebars.registerHelper("seen", (name: string) => this.state.getSeen(name));
30
+ this.handlebars.registerHelper("get", (attribute: string) => this.state.get(attribute));
31
+ this.handlebars.registerHelper("and", (...args) => args.slice(0,-1).every(Boolean));
32
+ this.handlebars.registerHelper("or", (...args) => args.slice(0,-1).some(Boolean));
33
+ this.handlebars.registerHelper("not", (v) => !v);
34
+ this.handlebars.registerHelper("eq", (a,b) => a == b);
35
+ this.handlebars.registerHelper("ne", (a,b) => a != b);
36
+ this.handlebars.registerHelper("gt", (a,b) => a > b);
37
+ this.handlebars.registerHelper("lt", (a,b) => a < b);
38
+ this.handlebars.registerHelper("gte", (a,b) => a >= b);
39
+ this.handlebars.registerHelper("lte", (a,b) => a <= b);
40
+ this.handlebars.registerHelper("array", function (...args) {
41
+ args.pop(); // remove last argument - options
42
+ return args;
43
+ });
44
+
45
+ const addAdditionalParameters = (options: any) => {
46
+ let result = "";
47
+ const setters = options.hash.set as string || "";
48
+ if (setters) {
49
+ result += ` data-set='${JSON.stringify(setters.split(",").map(s => s.trim()))}'`;
50
+ }
51
+ return result;
52
+ };
53
+
54
+ this.handlebars.registerHelper("section", (section: string, options) => {
55
+ const text = options.hash.text as string || section;
56
+ return new Handlebars.SafeString(`<a class="squiffy-link link-section" data-section="${section}"${addAdditionalParameters(options)} role="link" tabindex="0">${text}</a>`);
57
+ });
58
+
59
+ this.handlebars.registerHelper("passage", (passage: string, options) => {
60
+ const text = options.hash.text as string || passage;
61
+ return new Handlebars.SafeString(`<a class="squiffy-link link-passage" data-passage="${passage}"${addAdditionalParameters(options)} role="link" tabindex="0">${text}</a>`);
62
+ });
63
+ }
64
+
65
+ process(text: string, inline: boolean) {
66
+ const template = this.handlebars.compile(text);
67
+ text = template(this.state.getStore());
68
+
69
+ if (inline) {
70
+ return marked.parseInline(text, { async: false }).trim();
71
+ }
72
+ else {
73
+ return marked.parse(text, { async: false }).trim();
74
+ }
75
+ }
76
+ }
@@ -0,0 +1,41 @@
1
+ import type { HelperDelegate } from "handlebars";
2
+ import { SquiffyEventHandler, SquiffyEventMap } from "./events.js";
3
+ import { Animation } from "./animation.js";
4
+
5
+ export interface SquiffyPlugin {
6
+ name: string;
7
+ init(host: PluginHost): void | Promise<void>;
8
+ onWrite?(el: HTMLElement): void;
9
+ onLoad?(): void;
10
+ }
11
+
12
+ export interface HandleLinkResult {
13
+ disableLink?: boolean;
14
+ }
15
+
16
+ export interface PluginHost {
17
+ outputElement: HTMLElement;
18
+ registerHelper(name: string, helper: HelperDelegate): void;
19
+ registerLinkHandler(type: string, handler: (el: HTMLElement) => HandleLinkResult): void;
20
+ get(attribute: string): any;
21
+ set(attribute: string, value: any): void;
22
+ getSectionText(name: string): string | null;
23
+ getPassageText(name: string): string | null;
24
+ processText: (text: string, inline: boolean) => string;
25
+ addTransition: (fn: () => Promise<void>) => void;
26
+ animation: Animation;
27
+ on<E extends keyof SquiffyEventMap>(
28
+ event: E,
29
+ handler: SquiffyEventHandler<E>
30
+ ): () => void; // returns unsubscribe
31
+
32
+ off<E extends keyof SquiffyEventMap>(
33
+ event: E,
34
+ handler: SquiffyEventHandler<E>
35
+ ): void;
36
+
37
+ once<E extends keyof SquiffyEventMap>(
38
+ event: E,
39
+ handler: SquiffyEventHandler<E>
40
+ ): () => void;
41
+ }
package/src/types.ts ADDED
@@ -0,0 +1,81 @@
1
+ import { SquiffyEventHandler, SquiffyEventMap } from "./events.js";
2
+
3
+ export interface SquiffyInitOptions {
4
+ element: HTMLElement;
5
+ story: Story;
6
+ scroll?: string,
7
+ persist?: boolean,
8
+ onSet?: (attribute: string, value: any) => void,
9
+ }
10
+
11
+ export interface SquiffySettings {
12
+ scroll: string,
13
+ persist: boolean,
14
+ onSet: (attribute: string, value: any) => void,
15
+ }
16
+
17
+ export interface SquiffyApi {
18
+ begin: () => Promise<void>;
19
+ restart: () => void;
20
+ get: (attribute: string) => any;
21
+ set: (attribute: string, value: any) => void;
22
+ clickLink: (link: HTMLElement) => Promise<boolean>;
23
+ update: (story: Story) => void;
24
+ goBack: () => void;
25
+
26
+ on<E extends keyof SquiffyEventMap>(
27
+ event: E,
28
+ handler: SquiffyEventHandler<E>
29
+ ): () => void; // returns unsubscribe
30
+
31
+ off<E extends keyof SquiffyEventMap>(
32
+ event: E,
33
+ handler: SquiffyEventHandler<E>
34
+ ): void;
35
+
36
+ once<E extends keyof SquiffyEventMap>(
37
+ event: E,
38
+ handler: SquiffyEventHandler<E>
39
+ ): () => void;
40
+ }
41
+
42
+ // Previous versions of Squiffy had "squiffy", "get" and "set" as globals - we now pass these directly into JS functions.
43
+ // We may tidy up this API at some point, though that would be a breaking change.
44
+ interface SquiffyJsFunctionApi {
45
+ get: (attribute: string) => any;
46
+ set: (attribute: string, value: any) => void;
47
+ ui: {
48
+ transition: (f: any) => void;
49
+ };
50
+ story: {
51
+ go: (section: string) => void;
52
+ };
53
+ }
54
+
55
+ export interface Story {
56
+ js: ((
57
+ squiffy: SquiffyJsFunctionApi,
58
+ get: (attribute: string) => any,
59
+ set: (attribute: string, value: any) => void
60
+ ) => void)[];
61
+ start: string;
62
+ id?: string | null;
63
+ uiJsIndex?: number;
64
+ sections: Record<string, Section>;
65
+ }
66
+
67
+ export interface Section {
68
+ text?: string;
69
+ clear?: boolean;
70
+ attributes?: string[],
71
+ jsIndex?: number;
72
+ passages?: Record<string, Passage>;
73
+ passageCount?: number;
74
+ }
75
+
76
+ export interface Passage {
77
+ text?: string;
78
+ clear?: boolean;
79
+ attributes?: string[];
80
+ jsIndex?: number;
81
+ }
package/src/updater.ts ADDED
@@ -0,0 +1,77 @@
1
+ import { Story } from "./types.js";
2
+
3
+ export function updateStory(oldStory: Story,
4
+ newStory: Story,
5
+ outputElement: HTMLElement,
6
+ ui: any,
7
+ disableLink: (link: Element) => void) {
8
+
9
+ function safeQuerySelector(name: string) {
10
+ return name.replace(/'/g, "\\'");
11
+ }
12
+
13
+ function getSectionContent(section: string) {
14
+ return outputElement.querySelectorAll(`[data-source='[[${safeQuerySelector(section)}]]']`);
15
+ }
16
+
17
+ function getPassageContent(section: string, passage: string) {
18
+ return outputElement.querySelectorAll(`[data-source='[[${safeQuerySelector(section)}]][${safeQuerySelector(passage)}]']`);
19
+ }
20
+
21
+ function updateElementTextPreservingDisabledPassageLinks(element: Element, text: string) {
22
+ // Record which passage links are disabled
23
+ const disabledPassages = Array.from(element
24
+ .querySelectorAll("a.link-passage.disabled"))
25
+ .map((el: HTMLElement) => el.getAttribute("data-passage"));
26
+
27
+ element.innerHTML = text;
28
+
29
+ // Re-disable links that were disabled before the update
30
+ for (const passage of disabledPassages) {
31
+ const link = element.querySelector(`a.link-passage[data-passage="${passage}"]`);
32
+ if (link) disableLink(link);
33
+ }
34
+ }
35
+
36
+ for (const existingSection of Object.keys(oldStory.sections)) {
37
+ const elements = getSectionContent(existingSection);
38
+ if (elements.length) {
39
+ const newSection = newStory.sections[existingSection];
40
+ if (!newSection) {
41
+ // section has been deleted
42
+ for (const element of elements) {
43
+ const parentOutputSection = element.closest(".squiffy-output-section");
44
+ parentOutputSection.remove();
45
+ }
46
+ }
47
+ else if (newSection.text != oldStory.sections[existingSection].text) {
48
+ // section has been updated
49
+ for (const element of elements) {
50
+ updateElementTextPreservingDisabledPassageLinks(element, ui.processText(newSection.text, false));
51
+ }
52
+ }
53
+ }
54
+
55
+ if (!oldStory.sections[existingSection].passages) continue;
56
+
57
+ for (const existingPassage of Object.keys(oldStory.sections[existingSection].passages)) {
58
+ const elements = getPassageContent(existingSection, existingPassage);
59
+ if (!elements.length) continue;
60
+
61
+ const newPassage = newStory.sections[existingSection]?.passages && newStory.sections[existingSection]?.passages[existingPassage];
62
+ if (!newPassage) {
63
+ // passage has been deleted
64
+ for (const element of elements) {
65
+ const parentOutputPassage = element.closest(".squiffy-output-passage");
66
+ parentOutputPassage.remove();
67
+ }
68
+ }
69
+ else if (newPassage.text && newPassage.text != oldStory.sections[existingSection].passages[existingPassage].text) {
70
+ // passage has been updated
71
+ for (const element of elements) {
72
+ updateElementTextPreservingDisabledPassageLinks(element, ui.processText(newPassage.text, false));
73
+ }
74
+ }
75
+ }
76
+ }
77
+ }
package/src/utils.ts ADDED
@@ -0,0 +1,17 @@
1
+ export function fadeReplace(element: HTMLElement, text: string): Promise<void> {
2
+ return new Promise((resolve) => {
3
+ element.addEventListener("transitionend", function () {
4
+ element.innerHTML = text;
5
+
6
+ element.addEventListener("transitionend", function () {
7
+ element.classList.remove("fade-in");
8
+ resolve();
9
+ }, { once: true });
10
+
11
+ element.classList.remove("fade-out");
12
+ element.classList.add("fade-in");
13
+ }, { once: true });
14
+
15
+ element.classList.add("fade-out");
16
+ });
17
+ }
package/tsconfig.json CHANGED
@@ -8,7 +8,10 @@
8
8
  "skipLibCheck": true,
9
9
  "outDir": "dist",
10
10
  "declaration": true,
11
+ "module": "NodeNext",
12
+ "moduleResolution": "nodenext",
13
+ "allowSyntheticDefaultImports": true
11
14
  },
12
- "include": ["src/squiffy.runtime.ts"]
15
+ "include": ["src/*.ts"]
13
16
  }
14
17
 
package/vite.config.ts ADDED
@@ -0,0 +1,36 @@
1
+ import { defineConfig } from "vite";
2
+ import dts from "vite-plugin-dts";
3
+
4
+ export default defineConfig({
5
+ build: {
6
+ target: "es2020",
7
+ sourcemap: true,
8
+ minify: "esbuild",
9
+
10
+ lib: {
11
+ entry: "src/squiffy.runtime.ts",
12
+ name: "squiffyRuntime"
13
+ },
14
+
15
+ rollupOptions: {
16
+ output: [
17
+ {
18
+ format: "es",
19
+ entryFileNames: "squiffy.runtime.js"
20
+ },
21
+ {
22
+ format: "iife",
23
+ name: "squiffyRuntime",
24
+ entryFileNames: "squiffy.runtime.global.js"
25
+ }
26
+ ]
27
+ }
28
+ },
29
+
30
+ plugins: [
31
+ dts({
32
+ entryRoot: "src",
33
+ outDir: "dist"
34
+ })
35
+ ]
36
+ });
@@ -0,0 +1,9 @@
1
+ import { defineConfig } from "vitest/config";
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ globals: true,
6
+ environment: "jsdom",
7
+ setupFiles: "./vitest.setup.ts",
8
+ },
9
+ });
@@ -0,0 +1,16 @@
1
+ import cssEscape from "css.escape";
2
+
3
+ declare global {
4
+ interface CSS {
5
+ escape(value: string): string;
6
+ }
7
+ }
8
+
9
+ const g = globalThis as any;
10
+
11
+ if (!g.CSS) g.CSS = {};
12
+ if (typeof g.CSS.escape !== "function") {
13
+ g.CSS.escape = cssEscape;
14
+ }
15
+
16
+ export {};