├── .gitignore ├── .flowconfig ├── .babelrc ├── .github └── workflows │ └── test.yml ├── src ├── backend │ ├── httpGet.js │ ├── local.js │ ├── index.js │ └── gist.js ├── parse.js ├── cache.js ├── components.js ├── basic.js └── index.js ├── test ├── parse.test.js └── index.test.js ├── .eslintrc.js ├── dist ├── index.html └── style.css ├── webpack.config.js ├── CONTRIBUTING.md ├── LICENSE ├── package.json ├── README.md └── jest.config.js /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | /dist/main.js* 3 | /dist/dev 4 | -------------------------------------------------------------------------------- /.flowconfig: -------------------------------------------------------------------------------- 1 | [strict] 2 | nonstrict-import 3 | unclear-type 4 | unsafe-getters-setters 5 | untyped-import 6 | untyped-type-import -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | [ 4 | "@babel/preset-env", 5 | { 6 | "useBuiltIns": "usage" 7 | } 8 | ], 9 | "@babel/preset-flow" 10 | ] 11 | } -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test, lint, and type check 2 | on: [push, pull_request] 3 | jobs: 4 | build: 5 | runs-on: ubuntu-latest 6 | steps: 7 | - uses: actions/checkout@v2 8 | - name: Use Node.js 9 | uses: actions/setup-node@v1 10 | with: 11 | node-version: 14.x 12 | - run: npm install 13 | - run: npm run lint 14 | - run: npm run flow 15 | - run: npm test -------------------------------------------------------------------------------- /src/backend/httpGet.js: -------------------------------------------------------------------------------- 1 | // @flow strict 2 | 3 | // 4 | // Sends a HTTP GET request to `url`. 5 | // 6 | export default function (url: string): Promise { 7 | return new Promise((resolve, reject) => { 8 | const xhr = new XMLHttpRequest() 9 | xhr.open("GET", url) 10 | xhr.onload = () => { 11 | if (xhr.status >= 200 && xhr.status < 300) { 12 | resolve(xhr.responseText) 13 | } else { 14 | reject(xhr.statusText) 15 | } 16 | } 17 | xhr.send() 18 | }) 19 | } 20 | -------------------------------------------------------------------------------- /test/parse.test.js: -------------------------------------------------------------------------------- 1 | import parse from "../src/parse" 2 | 3 | describe("parse", () => { 4 | test("parses the gist id", () => { 5 | expect(parse("#gist-id")).toContain("gist-id") 6 | }) 7 | 8 | test("returns 'index' as the default scene", () => { 9 | expect(parse("#gist-id")).toContain("index") 10 | }) 11 | 12 | test("returns 'index' as the root scene", () => { 13 | expect(parse("#gist-id/")).toContain("index") 14 | }) 15 | 16 | test("returns the correct scene", () => { 17 | expect(parse("#gist-id/foo")).toContain("foo") 18 | }) 19 | 20 | test("returns the correct sub-scene", () => { 21 | expect(parse("#gist-id/foo/bar")).toContain("foo/bar") 22 | }) 23 | }) 24 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "env": { 3 | "node": true, 4 | "browser": true, 5 | "es6": true 6 | }, 7 | "plugins": [ 8 | "jest", 9 | "flowtype" 10 | ], 11 | "extends": [ 12 | "eslint:recommended", 13 | "plugin:jest/recommended", 14 | "plugin:flowtype/recommended" 15 | ], 16 | "parser": "babel-eslint", 17 | "parserOptions": { 18 | "ecmaVersion": 2015, 19 | "sourceType": "module" 20 | }, 21 | "rules": { 22 | "indent": [ 23 | "error", 24 | 2, 25 | { "SwitchCase": 1 } 26 | ], 27 | "linebreak-style": [ 28 | "error", 29 | "unix" 30 | ], 31 | "quotes": [ 32 | "error", 33 | "double" 34 | ], 35 | "semi": [ 36 | "error", 37 | "never" 38 | ] 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /dist/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | gist-txt 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 |
12 |
Loading...
13 |
14 | 15 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /src/backend/local.js: -------------------------------------------------------------------------------- 1 | // @flow strict 2 | 3 | // 4 | // Local backend 5 | // 6 | 7 | export type Config = { storage: "local", path: string } 8 | 9 | var path: string 10 | 11 | // 12 | // Initializes the backend using the input configuration. 13 | // 14 | // The initialization of the local storage backend consists just of storing the 15 | // the value of the `path` configuration property, that is the path in the local 16 | // development server from where content should be served. 17 | // 18 | function init(config: Config): Promise { 19 | path = config.path 20 | return Promise.resolve() 21 | } 22 | 23 | // 24 | // Returns the URL of the file referenced by `filename`. 25 | // 26 | // The URL is a path to the file served from a local development server. 27 | // 28 | function fileURL(filename: string): Promise { 29 | return Promise.resolve(`${path}/${filename}`) 30 | } 31 | 32 | export default { 33 | init, 34 | fileURL 35 | } 36 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | var path = require('path'); 2 | var webpack = require('webpack'); 3 | 4 | module.exports = { 5 | entry: './src/index.js', 6 | output: { 7 | filename: 'main.js', 8 | path: path.resolve(__dirname, 'dist') 9 | }, 10 | module: { 11 | rules: [ 12 | { 13 | test: /\.js$/, 14 | exclude: /node_modules/, 15 | use: { 16 | loader: 'babel-loader', 17 | options: { 18 | presets: ['@babel/preset-env'] 19 | } 20 | } 21 | } 22 | ] 23 | }, 24 | // Fixes "Error: Can't resolve 'buffer'" in js-yaml 25 | // Can be removed after upgrading to js-yaml >= 4.1 26 | // See https://github.com/nodeca/js-yaml/commit/c15d424448108771708eb942515b06020ecfe84c 27 | // See also https://github.com/webpack/changelog-v5/issues/10 28 | plugins: [ 29 | new webpack.ProvidePlugin({ 30 | Buffer: ["buffer", "Buffer"], 31 | process: "process", 32 | }), 33 | ], 34 | devtool: 'source-map' 35 | }; 36 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Fork, then clone the repo: 4 | 5 | ```sh 6 | git clone git@github.com:your-username/gist-txt.git 7 | ``` 8 | 9 | Install npm dependencies: 10 | 11 | ```sh 12 | npm install 13 | ``` 14 | 15 | Continuously build a development version of the bundle: 16 | 17 | ```sh 18 | npm run-script watch 19 | ``` 20 | 21 | Run a local HTTP server: 22 | 23 | ```sh 24 | npm run-script serve 25 | ``` 26 | 27 | Once you're finished with your work, push to your fork and [submit a pull 28 | request](https://github.com/potomak/gist-txt/compare/). 29 | 30 | At this point you're waiting on us. We like to at least comment on pull requests 31 | within three business days (and, typically, one business day). We may suggest 32 | some changes or improvements or alternatives. 33 | 34 | Some things that will increase the chance that your pull request is accepted: 35 | 36 | * write tests 37 | * be consistent 38 | * write a [good commit 39 | message](http://tbaggery.com/2008/04/19/a-note-about-git-commit-messages.html) 40 | -------------------------------------------------------------------------------- /src/parse.js: -------------------------------------------------------------------------------- 1 | // @flow strict 2 | 3 | // 4 | // A gist-txt location hash has the form: 5 | // 6 | // #/ 7 | // 8 | // To parse the hash: 9 | // 10 | // 1. remove the '#' refix 11 | // 2. split the remaining string by '/' 12 | // 3. assign the first *segment* to the global variable `gistId` 13 | // 4. join the remaining segments with '/' 14 | // 15 | // Note: gists' files can't include the '/' character in the name so, even if 16 | // the remaining portion of the segments array is joined by '/', that array 17 | // should always contain at most one element. 18 | // 19 | // If the scene name is blank return 'index', the default name of the main 20 | // scene, otherwise return the scene name found. 21 | // 22 | export default function (hash: string): [string, string] { 23 | const path = hash.slice(1) 24 | const segments = path.split("/") 25 | const gistId = segments.shift() 26 | let scene = segments.join("/") 27 | 28 | if (scene === "") { 29 | scene = "index" 30 | } 31 | 32 | return [gistId, scene] 33 | } 34 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Giovanni Cappellotto 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /src/cache.js: -------------------------------------------------------------------------------- 1 | // @flow strict 2 | 3 | // 4 | // Cache 5 | // 6 | // For now the cache is implemented as a simple JavaScript object. 7 | // 8 | 9 | // $FlowFixMe - Unclear type 10 | var data: { [string]: any } = {} 11 | 12 | // 13 | // Returns a promise that resolves with the content indexed by `key` in the 14 | // cache or rejects in case the `key` is invalid. 15 | // 16 | // $FlowFixMe - Unclear type 17 | function get(key: string): Promise { 18 | if (data[key] === undefined) { 19 | return Promise.reject("Cache miss") 20 | } 21 | 22 | return Promise.resolve(data[key]) 23 | } 24 | 25 | // 26 | // Adds a reference `key` to `value` in the cache and returns a promise that 27 | // trivially resolves with `value`. 28 | // 29 | // $FlowFixMe - Unclear type 30 | function set(key: string, value: any): Promise { 31 | data[key] = value 32 | 33 | return Promise.resolve(value) 34 | } 35 | 36 | // 37 | // It invalidates the whole cache. 38 | // 39 | // This function is useful for resetting the cache while testing the game 40 | // engine. 41 | // 42 | function invalidate() { 43 | data = {} 44 | } 45 | 46 | export default { 47 | get, 48 | set, 49 | invalidate 50 | } 51 | -------------------------------------------------------------------------------- /dist/style.css: -------------------------------------------------------------------------------- 1 | body { 2 | font-family: Georgia, serif; 3 | margin: 1em auto 4em auto; 4 | width: 720px; 5 | color: #222; 6 | } 7 | 8 | h1, h2, h3, h4 { 9 | font-family: Helvetica, sans-serif; 10 | } 11 | 12 | footer { 13 | margin-top: 8em; 14 | } 15 | 16 | p { 17 | line-height: 1.5em; 18 | } 19 | 20 | blockquote, footer { 21 | color: #444; 22 | } 23 | 24 | a { 25 | color: #00ccff; 26 | } 27 | 28 | a:not(:hover) { 29 | text-decoration: none; 30 | } 31 | 32 | pre, code { 33 | font-family: monospace; 34 | line-height: normal; 35 | } 36 | 37 | pre { 38 | border-left: solid 2px #ccc; 39 | padding-left: 18px; 40 | margin: 2em 0 2em -20px; 41 | overflow-x: auto; 42 | } 43 | 44 | @media screen and (max-width:825px) { 45 | body { 46 | margin: 1em 20px 4em 20px; 47 | width: auto; 48 | } 49 | 50 | pre { 51 | margin-left: 0; 52 | } 53 | 54 | img, video, canvas { 55 | max-width: 100%; 56 | } 57 | } 58 | 59 | #loading, 60 | #error { 61 | width: 100%; 62 | height: 100%; 63 | position: absolute; 64 | top: 0; 65 | left: 0; 66 | text-align: center; 67 | padding-top: 10em; 68 | background-color: rgba(255, 255, 255, 0.8); 69 | } 70 | 71 | #error { 72 | display: none; 73 | } 74 | -------------------------------------------------------------------------------- /src/components.js: -------------------------------------------------------------------------------- 1 | // @flow strict 2 | 3 | function requireElement(element: ?HTMLElement): HTMLElement { 4 | if (element == null) { 5 | throw new Error("Missing required element") 6 | } 7 | return element 8 | } 9 | 10 | function requireAnchor(element: ?HTMLElement): HTMLAnchorElement { 11 | if (element instanceof HTMLAnchorElement) { 12 | return element 13 | } 14 | throw new Error("Element is not an anchor") 15 | } 16 | 17 | function error(): HTMLElement { 18 | return requireElement(document.getElementById("error")) 19 | } 20 | 21 | function loading(): HTMLElement { 22 | return requireElement(document.getElementById("loading")) 23 | } 24 | 25 | function footer(): HTMLElement { 26 | return requireElement(document.querySelector("footer")) 27 | } 28 | 29 | function content(): HTMLElement { 30 | return requireElement(document.getElementById("content")) 31 | } 32 | 33 | function sourceLink(): HTMLAnchorElement { 34 | return requireAnchor(document.querySelector("a#source")) 35 | } 36 | 37 | function creditsLink(): HTMLAnchorElement { 38 | return requireAnchor(document.querySelector("a[rel='author']")) 39 | } 40 | 41 | export default { 42 | error, 43 | loading, 44 | footer, 45 | content, 46 | sourceLink, 47 | creditsLink 48 | } 49 | -------------------------------------------------------------------------------- /src/backend/index.js: -------------------------------------------------------------------------------- 1 | // @flow strict 2 | 3 | // 4 | // Backend 5 | // 6 | 7 | import type { Config as GistConfig } from "./gist" 8 | import type { Config as LocalConfig } from "./local" 9 | 10 | type Client = { 11 | fileURL: (string) => Promise, 12 | } 13 | 14 | type Config = GistConfig | LocalConfig 15 | 16 | var client: Client 17 | 18 | import gist from "./gist" 19 | import local from "./local" 20 | import httpGet from "./httpGet" 21 | 22 | // 23 | // Initializes the backend using the input configuration. 24 | // 25 | // The backend client is selected depending on the storage type defined in the 26 | // `storage` configuration property. 27 | // 28 | function init(config: Config): Promise { 29 | switch (config.storage) { 30 | case "local": 31 | client = local 32 | return local.init(config) 33 | case "gist": 34 | client = gist 35 | return gist.init(config) 36 | } 37 | } 38 | 39 | // 40 | // Returns the URL of the file referenced by `filename`. 41 | // 42 | function fileURL(filename: string): Promise { 43 | return client.fileURL(filename) 44 | } 45 | 46 | // 47 | // Sends a GET request to get the content of the file referenced by `filename`. 48 | // 49 | function fetchFileContent(filename: string): Promise { 50 | return client.fileURL(filename).then(url => httpGet(url)) 51 | } 52 | 53 | export default { 54 | init, 55 | fileURL, 56 | fetchFileContent 57 | } 58 | -------------------------------------------------------------------------------- /src/backend/gist.js: -------------------------------------------------------------------------------- 1 | // @flow strict 2 | 3 | // 4 | // Gist backend 5 | // 6 | 7 | export type Config = { storage: "gist", gistId: string } 8 | 9 | type File = { raw_url: string, ... } 10 | type Gist = { files: { [string]: File }, ... } 11 | 12 | var files: { [string]: File } 13 | 14 | import httpGet from "./httpGet" 15 | 16 | // 17 | // Initializes the backend using the input configuration. 18 | // 19 | // For initializing the gist backend a request is made to the Gist API for 20 | // fetching the content of the gist referenced by the `gistId` configuration 21 | // property. 22 | // 23 | // The result is a list of files that are part of the gist, that is basically 24 | // a git repo. The list of file objects is stored in a dictionary indexed by 25 | // file name. 26 | // 27 | function init(config: Config): Promise { 28 | return httpGet(`https://api.github.com/gists/${config.gistId}`) 29 | .then(JSON.parse) 30 | .then((gist: Gist) => { files = gist.files }) 31 | } 32 | 33 | // 34 | // Returns the `raw_url` property of the file referenced by `filename`. 35 | // 36 | function fileURL(filename: string): Promise { 37 | return file(filename).then(file => file.raw_url) 38 | } 39 | 40 | // 41 | // Returns true if a file exists in the selected gist. 42 | // 43 | function fileExists(filename: string): boolean { 44 | return files[filename] !== undefined 45 | } 46 | 47 | // 48 | // Returns a promise that resolves with a `file` object if the file exists or 49 | // rejects otherwise. 50 | // 51 | function file(filename: string): Promise { 52 | if (fileExists(filename)) { 53 | return Promise.resolve(files[filename]) 54 | } 55 | 56 | return Promise.reject("File not found") 57 | } 58 | 59 | export default { 60 | init, 61 | fileURL 62 | } 63 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "gist-txt", 3 | "version": "2.3.5", 4 | "description": "A minimal text adventure engine", 5 | "browser": "dist/main.js", 6 | "scripts": { 7 | "test": "jest", 8 | "doc": "docco src/index.js", 9 | "lint": "eslint 'src/**/*.js' 'test/**/*.js'", 10 | "build": "webpack --mode production", 11 | "watch": "webpack --mode development --watch", 12 | "serve": "http-server dist -c-1", 13 | "flow": "flow" 14 | }, 15 | "repository": { 16 | "type": "git", 17 | "url": "git://github.com/potomak/gist-txt.git" 18 | }, 19 | "keywords": [ 20 | "adventure", 21 | "engine", 22 | "game", 23 | "gist", 24 | "Git", 25 | "github", 26 | "text adventure", 27 | "text", 28 | "interactive", 29 | "fiction", 30 | "interactive fiction", 31 | "tool", 32 | "Twine" 33 | ], 34 | "author": "Giovanni Cappellotto", 35 | "license": "MIT", 36 | "bugs": { 37 | "url": "https://github.com/potomak/gist-txt/issues" 38 | }, 39 | "homepage": "https://github.com/potomak/gist-txt", 40 | "devDependencies": { 41 | "@babel/cli": "^7.13.16", 42 | "@babel/core": "^7.13.16", 43 | "@babel/preset-env": "^7.1.0", 44 | "@babel/preset-flow": "^7.13.13", 45 | "babel-eslint": "^10.1.0", 46 | "babel-loader": "^8.0.4", 47 | "docco": "^0.8.0", 48 | "eslint": "^7.1.0", 49 | "eslint-plugin-flowtype": "^5.7.2", 50 | "eslint-plugin-jest": "^21.24.1", 51 | "flow-bin": "^0.149.0", 52 | "http-server": "^0.12.3", 53 | "jest": "^26.6.3", 54 | "webpack": "^5.35.1", 55 | "webpack-cli": "^4.6.0" 56 | }, 57 | "dependencies": { 58 | "@babel/polyfill": "^7.0.0", 59 | "buffer": "^6.0.3", 60 | "gray-matter": "^4.0.1", 61 | "js-yaml": "^3.14.1", 62 | "marked": ">=0.7.0", 63 | "mustache": "^3.0.0" 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/basic.js: -------------------------------------------------------------------------------- 1 | // @flow strict 2 | 3 | function show(element: HTMLElement) { 4 | element.style.display = "block" 5 | } 6 | 7 | function hide(element: HTMLElement) { 8 | element.style.display = "none" 9 | } 10 | 11 | function enable(element: HTMLStyleElement) { 12 | element.disabled = false 13 | } 14 | 15 | function disable(element: HTMLStyleElement) { 16 | element.disabled = true 17 | } 18 | 19 | function scrollTop() { 20 | if (document.body == null) { 21 | return 22 | } 23 | document.body.scrollTop = 0 24 | if (document.documentElement == null) { 25 | return 26 | } 27 | document.documentElement.scrollTop = 0 28 | } 29 | 30 | // 31 | // Appends a `