├── stories └── .empty ├── .node-version ├── .npmrc ├── .prettierignore ├── docs ├── add-format.gif ├── delete-format.gif ├── trialogue-create.gif └── tutorial │ ├── 02-story.png │ ├── 03-story.png │ ├── 04-story.png │ ├── 01-add-speaker.png │ ├── 02-showing-choice.png │ ├── 01-empty-conversation.png │ ├── 02-link-autocomplete.png │ └── 01-conversation-speaker.png ├── index.js ├── .prettierrc ├── .dependabot └── config.yml ├── examples ├── index.html ├── sample.twee └── sample.html ├── .babelrc ├── src ├── common │ ├── stripComments.js │ ├── extractDirectives.js │ └── extractLinks.js ├── template │ ├── icon.svg │ ├── index.html │ └── botscripten.css ├── twine │ ├── index.js │ ├── Passage.js │ └── Story.js └── node │ └── index.js ├── NOTICE ├── dist ├── Twine2 │ └── Botscripten │ │ ├── icon.svg │ │ └── format.js ├── npm │ ├── common │ │ ├── stripComments.js │ │ ├── extractDirectives.js │ │ └── extractLinks.js │ └── node │ │ └── index.js └── botscripten.umd.js ├── commitlint.config.js ├── LICENSE ├── .github └── workflows │ ├── ci.yaml │ └── semantic_release.yaml ├── rollup.config.js ├── tests └── smoke.test.js ├── scripts ├── serve.js └── createTwine.js ├── .gitignore ├── RELEASING.md ├── release.config.js ├── .czrc ├── package.json ├── FOR_AUTHORS.md ├── CHANGELOG.md └── README.md /stories/.empty: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.node-version: -------------------------------------------------------------------------------- 1 | 12.14.1 -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | scripts-prepend-node-path=true -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | examples/*.html -------------------------------------------------------------------------------- /docs/add-format.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thecodedrift/botscripten/HEAD/docs/add-format.gif -------------------------------------------------------------------------------- /docs/delete-format.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thecodedrift/botscripten/HEAD/docs/delete-format.gif -------------------------------------------------------------------------------- /docs/trialogue-create.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thecodedrift/botscripten/HEAD/docs/trialogue-create.gif -------------------------------------------------------------------------------- /docs/tutorial/02-story.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thecodedrift/botscripten/HEAD/docs/tutorial/02-story.png -------------------------------------------------------------------------------- /docs/tutorial/03-story.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thecodedrift/botscripten/HEAD/docs/tutorial/03-story.png -------------------------------------------------------------------------------- /docs/tutorial/04-story.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thecodedrift/botscripten/HEAD/docs/tutorial/04-story.png -------------------------------------------------------------------------------- /docs/tutorial/01-add-speaker.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thecodedrift/botscripten/HEAD/docs/tutorial/01-add-speaker.png -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | // expose main module 2 | const parser = require("./dist/npm/node/index").default; 3 | module.exports = parser; 4 | -------------------------------------------------------------------------------- /docs/tutorial/02-showing-choice.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thecodedrift/botscripten/HEAD/docs/tutorial/02-showing-choice.png -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | endOfLine: "lf" 2 | trailingComma: "es5" 3 | overrides: 4 | - files: ".prettierrc" 5 | options: 6 | parser: "yaml" -------------------------------------------------------------------------------- /docs/tutorial/01-empty-conversation.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thecodedrift/botscripten/HEAD/docs/tutorial/01-empty-conversation.png -------------------------------------------------------------------------------- /docs/tutorial/02-link-autocomplete.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thecodedrift/botscripten/HEAD/docs/tutorial/02-link-autocomplete.png -------------------------------------------------------------------------------- /docs/tutorial/01-conversation-speaker.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thecodedrift/botscripten/HEAD/docs/tutorial/01-conversation-speaker.png -------------------------------------------------------------------------------- /.dependabot/config.yml: -------------------------------------------------------------------------------- 1 | version: 1 2 | update_configs: 3 | - package_manager: javascript 4 | directory: / 5 | update_schedule: live 6 | commit_message: 7 | prefix: ⬆️ Upgrade 8 | include_scope: false 9 | default_labels: 10 | - dependabot 11 | -------------------------------------------------------------------------------- /examples/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 |

Botscripten Test Scripts

5 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | [ 4 | "@babel/preset-env", 5 | { 6 | "targets": { 7 | "node": "current" 8 | } 9 | } 10 | ] 11 | ], 12 | "plugins": [ 13 | "@babel/plugin-transform-runtime", 14 | "@babel/plugin-proposal-object-rest-spread", 15 | "@babel/plugin-proposal-class-properties" 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /src/common/stripComments.js: -------------------------------------------------------------------------------- 1 | const TOKEN_ESCAPED_OCTO = "__TOKEN_ESCAPED_BACKSLASH_OCTO__"; 2 | 3 | const BLOCK_COMMENT = /###[\s\S]*?###/gm; 4 | const INLINE_COMMENT = /^#.*$/gm; 5 | 6 | const stripComments = str => 7 | str 8 | .replace("\\#", TOKEN_ESCAPED_OCTO) 9 | .replace(BLOCK_COMMENT, "") 10 | .replace(INLINE_COMMENT, "") 11 | .replace(TOKEN_ESCAPED_OCTO, "#") 12 | .trim(); 13 | 14 | export default stripComments; 15 | -------------------------------------------------------------------------------- /NOTICE: -------------------------------------------------------------------------------- 1 | Botscripten (formerly Chatbook) is based on Open Source software, and would not be possible with 2 | the efforts of those behind Trialogue, Paloma, and Snowman 3 | 4 | Trialogue is licensed under the MIT License 5 | Copyright (c) 2019 Philo van Kemenade 6 | 7 | Paloma is licensed under the MIT License 8 | Copyright (c) 2016 M. C. DeMarco 9 | Copyright (c) 2014 Chris Klimas 10 | 11 | Snowman is licensed under the MIT License 12 | Copyright (c) 2014 Chris Klimas 13 | -------------------------------------------------------------------------------- /src/template/icon.svg: -------------------------------------------------------------------------------- 1 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /dist/Twine2/Botscripten/icon.svg: -------------------------------------------------------------------------------- 1 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /dist/npm/common/stripComments.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | Object.defineProperty(exports, "__esModule", { 4 | value: true 5 | }); 6 | exports.default = void 0; 7 | const TOKEN_ESCAPED_OCTO = "__TOKEN_ESCAPED_BACKSLASH_OCTO__"; 8 | const BLOCK_COMMENT = /###[\s\S]*?###/gm; 9 | const INLINE_COMMENT = /^#.*$/gm; 10 | 11 | const stripComments = str => str.replace("\\#", TOKEN_ESCAPED_OCTO).replace(BLOCK_COMMENT, "").replace(INLINE_COMMENT, "").replace(TOKEN_ESCAPED_OCTO, "#").trim(); 12 | 13 | var _default = stripComments; 14 | exports.default = _default; -------------------------------------------------------------------------------- /commitlint.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: ["@commitlint/config-conventional"], 3 | rules: { 4 | "scope-empty": [0, "always"], 5 | "type-enum": [ 6 | 1, 7 | "always", 8 | [ 9 | "feat", 10 | "fix", 11 | "docs", 12 | "style", 13 | "refactor", 14 | "release", 15 | "perf", 16 | "test", 17 | "deps", 18 | "chore", 19 | "wip", 20 | ], 21 | ], 22 | "subject-case": [2, "always", "sentence-case"], 23 | "signed-off-by": [0, "always"], 24 | }, 25 | }; 26 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2019 Aibex, Inc 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | Unless required by applicable law or agreed to in writing, software 10 | distributed under the License is distributed on an "AS IS" BASIS, 11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | See the License for the specific language governing permissions and 13 | limitations under the License. 14 | -------------------------------------------------------------------------------- /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | pull_request: 4 | branches: 5 | - next 6 | - beta 7 | - master 8 | jobs: 9 | build_and_test: 10 | name: Build and Test 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Checkout 14 | uses: actions/checkout@v2 15 | - name: Set Node Version 16 | uses: actions/setup-node@v1 17 | with: 18 | node-version: 12.14.1 19 | - name: Installing Dependencies 20 | run: npm ci --quiet --no-progress 21 | - name: Building 22 | run: npm run build 23 | - name: Testing & Certifying 24 | run: npm run ci 25 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import resolve from "rollup-plugin-node-resolve"; 2 | import commonjs from "rollup-plugin-commonjs"; 3 | import babel from "rollup-plugin-babel"; 4 | import json from "rollup-plugin-json"; 5 | import { uglify } from "rollup-plugin-uglify"; 6 | import pkg from "./package.json"; 7 | 8 | export default [ 9 | // browser-friendly UMD build 10 | { 11 | input: "src/twine/index.js", 12 | output: { 13 | name: "botscripten", 14 | file: pkg.browser, 15 | format: "umd", 16 | }, 17 | plugins: [ 18 | json(), 19 | resolve(), 20 | babel({ 21 | runtimeHelpers: true, 22 | exclude: ["node_modules/**"], 23 | presets: ["@babel/preset-env"], // override node target for browser UMD 24 | }), 25 | commonjs(), 26 | uglify(), 27 | ], 28 | }, 29 | ]; 30 | -------------------------------------------------------------------------------- /src/common/extractDirectives.js: -------------------------------------------------------------------------------- 1 | const TOKEN_ESCAPED_OCTO = "__TOKEN_ESCAPED_BACKSLASH_OCTO__"; 2 | const BLOCK_DIRECTIVE = /^###@([\S]+)([\s\S]*?)###/gm; 3 | const INLINE_DIRECTIVE = /^#@([\S]+)(.*)$/gm; 4 | 5 | const extractDirectives = s => { 6 | const directives = []; 7 | 8 | // avoid using escaped items 9 | s = s.replace("\\#", TOKEN_ESCAPED_OCTO); 10 | 11 | while (s.match(BLOCK_DIRECTIVE)) { 12 | s = s.replace(BLOCK_DIRECTIVE, (match, dir, content) => { 13 | directives.push({ name: `@${dir}`, content: content.trim() }); 14 | return ""; 15 | }); 16 | } 17 | 18 | while (s.match(INLINE_DIRECTIVE)) { 19 | s = s.replace(INLINE_DIRECTIVE, (match, dir, content) => { 20 | directives.push({ name: `@${dir}`, content: content.trim() }); 21 | return ""; 22 | }); 23 | } 24 | 25 | return directives; 26 | }; 27 | 28 | export default extractDirectives; 29 | -------------------------------------------------------------------------------- /.github/workflows/semantic_release.yaml: -------------------------------------------------------------------------------- 1 | name: Semantic Release 2 | on: 3 | push: 4 | branches: 5 | - next 6 | - beta 7 | - master 8 | jobs: 9 | semantic_release: 10 | name: Build, Test, Release 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Checkout 14 | uses: actions/checkout@v2 15 | - name: Set Node Version 16 | uses: actions/setup-node@v1 17 | with: 18 | node-version: 12.14.1 19 | - name: Installing Dependencies 20 | run: npm ci --quiet --no-progress 21 | - name: Building 22 | run: npm run build 23 | - name: Testing & Certifying 24 | run: npm run ci 25 | - name: Releasing 26 | run: npm run release 27 | env: 28 | GITHUB_TOKEN: ${{ secrets.PERSONAL_ACCESS_TOKEN }} #required for writing CHANGELOG to protected branch 29 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 30 | -------------------------------------------------------------------------------- /tests/smoke.test.js: -------------------------------------------------------------------------------- 1 | const fs = require("fs-extra"); 2 | const path = require("path"); 3 | 4 | const EXAMPLES = path.resolve(__dirname, "../examples"); 5 | 6 | const parse = require("../index"); 7 | 8 | const getFile = name => 9 | fs.readFileSync(path.resolve(EXAMPLES, `./${name}`)).toString(); 10 | 11 | describe("Parser Smoke Tests", () => { 12 | test("Parser is defined", () => { 13 | expect(typeof parse).toBe("function"); 14 | }); 15 | }); 16 | 17 | describe("onboarding.html Parse", () => { 18 | let html = ""; 19 | beforeAll(() => { 20 | html = getFile("sample.html"); 21 | }); 22 | 23 | test("parse() returns an story object with passage objects, tags, and directives", () => { 24 | const story = parse(html); 25 | expect(typeof story).toBe("object"); 26 | expect(story.name).toBe("Test"); 27 | expect(story.start).toBe("Start"); 28 | expect(story.format).toMatch(/Botscripten/); 29 | expect(story.passages[story.passageIndex[story.start]]).toMatchObject({ 30 | pid: story.passageIndex[story.start], 31 | }); 32 | }); 33 | }); 34 | -------------------------------------------------------------------------------- /dist/npm/common/extractDirectives.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | Object.defineProperty(exports, "__esModule", { 4 | value: true 5 | }); 6 | exports.default = void 0; 7 | const TOKEN_ESCAPED_OCTO = "__TOKEN_ESCAPED_BACKSLASH_OCTO__"; 8 | const BLOCK_DIRECTIVE = /^###@([\S]+)([\s\S]*?)###/gm; 9 | const INLINE_DIRECTIVE = /^#@([\S]+)(.*)$/gm; 10 | 11 | const extractDirectives = s => { 12 | const directives = []; // avoid using escaped items 13 | 14 | s = s.replace("\\#", TOKEN_ESCAPED_OCTO); 15 | 16 | while (s.match(BLOCK_DIRECTIVE)) { 17 | s = s.replace(BLOCK_DIRECTIVE, (match, dir, content) => { 18 | directives.push({ 19 | name: `@${dir}`, 20 | content: content.trim() 21 | }); 22 | return ""; 23 | }); 24 | } 25 | 26 | while (s.match(INLINE_DIRECTIVE)) { 27 | s = s.replace(INLINE_DIRECTIVE, (match, dir, content) => { 28 | directives.push({ 29 | name: `@${dir}`, 30 | content: content.trim() 31 | }); 32 | return ""; 33 | }); 34 | } 35 | 36 | return directives; 37 | }; 38 | 39 | var _default = extractDirectives; 40 | exports.default = _default; -------------------------------------------------------------------------------- /scripts/serve.js: -------------------------------------------------------------------------------- 1 | const path = require("path"); 2 | const util = require("util"); 3 | const outdent = require("outdent"); 4 | 5 | const StaticServer = require("static-server"); 6 | 7 | const servers = [ 8 | new StaticServer({ 9 | rootPath: path.resolve(__dirname, "../examples/"), 10 | port: 3000, 11 | name: "Botscripten-example-server", // optional, will set "X-Powered-by" HTTP header 12 | }), 13 | new StaticServer({ 14 | rootPath: path.resolve(__dirname, "../dist/Twine2/"), 15 | port: 3001, 16 | name: "Botscripten-dist-server", // optional, will set "X-Powered-by" HTTP header 17 | }), 18 | ]; 19 | 20 | const allServers = servers.map(s => { 21 | const pvStart = util.promisify(s.start); 22 | return s.start(); 23 | }); 24 | 25 | Promise.all(allServers).then(() => { 26 | console.log(outdent` 27 | 🌎 Example Server listening to 3000 28 | 📦 Format/Dist Server listening to 3001 29 | Active URLs: 30 | Botscripten @ http://localhost:3001/Botscripten/format.js 31 | Samples @ http://localhost:3000 32 | 33 | Please remember to remove these from Twine when done testing`); 34 | }); 35 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | *.pid.lock 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | 20 | # nyc test coverage 21 | .nyc_output 22 | 23 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 24 | .grunt 25 | 26 | # Bower dependency directory (https://bower.io/) 27 | bower_components 28 | 29 | # node-waf configuration 30 | .lock-wscript 31 | 32 | # Compiled binary addons (https://nodejs.org/api/addons.html) 33 | build/Release 34 | 35 | # Dependency directories 36 | node_modules/ 37 | jspm_packages/ 38 | 39 | # TypeScript v1 declaration files 40 | typings/ 41 | 42 | # Optional npm cache directory 43 | .npm 44 | 45 | # Optional eslint cache 46 | .eslintcache 47 | 48 | # Optional REPL history 49 | .node_repl_history 50 | 51 | # Output of 'npm pack' 52 | *.tgz 53 | 54 | # Yarn Integrity file 55 | .yarn-integrity 56 | 57 | # dotenv environment variables file 58 | .env 59 | 60 | # next.js build output 61 | .next 62 | 63 | # Botscripten specific ignores 64 | stories/ 65 | dist/node 66 | -------------------------------------------------------------------------------- /src/common/extractLinks.js: -------------------------------------------------------------------------------- 1 | const LINK_PATTERN = /\[\[(.*?)\]\]/gm; 2 | 3 | const extractLinks = str => { 4 | const links = []; 5 | const original = str; 6 | 7 | while (str.match(LINK_PATTERN)) { 8 | str = str.replace(LINK_PATTERN, (match, t) => { 9 | let display = t; 10 | let target = t; 11 | 12 | // display|target format 13 | const barIndex = t.indexOf("|"); 14 | const rightArrIndex = t.indexOf("->"); 15 | const leftArrIndex = t.indexOf("<-"); 16 | 17 | switch (true) { 18 | case barIndex >= 0: 19 | display = t.substr(0, barIndex); 20 | target = t.substr(barIndex + 1); 21 | break; 22 | case rightArrIndex >= 0: 23 | display = t.substr(0, rightArrIndex); 24 | target = t.substr(rightArrIndex + 2); 25 | break; 26 | case leftArrIndex >= 0: 27 | display = t.substr(leftArrIndex + 2); 28 | target = t.substr(0, leftArrIndex); 29 | break; 30 | } 31 | 32 | links.push({ 33 | display, 34 | target, 35 | }); 36 | 37 | return ""; // render nothing if it's a twee link 38 | }); 39 | } 40 | 41 | return { 42 | links, 43 | updated: str, 44 | original, 45 | }; 46 | }; 47 | 48 | export default extractLinks; 49 | -------------------------------------------------------------------------------- /dist/npm/common/extractLinks.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | Object.defineProperty(exports, "__esModule", { 4 | value: true 5 | }); 6 | exports.default = void 0; 7 | const LINK_PATTERN = /\[\[(.*?)\]\]/gm; 8 | 9 | const extractLinks = str => { 10 | const links = []; 11 | const original = str; 12 | 13 | while (str.match(LINK_PATTERN)) { 14 | str = str.replace(LINK_PATTERN, (match, t) => { 15 | let display = t; 16 | let target = t; // display|target format 17 | 18 | const barIndex = t.indexOf("|"); 19 | const rightArrIndex = t.indexOf("->"); 20 | const leftArrIndex = t.indexOf("<-"); 21 | 22 | switch (true) { 23 | case barIndex >= 0: 24 | display = t.substr(0, barIndex); 25 | target = t.substr(barIndex + 1); 26 | break; 27 | 28 | case rightArrIndex >= 0: 29 | display = t.substr(0, rightArrIndex); 30 | target = t.substr(rightArrIndex + 2); 31 | break; 32 | 33 | case leftArrIndex >= 0: 34 | display = t.substr(leftArrIndex + 2); 35 | target = t.substr(0, leftArrIndex); 36 | break; 37 | } 38 | 39 | links.push({ 40 | display, 41 | target 42 | }); 43 | return ""; // render nothing if it's a twee link 44 | }); 45 | } 46 | 47 | return { 48 | links, 49 | updated: str, 50 | original 51 | }; 52 | }; 53 | 54 | var _default = extractLinks; 55 | exports.default = _default; -------------------------------------------------------------------------------- /RELEASING.md: -------------------------------------------------------------------------------- 1 | # This Package is Maintained via `semantic-release` 2 | 3 | Releases in this package are automatically managed by `semantic-release` with the following branch designations: 4 | 5 | - Commits to `master` are automatically **versioned** and released as both **versioned** (x.y.x) and under the tag `latest` 6 | - Commits to `beta` are automatically released under the tag `beta` 7 | - Commits to `next` are automatically released under the tax `next` and may contain `rc` information. 8 | 9 | # Default PR Branch 10 | 11 | PRs are automatically targeted for `next`, which includes all improvements and bug fixes that have been accepted and merged. We do not make an attempt to batch together commits with backwards-incompatible changes; these changes land on merge. 12 | 13 | # Lifecycle 14 | 15 | 1. Code is submitted as a PR against `next` 16 | 2. On PR accept & merge, `semantic-release` will push the update to the `next` tag. 17 | 3. When a reasonable amount of time (or changes) have amassed, `next` is merged to `beta` via a PR 18 | 4. On PR accept & merge, `semantic-release` will push the update to the `beta` tag with `rc` information 19 | 5. Bug fixes can be submitted either against `next` or `beta`. Bug fixes against `beta` will be cherry-picked back to `next`. 20 | 6. On stability and agreement for release, `beta` is merged to `master` via a PR 21 | 7. On PR accept & merge, `semantic-release` will publish to npm with the `latest` tag, as well as a `x.y.x` version. 22 | -------------------------------------------------------------------------------- /src/twine/index.js: -------------------------------------------------------------------------------- 1 | import Story from "./Story"; 2 | 3 | (win => { 4 | if (typeof win !== "undefined") { 5 | win.document.addEventListener("DOMContentLoaded", function(event) { 6 | win.globalEval = eval; 7 | win.story = new Story(win); 8 | win.story.start(); 9 | if (win.document.querySelector("#show_directives").checked) { 10 | win.document.body.classList.add("show-directives"); 11 | } 12 | if (win.document.querySelector("#proofing").checked) { 13 | win.document.body.classList.add("proof"); 14 | } else { 15 | win.document.body.classList.add("run"); 16 | } 17 | }); 18 | 19 | win.document 20 | .querySelector("#show_directives") 21 | .addEventListener("change", e => { 22 | if (e.target.checked) { 23 | win.document.body.classList.add("show-directives"); 24 | } else { 25 | win.document.body.classList.remove("show-directives"); 26 | } 27 | }); 28 | 29 | win.document.querySelector("#proofing").addEventListener("change", e => { 30 | if (e.target.checked) { 31 | win.document.body.classList.add("proof"); 32 | win.document.body.classList.remove("run"); 33 | } else { 34 | win.document.body.classList.add("run"); 35 | win.document.body.classList.remove("proof"); 36 | } 37 | }); 38 | 39 | document 40 | .querySelector( 41 | "tw-passagedata[pid='" + 42 | document.querySelector("tw-storydata").getAttribute("startnode") + 43 | "']" 44 | ) 45 | .classList.add("start"); 46 | } 47 | })(window || undefined); 48 | -------------------------------------------------------------------------------- /src/template/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | <%= name %> 6 | 7 | 8 | 12 | 13 | 14 | 15 | 16 | <%= passages %> 17 | 18 | 37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 | 47 | 48 | 49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 | 60 |
61 | <%= stylesheet %> <%= script %> 62 |
63 | 64 | 65 | -------------------------------------------------------------------------------- /examples/sample.twee: -------------------------------------------------------------------------------- 1 | :: StoryTitle 2 | Test 3 | 4 | 5 | :: StoryData 6 | { 7 | "ifid": "3A31AA6F-EF9A-43B1-BF53-08AF770247A8", 8 | "format": "TEST_Botscripten", 9 | "format-version": "0.5.1", 10 | "start": "Start", 11 | "zoom": 1 12 | } 13 | 14 | 15 | :: Start [speaker-bot wait] {"position":"412,189","size":"100,100"} 16 | Hey there! 17 | 18 | I'm the chat bot, and this is a demo! 19 | 20 | [[A demo?]] 21 | 22 | 23 | :: A demo? [speaker-bot] {"position":"412,339","size":"100,100"} 24 | Yep! A demo! With choices! 25 | 26 | [[Choices are neat -> Choice Good]] 27 | [[I'm not a fan -> Choice Bad]] 28 | 29 | 30 | :: Choice Good [speaker-bot] {"position":"337,489","size":"100,100"} 31 | #@set choice good 32 | 33 | Oh, that's great! I like having choices too. 34 | 35 | [[Directives]] 36 | 37 | 38 | :: Choice Bad [speaker-bot] {"position":"487,489","size":"100,100"} 39 | #@set choice bad 40 | 41 | Oh no. Well, at least there was only one of them! 42 | 43 | [[Directives]] 44 | 45 | 46 | :: Directives [speaker-bot wait] {"position":"412,634","size":"100,100"} 47 | See that block above with the #@ symbol? That's a directive. 48 | 49 | Directives are how you can tell your scripting system to **DO** more than just chat like a bot. 50 | 51 | In this case, we told our scripting engine we wanted to set the "choice" var. 52 | 53 | [[Does it always go on one line?]] 54 | 55 | 56 | :: Does it always go on one line? [speaker-bot] {"position":"412,784","size":"100,100"} 57 | ###@sql 58 | SELECT * FROM users 59 | WHERE username = "chatbot" 60 | ### 61 | 62 | Not really. The above part is a "Block Directive" and everything inside is treated as a single statement. 63 | 64 | [[So, how does it run?]] 65 | 66 | 67 | :: So, how does it run? [speaker-bot] {"position":"412,934","size":"100,100"} 68 | That's the fun part. It's up to you. Botscripten is meant to make it easy to put together guided conversations. You can use your own directive language to run queries, ask open ended questions, or do whatever techno-wizardry suits you. 69 | 70 | Go forth and build! 71 | 72 | 73 | -------------------------------------------------------------------------------- /scripts/createTwine.js: -------------------------------------------------------------------------------- 1 | const fs = require("fs-extra"); 2 | const path = require("path"); 3 | const tmpl = require("lodash.template"); 4 | const pkg = require("../package.json"); 5 | const CleanCSS = require("clean-css"); 6 | const htmlMin = require("html-minifier").minify; 7 | 8 | const build = (name, description, outDir, { template, js, css }) => { 9 | // common 10 | const svg = path.resolve(__dirname, "../src/template/icon.svg"); 11 | const svgOut = path.resolve(outDir, "./icon.svg"); 12 | const targetFile = path.resolve(outDir, "./format.js"); 13 | 14 | const jsContent = fs.readFileSync(js, "utf8").toString(); 15 | const cssContent = fs.readFileSync(css, "utf8").toString(); 16 | const tmplContent = fs.readFileSync(template, "utf8").toString(); 17 | const minCss = new CleanCSS({}).minify(cssContent.replace(/\s+/g, " ")); 18 | 19 | const storyFile = tmpl(tmplContent)({ 20 | name: "{{STORY_NAME}}", 21 | passages: "{{STORY_DATA}}", 22 | script: ``, 23 | stylesheet: ``, 24 | }); 25 | 26 | const minStory = htmlMin(storyFile, { 27 | collapseInlineTagWhitespace: true, 28 | collapseWhitespace: true, 29 | removeComments: false, 30 | useShortDoctype: true, 31 | }); 32 | 33 | const formatData = { 34 | name, 35 | description, 36 | author: pkg.author.replace(/ <.*>/, ""), 37 | image: "icon.svg", 38 | url: pkg.repository, 39 | version: pkg.version, 40 | proofing: false, 41 | source: minStory, 42 | }; 43 | 44 | fs.mkdirpSync(outDir); 45 | fs.copySync(svg, svgOut); 46 | fs.writeFileSync( 47 | targetFile, 48 | `window.storyFormat(${JSON.stringify(formatData)})` 49 | ); 50 | }; 51 | 52 | build( 53 | "Botscripten", 54 | "An interactive chat viewer", 55 | path.resolve(__dirname, "../dist/Twine2/Botscripten"), 56 | { 57 | template: path.resolve(__dirname, "../src/template/index.html"), 58 | css: path.resolve(__dirname, "../src/template/botscripten.css"), 59 | js: path.resolve(__dirname, "../dist/botscripten.umd.js"), 60 | } 61 | ); 62 | 63 | console.log("OK"); 64 | -------------------------------------------------------------------------------- /release.config.js: -------------------------------------------------------------------------------- 1 | const fs = require("fs"); 2 | const czrc = JSON.parse(fs.readFileSync("./.czrc", "utf8")); 3 | 4 | const generateTypes = () => 5 | czrc.config["cz-emoji"].types.map(t => { 6 | const opts = { 7 | type: t.name.toLowerCase(), 8 | hidden: !t.changelog || !t.changelog.section ? true : false, 9 | }; 10 | if (t.changelog && t.changelog.section) { 11 | opts.section = t.changelog.section; 12 | } 13 | return opts; 14 | }); 15 | 16 | const generateReleaseRules = () => 17 | czrc.config["cz-emoji"].types 18 | .map(t => { 19 | if (!t.release) { 20 | return false; 21 | } 22 | if (typeof t.release === "string") { 23 | return { 24 | type: t.name, 25 | release: t.release, 26 | }; 27 | } 28 | 29 | // lots of options 30 | let opts = {}; 31 | opts.type = t.name; 32 | if (t.release.scope) { 33 | opts.scope = t.release.scope; 34 | opts.release = t.release.release; 35 | } 36 | return opts; 37 | }) 38 | .filter(Boolean); 39 | 40 | const ccOptions = { 41 | releaseRules: generateReleaseRules(), 42 | parserOpts: { 43 | headerPattern: /^.+?\s+(\w+)(?:\((.+)\))?!?: (.+)$/, // emoji friendly 44 | headerCorrespondence: ["type", "scope", "subject"], 45 | }, 46 | linkReferences: true, 47 | presetConfig: { 48 | header: "Changelog", 49 | types: generateTypes(), 50 | releaseCommitMessageFormat: 51 | "🧹 Chore: Adds changelog for {{currentTag}} [skip ci]", 52 | }, 53 | }; 54 | 55 | module.exports = { 56 | plugins: [ 57 | [ 58 | "@semantic-release/commit-analyzer", 59 | { 60 | preset: "conventionalcommits", 61 | ...ccOptions, 62 | }, 63 | ], 64 | [ 65 | "@semantic-release/release-notes-generator", 66 | { 67 | preset: "conventionalcommits", 68 | ...ccOptions, 69 | }, 70 | ], 71 | [ 72 | "@semantic-release/changelog", 73 | { 74 | changelogFile: "CHANGELOG.md", 75 | }, 76 | ], 77 | "@semantic-release/npm", 78 | [ 79 | "@semantic-release/git", 80 | { 81 | assets: ["package.json", "dist/**/*", "examples/**/*", "CHANGELOG.md"], 82 | message: 83 | "🧹 Chore: Releases ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}", 84 | }, 85 | ], 86 | ], 87 | }; 88 | -------------------------------------------------------------------------------- /src/node/index.js: -------------------------------------------------------------------------------- 1 | // node parser interface 2 | import cheerio from "cheerio"; 3 | import extractDirectives from "../common/extractDirectives"; 4 | import extractLinks from "../common/extractLinks"; 5 | import stripComments from "../common/stripComments"; 6 | import unescape from "lodash.unescape"; 7 | 8 | const storyDefaults = { 9 | name: "", 10 | start: null, 11 | startId: null, 12 | creator: "", 13 | creatorVersion: "", 14 | ifid: "", 15 | zoom: "", 16 | format: "", 17 | formatVersion: "", 18 | options: "", 19 | tags: [], 20 | passages: [], 21 | passageIndex: {}, 22 | }; 23 | 24 | const tagDefaults = { 25 | name: "", 26 | color: "", 27 | }; 28 | 29 | const passageDefaults = { 30 | pid: null, 31 | name: "", 32 | tags: [], 33 | directives: [], 34 | links: [], 35 | position: "", 36 | size: "", 37 | content: "", 38 | }; 39 | 40 | const parse = str => { 41 | // by default, JSDOM will not execute any JavaScript encountered 42 | const $ = cheerio.load(str); 43 | const $s = $("tw-storydata").first(); 44 | const startId = $s.attr("startnode"); 45 | const tags = []; 46 | const passages = {}; 47 | const pIndex = {}; 48 | 49 | $("tw-passagedata").each((index, pg) => { 50 | const $pg = $(pg); 51 | const raw = $pg.text(); 52 | const directives = extractDirectives(raw); 53 | let content = stripComments(raw); 54 | 55 | const linkData = extractLinks(content); 56 | content = linkData.updated; 57 | 58 | content = content.trim(); 59 | const pid = $pg.attr("pid"); 60 | 61 | passages[pid] = { 62 | ...passageDefaults, 63 | pid: $pg.attr("pid"), 64 | name: unescape($pg.attr("name") || ""), 65 | tags: ($pg.attr("tags") || "").split(/[\s]+/g), 66 | position: $pg.attr("position") || `${index * 10},${index * 10}`, 67 | size: $pg.attr("size") || "100,100", 68 | links: linkData.links, 69 | original: pg.innerHTML, 70 | directives, 71 | content, 72 | }; 73 | 74 | pIndex[$pg.attr("name")] = pid; 75 | }); 76 | 77 | $("tw-tag").each((index, tg) => { 78 | const $tg = $(tg); 79 | tags.push({ 80 | ...tagDefaults, 81 | name: $tg.attr("name") || "", 82 | color: $tg.attr("color") || "", 83 | }); 84 | }); 85 | 86 | return { 87 | ...storyDefaults, 88 | startId, 89 | name: unescape($s.attr("name") || ""), 90 | start: unescape(passages[startId].name), // Twine starts PIDs at 91 | creator: unescape($s.attr("creator") || ""), 92 | creatorVersion: $s.attr("creator-verson") || "", 93 | ifid: $s.attr("ifid") || "", 94 | zoom: $s.attr("zoom") || "1", 95 | format: $s.attr("format") || "", 96 | formatVersion: $s.attr("format-version") || "", 97 | options: unescape($s.attr("options") || ""), 98 | passageIndex: pIndex, 99 | tags, 100 | passages, 101 | }; 102 | }; 103 | 104 | export default parse; 105 | -------------------------------------------------------------------------------- /src/twine/Passage.js: -------------------------------------------------------------------------------- 1 | import unescape from "lodash.unescape"; 2 | import extractDirectives from "../common/extractDirectives"; 3 | import extractLinks from "../common/extractLinks"; 4 | import stripComments from "../common/stripComments"; 5 | 6 | const findStory = win => { 7 | if (win && win.story) { 8 | return win.story; 9 | } 10 | return { state: {} }; 11 | }; 12 | 13 | const renderPassage = passage => { 14 | const source = passage.source; 15 | 16 | const directives = extractDirectives(source); 17 | let result = stripComments(source); 18 | 19 | if (passage) { 20 | // remove links if set previously 21 | passage.links = []; 22 | } 23 | 24 | // [[links]] 25 | const linkData = extractLinks(result); 26 | result = linkData.updated; 27 | if (passage) { 28 | passage.links = linkData.links; 29 | } 30 | 31 | // before handling any tags, handle any/all directives 32 | directives.forEach(d => { 33 | if (!passage.story.directives[d.name]) return; 34 | passage.story.directives[d.name].forEach(run => { 35 | result = run(d.content, result, passage, passage.story); 36 | }); 37 | }); 38 | 39 | // if no speaker tag, return an empty render set 40 | if (!passage.getSpeaker()) { 41 | return { 42 | directives, 43 | text: [], 44 | }; 45 | } 46 | 47 | // if prompt tag is set, notify the story 48 | if (passage) { 49 | const prompts = passage.prefixTag("prompt"); 50 | if (prompts.length) { 51 | passage.story.prompt(prompts[0]); 52 | } 53 | } 54 | 55 | if (passage.hasTag("oneline")) { 56 | return { 57 | directives, 58 | text: [result], 59 | }; 60 | } 61 | 62 | // if this is a multiline item, trim, split, and mark each item 63 | // return the array 64 | result = result.trim(); 65 | return { 66 | directives, 67 | text: result.split(/[\r\n]+/g), 68 | }; 69 | }; 70 | 71 | class Passage { 72 | id = null; 73 | name = null; 74 | tags = null; 75 | tagDict = {}; 76 | source = null; 77 | links = []; 78 | 79 | constructor(id, name, tags, source, story) { 80 | this.id = id; 81 | this.name = name; 82 | this.tags = tags; 83 | this.source = unescape(source); 84 | this.story = story; 85 | 86 | this.tags.forEach(t => (this.tagDict[t] = 1)); 87 | } 88 | 89 | getSpeaker = () => { 90 | const speakerTag = this.tags.find(t => t.indexOf("speaker-") === 0) || ""; 91 | if (speakerTag) return speakerTag.replace(/^speaker-/, ""); 92 | return null; 93 | }; 94 | 95 | prefixTag = (pfx, asDict) => 96 | this.tags 97 | .filter(t => t.indexOf(`${pfx}-`) === 0) 98 | .map(t => t.replace(`${pfx}-`, "")) 99 | .reduce( 100 | (a, t) => { 101 | if (asDict) 102 | return { 103 | ...a, 104 | [t]: 1, 105 | }; 106 | 107 | return [...a, t]; 108 | }, 109 | asDict ? {} : [] 110 | ); 111 | 112 | hasTag = t => this.tagDict[t]; 113 | 114 | // static and instance renders 115 | static render = str => 116 | renderPassage( 117 | new Passage(null, null, null, str, findStory(window || null)) 118 | ); 119 | render = () => renderPassage(this); 120 | } 121 | 122 | export default Passage; 123 | -------------------------------------------------------------------------------- /.czrc: -------------------------------------------------------------------------------- 1 | { 2 | "path": "cz-emoji", 3 | "config": { 4 | "cz-emoji": { 5 | "skipQuestions": [ 6 | "scope" 7 | ], 8 | "types": [ 9 | { 10 | "emoji": "✨", 11 | "code": "✨ Feat:", 12 | "description": "A new feature or functionality", 13 | "name": "Feat", 14 | "changelog": { 15 | "section": "✨ Features" 16 | }, 17 | "release": "minor" 18 | }, 19 | { 20 | "emoji": "🐛", 21 | "code": "🐛 Fix:", 22 | "description": "Fixing code", 23 | "name": "Fix", 24 | "changelog": { 25 | "section": "🐛 Bug Fixes" 26 | }, 27 | "release": "patch" 28 | }, 29 | { 30 | "emoji": "🚑", 31 | "code": "🚑 Hotfix:", 32 | "description": "Bypassing some safety, this needs to go straight to production!", 33 | "name": "Hotfix", 34 | "changelog": { 35 | "section": "🚑 Critical Fixes" 36 | }, 37 | "release": "patch" 38 | }, 39 | { 40 | "emoji": "📝", 41 | "code": "📝 Docs:", 42 | "description": "Documentation only changes", 43 | "name": "Docs" 44 | }, 45 | { 46 | "emoji": "🎨", 47 | "code": "🎨 Style:", 48 | "description": "Improving the style/structure of the code", 49 | "name": "Style" 50 | }, 51 | { 52 | "emoji": "♻️", 53 | "code": "♻️ Refactor:", 54 | "description": "A code change that does not alter public facing APIs", 55 | "name": "Refactor" 56 | }, 57 | { 58 | "emoji": "⚡️", 59 | "code": "⚡️ Perf:", 60 | "description": "Improves Performance", 61 | "name": "Perf" 62 | }, 63 | { 64 | "emoji": "✅", 65 | "code": "✅ Test:", 66 | "description": "Adds, Removes, or Fixes something related to tests", 67 | "name": "Test" 68 | }, 69 | { 70 | "emoji": "⬆️", 71 | "code": "⬆️ Upgrade:", 72 | "description": "Upgrades dependencies", 73 | "name": "Upgrade", 74 | "changelog": { 75 | "section": "⬆️ Dependency Changes" 76 | }, 77 | "release": "patch" 78 | }, 79 | { 80 | "emoji": "⬇️", 81 | "code": "⬇️ Downgrade:", 82 | "description": "Downgrades or Removes dependencies", 83 | "name": "Downgrade", 84 | "changelog": { 85 | "section": "⬆️ Dependency Changes" 86 | }, 87 | "release": "patch" 88 | }, 89 | { 90 | "emoji": "📌", 91 | "code": "📌 Pin:", 92 | "description": "Pin a dependency to a specific version", 93 | "name": "Pin", 94 | "changelog": { 95 | "section": "⬆️ Dependency Changes" 96 | }, 97 | "release": "patch" 98 | }, 99 | { 100 | "emoji": "🧹", 101 | "code": "🧹 Chore:", 102 | "description": "Misc changes, often by automated systems", 103 | "name": "Chore" 104 | }, 105 | { 106 | "emoji": "🚧", 107 | "code": "🚧 WIP:", 108 | "description": "Work in Progress (rebase these out!)", 109 | "name": "WIP" 110 | } 111 | ] 112 | } 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "botscripten", 3 | "version": "1.2.4", 4 | "description": "Craft rich bot conversations using the Twine/Twee format", 5 | "repository": "github:aibexhq/botscripten", 6 | "author": "Jakob Heuser ", 7 | "license": "Apache-2.0", 8 | "engines": { 9 | "node": ">=10.18.0 <11 || >=12.14.0 <13 || >=13.5.0" 10 | }, 11 | "browserslist": "> 0.25%, not dead", 12 | "main": "index.js", 13 | "browser": "dist/botscripten.umd.js", 14 | "scripts": { 15 | "dev": "NODE_ENV=development concurrently -r \"npm:dev:*\"", 16 | "test": "npm run build && jest", 17 | "build": "npm run clean:dist && concurrently \"npm:build:*\"", 18 | "buildAll": "npm run clean:dist && concurrently \"npm:build:*\" && npm run examples", 19 | "tweego": "TWEEGO_PATH=./dist/Twine2 tweego", 20 | "ci": "pretty-quick --check && npm run build && jest", 21 | "release": "semantic-release", 22 | "____________________": "echo \"The following sub-commands are used to serve the main commands above\"", 23 | "build:twine": "rollup -c && node scripts/createTwine", 24 | "build:node": "rm -rf dist/node/* && babel src/node --out-dir dist/npm/node && babel src/common --out-dir dist/npm/common", 25 | "clean:dist": "rm -rf dist/", 26 | "dev:watch": "nodemon -L --ignore dist/ --exec npm run buildAll", 27 | "dev:serve": "nodemon -L --ignore dist/ --exec node scripts/serve", 28 | "examples": "concurrently -r \"npm:examples:*\"", 29 | "examples:sample": "npm run tweego -f Botscripten -o ./examples/sample.html ./examples/sample.twee", 30 | "format:prettier": "pretty-quick --staged", 31 | "husky:precommit": "concurrently \"npm:format:*\" && npm run build && git add dist/", 32 | "commit:lint": "commitlint --edit \"$1\"" 33 | }, 34 | "keywords": [ 35 | "twine", 36 | "twinery", 37 | "storyformat", 38 | "trialogue", 39 | "botscript", 40 | "bot", 41 | "botframework" 42 | ], 43 | "dependencies": { 44 | "@babel/runtime": "^7.7.7", 45 | "cheerio": "^1.0.0-rc.3", 46 | "html-entities": "^1.2.1", 47 | "jest": "^25.2.3", 48 | "lodash.escape": "^4.0.1", 49 | "lodash.template": "^4.5.0", 50 | "lodash.unescape": "^4.0.1" 51 | }, 52 | "devDependencies": { 53 | "@babel/cli": "^7.7.7", 54 | "@babel/core": "^7.7.7", 55 | "@babel/plugin-proposal-class-properties": "^7.7.0", 56 | "@babel/plugin-proposal-object-rest-spread": "^7.7.7", 57 | "@babel/plugin-transform-runtime": "^7.7.6", 58 | "@babel/preset-env": "^7.9.5", 59 | "@commitlint/cli": "^16.2.1", 60 | "@commitlint/config-conventional": "^16.2.1", 61 | "@semantic-release/changelog": "^5.0.0", 62 | "@semantic-release/git": "^9.0.0", 63 | "clean-css": "^4.2.3", 64 | "concurrently": "^5.0.2", 65 | "extwee": "^1.5.0", 66 | "fs-extra": "^9.0.0", 67 | "html-minifier": "^4.0.0", 68 | "husky": "^4.2.1", 69 | "nodemon": "^2.0.2", 70 | "outdent": "^0.7.0", 71 | "prettier": "2.0.2", 72 | "pretty-quick": "^2.0.0", 73 | "rollup": "^2.2.0", 74 | "rollup-plugin-babel": "^4.2.0", 75 | "rollup-plugin-commonjs": "^10.1.0", 76 | "rollup-plugin-json": "^4.0.0", 77 | "rollup-plugin-node-resolve": "^5.2.0", 78 | "rollup-plugin-uglify": "^6.0.4", 79 | "semantic-release": "^17.0.2", 80 | "static-server": "^2.2.1" 81 | }, 82 | "husky": { 83 | "hooks": { 84 | "pre-commit": "npm run husky:precommit", 85 | "commit-msg": "if [ -t 1 ] ; then npm run commit:lint; fi" 86 | } 87 | }, 88 | "publishConfig": { 89 | "access": "public", 90 | "registry": "https://registry.npmjs.org/" 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /dist/npm/node/index.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | var _interopRequireDefault = require("@babel/runtime/helpers/interopRequireDefault"); 4 | 5 | Object.defineProperty(exports, "__esModule", { 6 | value: true 7 | }); 8 | exports.default = void 0; 9 | 10 | var _defineProperty2 = _interopRequireDefault(require("@babel/runtime/helpers/defineProperty")); 11 | 12 | var _cheerio = _interopRequireDefault(require("cheerio")); 13 | 14 | var _extractDirectives = _interopRequireDefault(require("../common/extractDirectives")); 15 | 16 | var _extractLinks = _interopRequireDefault(require("../common/extractLinks")); 17 | 18 | var _stripComments = _interopRequireDefault(require("../common/stripComments")); 19 | 20 | var _lodash = _interopRequireDefault(require("lodash.unescape")); 21 | 22 | function ownKeys(object, enumerableOnly) { var keys = Object.keys(object); if (Object.getOwnPropertySymbols) { var symbols = Object.getOwnPropertySymbols(object); if (enumerableOnly) symbols = symbols.filter(function (sym) { return Object.getOwnPropertyDescriptor(object, sym).enumerable; }); keys.push.apply(keys, symbols); } return keys; } 23 | 24 | function _objectSpread(target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i] != null ? arguments[i] : {}; if (i % 2) { ownKeys(Object(source), true).forEach(function (key) { (0, _defineProperty2.default)(target, key, source[key]); }); } else if (Object.getOwnPropertyDescriptors) { Object.defineProperties(target, Object.getOwnPropertyDescriptors(source)); } else { ownKeys(Object(source)).forEach(function (key) { Object.defineProperty(target, key, Object.getOwnPropertyDescriptor(source, key)); }); } } return target; } 25 | 26 | const storyDefaults = { 27 | name: "", 28 | start: null, 29 | startId: null, 30 | creator: "", 31 | creatorVersion: "", 32 | ifid: "", 33 | zoom: "", 34 | format: "", 35 | formatVersion: "", 36 | options: "", 37 | tags: [], 38 | passages: [], 39 | passageIndex: {} 40 | }; 41 | const tagDefaults = { 42 | name: "", 43 | color: "" 44 | }; 45 | const passageDefaults = { 46 | pid: null, 47 | name: "", 48 | tags: [], 49 | directives: [], 50 | links: [], 51 | position: "", 52 | size: "", 53 | content: "" 54 | }; 55 | 56 | const parse = str => { 57 | // by default, JSDOM will not execute any JavaScript encountered 58 | const $ = _cheerio.default.load(str); 59 | 60 | const $s = $("tw-storydata").first(); 61 | const startId = $s.attr("startnode"); 62 | const tags = []; 63 | const passages = {}; 64 | const pIndex = {}; 65 | $("tw-passagedata").each((index, pg) => { 66 | const $pg = $(pg); 67 | const raw = $pg.text(); 68 | const directives = (0, _extractDirectives.default)(raw); 69 | let content = (0, _stripComments.default)(raw); 70 | const linkData = (0, _extractLinks.default)(content); 71 | content = linkData.updated; 72 | content = content.trim(); 73 | const pid = $pg.attr("pid"); 74 | passages[pid] = _objectSpread({}, passageDefaults, { 75 | pid: $pg.attr("pid"), 76 | name: (0, _lodash.default)($pg.attr("name") || ""), 77 | tags: ($pg.attr("tags") || "").split(/[\s]+/g), 78 | position: $pg.attr("position") || `${index * 10},${index * 10}`, 79 | size: $pg.attr("size") || "100,100", 80 | links: linkData.links, 81 | original: pg.innerHTML, 82 | directives, 83 | content 84 | }); 85 | pIndex[$pg.attr("name")] = pid; 86 | }); 87 | $("tw-tag").each((index, tg) => { 88 | const $tg = $(tg); 89 | tags.push(_objectSpread({}, tagDefaults, { 90 | name: $tg.attr("name") || "", 91 | color: $tg.attr("color") || "" 92 | })); 93 | }); 94 | return _objectSpread({}, storyDefaults, { 95 | startId, 96 | name: (0, _lodash.default)($s.attr("name") || ""), 97 | start: (0, _lodash.default)(passages[startId].name), 98 | // Twine starts PIDs at 99 | creator: (0, _lodash.default)($s.attr("creator") || ""), 100 | creatorVersion: $s.attr("creator-verson") || "", 101 | ifid: $s.attr("ifid") || "", 102 | zoom: $s.attr("zoom") || "1", 103 | format: $s.attr("format") || "", 104 | formatVersion: $s.attr("format-version") || "", 105 | options: (0, _lodash.default)($s.attr("options") || ""), 106 | passageIndex: pIndex, 107 | tags, 108 | passages 109 | }); 110 | }; 111 | 112 | var _default = parse; 113 | exports.default = _default; -------------------------------------------------------------------------------- /src/template/botscripten.css: -------------------------------------------------------------------------------- 1 | /** 2 | * Global styles 3 | */ 4 | :root { 5 | --nav-height: 46px; 6 | --nav-buffer: 10px; 7 | --max-width: 768px; 8 | --bg-color: #4d5263; /*global chat bg color*/ 9 | --user-color: #7aaebb; /*user name & placeholder avatar bg*/ 10 | --speaker-color: rgb( 11 | 136, 12 | 136, 13 | 136 14 | ); /*default color for speaker name & placeholder avatar bg*/ 15 | --directive-border: #c3c3c3; 16 | --navbar-bg-color: #fff; 17 | --passage-bg-color: #fff; 18 | --passage-text-color: #222; 19 | } 20 | 21 | body { 22 | font-family: "Lucida Sans", "Lucida Sans Regular", "Lucida Grande", 23 | "Lucida Sans Unicode", Geneva, Verdana, sans-serif; 24 | font-size: 16px; 25 | margin: 0; 26 | padding: 0; 27 | } 28 | 29 | body.proof twine { 30 | padding: 50px; 31 | font-size: 12px; 32 | } 33 | 34 | #twine-user-stylesheet, 35 | #twine-user-script, 36 | twine, 37 | .proof #runtime main, 38 | .proof #user-response-panel { 39 | display: none !important; 40 | } 41 | 42 | .proof twine, 43 | .proof twine * { 44 | display: block !important; 45 | font-family: monospace; 46 | white-space: pre; 47 | } 48 | 49 | /** 50 | * Nav (available in all modes) 51 | */ 52 | nav { 53 | position: fixed; 54 | z-index: 1; 55 | height: var(--nav-height); 56 | background: var(--navbar-bg-color); 57 | width: 100vw; 58 | max-width: var(--max-width); 59 | top: 0; 60 | border: 5px solid var(--speaker-color); 61 | border-top: 0; 62 | border-left: 0; 63 | border-right: 0; 64 | } 65 | 66 | nav h1 { 67 | margin: 0; 68 | font-size: 26px; 69 | padding: 10px 1em 0 1em; 70 | line-height: 26px; 71 | } 72 | 73 | #nav-reset { 74 | position: relative; 75 | } 76 | 77 | #toggles { 78 | position: absolute; 79 | right: 1em; 80 | top: 50%; 81 | } 82 | 83 | #toggles label { 84 | margin-left: 1em; 85 | } 86 | 87 | twine, 88 | #runtime { 89 | margin-top: var(--nav-height); 90 | } 91 | 92 | /* 93 | * Proofing styles 94 | * Set when body has a .proof class. Hides the majority of runtime 95 | * and uses pseudoclasses to display the information in a twee-like 96 | * format 97 | */ 98 | .proof tw-storydata::before { 99 | content: ":: StoryTitle\A"attr(name); 100 | color: green; 101 | } 102 | .proof tw-passagedata::before { 103 | content: ":: " attr(name) " [" attr(tags) "]"; 104 | color: blue; 105 | margin: 1em 0 0 0; 106 | display: block; 107 | font-weight: bold; 108 | } 109 | .proof tw-passagedata.start::before { 110 | content: "🚀 :: " attr(name) " [" attr(tags) "]"; 111 | } 112 | .proof tw-passagedata + tw-passagedata { 113 | border-top: 1pt dashed black; 114 | padding: 1em 0; 115 | margin: 1em 0 0 0; 116 | } 117 | 118 | .proof #runtime { 119 | position: absolute; 120 | top: 0; 121 | } 122 | 123 | /** 124 | * Runtime (interactive) code 125 | * All runtime items are in the section#runtime 126 | */ 127 | #runtime { 128 | display: flex; 129 | flex-direction: column; 130 | height: calc(100vh - var(--nav-height)); 131 | max-height: calc(100% - var(--nav-height)); 132 | max-width: var(--max-width); 133 | } 134 | 135 | #runtime main { 136 | flex: 1 1 auto; 137 | background-color: var(--bg-color); 138 | padding: var(--nav-buffer) 15px 15px 15px; 139 | } 140 | 141 | /** 142 | * Chat passages 143 | * These are repeatable passages added in the interactive code. 144 | * They are the "chat lines" and appear in the history, the 145 | * passage, and are also used as part of the "wave" 146 | */ 147 | .chat-passage-reset { 148 | position: relative; 149 | } 150 | 151 | .chat-passage:before { 152 | content: attr(data-speaker); 153 | display: block; 154 | font-size: 0.75rem; 155 | text-transform: capitalize; 156 | color: var(--speaker-color); 157 | } 158 | 159 | .chat-passage { 160 | background: var(--passage-bg-color); 161 | margin: 0 0 1em 0; 162 | padding: 0.33em; 163 | } 164 | 165 | .chat-passage p { 166 | margin: 0; 167 | } 168 | 169 | .chat-passage-wrapper { 170 | display: flex; 171 | flex-direction: row; 172 | } 173 | 174 | .chat-passage-wrapper[data-speaker="you"] { 175 | flex-direction: row-reverse; 176 | } 177 | 178 | .chat-passage-wrapper .chat-passage { 179 | margin-left: 45px; 180 | } 181 | 182 | .chat-passage-wrapper[data-speaker="you"] .chat-passage { 183 | margin-right: 45px; 184 | } 185 | 186 | .chat-passage-wrapper:before { 187 | margin-right: 5px; 188 | position: absolute; 189 | vertical-align: top; 190 | content: ""; 191 | width: 30px; 192 | height: 30px; 193 | background-size: 30px 30px; 194 | border-radius: 15px; 195 | background-color: var(--speaker-color); 196 | } 197 | 198 | .chat-passage-wrapper[data-speaker="you"]:before { 199 | display: none; 200 | } 201 | 202 | .chat-passage-wrapper[data-speaker="you"]:after { 203 | margin-left: -30px; 204 | position: absolute; 205 | top: 0; 206 | right: 0; 207 | vertical-align: top; 208 | content: ""; 209 | width: 30px; 210 | height: 30px; 211 | background-size: 30px 30px; 212 | border-radius: 15px; 213 | background-color: var(--user-color); 214 | } 215 | 216 | /** 217 | * Directives 218 | * When enabled, directives show the "behind the scenes" items that would 219 | * have been otherwise called as part of a given step. They are inserted 220 | * **first** in a conversation to simulate how they are handled in a 221 | * botscripten engine. 222 | */ 223 | body.show-directives .directives { 224 | display: block; 225 | } 226 | 227 | .directives { 228 | display: none; 229 | font-family: Consolas, "Andale Mono WT", "Andale Mono", "Lucida Console", 230 | "Lucida Sans Typewriter", "DejaVu Sans Mono", "Bitstream Vera Sans Mono", 231 | "Liberation Mono", "Nimbus Mono L", Monaco, "Courier New", Courier, 232 | monospace; 233 | font-size: 12px; 234 | } 235 | 236 | .directive { 237 | white-space: pre; 238 | border: 1px dashed var(--directive-border); 239 | position: relative; 240 | overflow-x: auto; 241 | padding-top: 20px; 242 | color: var(--passage-bg-color); 243 | margin-bottom: 1em; 244 | } 245 | 246 | .directive::after { 247 | content: attr(name); 248 | position: absolute; 249 | right: 2px; 250 | top: 2px; 251 | font-weight: bold; 252 | font-size: 14px; 253 | letter-spacing: 0.2em; 254 | color: var(--passage-bg-color); 255 | } 256 | 257 | /** 258 | * User response panel 259 | * When presented with a choice, the user will select 260 | * a response from this panel to continue the conversation 261 | */ 262 | #user-response-wrapper { 263 | padding-right: 15px; 264 | padding-left: 15px; 265 | flex: 1 1 auto; 266 | display: flex; 267 | flex-direction: column; 268 | justify-content: end; 269 | border-top: 3px solid var(--directive-border); 270 | } 271 | 272 | #user-response-panel { 273 | height: 100px; 274 | display: flex; 275 | flex-direction: row; 276 | align-items: center; 277 | justify-content: center; 278 | } 279 | 280 | #user-response-panel .user-response { 281 | display: inline-block; 282 | padding: 0.5rem; 283 | margin: 0.25rem; 284 | background-color: white; 285 | color: #333; 286 | border-top: 2px solid transparent; 287 | transition: border-color 0.15s ease-in; 288 | } 289 | #user-response-panel .user-response:hover { 290 | text-decoration: none; 291 | border-color: var(--user-color); 292 | } 293 | 294 | /** 295 | * Typing animation 296 | * Always active, the wave shows typing dots and its visibility 297 | * is controlled by the chat system 298 | */ 299 | #animation-container .wave { 300 | position: relative; 301 | text-align: center; 302 | margin-left: auto; 303 | margin-right: auto; 304 | } 305 | 306 | #animation-container .dot { 307 | display: inline-block; 308 | width: 6px; 309 | height: 6px; 310 | border-radius: 50%; 311 | margin-right: 2px; 312 | background: var(--passage-text-color); 313 | animation: wave 1.2s ease-in-out infinite; 314 | } 315 | 316 | #animation-container .dot:nth-child(2) { 317 | animation-delay: 0.1s; 318 | } 319 | 320 | #animation-container .dot:nth-child(3) { 321 | animation-delay: 0.2s; 322 | } 323 | 324 | @keyframes wave { 325 | 0%, 326 | 50%, 327 | 100% { 328 | transform: initial; 329 | } 330 | 331 | 25% { 332 | transform: translateY(-15px); 333 | } 334 | } 335 | -------------------------------------------------------------------------------- /src/twine/Story.js: -------------------------------------------------------------------------------- 1 | import Passage from "./Passage"; 2 | import escape from "lodash.escape"; 3 | import unescape from "lodash.unescape"; 4 | 5 | const selectPassages = "tw-passagedata"; 6 | const selectCss = '*[type="text/twine-css"]'; 7 | const selectJs = '*[type="text/twine-javascript"]'; 8 | const selectActiveLink = "#user-response-panel a[data-passage]"; 9 | const selectActiveButton = "#user-response-panel button[data-passage]"; 10 | const selectActiveInput = "#user-response-panel input"; 11 | const selectActive = "#active-passage"; 12 | const selectHistory = "#history"; 13 | const selectResponses = "#user-response-panel"; 14 | const typingIndicator = "#animation-container"; 15 | 16 | const IS_NUMERIC = /^[\d]+$/; 17 | 18 | /** 19 | * Determine if a provided string contains only numbers 20 | * In the case of `pid` values for passages, this is true 21 | */ 22 | const isNumeric = d => IS_NUMERIC.test(d); 23 | 24 | /** 25 | * Format a user passage (such as a response) 26 | */ 27 | const USER_PASSAGE_TMPL = ({ id, text }) => ` 28 |
29 |
30 |
31 | ${text} 32 |
33 |
34 |
35 | `; 36 | 37 | /** 38 | * Format a message from a non-user 39 | */ 40 | const OTHER_PASSAGE_TMPL = ({ speaker, tags, text }) => ` 41 |
42 |
45 |
46 | ${text} 47 |
48 |
49 |
50 | `; 51 | 52 | const DIRECTIVES_TMPL = directives => ` 53 |
54 | ${directives 55 | .map( 56 | ({ name, content }) => 57 | `
${content.trim()}
` 58 | ) 59 | .join("")} 60 |
61 | `; 62 | 63 | /** 64 | * Forces a delay via promises in order to spread out messages 65 | */ 66 | const delay = async (t = 0) => new Promise(resolve => setTimeout(resolve, t)); 67 | 68 | // Find one/many nodes within a context. We [...findAll] to ensure we're cast as an array 69 | // not as a node list 70 | const find = (ctx, s) => ctx.querySelector(s); 71 | const findAll = (ctx, s) => [...ctx.querySelectorAll(s)] || []; 72 | 73 | /** 74 | * Standard Twine Format Story Object 75 | */ 76 | class Story { 77 | version = 2; // Twine v2 78 | 79 | document = null; 80 | story = null; 81 | name = ""; 82 | startsAt = 0; 83 | current = 0; 84 | history = []; 85 | passages = {}; 86 | showPrompt = false; 87 | errorMessage = "\u26a0 %s"; 88 | 89 | directives = {}; 90 | elements = {}; 91 | 92 | userScripts = []; 93 | userStyles = []; 94 | 95 | constructor(win, src) { 96 | this.window = win; 97 | 98 | if (src) { 99 | this.document = document.implementation.createHTMLDocument( 100 | "Botscripten Injected Content" 101 | ); 102 | } else { 103 | this.document = document; 104 | } 105 | 106 | this.story = find(this.document, "tw-storydata"); 107 | 108 | // elements 109 | this.elements = { 110 | active: find(this.document, selectActive), 111 | history: find(this.document, selectHistory), 112 | }; 113 | 114 | // properties of story node 115 | this.name = this.story.getAttribute("name") || ""; 116 | this.startsAt = this.story.getAttribute("startnode") || 0; 117 | 118 | findAll(this.story, selectPassages).forEach(p => { 119 | const id = parseInt(p.getAttribute("pid")); 120 | const name = p.getAttribute("name"); 121 | const tags = (p.getAttribute("tags") || "").split(/\s+/g); 122 | const passage = p.innerHTML || ""; 123 | 124 | this.passages[id] = new Passage(id, name, tags, passage, this); 125 | }); 126 | 127 | find(this.document, "title").innerHTML = this.name; 128 | 129 | this.userScripts = (findAll(this.document, selectJs) || []).map( 130 | el => el.innerHTML 131 | ); 132 | this.userStyles = (findAll(this.document, selectCss) || []).map( 133 | el => el.innerHTML 134 | ); 135 | } 136 | 137 | /** 138 | * Starts the story by setting up listeners and then advancing 139 | * to the first item in the stack 140 | */ 141 | start = () => { 142 | // activate userscripts and styles 143 | this.userStyles.forEach(s => { 144 | const t = this.document.createElement("style"); 145 | t.innerHTML = s; 146 | this.document.body.appendChild(t); 147 | }); 148 | this.userScripts.forEach(s => { 149 | // eval is evil, but this is simply how Twine works 150 | // eslint-disable-line 151 | globalEval(s); 152 | }); 153 | 154 | // when you click on a[data-passage] (response link)... 155 | this.document.body.addEventListener("click", e => { 156 | if (!e.target.matches(selectActiveLink)) { 157 | return; 158 | } 159 | 160 | this.advance( 161 | this.findPassage(e.target.getAttribute("data-passage")), 162 | e.target.innerHTML 163 | ); 164 | }); 165 | 166 | // when you click on button[data-passage] (response input)... 167 | this.document.body.addEventListener("click", e => { 168 | if (!e.target.matches(selectActiveButton)) { 169 | return; 170 | } 171 | 172 | // capture and disable showPrompt feature 173 | const value = find(this.document, selectActiveInput).value; 174 | this.showPrompt = false; 175 | 176 | this.advance( 177 | this.findPassage(e.target.getAttribute("data-passage")), 178 | value 179 | ); 180 | }); 181 | 182 | this.advance(this.findPassage(this.startsAt)); 183 | }; 184 | 185 | /** 186 | * Find a passage based on its id or name 187 | */ 188 | findPassage = idOrName => { 189 | idOrName = `${idOrName}`.trim(); 190 | if (isNumeric(idOrName)) { 191 | return this.passages[idOrName]; 192 | } else { 193 | // handle passages with ' and " (can't use a css selector consistently) 194 | const p = findAll(this.story, "tw-passagedata").filter( 195 | p => unescape(p.getAttribute("name")).trim() === idOrName 196 | )[0]; 197 | if (!p) return null; 198 | return this.passages[p.getAttribute("pid")]; 199 | } 200 | }; 201 | 202 | /** 203 | * Advance the story to the passage specified, optionally adding userText 204 | */ 205 | advance = async (passage, userText = null) => { 206 | this.history.push(passage.id); 207 | const last = this.current; 208 | 209 | // .active is captured & cleared 210 | const existing = this.elements.active.innerHTML; 211 | this.elements.active.innerHTML = ""; 212 | 213 | // whatever was in active is moved up into history 214 | this.elements.history.innerHTML += existing; 215 | 216 | // if there is userText, it is added to .history 217 | if (userText) { 218 | this.renderUserMessage( 219 | last, 220 | userText, 221 | s => (this.elements.history.innerHTML += s) 222 | ); 223 | } 224 | 225 | // The new passage is rendered and placed in .active 226 | // after all renders, user options are displayed 227 | await this.renderPassage( 228 | passage, 229 | s => (this.elements.active.innerHTML += s) 230 | ); 231 | 232 | if (!passage.hasTag("wait") && passage.links.length === 1) { 233 | // auto advance if the wait tag is not set and there is exactly 234 | // 1 link found in our pssage. 235 | this.advance(this.findPassage(passage.links[0].target)); 236 | return; 237 | } 238 | 239 | this.renderChoices(passage); 240 | }; 241 | 242 | /** 243 | * Render text as if it came from the user 244 | */ 245 | renderUserMessage = async (pid, text, renderer) => { 246 | await renderer( 247 | USER_PASSAGE_TMPL({ 248 | id: pid, 249 | text, 250 | }) 251 | ); 252 | this.scrollToBottom(); 253 | return Promise.resolve(); 254 | }; 255 | 256 | /** 257 | * Render a Twine passage object 258 | */ 259 | renderPassage = async (passage, renderer) => { 260 | const speaker = passage.getSpeaker(); 261 | let statements = passage.render(); 262 | console.log(statements.directives); 263 | 264 | await renderer(DIRECTIVES_TMPL(statements.directives)); 265 | 266 | let next = statements.text.shift(); 267 | this.showTyping(); 268 | while (next) { 269 | const content = OTHER_PASSAGE_TMPL({ 270 | speaker, 271 | tags: passage.tags, 272 | text: next, 273 | }); 274 | await delay(this.calculateDelay(next)); // todo 275 | await renderer(content); 276 | next = statements.text.shift(); 277 | } 278 | this.hideTyping(); 279 | this.scrollToBottom(); 280 | 281 | return Promise.resolve(); 282 | }; 283 | 284 | /** 285 | * A rough function for determining a waiting period based on string length 286 | */ 287 | calculateDelay = txt => { 288 | const typingDelayRatio = 0.3; 289 | const rate = 20; // ms 290 | return txt.length * rate * typingDelayRatio; 291 | }; 292 | 293 | /** 294 | * Shows the typing indicator 295 | */ 296 | showTyping = () => { 297 | find(this.document, typingIndicator).style.visibility = "visible"; 298 | }; 299 | 300 | /** 301 | * Hides the typing indicator 302 | */ 303 | hideTyping = () => { 304 | find(this.document, typingIndicator).style.visibility = "hidden"; 305 | }; 306 | 307 | /** 308 | * Scrolls the document as far as possible (based on history container's height) 309 | */ 310 | scrollToBottom = () => { 311 | const hist = find(this.document, selectHistory); 312 | document.scrollingElement.scrollTop = hist.offsetHeight; 313 | }; 314 | 315 | /** 316 | * Clears the choices panel 317 | */ 318 | removeChoices = () => { 319 | const panel = find(this.document, selectResponses); 320 | panel.innerHTML = ""; 321 | }; 322 | 323 | /** 324 | * Renders the choices panel with a set of options based on passage links 325 | */ 326 | renderChoices = passage => { 327 | this.removeChoices(); 328 | const panel = find(this.document, selectResponses); 329 | passage.links.forEach(l => { 330 | panel.innerHTML += `${l.display}`; 333 | }); 334 | }; 335 | 336 | /** 337 | * Registers a custom directive for this story 338 | * Signature of (directiveContent, outputText, story, passage, next) 339 | */ 340 | directive = (id, cb) => { 341 | if (!this.directives[id]) { 342 | this.directives[id] = []; 343 | } 344 | this.directives[id].push(cb); 345 | }; 346 | } 347 | 348 | export default Story; 349 | -------------------------------------------------------------------------------- /FOR_AUTHORS.md: -------------------------------------------------------------------------------- 1 | # Botscripten + Twine 2 | 3 | **Upgrading? Check the [Changelog](/CHANGELOG.md)** 4 | 5 | # 🚀 Setup 6 | 7 | 1. Either install twine from [twinery.org](https://twinery.org) or use the web version 8 | 2. On the Twine home screen, select `Formats` and choose the `Add a New Format` tab 9 | 3. Paste `https://cdn.jsdelivr.net/gh/aibex/botscripten@master/dist/Twine2/Botscripten/format.js` into the text box and click `Add` 10 | 4. Return to the `Story Formats` tab and select the new "Botscripten" Story format by Aibex, Inc 11 | 12 | ![adding a story format](/docs/add-format.gif) 13 | 14 | # 💬 Making Your First Conversation (tutorial) 15 | 16 | ## Add a New Story 17 | 18 | With the above setup complete, you can select the green `+ Story` from the right-hand menu. Twine will ask you for a name. Let's call it `My Test Conversation` for now. Once you've selected a name, Twine's editor will open. 19 | 20 | You'll now see an empty box called **Untitled Passage**. Twine works off of a concept called **Passages**. Every passage must have a title, and the default is called "Untitled Passage". Let's change it to something more appropriate. 21 | 22 | ## Your First Passage 23 | 24 | 1. Double Click the "Untitled Passage" to open up the **Passage Editor** 25 | 2. Since this is the start, let's rename this passage to `Start` 26 | 3. Finally, let's put some text into the content area. If you're not feeling creative at the moment, "Hello!" works. 27 | 4. Close the **Passage Editor** and hit the **Play** button. Twine will open a new window with... not much yet. 28 | 29 | ![empty conversation](/docs/tutorial/01-empty-conversation.png) 30 | 31 | The reason we don't see anything yet is we haven't told Botscripten **who** is supposed to be speaking. By default, **nothing is said** until you specifically tell it to. Let's go back to our Twine editor, and double click on "Start", our passage from earlier. 32 | 33 | 1. Click `+ Tag` and add the text `speaker-bot` 34 | 2. 4. Close the **Passage Editor** and hit the **Play** button. 35 | 36 | ![coversation with text](/docs/tutorial/01-add-speaker.png) 37 | 38 | ![coversation with text](/docs/tutorial/01-conversation-speaker.png) 39 | 40 | Tags can be used for organization of Twine stories. In botscripten, we also use tags to add additional meaning to our **Passages**. 41 | 42 | #### 👻 Bonus: Supported Tags 43 | 44 | | tag | explanation | 45 | | :--------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | 46 | | `oneline` | By default, chat bots will display one message per line, similar to an SMS conversation. If you'd like to send your passage content as a paragraph, use the `oneline` tag. The entire Twine Passage will then be treated as a single message. | 47 | | `speaker-*` _(prefix)_ | The `speaker-*` tag describes "who" the message is from. For example, a tag of `speaker-bob` would imply the message should come from `bob`. The speaker tag is arbitrary, but should be consistent to identify "who" is talking. We use `speaker-karl` for messages from Karl A. Ibex | 48 | | `wait` | Adding `wait` will prevent the conversation from automatically advancing. When Botscripten sees only one link, it will automatically advance. We recommend equating the `wait` tag with the chat user nodding in agreement. | 49 | 50 | ## Your Second and Third Passages - Creating Branches and Choices 51 | 52 | _Now you're going to create some additional passages using the `+ Passage` button in the bottom right of the Twine Editor. We've intentionally left some details out at this point, as we want you to click around in the interface. You won't break anything, and it's pretty easy to delete your passages at this stage._ 53 | 54 | 1. Return to the Twine Editor and create two additional **Passages** 55 | 2. The first passage, titled "I'm doing good" should contain the text "That's awesome!" 56 | 3. The second passage, titled "Eh, not so good" should contain the text "I'm sorry to hear that..." 57 | 4. Change the tag for "Start" from `speaker-bot` to `speaker-karl`. Give it a color of "green" 58 | 5. Remember to add the `speaker-karl` tag. 59 | 60 | When completed, your Twine Editor should look like this: 61 | 62 | ![three independent passages](/docs/tutorial/02-story.png) 63 | 64 | If we were to press Play, we'd see no change in our conversation. That's because we haven't linked them yet. 65 | 66 | ## Linking Passages 67 | 68 | 1. Return to your "Start" Passage 69 | 2. After `Hello`, add a new line and type `[[I` 70 | 71 | The `[[` (two angle brackets) tells Twine you are creating a **Passage Link**. Passage Links point to other passages in your Twine file. Because we named our passages "I'm doing good" and "Eh, not so good" earlier, Twine can automatically suggest these passages for us. 72 | 73 | ![autocomplete](/docs/tutorial/02-link-autocomplete.png) 74 | 75 | Go ahead and create a link for each passage. `[[I'm doing good]]` and `[[Eh, not so good]]`. Once you're done, hit Play: 76 | 77 | ![showing a choice](/docs/tutorial/02-showing-choice.png) 78 | 79 | You'll see that you now have a choice! Choices are one of the most powerful tools we have in Twine, as they allow us to take the conversation in different directions based on how the user responds. In our example above, we present a choice to the user. Based on their decision, we can cover other topics or ask them about why they're not feeling great. (And the latter is what we're going to do next!) 80 | 81 | #### 👻 Bonus: Link Naming 82 | 83 | Links don't have to be exactly matching the Passage you want. Twine and Botscripten also support the format of `[[A->B]]`, where `A` is the text you want to display and `B` is the Passage Name. You cannot have any spaces around the arrow `->`. In our previous example, we could change the feeling good Link to `[[Swell->I'm doing good]]`. The user would then be given the choice of `Swell`, but behind the scenes we will still take them to `I'm Doing Good`. In large conversations, this can let you name Passages based on their purpose, independent of what the user picks. 84 | 85 | ## The Fourth and Fifth Passages - Asking Why 86 | 87 | _Like before, we're going to give minimal guidance so that you can play around with the interface. It's one of the best ways to learn._ 88 | 89 | 1. Open up `Eh, not so good` and add a link to `ask why` (Twine will create this Passage for you automatically) 90 | 2. Open up `ask why` and add the content of `#@prompt why_reason` 91 | 3. Add a new line (or many) and add a link to `confirm why` 92 | 4. Open up `confirm why` and add the text "Ah, I see. Thanks for sharing". Don't forget to set the `speaker-karl` tag 93 | 94 | Ready to check your answer? 95 | 96 | ![asking for input](/docs/tutorial/03-story.png) 97 | 98 | When we press "Play" now, and choose "Eh, not so good" we'll see additional lines of dialogue, including a dashed box around what used to be our `ask why` Passage. What's happened is that Botscripten saw you put in a **Directive**, text with special meaning in Aibex. In this case, we added the `#@prompt` directive. On Aibex, Karl will stop speaking and ask the user for an open ended response. This lets the user reflect at their own pace. 99 | 100 | **Directives** allow Karl to do more than just hold a conversation. We also use Directives for adding items to your journey, scheduling followup conversations, and more. We support quite a few, but 99% of your conversations will use `#@prompt` and `#@end`. 101 | 102 | #### 👻 Bonus: (Almost) All the Directives 103 | 104 | | directive | description | 105 | | :----------------------- | :----------------------------------------------------------------------------------------------------------------------------- | 106 | | `#@end` | Signals the end of a conversation, prompting a "close this conversation" button to appear | 107 | | `#@prompt varName` | Prompts the user for free-text input, saved into the provided `varName` | 108 | | `#@set varName varValue` | Store a value in the conversation. `varName` cannot contain spaces, and everything after `varName` is assumed to be the value. | 109 | 110 | ## The Sixth Passage - Ending 111 | 112 | _One more time here, and you're about to become a Botscripten master!_ 113 | 114 | 1. Create a new Passage called `end` 115 | 2. Inside of the Passage, add the directive `#@end` 116 | 3. Link "confirm why" and "I'm doing good" to your new "end" Passage 117 | 118 | Your final Twine story should look like this (feel free to move the Passages around. Twine keeps the lines connected for you): 119 | 120 | ![compeleted story](/docs/tutorial/04-story.png) 121 | 122 | Now, no matter which route you take, you'll end up at our final node, "end" which contains the `#@end` directive. This tells Karl and Aibex the conversation's complete and explicitly adds a "close conversation" button. You might be asking _why do we need the end directive?_ at this point, and the answer is because "finishing" a conversation (hitting `#@end`) is different than "abandoning" a conversation by closing out Karl mid-chat. Without an ending, we'd have no way of knowing what happened. This is especially true when someone is talking to Karl over Slack, SMS, Facebook, or other environments where we don't control the experience. 123 | 124 | # Learning More About Twine 125 | 126 | Twine is a rich tool for Interactive Fiction. Using other story formats, authors have created everything from "Choose Your Own Adventure"-style stories to text based video games. 127 | 128 | - [The Twine Story Map](http://twinery.org/wiki/twine2:the_story_map) - A tutorial on the Twine Editor interface 129 | - [Advanced Passage Editing](http://twinery.org/wiki/twine2:editing_passages) - A walkthrough on the Passage Editor screen 130 | - [Finding & Replacing Text Across Passages](http://twinery.org/wiki/twine2:finding_and_replacing_text) - Advanced Twine Editor features 131 | - [IFDB Twine Stories](https://ifdb.tads.org/search?searchbar=system%3Atwine&searchGo.x=0&searchGo.y=0) - Inspiration and ways to pass time, reading works created by other authors (using other Twine story formats) 132 | 133 | # Learning More About Botscripten 134 | 135 | - [Return to the main Botscripten docs](./README.md) 136 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ### [1.2.4](https://github.com/aibexhq/botscripten/compare/v1.2.3...v1.2.4) (2021-08-02) 2 | 3 | 4 | ### ⬆️ Dependency Changes 5 | 6 | * Bump @babel/cli from 7.8.4 to 7.14.8 ([14466c6](https://github.com/aibexhq/botscripten/commit/14466c69220d647ba1437cc163f7ac5c350f27e6)) 7 | * Bump rollup from 2.12.0 to 2.55.1 ([9a71bc4](https://github.com/aibexhq/botscripten/commit/9a71bc4855fe65bb9f2cecba3a630b173a6b0ab2)) 8 | 9 | ### [1.2.3](https://github.com/aibexhq/botscripten/compare/v1.2.2...v1.2.3) (2020-06-01) 10 | 11 | 12 | ### ⬆️ Dependency Changes 13 | 14 | * [Security] Bump acorn from 5.7.3 to 5.7.4 ([14cdfb4](https://github.com/aibexhq/botscripten/commit/14cdfb4239e3eaba15feeb69646a6eedec30ed15)) 15 | * Upgrades all dependencies ([#131](https://github.com/aibexhq/botscripten/issues/131)) ([3ef398f](https://github.com/aibexhq/botscripten/commit/3ef398f417ad6c134a06cfb79ee204478743e8b4)) 16 | 17 | 18 | ### 🐛 Bug Fixes 19 | 20 | * Adds suport for prettier-ignore ([#130](https://github.com/aibexhq/botscripten/issues/130)) ([2badc22](https://github.com/aibexhq/botscripten/commit/2badc228266aa006b00a69a9c412cf98e232176e)) 21 | * Fixes release pipeline by adding tokens ([7237ee8](https://github.com/aibexhq/botscripten/commit/7237ee831b743013b9781fe81661893f2b8f3031)) 22 | * Merges next into master and removes extra complexity ([2e84680](https://github.com/aibexhq/botscripten/commit/2e846805b4f67c76664134e9bc5a3ed07de5a227)) 23 | 24 | ### [1.2.2](https://github.com/aibexhq/botscripten/compare/v1.2.1...v1.2.2) (2020-05-05) 25 | 26 | ### 🐛 Bug Fixes 27 | 28 | - Adds beta release channel to circleci ([61174b9](https://github.com/aibexhq/botscripten/commit/61174b9090e7e3f55f0b341d067b084fcbc5c0a3)) 29 | - Adds suport for prettier-ignore ([#130](https://github.com/aibexhq/botscripten/issues/130)) ([2badc22](https://github.com/aibexhq/botscripten/commit/2badc228266aa006b00a69a9c412cf98e232176e)) 30 | 31 | ### ⬆️ Dependency Changes 32 | 33 | - [Security] Bump acorn from 5.7.3 to 5.7.4 ([14cdfb4](https://github.com/aibexhq/botscripten/commit/14cdfb4239e3eaba15feeb69646a6eedec30ed15)) 34 | - Upgrades all dependencies ([#131](https://github.com/aibexhq/botscripten/issues/131)) ([3ef398f](https://github.com/aibexhq/botscripten/commit/3ef398f417ad6c134a06cfb79ee204478743e8b4)) 35 | 36 | ### [1.2.2](https://github.com/aibexhq/botscripten/compare/v1.2.1...v1.2.2) (2020-03-27) 37 | 38 | ### 🐛 Bug Fixes 39 | 40 | - Adds beta release channel to circleci ([61174b9](https://github.com/aibexhq/botscripten/commit/61174b9090e7e3f55f0b341d067b084fcbc5c0a3)) 41 | - Adds suport for prettier-ignore ([#130](https://github.com/aibexhq/botscripten/issues/130)) ([2badc22](https://github.com/aibexhq/botscripten/commit/2badc228266aa006b00a69a9c412cf98e232176e)) 42 | 43 | ### ⬆️ Dependency Changes 44 | 45 | - [Security] Bump acorn from 5.7.3 to 5.7.4 ([14cdfb4](https://github.com/aibexhq/botscripten/commit/14cdfb4239e3eaba15feeb69646a6eedec30ed15)) 46 | - Upgrades all dependencies ([#131](https://github.com/aibexhq/botscripten/issues/131)) ([3ef398f](https://github.com/aibexhq/botscripten/commit/3ef398f417ad6c134a06cfb79ee204478743e8b4)) 47 | 48 | ### [1.2.2](https://github.com/aibexhq/botscripten/compare/v1.2.1...v1.2.2) (2020-03-27) 49 | 50 | ### 🐛 Bug Fixes 51 | 52 | - Adds beta release channel to circleci ([61174b9](https://github.com/aibexhq/botscripten/commit/61174b9090e7e3f55f0b341d067b084fcbc5c0a3)) 53 | - Adds suport for prettier-ignore ([#130](https://github.com/aibexhq/botscripten/issues/130)) ([2badc22](https://github.com/aibexhq/botscripten/commit/2badc228266aa006b00a69a9c412cf98e232176e)) 54 | 55 | ### ⬆️ Dependency Changes 56 | 57 | - Upgrades all dependencies ([#131](https://github.com/aibexhq/botscripten/issues/131)) ([3ef398f](https://github.com/aibexhq/botscripten/commit/3ef398f417ad6c134a06cfb79ee204478743e8b4)) 58 | 59 | ### [1.2.2](https://github.com/aibexhq/botscripten/compare/v1.2.1...v1.2.2) (2020-03-27) 60 | 61 | ### 🐛 Bug Fixes 62 | 63 | - Adds beta release channel to circleci ([61174b9](https://github.com/aibexhq/botscripten/commit/61174b9090e7e3f55f0b341d067b084fcbc5c0a3)) 64 | - Adds suport for prettier-ignore ([#130](https://github.com/aibexhq/botscripten/issues/130)) ([2badc22](https://github.com/aibexhq/botscripten/commit/2badc228266aa006b00a69a9c412cf98e232176e)) 65 | 66 | ### [1.2.1](https://github.com/aibexhq/botscripten/compare/v1.2.0...v1.2.1) (2020-03-09) 67 | 68 | ### 🐛 Bug Fixes 69 | 70 | - Adds beta release channel to circleci ([02047d0](https://github.com/aibexhq/botscripten/commit/02047d0a698c48d63f1d6a4fb42fdab98307b2d5)) 71 | 72 | ### [1.2.1-beta.1](https://github.com/aibexhq/botscripten/compare/v1.2.0...v1.2.1-beta.1) (2020-03-09) 73 | 74 | ### 🐛 Bug Fixes 75 | 76 | - Adds beta release channel to circleci ([61174b9](https://github.com/aibexhq/botscripten/commit/61174b9090e7e3f55f0b341d067b084fcbc5c0a3)) 77 | 78 | ## [1.2.0](https://github.com/aibexhq/botscripten/compare/v1.1.0...v1.2.0) (2020-03-04) 79 | 80 | ### ✨ Features 81 | 82 | - Runs botscripten offline ([580a0a0](https://github.com/aibexhq/botscripten/commit/580a0a087145e0f95f697d0e6d8ee4596354576f)) 83 | 84 | ## [1.1.0](https://github.com/aibexhq/botscripten/compare/v1.0.0...v1.1.0) (2020-03-04) 85 | 86 | ### 🐛 Bug Fixes 87 | 88 | - Fixes semantic-release to work with our release style ([1d278df](https://github.com/aibexhq/botscripten/commit/1d278df0381d6e4eac3ac4f49d122c1a7c9b966c)) 89 | 90 | ### ✨ Features 91 | 92 | - Completes the removal of botscriptenviewer ([bf5ef99](https://github.com/aibexhq/botscripten/commit/bf5ef998f683761f00ffc5f23002081eb0efbec8)) 93 | - Enables semantic-release by disabling --dry-run ([6973f61](https://github.com/aibexhq/botscripten/commit/6973f61db73bd4d97ea7911dddc84c12cba746a1)) 94 | 95 | # Changelog 96 | 97 | # v1.0.0 98 | 99 | - **Features** 100 | - Changed default color to darker blue to match Botscripten theme 101 | - Created a simpler `sample.html` that demonsrates botscripten in a more straightforward manner 102 | - **Fixes** 103 | - Fixed alignment of header over chat dialog 104 | 105 | **BREAKING CHANGES** 106 | 107 | - This release commits us to the semantic version standard going forward 108 | 109 | This is our first release using `semantic-release` and there are still rough edges in changelog generation. 110 | 111 | # 0.5.1 112 | 113 | - **Features** 114 | - There's now just a single story format, "Botscripten" to manage. Prior versions will continue to work, as the CDN will cache those indefinitely. We were able to leverage smaller file sizes for the botscripten output by using cdn.jsdelivr.net URLs for the botscripten files instead of inlining them into the template. While this requires an internet connection to run botscripten stories, it simplifies the install for individuals. 115 | 116 | # 0.5.0 117 | 118 | - **Breaking Changes** 119 | - Chatbook was renamed to `Botscripten`. `package.json` files will need to reference `@aibex/botscripten` beginning with 0.5.0. This was done to avoid confusion with "Chapbook", the new Twine story format from Twine's creator. 120 | 121 | # 0.4.0 122 | 123 | - **Breaking Changes** 124 | - In the chatbook parser, passages are now returned as an object to avoid empty passages at index 0 (twine numbering starts at zero) 125 | 126 | **0.3.0 > 0.4.0 Migration Guide**
127 | To migrate to 0.4.0, change any instances where you were looping over `story.passages` to loop instead over `story.passageIndex` and get the object from the passages collection, or iterate over the object keys of passages itself. 128 | 129 | ```js 130 | // prior 131 | for (const p in story.passages) { 132 | // ... 133 | } 134 | 135 | // new 136 | for (const pid of Object.keys(story.passages)) { 137 | const p = story.passages[pid]; 138 | // ... 139 | } 140 | ``` 141 | 142 | # 0.3.0 143 | 144 | - **Breaking Changes** 145 | - The `system` tag is removed. Passages are assumed to be system level passages unless a `speaker-*` tag is defined 146 | - `multiline` is the default behavior. To deliver a large block of text with newlines, use the `oneline` tag 147 | - **Features** 148 | - "Show Directives" is now enabled by default 149 | - **Documentation Updates** 150 | - Added clarity to how directives parse. Adding a directive in the middle of a passage will cause unexpected behavior. This is because all directives are extracted and ran _FIRST_. The most common solution to this is to split a passage up, ensuring that the second passage begins with the relevant directive(s). 151 | 152 | **0.2.x > 0.3.0 Migration Guide**
153 | To migrate to 0.3.0, you'll want to ensure you have `speaker-*` tags on every line you wish to have a speaker for. Additionally, you will want to add the `oneline` tag to any passages you wish to deliver at once instead of incrementally. 154 | 155 | The `system` and `multline` tags can be removed at your leisure. 156 | 157 | # 0.2.0 158 | 159 | - **Breaking Changes** 160 | - The `auto` tag is removed, and is assumed to be the default behavior. If you would like to pause the conversation until the user takes an action, you should use the `wait` tag instead. 161 | - The `prompt-*` tag is removed. If you want to prompt, it's recommended to do so as a directive. This ensures that your Chatbook script is as portable as possible and isn't bound to the constraints of Twine's tag system 162 | - **Features** 163 | - ChatbookViewer - You can now use the `show directives` option to view any directives you've defined. This uses the same extractor as the npm module 164 | 165 | **0.1.1 > 0.2.0 Migration Guide**
166 | To migrate to 0.2.0, you'll want to add a `wait` tag anywhere you intentionally want Chatbook to pause for user input and there was only a single link available. `auto` tags can be cleaned up at your leisure. 167 | 168 | You'll also need to replace your `prompt-*` tags with a directive. We recommend `#@prompt saveAsName` which will require minimal code change for any parsers/interpreters that were relying on prompt tags. 169 | 170 | # 0.1.1 171 | 172 | - **Features** 173 | - node.js support. Chatbook now has a simple parser for Chatbook-formated Twine2 files. 174 | - Tests now running via `yarn test`. Currently used to validate the parser is working as-intended 175 | - **Fixes** 176 | - Removed `chatbook.umd.js` as it's an intermediate file in the twine build 177 | - Refactored build scripts and `concurrently` output for a cleaner dev console 178 | - Added Husky + Prettier for consistent JavaScript formatting 179 | - **Other** 180 | - Twine and npm versions have been separated. Updates to the parser should not reqiure people to consider upgrading their StoryFormat within Twine 181 | - An Acknowledgements section was added to the readme to specifically thank the prior work that made Chatbook possible. ❤️ 182 | 183 | # 0.1.0 (intermediate) 184 | 185 | Between 0.1.0 and 0.1.1, the `chatbook` repository was transferred to the `aibexhq` organization. Contributors remained the same. 186 | 187 | # 0.1.0 188 | 189 | - **Breaking Changes** 190 | - Comments are migrated from JavaScript style `/* ... */` to using Octothorpes `#` / `###`. This allos us to support Directives. 191 | - Removed support completely for `<% ... %>` to promote Twine file portability 192 | - Removed markdown support, as markdown implementations are not consistent between JavaScript (marked) and other platforms 193 | - **Features** 194 | - Directives - Directives allow you to attach meaning to comments for your runtime engine. These statements are the new Octothorpe comment, followed by an `@` sign, followed by the directive. For example, a line of `#@foo bar` in your Twine file would be a comment with the directive `@foo`, and the content `bar`. 195 | 196 | # 0.0.1 197 | 198 | Initial Release 199 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Botscripten 2 | 3 | > :warning: Botscripten is currently in _maintenance mode_ and is not receiving any new updates at this time, though PRs to fix bugs are welcome. 4 | 5 | **A modified Trialogue/Twine engine specifically for Building, Testing, and Exporting conversations as Minimal HTML5** 6 | 7 | ![Botscripten logo](dist/Twine2/Botscripten/icon.svg) 8 | 9 | [![Story Format Version](https://img.shields.io/badge/StoryFormat-1.0.0-blue)](/dist/Twine2) 10 | [![npm](https://img.shields.io/npm/v/botscripten)](https://www.npmjs.com/package/botscripten) 11 | [![Twine Version](https://img.shields.io/badge/Twine-2.2.0+-blueviolet)](http://twinery.org/) 12 | [![Dependabot Status](https://api.dependabot.com/badges/status?host=github&repo=jakobo/botscripten)](https://dependabot.com) 13 | 14 | **Upgrading? Check the [Changelog](/CHANGELOG.md)** 15 | 16 | Botscripten is a chat-style [Twine](http://twinery.org) Story Fromat based on [Trialogue](https://github.com/phivk/trialogue) and its predecessors. Unlike other Twine sources, Botscripten is optimized for **an external runtime**. That is, while you can use Botscripten for Interactive Fiction, that's not this story format's intent. 17 | 18 | Botscripten is also available as an npm parser, able to handle Passage-specific features found in the Botscripten format. It's available via `npm install botscripten` or `yarn add botscripten`. 19 | 20 | ✅ You want to use [Twine](http://twinery.org) to author complex branching dialogue
21 | ✅ You want a conversation format (think chatbot)
22 | ✅ You want simple built-in testing to step through flows and get feedback
23 | ✅ You want a minimal output format for an **external runtime** 24 | 25 | If "yes", then Botscripten is worth looking into. 26 | 27 | 🔮 [A Sample Conversation](http://htmlpreview.github.io/?https://github.com/jakobo/botscripten/blob/master/examples/sample.html) 28 | 29 | Botscripten comes with two distinct flavors: **An Interactive Output** for testing and stepping through conversations in a pseudo chat interface based on the Trialogue code, and built in proofing version. External JavaScript keeps the output file small, making it easy to use the pure HTML in other systems. 30 | 31 | - [Botscripten](#botscripten) 32 | - [🚀 Setup and Your First "Chat"](#-setup-and-your-first-chat) 33 | - [Add Botscripten as a Twine Story Format](#add-botscripten-as-a-twine-story-format) 34 | - [Create your first chat story](#create-your-first-chat-story) 35 | - [🏷 Botscripten Tags](#-botscripten-tags) 36 | - [🙈 Comments in Botscripten](#-comments-in-botscripten) 37 | - [🗂 Recipies](#-recipies) 38 | - ["Special" Comments (Directives)](#special-comments-directives) 39 | - [Conditional Branching (cycles, etc)](#conditional-branching-cycles-etc) 40 | - [Scripting Directives in Botscripten](#scripting-directives-in-botscripten) 41 | - [📖 Node Module Documentation](#-node-module-documentation) 42 | - [⚠️ Why would you use Botscripten over (Insert Twine Format)?](#️-why-would-you-use-botscripten-over-insert-twine-format) 43 | - [Developing on Botscripten](#developing-on-botscripten) 44 | - [Local Development](#local-development) 45 | - [Acknowledgements](#acknowledgements) 46 | 47 | # 🚀 Setup and Your First "Chat" 48 | 49 | ## Add Botscripten as a Twine Story Format 50 | 51 | ![add](/docs/add-format.gif) 52 | 53 | 1. From the Twine menu, select `Formats` 54 | 2. Then, select the `Add a New Format` tab 55 | 3. Paste `https://cdn.jsdelivr.net/gh/jakobo/botscripten@master/dist/Twine2/Botscripten/format.js` 56 | 4. Click `Add` 57 | 58 | Once you've done this, you will have access to the Botscripten story format in Twine. If you're migrating, be sure to check the [Changelog](CHANGELOG.md) for a migration guide. 59 | 60 | Upgrading is as simple as removing your old Botscripten and adding the new URL above. Any stories you publish will automatically work in the new format. 61 | 62 | _(If you are interested in the `next` version of botscripten, you may use `https://cdn.jsdelivr.net/gh/jakobo/botscripten@next/dist/Twine2/Botscripten/format.js` as your story format URL)_ 63 | 64 | ## Create your first chat story 65 | 66 | ![create a chat](/docs/trialogue-create.gif) 67 | 68 | 1. Create a story in the Twine editor. 69 | 2. Set your story format to `Botscripten` 70 | 3. Edit the start passage to include: 71 | - Title (e.g. start) 72 | - Passage text (e.g. "Hi 👋") 73 | - One or more links (e.g. `[[What's your name?]]`) 74 | - Speaker tag (e.g. `speaker-bot`). This will display the speaker's name (in this case `bot`) in standalone viewer 75 | 4. Edit the newly created passage(s) to include: 76 | - Passage text (e.g. "My name is Bot") 77 | - One or more links (e.g. `[[Back to start->start]]`) 78 | - Speaker tag (e.g. `speaker-bot`) 79 | 5. Hit `Play` to test the result 80 | 81 | # 🏷 Botscripten Tags 82 | 83 | Botscripten is designed to work exclusively with Twine's tag system. That means no code in your conversation nodes. This is important because behind the scenes, many other Twine formats convert Passages containing `<% ... %>` into JavaScript code, defeating the goal of portability. 84 | 85 | The following passage tags are supported by Botscripten. It is assumed that anyone consuming a Botscripten formatted Twine story will also support these tags. 86 | 87 | | tag | explanation | 88 | | :--------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | 89 | | `oneline` | By default, chat bots will display one message per line, similar to an SMS conversation. If you'd like to send content as a paragraph, use the `oneline` tag. The entire Twine node will then be treated as a single message. | 90 | | `speaker-*` _(prefix)_ | The `speaker-*` tag describes "who" the message is from. For example, a tag of `speaker-bob` would imply the message should come from `bob`. The speaker tag is arbitrary, but should be consistent to identify "who" is talking. | 91 | | `wait` | Adding `wait` will prevent the conversation from automatically advancing. Automatic advancement happens when there is exactly 1 link to follow, and the `wait` tag is not set. The most common reason for `wait` is to present some form of "continue" to the user. | 92 | 93 | To maintain compatibility with the [Twee 3 Specification](https://github.com/iftechfoundation/twine-specs/blob/master/twee-3-specification.md), the tags `script` and `stylesheet` should **never** be used. 94 | 95 | # 🙈 Comments in Botscripten 96 | 97 | The Botscripten story format allows for simple comments. Lines beginning with an octothorpe `#` are removed from chat lines when playing a story, but remain in the source code for external tools. 98 | 99 | If you'd like to place a comment across multiple lines, you can use a triple-octothorpe `###`. Everything until the next `###` will be considered a comment. 100 | 101 | The following are all comments in Botscripten: 102 | 103 | ``` 104 | # I'm a comment, because I have a "#" at the start of the line 105 | 106 | # It can 107 | # cover 108 | # multiple lines 109 | 110 | ### 111 | You can also use a triple # to denote a block 112 | and everything is ommitted until the next 113 | triple # 114 | ### 115 | 116 | ### 117 | If you need a literal #, you can escape it with a 118 | backslash like this: \### 119 | ### 120 | ``` 121 | 122 | # 🗂 Recipies 123 | 124 | Below are some common challenges & solutions to writing Twine scripts in Botscripten format 125 | 126 | ## "Special" Comments (Directives) 127 | 128 | If you look at the [sample](/examples/sample.twee), you'll notice many of the comments contain an `@yaml` statement. While `Botscripten` (viewer) doesn't care about these items (they're comments after all), any other system parsing the Twine file can read these statements out of the comment blocks. Additionally, if you use botscripen's npm engine, you'll have access to these special comments as part of the story parsing. 129 | 130 | These special comments are called **Directives** and they consist of the comment identifier (`#` or `###`) immediatly followed by `@` and a `word`. These are all Directives: 131 | 132 | ``` 133 | #@doAThing 134 | 135 | #@make party 136 | 137 | ###@sql 138 | INSERT INTO winners (name, time) VALUES ('you', NOW()) 139 | ### 140 | ``` 141 | 142 | Anyone parsing Botscripten Twine files can assume that the regular expressions `/^#@([\S]+)(.*)/g` (inline) and `/^###@([\S]+)([\s\S]*?)###/gm` (block) will match and extract the directive and the remainder of the comment. 143 | 144 | For consistency between systems, directives should be run when a Passage is parsed, but before any tag behavior (such as `wait` or `speaker-*` are applied) This allows directives to form opinions about the Passage and it's output before rendering occurs. 145 | 146 | There is no set definition for directives, as adding a directive to Botscripten would require **every external parser to also support it**. This is also why Botscripten is so light- there's almost no parsing being done of the individual Passages. 147 | 148 | But if you'd like some examples, these are some directives we think are pretty useful and are worth implementing in your own conversation engine: 149 | 150 | - `#@set ` - A directive that sets a local variable `` to value `` within the conversation 151 | - `#@increment ` - A directive to increment a local variable `` by amount `` 152 | - `#@end` - A directive that tells the system to end a conversation (don't put any `[[links]]` in this passage obviously!) 153 | 154 | ## Conditional Branching (cycles, etc) 155 | 156 | Since Botscripten does not maintain a concept of state, nor have a way to script items such as cycling or conditional links, you should present **all possible branches** using the `[[link]]` syntax. This will allow you to view all permutations in Botscripten when testing conversations locally. 157 | 158 | Conditional branching can then be implemented as a [Directive](#%22special%22-comments-directives). This gives you control outside of the Twine environment as to which link is followed under what conditions. We're partial to a `###@next ... ###` directive, but feel free to create your own! 159 | 160 | ## Scripting Directives in Botscripten 161 | 162 | If you absolutely want to handle Directives in Botscripten, you can do so by selecting `Edit Story JavaScript` in Twine, and registering a handler for your directive. For example, this logs all `@log` directives' content to the developer tools console. 163 | 164 | ```js 165 | story.directive("@log", function (info, rendered, passage, story) { 166 | console.log("LOG data from " + passage.id); 167 | console.log("Directive contained: " + info); 168 | return rendered; // return the original (or altered) output 169 | }); 170 | ``` 171 | 172 | Directives are evaluated after the Passage is parsed, but before any tag behaviors are applied. 173 | 174 | # 📖 Node Module Documentation 175 | 176 | Most individuals are interested in writing for the Botscripten format, not consuming it. If you are looking to read Botscripten's Twine HTML files, and are also in a node.js environment, you can install Botscripten over npm/yarn and access the parser. Parsing a valid Botscripten HTML file will yield the following: 177 | 178 | ```js 179 | import botscripten from "botscripten"; 180 | import fs from "fs"; 181 | 182 | const story = botscripten(fs.readFileSync("your/file.html").toString()); 183 | 184 | story = { 185 | name: "", // story name 186 | start: null, // name ID of starting story node 187 | startId: null, // numeric ID of starting story node 188 | creator: "", // creator of story file 189 | creatorVersion: "", // version of creator used 190 | ifid: "", // IFID - Interactive Fiction ID 191 | zoom: "", // Twine Zoom Level 192 | format: "", // Story Format (Botscripten) 193 | formatVersion: "", // Version of Botscripten used 194 | options: "", // Twine options 195 | tags: [ 196 | { 197 | // A collection of tags in the following format... 198 | name: "", // Tag name 199 | color: "", // Tag color in Twine 200 | }, 201 | // ... 202 | ], 203 | passages: { 204 | // A collection of passages in the following format... 205 | // pid is the passage's numeric ID 206 | [pid]: { 207 | pid: null, // The passage's numeric ID 208 | name: "", // The passage name 209 | tags: [], // An array of tags for this passage 210 | directives: [ 211 | { 212 | // An array of Botscripten directives in the following format... 213 | name: "", // The directive name 214 | content: "", // The content in the directive, minus the name 215 | }, 216 | // ... 217 | ], 218 | links: [ 219 | { 220 | // Links discovered in the passage in the following format... 221 | display: "", // The display text for a given link 222 | target: "", // The destination Passage's name 223 | }, 224 | // ... 225 | ], 226 | position: "", // The Twine position of this passage 227 | size: "", // The Twine size of this passage 228 | content: "", // The passage content minus links, comments, and directives 229 | }, 230 | // ... 231 | }, 232 | passageIndex: { 233 | [name]: id, // A lookup index of [Passage Name]: passageNumericId 234 | //... 235 | }, 236 | }; 237 | ``` 238 | 239 | # ⚠️ Why would you use Botscripten over (Insert Twine Format)? 240 | 241 | First off, every Twine format I've worked with is amazing and super thougtful. If your goal is to create interactive fiction, self-contained tutorials, etc, you should just use Trialogue, Harlowe, or Sugarcube. However, if you're using Twine as a conversation editor (and you are more interested in the `tw-passagedata` blocks and the data structure behind Twine) Botscripten may be for you. 242 | 243 | - **Zero `story.*` Calls** To be as portable as possible, No template tags may be used. That means your code cannot contain the `<% ... %>` blocks seen in Trialogue/Paloma. These tags are incredibly difficult to parse/lex, because they assume a JavaScript environmemt at runtime. And since you don't know where your Twine file is going to run, you must decouple the programming from the data. 244 | - **Tags drive behavior** Because of that first restriction, we need a way to perform actions within Botscripten. Thankfully, Twine's Tag system is up to the task. **We strive to keep the tag count low to minimize the number of reserved tags in the system.** 245 | - **Dev Experience** Iterating on Twine templates is hard. A lot of time was spent to make the dev experience as simple as (1) put [tweego](https://www.motoslave.net/tweego/) in your executable path, and (2) type `npm run dev`. 246 | - **Multiple Formats** Botscripten provides two syncrhonized formats from the same repository. Features in the proofing / html5-min version will also show up simultaneously in the Interactive one. 247 | 248 | # Developing on Botscripten 249 | 250 | ## Local Development 251 | 252 | 1. Acquire [tweego](https://www.motoslave.net/tweego/) and place it in your development path. 253 | 2. Check out this repository 254 | 3. run `npm install` to install your dependencies 255 | 4. run `npm run dev` to start developing using the twee files in the `examples` folder 256 | 257 | - Examples are available under `http://localhost:3000` 258 | - TEST_Botscripten can be installed in Twine from `http://localhost:3001/Botscripten` 259 | - When you are done developing/testing, be sure to remove the TEST_Botscripten format. If you forget, just restart the dev server so Twine doesn't complain every time you start it up 260 | 261 | For local testing convienence, we have a `npm run tweego` command. It ensures that Botscripten is in the `tweego` path before performing a build. 262 | 263 | As an example, the sample document was converted from Twine to Twee using the command `npm run tweego -- -d ./stories/sample.html -o ./examples/sample.twee`. (You may need to manually edit the html file to set the format to "Botscripten") 264 | 265 | # Acknowledgements 266 | 267 | Botscripten would not be possible without the amazing work of [Philo van Kemenade](https://github.com/phivk) for imagining Twine as a conversational tool, [M. C. DeMarco](http://mcdemarco.net/tools/scree/paloma/) who reimagined the original "Jonah" format for Twine 2, and [Chris Klimas](https://www.patreon.com/klembot/) creator and current maintainer of Twine. 268 | -------------------------------------------------------------------------------- /dist/botscripten.umd.js: -------------------------------------------------------------------------------- 1 | !function(t){"function"==typeof define&&define.amd?define(t):t()}(function(){"use strict";var a=function(t,e){if(!(t instanceof e))throw new TypeError("Cannot call a class as a function")};var s=function(t,e,r){return e in t?Object.defineProperty(t,e,{value:r,enumerable:!0,configurable:!0,writable:!0}):t[e]=r,t};var n=function(t,e){(null==e||e>t.length)&&(e=t.length);for(var r=0,n=new Array(e);r",""":'"',"'":"'","`":"`"},function(t){return null==w?void 0:w[t]}),L=Object.prototype.toString,O=b.Symbol,E=O?O.prototype:void 0,j=E?E.toString:void 0;function S(t){if("string"==typeof t)return t;if("symbol"==typeof(e=t)||function(t){return t&&"object"==typeof t}(e)&&L.call(e)==p)return j?j.call(t):"";var e,r=t+"";return"0"==r&&1/t==-h?"-0":r}var k=function(t){var e;return(t=null==(e=t)?"":S(e))&&y.test(t)?t.replace(v,x):t},T=/^###@([\S]+)([\s\S]*?)###/gm,_=/^#@([\S]+)(.*)$/gm,P=/\[\[(.*?)\]\]/gm,A="__TOKEN_ESCAPED_BACKSLASH_OCTO__",M=/###[\s\S]*?###/gm,H=/^#.*$/gm;function C(e,t){var r=Object.keys(e);if(Object.getOwnPropertySymbols){var n=Object.getOwnPropertySymbols(e);t&&(n=n.filter(function(t){return Object.getOwnPropertyDescriptor(e,t).enumerable})),r.push.apply(r,n)}return r}function D(r){var t=r.source,e=function(t){var n=[];for(t=t.replace("\\#","__TOKEN_ESCAPED_BACKSLASH_OCTO__");t.match(T);)t=t.replace(T,function(t,e,r){return n.push({name:"@".concat(e),content:r.trim()}),""});for(;t.match(_);)t=t.replace(_,function(t,e,r){return n.push({name:"@".concat(e),content:r.trim()}),""});return n}(t),n=t.replace("\\#",A).replace(M,"").replace(H,"").replace(A,"#").trim();r&&(r.links=[]);var o=function(t){for(var s=[],e=t;t.match(P);)t=t.replace(P,function(t,e){var r=e,n=e,o=e.indexOf("|"),i=e.indexOf("->"),a=e.indexOf("<-");switch(!0){case 0<=o:r=e.substr(0,o),n=e.substr(o+1);break;case 0<=i:r=e.substr(0,i),n=e.substr(i+2);break;case 0<=a:r=e.substr(a+2),n=e.substr(0,a)}return s.push({display:r,target:n}),""});return{links:s,updated:t,original:e}}(n);if(n=o.updated,r&&(r.links=o.links),e.forEach(function(e){r.story.directives[e.name]&&r.story.directives[e.name].forEach(function(t){n=t(e.content,n,r,r.story)})}),!r.getSpeaker())return{directives:e,text:[]};if(r){var i=r.prefixTag("prompt");i.length&&r.story.prompt(i[0])}return r.hasTag("oneline")?{directives:e,text:[n]}:{directives:e,text:(n=n.trim()).split(/[\r\n]+/g)}}function q(t,e,r,n,o){var i=this;a(this,q),s(this,"id",null),s(this,"name",null),s(this,"tags",null),s(this,"tagDict",{}),s(this,"source",null),s(this,"links",[]),s(this,"getSpeaker",function(){var t=i.tags.find(function(t){return 0===t.indexOf("speaker-")})||"";return t?t.replace(/^speaker-/,""):null}),s(this,"prefixTag",function(e,r){return i.tags.filter(function(t){return 0===t.indexOf("".concat(e,"-"))}).map(function(t){return t.replace("".concat(e,"-"),"")}).reduce(function(t,e){return r?function(e){for(var t=1;t"'`]/g,I=RegExp(G.source),B="object"==typeof t&&t&&t.Object===Object&&t,K="object"==typeof self&&self&&self.Object===Object&&self,R=B||K||Function("return this")();var $,U=($={"&":"&","<":"<",">":">",'"':""","'":"'","`":"`"},function(t){return null==$?void 0:$[t]}),Y=Object.prototype.toString,z=R.Symbol,J=z?z.prototype:void 0,Q=J?J.toString:void 0;function V(t){if("string"==typeof t)return t;if("symbol"==typeof(e=t)||function(t){return t&&"object"==typeof t}(e)&&Y.call(e)==F)return Q?Q.call(t):"";var e,r=t+"";return"0"==r&&1/t==-N?"-0":r}function W(t,e){return t.querySelector(e)}function X(t,e){return c(t.querySelectorAll(e))||[]}function Z(t,e){var h=this;a(this,Z),s(this,"version",2),s(this,"document",null),s(this,"story",null),s(this,"name",""),s(this,"startsAt",0),s(this,"current",0),s(this,"history",[]),s(this,"passages",{}),s(this,"showPrompt",!1),s(this,"errorMessage","⚠ %s"),s(this,"directives",{}),s(this,"elements",{}),s(this,"userScripts",[]),s(this,"userStyles",[]),s(this,"start",function(){h.userStyles.forEach(function(t){var e=h.document.createElement("style");e.innerHTML=t,h.document.body.appendChild(e)}),h.userScripts.forEach(function(t){globalEval(t)}),h.document.body.addEventListener("click",function(t){t.target.matches("#user-response-panel a[data-passage]")&&h.advance(h.findPassage(t.target.getAttribute("data-passage")),t.target.innerHTML)}),h.document.body.addEventListener("click",function(t){if(t.target.matches("#user-response-panel button[data-passage]")){var e=W(h.document,"#user-response-panel input").value;h.showPrompt=!1,h.advance(h.findPassage(t.target.getAttribute("data-passage")),e)}}),h.advance(h.findPassage(h.startsAt))}),s(this,"findPassage",function(e){if(e="".concat(e).trim(),t=e,it.test(t))return h.passages[e];var t,r=X(h.story,"tw-passagedata").filter(function(t){return k(t.getAttribute("name")).trim()===e})[0];return r?h.passages[r.getAttribute("pid")]:null}),s(this,"advance",function(){var e=f(d.mark(function t(e){var r,n,o,i=arguments;return d.wrap(function(t){for(;;)switch(t.prev=t.next){case 0:return r=1\n
\n
\n ').concat(n,"\n
\n
\n \n")));case 2:return h.scrollToBottom(),t.abrupt("return",Promise.resolve());case 4:case"end":return t.stop()}var e,r,n},t)}));return function(t,e,r){return n.apply(this,arguments)}}()),s(this,"renderPassage",function(){var r=f(d.mark(function t(a,s){var c,u,l,f;return d.wrap(function(t){for(;;)switch(t.prev=t.next){case 0:return c=a.getSpeaker(),u=a.render(),console.log(u.directives),t.next=5,s((i=u.directives,'\n
\n '.concat(i.map(function(t){var e=t.name,r=t.content;return'
').concat(r.trim(),"
")}).join(""),"\n
\n")));case 5:l=u.text.shift(),h.showTyping();case 7:if(l)return e={speaker:c,tags:a.tags,text:l},n=void 0,r=e.speaker,n=e.tags,o=e.text,f='\n
\n
\n
\n ').concat(o,"\n
\n
\n
\n"),t.next=11,at(h.calculateDelay(l));t.next=16;break;case 11:return t.next=13,s(f);case 13:l=u.text.shift(),t.next=7;break;case 16:return h.hideTyping(),h.scrollToBottom(),t.abrupt("return",Promise.resolve());case 19:case"end":return t.stop()}var e,r,n,o,i},t)}));return function(t,e){return r.apply(this,arguments)}}()),s(this,"calculateDelay",function(t){return 20*t.length*.3}),s(this,"showTyping",function(){W(h.document,ot).style.visibility="visible"}),s(this,"hideTyping",function(){W(h.document,ot).style.visibility="hidden"}),s(this,"scrollToBottom",function(){var t=W(h.document,rt);document.scrollingElement.scrollTop=t.offsetHeight}),s(this,"removeChoices",function(){W(h.document,nt).innerHTML=""}),s(this,"renderChoices",function(t){h.removeChoices();var e=W(h.document,nt);t.links.forEach(function(t){e.innerHTML+='').concat(t.display,"")})}),s(this,"directive",function(t,e){h.directives[t]||(h.directives[t]=[]),h.directives[t].push(e)}),this.window=t,this.document=e?document.implementation.createHTMLDocument("Botscripten Injected Content"):document,this.story=W(this.document,"tw-storydata"),this.elements={active:W(this.document,"#active-passage"),history:W(this.document,rt)},this.name=this.story.getAttribute("name")||"",this.startsAt=this.story.getAttribute("startnode")||0,X(this.story,"tw-passagedata").forEach(function(t){var e=parseInt(t.getAttribute("pid")),r=t.getAttribute("name"),n=(t.getAttribute("tags")||"").split(/\s+/g),o=t.innerHTML||"";h.passages[e]=new q(e,r,n,o,h)}),W(this.document,"title").innerHTML=this.name,this.userScripts=(X(this.document,'*[type="text/twine-javascript"]')||[]).map(function(t){return t.innerHTML}),this.userStyles=(X(this.document,'*[type="text/twine-css"]')||[]).map(function(t){return t.innerHTML})}var tt,et=function(t){var e;return(t=null==(e=t)?"":V(e))&&I.test(t)?t.replace(G,U):t},rt="#history",nt="#user-response-panel",ot="#animation-container",it=/^[\d]+$/,at=function(){var t=f(d.mark(function t(){var e,r=arguments;return d.wrap(function(t){for(;;)switch(t.prev=t.next){case 0:return e=0{{STORY_NAME}}{{STORY_DATA}}
"}) -------------------------------------------------------------------------------- /examples/sample.html: -------------------------------------------------------------------------------- 1 | Test
--------------------------------------------------------------------------------