├── .gitignore ├── .prettierignore ├── .prettierrc ├── .travis.yml ├── AUTHORS ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── index.js ├── lib ├── errors.js ├── find-trees.js ├── is-url-like.js ├── normalize-tree.js ├── read-trees.js ├── schema-validator.js ├── settings.js ├── slugify.js ├── tree-tools.js ├── validate-code.js └── validate-tree.js ├── package-lock.json ├── package.json ├── public ├── css │ ├── app.css │ └── normalize.css ├── js │ └── treenee.js └── mermaid ├── routes ├── 404.js ├── code.js ├── graphs │ └── index.js ├── index.js ├── node.js ├── public.js └── tree.js ├── scripts └── task-build.sh ├── settings-schema.json ├── templates ├── 404.html ├── graphs │ ├── index.html │ └── tree.html ├── helpers │ ├── eq.js │ ├── is-url.js │ ├── not.js │ └── t.js ├── index.html ├── layouts │ └── layout.html ├── node.html └── tree.html ├── tests ├── lib │ ├── is-url-like.test.js │ ├── normalize-tree.test.js │ ├── read-trees.test.js │ ├── settings.test.js │ ├── slugify.test.js │ ├── tree-tools.test.js │ └── validate-tree.test.js └── trees │ └── test-tree │ └── tree.yaml ├── tree-schema.json └── trees └── example └── tree.yaml /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | settings.json 3 | public/css/custom.css 4 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | **/templates/**/* 2 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "printWidth": 100, 4 | "arrowParens": "avoid", 5 | "trailingComma": "none" 6 | } 7 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "node" 4 | -------------------------------------------------------------------------------- /AUTHORS: -------------------------------------------------------------------------------- 1 | Main developer 2 | ============== 3 | 4 | Claudio Cicali 5 | http://ccl.me 6 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Version 1.3.3 August 22th, 2020 2 | 3 | - Uses a more modern design 4 | - Increases the font size 5 | 6 | # Version 1.3.2 August 21st, 2020 7 | 8 | - Adds the `showIntro` option for each tree 9 | - More documentation and the `test-dev` npm script 10 | 11 | # Version 1.3.1 August 17th, 2020 12 | 13 | - Uses a custom slugify method 14 | - Fixes bugs on the translator 15 | 16 | # Version 1.3.0 August 15th, 2020 17 | 18 | - Added support for Markdown (in the node's body only) and text only (uses the new `bodyFormat` tree option) 19 | - Renamed `--build-mode` to `--builder-mode` 20 | - Added the `--watch-tree` option to watch file changes in the tree definitions 21 | - Added the `--use-stable-ids` option to generate option id in a predictable way (useful while developing trees) 22 | - Tree validation always performs schema validation, also when in `builder mode` 23 | - Added json-schema validation to the settings file 24 | - Added all the strings in the settings to provide a poor man "i18n" 25 | Breaking change: the settings `custom` key has been renamed to `string` 26 | - Added the `t` helper for string transposition 27 | 28 | # Version 1.2.0 August 10th, 2020 29 | 30 | - Moved to YAML for the tree definition 31 | 32 | # Version 1.1.0 August 8th, 2020 33 | 34 | - Use onSelect and deprecates nextNodeId 35 | 36 | # Version 1.0.0 August 1st, 2020 37 | 38 | First public release 39 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to Treenee 2 | 3 | Treenee is developed primarly by Claudio Cicali but pull requests are (generally) welcome! 4 | 5 | If want to contribute, please keep in mind a few rules: 6 | 7 | - every PR must address one single problem and should be as small as possible 8 | - if possible, open an issue that will be referenced by the PR (maybe there will be some discussion about what you want to do?) 9 | - if you add or change a configuration option, you must also update the `settings-schema.json` file 10 | - if you add or change a tree option, you must also update the `tree-schema.json` file 11 | - if you want to use an 3rd-party module, be sure that its license is compatible with Treenee's (MIT) 12 | - add a meaningful description to the PR and possibly some comments in the code as well 13 | - be kind and write a test for your change 14 | - be patient: I will review and merge your PR as soon as I have time and not as soon as you publish it 15 | - a PR that might have an impact on existing installations (like for example changing the tree configuration) it's not guaranteed to be merged even if it makes a lot of sense 16 | 17 | A big PR, even if would add a lot of value to Treenee, would have problems to be merged. I cannot trust you 100% on the regression tests that you may (or many not) have ran, and I want to try my best to always deliver a Treenee which won't break things to people upgrading it. Unfortunately I don't have a set of integration tests to test for regressions (only units tests) and all the tests I need to run I do them manually, which translates in more time to spend on the project. 18 | 19 | ## Test 20 | 21 | Run `npm test` to ensure that your code didn't break anything obvious 22 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | (The MIT License) 2 | 3 | Copyright (c) 2020 Claudio Cicali 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the 'Software'), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Treenee 2 | 3 | [![Build Status](https://travis-ci.org/claudioc/treenee.png?branch=master)](https://travis-ci.org/claudioc/treenee) 4 | 5 | Treenee is a web application made for presenting simple decision trees to humans; each "node" of the tree is supposed to show the user a prompt with several possible options to select from as the next step: upon selection, Treenee will render the corresponding node until the end of the tree, when a "next node" cannot be selected anymore. 6 | 7 | The last node of the tree also doesn't contain any further options but usually shows a page with further instructions, a result, or anything you can think of it may come as a "final decision", so to speak. 8 | 9 | A demo server with the default installation is available at [Treenee.com](https://treenee.com). 10 | 11 | ## Features (and limits) 12 | 13 | - Database not needed; all tree definitions are just YAML files (one file for each tree, in case you need to serve more than one) 14 | - No logins, no registration; access to the application is always anonymous (see below for a discussion about [security](#security)) 15 | - Trees can be made "private" with a code, to make them accessible only to people who know that code. The code is requested in the home page 16 | - Three rendering modes for the pages: html, markdown and text (no formatting) 17 | - Each answer can be given a "value" which is added until the end so that each user can get a "score" (read more in the [Score section](#score) below) 18 | - Optionally, a person can only visit a node once (option `trackVisits`, which defaults to `false`); going back and forth the nodes with the browser's capabilities is fully handled 19 | - Many validating checks are performed on the inputs to ensure the trees are consistent and robust 20 | - A cookie is used to maintain track of the session data 21 | - The JSON schema for the tree definition is provided in `tree-schema.json` 22 | - The JSON schema for the settings is provided in `settings-schema.json` 23 | - (optional) graph visualization of each tree is implemented using (using [Mermaid](https://mermaid-js.github.io/)) 24 | 25 | ## Installation (for development) 26 | 27 | First of all `npm install` and then `npm start` (uses nodemon). 28 | 29 | If you run the server with `npm run start-dev`, then: 30 | 31 | - all changes in the tree configuration(s) are reloaded automatically 32 | - most of the validations on the trees are skipped (using the `--builder-mode` command line option, see [below](#builder-mode)) 33 | - the ids are created stable, see [below](#node-ids) 34 | 35 | Tests are run either with `npm test` (for CI, doesn't watch changes) or `npm run test-dev`, which runs watching changes. 36 | 37 | In case you want to just validate the JSON trees definition and not run the server, you can run `node index.js --dry-run`. 38 | 39 | The default installation comes with a (silly) example tree in `./trees/example`. 40 | 41 | Trees are automatically loaded from the trees directory; you can _exclude_ trees to be read, using the specific settings option. 42 | 43 | ## Deployment (for production) 44 | 45 | Since this is just a nodejs application, I think my opinion here is as good as yours. 46 | 47 | You can create a static working copy of the website by just running `wget --mirror --convert-links --adjust-extension --page-requisites --no-parent http://localhost:3000` (if you are running the server on port 3000). 48 | 49 | If you are interested in working with statically generated files, please take a look at the (unsupported) script `./scripts/task-build.sh` for inspiration. 50 | 51 | ## Configuration 52 | 53 | You probably want to be able to configure some of the aspects of the application; to do so you just need to create a file called `settings.json` in the root of the application (where the main index.js is located) or you can specify a custom location of the file with the `-s` server setting. Run the server with the `--sample-settings` switch to dump a template file to be used (you can leave the comments inside). 54 | 55 | Each configuration option meaning is described in the settings.json template file. 56 | 57 | If you want to create a sample tree, run `node index --sample-tree` to output a YAML that you can use as a starting point. 58 | 59 | ## Translations 60 | 61 | All the strings used by the Treenee user interface can be translated (or just changed 62 | from their default value). To do so, use the `settings.json` file (see above the [Configuration](#configuration) section), and work on the `strings` object in it. 63 | 64 | Hint: for the sake of self-documenting, and to simplify debugging translation issues, each string in the final HTML is wrapped within a `` with a class having the same name then the key used for translation. 65 | 66 | ## Content rendering (Markdown, HTML, text) 67 | 68 | Each node can be configured to use a specific rendering mode for the main text in the page (which the `body` of the node, in the tree definition), using the `bodyFormat` option. 69 | 70 | - Markdown parser uses [markdown-it](https://markdown-it.github.io/). It is configured to not render HTML 71 | - The HTML format just put anything you write directly in the page: I haven't thought too much about security here so be warned about what you allow your users to do with that 72 | - The text format doesn't remove any tag, but escapes everything 73 | 74 | ## Security 75 | 76 | Treenee doesn't have any concept of "user" and it always runs anonymously; one (encrypted) session cookie is used to just keep track of the scores during a tree navigation. 77 | 78 | Since Treenee is not supposed to be directly exposed to the internet but to run behind a nginx server or similar, if you need to run Treenee in a public space with a bit of authentication my suggestion is to use a simple [basic authentication](https://docs.nginx.com/nginx/admin-guide/security-controls/configuring-http-basic-authentication) managed by the web server itself. 79 | 80 | The same applies of course for TLS: the Treenee server itself only speaks HTTP. If you need HTTPS you need to terminate it to the server running in front of it. 81 | 82 | ## Score 83 | 84 | Admittedly, "scoring" is quite useless as it is implemented right now. If you add a "value" to each option of a nod, then Treenee will sum all the score until the end of the navigation. The final score is then showed on any final page. 85 | 86 | ## Look customization 87 | 88 | There are not many options to customize how Treenee looks, unfortunately. It is planned to have some kind of "theme" support in the future but for the moment all you can do is to hack something directly into the `./public/css` directory. 89 | 90 | To avoid potential issues during future updates, Treenee will try loading a `./public/css/custom.css` file which is _not_ provided by the default installation, which means that if you want to customize the look and feel, just put your css in that file. 91 | 92 | ## Advanced concepts 93 | 94 | ### Node ids 95 | 96 | When Treenee loads each tree definition, it gives each node's option (when present) a randomly generated identifier and this _id_ is not "stable" across server restarts: when you restart the server, the tree definition are read again and the id are generated anew. The reasons behind it is to avoid issues with browser cache and to be perfectly sure that every option is reached from a unique path and those pages are difficult to "bookmark". 97 | 98 | You can actually change this behavior using the `--stable-ids` server option, so that each option id is generated from the "index" of the array they belong. This is supposed to only be used during development (so that you can actually reload an option page without getting a 404 after a server restart), because those "stable ids" are not really smart and may become messy after several options edits: use with care. 99 | 100 | ### Builder mode 101 | 102 | When the server is run with the `--builder-mode` switch, then only the schema validation of the tree definition is performed. Any internal inconsistency is not detected to avoid annoying errors while developing a tree definition. 103 | 104 | Always remember to perform a full validation of the tree before submitting changes to a live environment, either by using `--dry-run` or just `npm start`ing the server. 105 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const Hapi = require('@hapi/hapi'); 4 | const Vision = require('@hapi/vision'); 5 | const Inert = require('@hapi/inert'); 6 | const Yar = require('@hapi/yar'); 7 | const handlebars = require('hbs'); 8 | const path = require('path'); 9 | const program = require('commander'); 10 | const pkg = require('./package'); 11 | const crypto = require('crypto'); 12 | const _ = require('lodash'); 13 | 14 | const settings = require('./lib/settings'); 15 | const publicRoute = require('./routes/public'); 16 | const notFoundRoute = require('./routes/404'); 17 | const indexRoute = require('./routes/index'); 18 | const codeRoute = require('./routes/code'); 19 | const treeRoute = require('./routes/tree'); 20 | const nodeRoute = require('./routes/node'); 21 | const graphsRoute = require('./routes/graphs'); 22 | const readTrees = require('./lib/read-trees'); 23 | const { watch } = require('fs'); 24 | 25 | const init = async () => { 26 | program 27 | .version(pkg.version) 28 | .option('-s, --settings ', 'Location of the settings.json file (defaults to cwd)') 29 | .option('--sample-settings', 'Dumps a settings file template and exits') 30 | .option('--sample-tree', 'Dumps a super simple YAML template for a new tree') 31 | .option( 32 | '--no-stable-ids', 33 | 'Option Ids are randomly generated at each servere restart (default)' 34 | ) 35 | .option('--stable-ids', 'Option Ids are generated in a stable way', false) 36 | .option( 37 | '--no-watch-trees', 38 | 'Do not reload the tree definition when they change (requires a server restart, default)' 39 | ) 40 | .option( 41 | '--watch-trees', 42 | 'Watch changes to the tree definitions and reload them when they change', 43 | false 44 | ) 45 | .option('--no-builder-mode', 'Perform full validation of the trees (default)') 46 | .option( 47 | '--builder-mode', 48 | 'Bypass trees data validation and only perform schema validation (Warning: can crash the program)', 49 | false 50 | ) 51 | .option( 52 | '--dry-run', 53 | 'Do not start the server but just attempt to load the configuration and the trees' 54 | ) 55 | .parse(process.argv); 56 | 57 | if (program.sampleSettings) { 58 | console.log(settings.getDefaultsAsJSON()); 59 | process.exit(0); 60 | } 61 | 62 | if (program.sampleTree) { 63 | console.log(settings.getTreeTemplateAsYAML()); 64 | process.exit(0); 65 | } 66 | 67 | let settingsLocation = ''; 68 | if (process.env.TREENEE_SETTINGS) { 69 | settingsLocation = process.env.TREENEE_SETTINGS; 70 | } 71 | 72 | if (program.settings) { 73 | settingsLocation = program.settings; 74 | } 75 | 76 | const appSettings = await settings.load(settingsLocation); 77 | 78 | const server = Hapi.server({ 79 | // This will be available as server.settings.app (static configuration) 80 | app: appSettings, 81 | port: appSettings.port, 82 | host: 'localhost', 83 | routes: { 84 | files: { 85 | relativeTo: path.join(__dirname, 'public') 86 | } 87 | } 88 | }); 89 | 90 | // If the cookie password is not set, generate one 91 | let cookiePassword = appSettings.cookiePassword; 92 | if (!cookiePassword) { 93 | cookiePassword = crypto.createHash('sha256').update(Date.now().toString()).digest('base64'); 94 | } 95 | 96 | let sessionOptions = { 97 | storeBlank: false, 98 | cookieOptions: { 99 | password: cookiePassword, 100 | isSecure: false, 101 | strictHeader: false, 102 | path: '/' 103 | } 104 | }; 105 | 106 | await server.register([Vision, Inert]); 107 | await server.register({ 108 | plugin: Yar, 109 | options: sessionOptions 110 | }); 111 | 112 | await server.register({ 113 | plugin: require('hapi-pino'), 114 | options: { 115 | prettyPrint: process.env.NODE_ENV !== 'production', 116 | logRequestComplete: appSettings.logRequests 117 | } 118 | }); 119 | const validateTreeData = !program.builderMode; 120 | const treesLocation = path.resolve(appSettings.trees.location); 121 | const trees = await findTreesOrDie( 122 | server, 123 | appSettings, 124 | treesLocation, 125 | validateTreeData, 126 | program.stableIds 127 | ); 128 | 129 | server.app.trees = trees; 130 | 131 | if (program.watchTrees) { 132 | let watchTask = 0; 133 | watch( 134 | treesLocation, 135 | { 136 | recursive: true 137 | }, 138 | async () => { 139 | if (watchTask) { 140 | return; 141 | } 142 | watchTask = setTimeout(async () => { 143 | const trees = await findTreesOrDie( 144 | server, 145 | appSettings, 146 | treesLocation, 147 | validateTreeData, 148 | program.stableIds 149 | ); 150 | server.app.trees = trees; 151 | server.log('info', 'Reloading tree defition…'); 152 | watchTask = 0; 153 | }, 100); 154 | } 155 | ); 156 | } 157 | 158 | server.views({ 159 | engines: { 160 | html: handlebars 161 | }, 162 | isCached: false, 163 | relativeTo: __dirname, 164 | path: './templates', 165 | layout: true, 166 | layoutPath: './templates/layouts', 167 | helpersPath: './templates/helpers', 168 | // Use a "c_" prefix to easily identify the usage of these vars in templates 169 | context: { 170 | c_settings: appSettings, 171 | c_trees: trees 172 | } 173 | }); 174 | 175 | server.route(indexRoute); 176 | server.route(codeRoute); 177 | server.route(publicRoute); 178 | server.route(graphsRoute); 179 | server.route(notFoundRoute); 180 | server.route(treeRoute); 181 | server.route(nodeRoute); 182 | 183 | if (!program.dryRun) { 184 | await server.start(); 185 | server.log( 186 | 'info', 187 | `Server running on ${server.info.uri} using settings from ${appSettings._from}` 188 | ); 189 | } 190 | }; 191 | 192 | async function findTreesOrDie(server, settings, treesLocation, validateTreeData, useStableIds) { 193 | let trees; 194 | 195 | try { 196 | trees = await readTrees(treesLocation, settings.trees.exclude, validateTreeData, useStableIds); 197 | } catch (err) { 198 | server.log('error', err.message); 199 | process.exit(1); 200 | } 201 | 202 | if (trees.length === 0) { 203 | server.log('error', 'No usable trees found.'); 204 | process.exit(1); 205 | } 206 | 207 | return trees; 208 | } 209 | 210 | process.on('unhandledRejection', err => { 211 | console.log(err); 212 | process.exit(1); 213 | }); 214 | 215 | init(); 216 | -------------------------------------------------------------------------------- /lib/errors.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const errors = new Map(); 4 | 5 | errors.set(0, ''); 6 | errors.set(1, 'The access code is mandatory'); 7 | errors.set(2, 'This access code is unknown'); 8 | errors.set(3, 'The tree is not found or not accessible.'); 9 | 10 | module.exports = errors; 11 | -------------------------------------------------------------------------------- /lib/find-trees.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const _ = require('lodash'); 4 | const fs = require('fs'); 5 | const util = require('util'); 6 | const yaml = require('js-yaml'); 7 | const slugify = require('./slugify'); 8 | 9 | const readDirAsync = util.promisify(fs.readdir); 10 | const readFile = util.promisify(fs.readFile); 11 | 12 | let treesDir; 13 | let useStableOptionIds = false; 14 | 15 | async function findTrees(location, exclusions = [], useStableIds = false) { 16 | treesDir = location; 17 | useStableOptionIds = useStableIds; 18 | 19 | const treesSubdirectories = await (await readDirAsync(treesDir)).filter(file => { 20 | if (exclusions.includes(file)) { 21 | return false; 22 | } else { 23 | // Array.filter don't work with async 24 | const stat = fs.statSync(`${treesDir}/${file}`); 25 | return stat && stat.isDirectory(); 26 | } 27 | }); 28 | 29 | return _.compact(await Promise.all(treesSubdirectories.map(readTreeDefinition))); 30 | } 31 | 32 | const generateOptionId = () => { 33 | return Math.random().toString(36).substr(2, 5); 34 | }; 35 | 36 | const readTreeDefinition = async subdir => { 37 | const location = `${treesDir}/${subdir}`; 38 | 39 | const tree = yaml.safeLoad(await readFile(`${location}/tree.yaml`, 'utf8')); 40 | if (!tree.startNodeId) { 41 | // This also serves as some kind of backward compatibility for when 42 | // IDs were supposed to be numeric 43 | if (Array.isArray(tree.nodes)) { 44 | tree.startNodeId = tree.nodes[0].id; 45 | } 46 | } 47 | 48 | if (tree.trackVisits === undefined) { 49 | tree.trackVisits = true; 50 | } 51 | 52 | const usedIds = []; 53 | // Let's give each option an unique id (five random alphanumeric characters) 54 | // We also add the index of the option at the end of the id for ease of debug 55 | if (Array.isArray(tree.nodes)) { 56 | tree.nodes.forEach((node, nodeIdx) => { 57 | if (Array.isArray(node.options)) { 58 | node.options.forEach((option, idx) => { 59 | let id; 60 | do { 61 | id = useStableOptionIds ? `${nodeIdx}---${idx}` : generateOptionId(); 62 | } while (usedIds.includes(id)); 63 | option._id = `${id}-${idx}`; 64 | usedIds.push(id); 65 | }); 66 | } 67 | }); 68 | } 69 | 70 | // We don't want to use the lodash kebabCase because we don't like 71 | // the behaviour like "B67" => "b-67" 72 | const slug = slugify(tree.name); 73 | tree._meta = {}; 74 | tree._meta.location = location; 75 | tree._meta.isPublic = !tree.accessCode; 76 | tree._meta.slug = slug; 77 | return tree; 78 | }; 79 | 80 | module.exports = findTrees; 81 | -------------------------------------------------------------------------------- /lib/is-url-like.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const { URL, parse } = require('url'); 4 | 5 | module.exports = (str, protocols) => { 6 | try { 7 | new URL(str); 8 | const parsed = parse(str); 9 | return Array.isArray(protocols) 10 | ? parsed.protocol 11 | ? protocols.map(x => `${x.toLowerCase()}:`).includes(parsed.protocol) 12 | : false 13 | : true; 14 | } catch (err) { 15 | return false; 16 | } 17 | }; 18 | -------------------------------------------------------------------------------- /lib/normalize-tree.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const _cloneDeep = require('lodash/cloneDeep'); 4 | 5 | // This function normalizes some values in `tree` 6 | // Beware: the tree may have not been validated yet. 7 | function normalizeTree(tree) { 8 | if (!tree.nodes) { 9 | return tree; 10 | } 11 | 12 | if (!Array.isArray(tree.nodes)) { 13 | return tree; 14 | } 15 | 16 | const normalizedTree = _cloneDeep(tree); 17 | normalizedTree.nodes.forEach(node => { 18 | node.id = String(node.id); 19 | if (node.options) { 20 | node.options = normalizeOptions(node.options); 21 | } 22 | }); 23 | 24 | if (tree.bodyFormat === undefined) { 25 | tree.bodyFormat = 'html'; 26 | } 27 | 28 | if (tree.showIntro === undefined) { 29 | tree.showIntro = true; 30 | } 31 | 32 | return normalizedTree; 33 | } 34 | 35 | function normalizeOptions(options) { 36 | if (!Array.isArray(options)) { 37 | return options; 38 | } 39 | 40 | const betterOptions = _cloneDeep(options); 41 | 42 | // Backward compatibility for renaming "nextNodeId" to "onSelect" 43 | // nextNodeId is deprecated in favour of onSelect since August 2020 44 | betterOptions.forEach(option => { 45 | if (option.nextNodeId && option.onSelect) { 46 | option.nextNodeId = option.onSelect; 47 | return; 48 | } 49 | 50 | if (option.nextNodeId && option.onSelect === undefined) { 51 | option.onSelect = option.nextNodeId; 52 | return; 53 | } 54 | }); 55 | 56 | return betterOptions; 57 | } 58 | 59 | module.exports = normalizeTree; 60 | -------------------------------------------------------------------------------- /lib/read-trees.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const _ = require('lodash'); 4 | 5 | const normalizeTree = require('./normalize-tree'); 6 | const validateTree = require('./validate-tree'); 7 | const findTrees = require('./find-trees'); 8 | 9 | async function readTrees(location, exclusions = [], validateData = true, useStableIds = false) { 10 | const trees = await findTrees(location, exclusions, useStableIds); 11 | 12 | trees.forEach(tree => { 13 | validateTree(normalizeTree(tree), validateData); 14 | }); 15 | 16 | trees._meta = {}; 17 | trees._meta.privateCount = trees.filter(tree => tree.accessCode).length; 18 | trees._meta.publicCount = trees.length - trees._meta.privateCount; 19 | 20 | return trees; 21 | } 22 | 23 | module.exports = readTrees; 24 | -------------------------------------------------------------------------------- /lib/schema-validator.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const Ajv = require('ajv'); 4 | 5 | class SchemaValidator { 6 | constructor(schema) { 7 | this.schema = require(`../${schema}`); 8 | this.ajv = new Ajv({ 9 | allErrors: true 10 | }); 11 | } 12 | 13 | validate(instance) { 14 | return this.ajv.validate(this.schema, instance); 15 | } 16 | 17 | errors() { 18 | const messages = []; 19 | // This is pretty lame as error management https://github.com/ajv-validator/ajv#validation-errors 20 | this.ajv.errors.forEach(error => { 21 | const moreInfo = JSON.stringify(error.params); 22 | messages.push( 23 | `In ${error.dataPath ? error.dataPath : 'tree'}: ${error.message} (${moreInfo})` 24 | ); 25 | }); 26 | 27 | return messages; 28 | } 29 | } 30 | 31 | module.exports = SchemaValidator; 32 | -------------------------------------------------------------------------------- /lib/settings.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const _ = require('lodash'); 4 | const fs = require('fs'); 5 | const util = require('util'); 6 | const cJSON = require('comment-json'); 7 | const readFile = util.promisify(fs.readFile); 8 | const path = require('path'); 9 | const SchemaValidator = require('./schema-validator'); 10 | 11 | const schemaValidator = new SchemaValidator('settings-schema.json'); 12 | 13 | // Keep this in sync with settings-schema.json 14 | const defaultsJSON = `{ 15 | // TCP Port the server will listen to 16 | "port": 3000, 17 | // A password to encrypt the session data; if the password is not set 18 | // it will be generated on each server run 19 | "cookiePassword": "", 20 | // Application log verbosity (advanced) 21 | "logRequests": false, 22 | // Settings related to the trees 23 | "trees": { 24 | // Where to find the trees directory, relative to the server's cwd 25 | "location": "trees", 26 | // Trees you don't want to load (use the directory name) 27 | "exclude": [], 28 | // Wether or not to show graphs 29 | "showGraph": false 30 | }, 31 | // All the string used in the application 32 | "strings": { 33 | "layout_1": "Treenee", 34 | "layout_2": "Treenee – The decision tree engine", 35 | "index_1": "Available public trees", 36 | "index_2": "Available tree(s)", 37 | "index_3": "Enter the code of any private tree you want to access", 38 | "index_4": "Access", 39 | "node_1": "⚠ You already selected an option for this prompt.", 40 | "node_2": "You reached the end;", 41 | "node_3": "start again?", 42 | "node_4": "Final score:", 43 | "tree_1": "Start", 44 | "404_1": "Ooops!", 45 | "404_2": "404 - Page Not Found", 46 | "shared_1": "Home", 47 | "shared_2": "Back", 48 | "shared_3": "Abort and restart", 49 | "shared_4": "Graph" 50 | } 51 | } 52 | `; 53 | 54 | // Keep this in sync with tree-schema.json 55 | const treeTemplate = ` 56 | --- 57 | name: "CHANGE ME" 58 | title: "CHANGE ME" 59 | description: "CHANGE ME" 60 | accessCode: "" 61 | startNodeId: "start" 62 | trackVisits: false 63 | bodyFormat: html 64 | nodes: 65 | - id: "start" 66 | title: "CHANGE ME" 67 | body: | 68 | CHANGE ME WITH 69 | THE ACTUAL CONTENT 70 | FOR THE BODY 71 | prompt: "CHANGE ME" 72 | options: 73 | - text: "CHANGE ME" 74 | onSelect: "A node id or a URL" 75 | value: 0 76 | `; 77 | 78 | const defaults = cJSON.parse(defaultsJSON); 79 | 80 | /* 81 | * Attempts to load a configuration file or just uses the defaults 82 | * Returns a settings object 83 | */ 84 | async function load(location) { 85 | const useCustomSettingsLocation = !_.isEmpty(location); 86 | let settingsFromFile; 87 | try { 88 | let settingsLocation; 89 | if (useCustomSettingsLocation) { 90 | settingsLocation = location; 91 | } else { 92 | settingsLocation = path.join('./settings.json'); 93 | } 94 | settingsFromFile = await readFile(settingsLocation, 'utf8'); 95 | defaults._from = settingsLocation; 96 | } catch (err) { 97 | if (useCustomSettingsLocation) { 98 | throw err; 99 | } 100 | // No settings file is OK, move on and merge the defaults 101 | settingsFromFile = JSON.stringify({}); 102 | defaults._from = 'defaults'; 103 | } 104 | 105 | settingsFromFile = loadFrom(cJSON.parse(settingsFromFile)); 106 | 107 | return settingsFromFile; 108 | } 109 | 110 | function loadFrom(data = {}) { 111 | if (!schemaValidator.validate(data)) { 112 | throw new Error(`Validation failed for settings file: ${schemaValidator.errors().join(', ')}`); 113 | } 114 | 115 | return _.merge(defaults, data); 116 | } 117 | 118 | function getDefaultsAsJSON() { 119 | return defaultsJSON; 120 | } 121 | 122 | function getTreeTemplateAsYAML() { 123 | return treeTemplate; 124 | } 125 | 126 | module.exports = { 127 | getDefaultsAsJSON, 128 | getTreeTemplateAsYAML, 129 | load, 130 | loadFrom 131 | }; 132 | -------------------------------------------------------------------------------- /lib/slugify.js: -------------------------------------------------------------------------------- 1 | const _ = require('lodash'); 2 | 3 | const decamelize = str => { 4 | return ( 5 | str 6 | // Separate capitalized words. 7 | .replace(/([A-Z]{2,})(\d+)/g, '$1 $2') 8 | .replace(/([a-z\d]+)([A-Z]{2,})/g, '$1 $2') 9 | .replace(/([a-z\d])([A-Z])/g, '$1 $2') 10 | .replace(/([A-Z]+)([A-Z][a-z\d]+)/g, '$1 $2') 11 | ); 12 | }; 13 | 14 | const slugify = str => { 15 | let slugged = decamelize(_.deburr(str.trim())).toLowerCase(); 16 | const patternSlug = /[^a-z\d]+/g; 17 | slugged = slugged.replace(patternSlug, '-'); 18 | slugged = slugged.replace(/\\/g, ''); 19 | return slugged; 20 | }; 21 | 22 | module.exports = slugify; 23 | -------------------------------------------------------------------------------- /lib/tree-tools.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = { 4 | findTreeBySlug: (trees, slug) => { 5 | return trees.find(tree => { 6 | return slug === tree._meta.slug; 7 | }); 8 | }, 9 | 10 | findNodeById: (tree, nodeId) => { 11 | if (!tree.nodes) { 12 | return undefined; 13 | } 14 | return tree.nodes.find(node => { 15 | return nodeId === node.id; 16 | }); 17 | } 18 | }; 19 | -------------------------------------------------------------------------------- /lib/validate-code.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const crypto = require('crypto'); 4 | 5 | module.exports = (tree, accessCode) => { 6 | if (!tree.accessCode) { 7 | return true; 8 | } 9 | 10 | const hash = crypto.createHash('sha256').update(tree.accessCode).digest('base64'); 11 | 12 | return hash === accessCode; 13 | }; 14 | -------------------------------------------------------------------------------- /lib/validate-tree.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const _ = require('lodash'); 4 | const SchemaValidator = require('./schema-validator'); 5 | const isURLLike = require('../lib/is-url-like'); 6 | 7 | const schemaValidator = new SchemaValidator('tree-schema.json'); 8 | 9 | function validateTree(tree, validateData = true) { 10 | const e = _.curry(errorize)(tree); 11 | 12 | if (!(tree instanceof Object)) { 13 | throw new Error(e('A tree definition must be an object.')); 14 | } 15 | 16 | if (!schemaValidator.validate(tree)) { 17 | throw new Error(e(`Schema validation failed: ${schemaValidator.errors().join(', ')}`)); 18 | } 19 | 20 | if (!validateData) { 21 | return; 22 | } 23 | 24 | if (!tree.nodes.map(node => node.id).includes(tree.startNodeId)) { 25 | throw new Error(e(`Start node id ${tree.startNodeId} doesn't exist.`)); 26 | } 27 | 28 | tree.nodes.forEach(_.curry(validateNode)(tree)); 29 | 30 | validateIdUniqueness(tree.nodes, e('All the nodes ID must be unique.')); 31 | 32 | validateNoOrphanNodes(tree); 33 | } 34 | 35 | const validateNode = (tree, node) => { 36 | const e = _.curry(errorize)(tree); 37 | 38 | if (_.isNil(node.id)) { 39 | throw new Error(e(`Each node needs an id.`)); 40 | } 41 | 42 | if (_.isNil(node.title)) { 43 | throw new Error(e(`Each node needs a title.`)); 44 | } 45 | 46 | if (node.prompt && _.isEmpty(node.options)) { 47 | throw new Error(e(`"${node.title}": no options for the prompt.`)); 48 | } 49 | 50 | if (_.isNil(node.prompt) && node.options) { 51 | throw new Error(e(`"${node.title}": no prompt for the options.`)); 52 | } 53 | 54 | if (!_.isEmpty(node.options)) { 55 | node.options.forEach(_.curry(validateOption)(tree)(node)); 56 | } 57 | }; 58 | 59 | // All nodes must be pointed to at least by an option 60 | const validateNoOrphanNodes = tree => { 61 | const e = _.curry(errorize)(tree); 62 | const nodes = tree.nodes; 63 | 64 | if (!nodes) { 65 | return; 66 | } 67 | 68 | const allNodeIds = nodes.map(node => node.id); 69 | 70 | // Get all the possible options as an array (including 1, probably never referenced by an option) 71 | const optionReferences = _.uniq( 72 | _.concat( 73 | [tree.startNodeId], 74 | _.flatten( 75 | _.compact(nodes.map(node => node.options && node.options.map(option => option.onSelect))) 76 | ) 77 | ) 78 | ).filter(el => !isURLLike(el)); 79 | 80 | const orphans = _.difference(allNodeIds, optionReferences); 81 | if (orphans.length > 0) { 82 | throw new Error(e(`Unreferenced node(s) found (${orphans.join(', ')})`)); 83 | } 84 | }; 85 | 86 | const validateOption = (tree, node, option) => { 87 | if (option.onSelect === '' || isURLLike(option.onSelect)) { 88 | return; 89 | } 90 | 91 | const e = _.curry(errorize)(tree); 92 | 93 | if (!_.find(tree.nodes, node => node.id === option.onSelect)) { 94 | throw new Error(e(`${node.title}: option points to a missing node (${option.onSelect}).`)); 95 | } 96 | }; 97 | 98 | const validateIdUniqueness = (items, error) => { 99 | const ids = _.uniq(_.map(items, item => item.id)); 100 | if (ids.length !== items.length) { 101 | throw new Error(error); 102 | } 103 | }; 104 | 105 | const errorize = (tree, msg) => { 106 | return `Error in "${tree.name}": ${msg}`; 107 | }; 108 | 109 | module.exports = validateTree; 110 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "treenee", 3 | "version": "1.3.3", 4 | "description": "A simple decision tree engine", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "ava", 8 | "start": "nodemon index", 9 | "start-dev": "nodemon index --builder-mode --watch-trees --stable-ids", 10 | "test-dev": "ava -w" 11 | }, 12 | "author": "claudio.cicali@gmail.com", 13 | "license": "MIT", 14 | "dependencies": { 15 | "@hapi/boom": "^7.4.2", 16 | "@hapi/hapi": "^18.4.1", 17 | "@hapi/inert": "^5.2.0", 18 | "@hapi/joi": "^15.0.3", 19 | "@hapi/vision": "^5.5.2", 20 | "@hapi/yar": "^9.2.0", 21 | "ajv": "^6.12.3", 22 | "commander": "^5.1.0", 23 | "comment-json": "^3.0.2", 24 | "flat": "^5.0.0", 25 | "hapi-pino": "^8.0.0", 26 | "hbs": "^4.1.2", 27 | "js-yaml": "^3.14.0", 28 | "lodash": "^4.17.19", 29 | "markdown-it": "^11.0.0", 30 | "mermaid": "^8.5.0" 31 | }, 32 | "devDependencies": { 33 | "ava": "^3.10.1", 34 | "nodemon": "^2.0.3" 35 | }, 36 | "ava": { 37 | "verbose": true, 38 | "serial": true, 39 | "files": [ 40 | "tests/**/*" 41 | ] 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /public/css/app.css: -------------------------------------------------------------------------------- 1 | @import 'normalize.css'; 2 | 3 | :root { 4 | --background-color: rgb(245, 245, 245); 5 | --secondary-color: rgb(200, 215, 229); 6 | } 7 | 8 | *, 9 | *:before, 10 | *:after { 11 | box-sizing: inherit; 12 | } 13 | 14 | html { 15 | box-sizing: border-box; 16 | -webkit-text-size-adjust: 100%; 17 | } 18 | 19 | body { 20 | background-color: var(--background-color); 21 | color: #444; 22 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; 23 | line-height: 1.5; 24 | } 25 | 26 | body > header { 27 | background-color: var(--secondary-color); 28 | padding: 1rem; 29 | text-align: right; 30 | } 31 | 32 | footer { 33 | min-height: 4rem; 34 | } 35 | 36 | main { 37 | padding: 0 5vw; 38 | margin: 0 auto; 39 | max-width: 50rem; 40 | } 41 | 42 | .content { 43 | margin-bottom: 2rem; 44 | font-size: clamp(1rem, 4vw, 1.2rem); 45 | } 46 | 47 | .content h1 { 48 | border-bottom: 3px solid var(--secondary-color); 49 | font-weight: normal; 50 | margin-top: 0; 51 | padding: 1rem; 52 | padding-left: 0; 53 | } 54 | 55 | .content h2 { 56 | font-weight: normal; 57 | } 58 | 59 | .nav { 60 | border-top: 3px solid var(--secondary-color); 61 | text-align: right; 62 | } 63 | 64 | .nav a { 65 | color: #4e6a94; 66 | font-weight: bold; 67 | } 68 | 69 | .nav a:hover { 70 | opacity: 0.7; 71 | } 72 | 73 | .node .prompt { 74 | font-size: 1.2rem; 75 | font-weight: bold; 76 | } 77 | 78 | .nav, 79 | .node .options { 80 | padding: 0; 81 | } 82 | 83 | .nav li { 84 | display: inline-block; 85 | list-style-type: none; 86 | } 87 | 88 | .node .options li { 89 | display: block; 90 | list-style-type: none; 91 | margin-bottom: 1rem; 92 | background-color: rgb(255, 255, 255); 93 | border-left: 4px solid transparent; 94 | border-radius: 8px; 95 | box-shadow: 0 0.125em 0.25em 0 rgba(0, 0, 0, 0.2); 96 | margin: 0 auto; 97 | margin-bottom: 1rem; 98 | position: relative; 99 | width: 80%; 100 | } 101 | 102 | .node .options li:not(.unselectable-option):hover { 103 | border-left-color: rgb(72, 172, 152); 104 | } 105 | .node .options li:not(.unselectable-option)::after { 106 | color: rgb(220, 220, 220); 107 | content: '>'; 108 | font-size: 36px; 109 | right: 16px; 110 | position: absolute; 111 | top: 0; 112 | } 113 | 114 | .node .options li a { 115 | background-color: transparent; 116 | color: rgb(100, 100, 100); 117 | display: inline-block; 118 | padding: 1rem; 119 | width: 100%; 120 | } 121 | 122 | .tree .start a, 123 | .node .options li a { 124 | color: black; 125 | display: inline-block; 126 | min-width: 20rem; 127 | text-decoration: none; 128 | } 129 | 130 | .node .options li.unselectable-option { 131 | background-color: rgb(210, 210, 210); 132 | color: gray; 133 | padding: 1rem; 134 | } 135 | 136 | form .input { 137 | display: inline-block; 138 | } 139 | 140 | .error { 141 | color: red; 142 | } 143 | 144 | .tree .start { 145 | text-align: center; 146 | } 147 | 148 | .tree .start a { 149 | background-color: #b1e267; 150 | color: #517817; 151 | text-transform: uppercase; 152 | min-width: 10rem; 153 | } 154 | 155 | .tree .start a:hover { 156 | border-bottom-color: #517817; 157 | } 158 | 159 | .notice { 160 | background-color: #f8f09a; 161 | padding: 1rem; 162 | } 163 | 164 | .start-again { 165 | text-align: right; 166 | } 167 | -------------------------------------------------------------------------------- /public/css/normalize.css: -------------------------------------------------------------------------------- 1 | /*! normalize.css v8.0.1 | MIT License | github.com/necolas/normalize.css */ 2 | 3 | /* Document 4 | ========================================================================== */ 5 | 6 | /** 7 | * 1. Correct the line height in all browsers. 8 | * 2. Prevent adjustments of font size after orientation changes in iOS. 9 | */ 10 | 11 | html { 12 | line-height: 1.15; /* 1 */ 13 | -webkit-text-size-adjust: 100%; /* 2 */ 14 | } 15 | 16 | /* Sections 17 | ========================================================================== */ 18 | 19 | /** 20 | * Remove the margin in all browsers. 21 | */ 22 | 23 | body { 24 | margin: 0; 25 | } 26 | 27 | /** 28 | * Render the `main` element consistently in IE. 29 | */ 30 | 31 | main { 32 | display: block; 33 | } 34 | 35 | /** 36 | * Correct the font size and margin on `h1` elements within `section` and 37 | * `article` contexts in Chrome, Firefox, and Safari. 38 | */ 39 | 40 | h1 { 41 | font-size: 2em; 42 | margin: 0.67em 0; 43 | } 44 | 45 | /* Grouping content 46 | ========================================================================== */ 47 | 48 | /** 49 | * 1. Add the correct box sizing in Firefox. 50 | * 2. Show the overflow in Edge and IE. 51 | */ 52 | 53 | hr { 54 | box-sizing: content-box; /* 1 */ 55 | height: 0; /* 1 */ 56 | overflow: visible; /* 2 */ 57 | } 58 | 59 | /** 60 | * 1. Correct the inheritance and scaling of font size in all browsers. 61 | * 2. Correct the odd `em` font sizing in all browsers. 62 | */ 63 | 64 | pre { 65 | font-family: monospace, monospace; /* 1 */ 66 | font-size: 1em; /* 2 */ 67 | } 68 | 69 | /* Text-level semantics 70 | ========================================================================== */ 71 | 72 | /** 73 | * Remove the gray background on active links in IE 10. 74 | */ 75 | 76 | a { 77 | background-color: transparent; 78 | } 79 | 80 | /** 81 | * 1. Remove the bottom border in Chrome 57- 82 | * 2. Add the correct text decoration in Chrome, Edge, IE, Opera, and Safari. 83 | */ 84 | 85 | abbr[title] { 86 | border-bottom: none; /* 1 */ 87 | text-decoration: underline; /* 2 */ 88 | text-decoration: underline dotted; /* 2 */ 89 | } 90 | 91 | /** 92 | * Add the correct font weight in Chrome, Edge, and Safari. 93 | */ 94 | 95 | b, 96 | strong { 97 | font-weight: bolder; 98 | } 99 | 100 | /** 101 | * 1. Correct the inheritance and scaling of font size in all browsers. 102 | * 2. Correct the odd `em` font sizing in all browsers. 103 | */ 104 | 105 | code, 106 | kbd, 107 | samp { 108 | font-family: monospace, monospace; /* 1 */ 109 | font-size: 1em; /* 2 */ 110 | } 111 | 112 | /** 113 | * Add the correct font size in all browsers. 114 | */ 115 | 116 | small { 117 | font-size: 80%; 118 | } 119 | 120 | /** 121 | * Prevent `sub` and `sup` elements from affecting the line height in 122 | * all browsers. 123 | */ 124 | 125 | sub, 126 | sup { 127 | font-size: 75%; 128 | line-height: 0; 129 | position: relative; 130 | vertical-align: baseline; 131 | } 132 | 133 | sub { 134 | bottom: -0.25em; 135 | } 136 | 137 | sup { 138 | top: -0.5em; 139 | } 140 | 141 | /* Embedded content 142 | ========================================================================== */ 143 | 144 | /** 145 | * Remove the border on images inside links in IE 10. 146 | */ 147 | 148 | img { 149 | border-style: none; 150 | } 151 | 152 | /* Forms 153 | ========================================================================== */ 154 | 155 | /** 156 | * 1. Change the font styles in all browsers. 157 | * 2. Remove the margin in Firefox and Safari. 158 | */ 159 | 160 | button, 161 | input, 162 | optgroup, 163 | select, 164 | textarea { 165 | font-family: inherit; /* 1 */ 166 | font-size: 100%; /* 1 */ 167 | line-height: 1.15; /* 1 */ 168 | margin: 0; /* 2 */ 169 | } 170 | 171 | /** 172 | * Show the overflow in IE. 173 | * 1. Show the overflow in Edge. 174 | */ 175 | 176 | button, 177 | input { 178 | /* 1 */ 179 | overflow: visible; 180 | } 181 | 182 | /** 183 | * Remove the inheritance of text transform in Edge, Firefox, and IE. 184 | * 1. Remove the inheritance of text transform in Firefox. 185 | */ 186 | 187 | button, 188 | select { 189 | /* 1 */ 190 | text-transform: none; 191 | } 192 | 193 | /** 194 | * Correct the inability to style clickable types in iOS and Safari. 195 | */ 196 | 197 | button, 198 | [type='button'], 199 | [type='reset'], 200 | [type='submit'] { 201 | -webkit-appearance: button; 202 | } 203 | 204 | /** 205 | * Remove the inner border and padding in Firefox. 206 | */ 207 | 208 | button::-moz-focus-inner, 209 | [type='button']::-moz-focus-inner, 210 | [type='reset']::-moz-focus-inner, 211 | [type='submit']::-moz-focus-inner { 212 | border-style: none; 213 | padding: 0; 214 | } 215 | 216 | /** 217 | * Restore the focus styles unset by the previous rule. 218 | */ 219 | 220 | button:-moz-focusring, 221 | [type='button']:-moz-focusring, 222 | [type='reset']:-moz-focusring, 223 | [type='submit']:-moz-focusring { 224 | outline: 1px dotted ButtonText; 225 | } 226 | 227 | /** 228 | * Correct the padding in Firefox. 229 | */ 230 | 231 | fieldset { 232 | padding: 0.35em 0.75em 0.625em; 233 | } 234 | 235 | /** 236 | * 1. Correct the text wrapping in Edge and IE. 237 | * 2. Correct the color inheritance from `fieldset` elements in IE. 238 | * 3. Remove the padding so developers are not caught out when they zero out 239 | * `fieldset` elements in all browsers. 240 | */ 241 | 242 | legend { 243 | box-sizing: border-box; /* 1 */ 244 | color: inherit; /* 2 */ 245 | display: table; /* 1 */ 246 | max-width: 100%; /* 1 */ 247 | padding: 0; /* 3 */ 248 | white-space: normal; /* 1 */ 249 | } 250 | 251 | /** 252 | * Add the correct vertical alignment in Chrome, Firefox, and Opera. 253 | */ 254 | 255 | progress { 256 | vertical-align: baseline; 257 | } 258 | 259 | /** 260 | * Remove the default vertical scrollbar in IE 10+. 261 | */ 262 | 263 | textarea { 264 | overflow: auto; 265 | } 266 | 267 | /** 268 | * 1. Add the correct box sizing in IE 10. 269 | * 2. Remove the padding in IE 10. 270 | */ 271 | 272 | [type='checkbox'], 273 | [type='radio'] { 274 | box-sizing: border-box; /* 1 */ 275 | padding: 0; /* 2 */ 276 | } 277 | 278 | /** 279 | * Correct the cursor style of increment and decrement buttons in Chrome. 280 | */ 281 | 282 | [type='number']::-webkit-inner-spin-button, 283 | [type='number']::-webkit-outer-spin-button { 284 | height: auto; 285 | } 286 | 287 | /** 288 | * 1. Correct the odd appearance in Chrome and Safari. 289 | * 2. Correct the outline style in Safari. 290 | */ 291 | 292 | [type='search'] { 293 | -webkit-appearance: textfield; /* 1 */ 294 | outline-offset: -2px; /* 2 */ 295 | } 296 | 297 | /** 298 | * Remove the inner padding in Chrome and Safari on macOS. 299 | */ 300 | 301 | [type='search']::-webkit-search-decoration { 302 | -webkit-appearance: none; 303 | } 304 | 305 | /** 306 | * 1. Correct the inability to style clickable types in iOS and Safari. 307 | * 2. Change font properties to `inherit` in Safari. 308 | */ 309 | 310 | ::-webkit-file-upload-button { 311 | -webkit-appearance: button; /* 1 */ 312 | font: inherit; /* 2 */ 313 | } 314 | 315 | /* Interactive 316 | ========================================================================== */ 317 | 318 | /* 319 | * Add the correct display in Edge, IE 10+, and Firefox. 320 | */ 321 | 322 | details { 323 | display: block; 324 | } 325 | 326 | /* 327 | * Add the correct display in all browsers. 328 | */ 329 | 330 | summary { 331 | display: list-item; 332 | } 333 | 334 | /* Misc 335 | ========================================================================== */ 336 | 337 | /** 338 | * Add the correct display in IE 10+. 339 | */ 340 | 341 | template { 342 | display: none; 343 | } 344 | 345 | /** 346 | * Add the correct display in IE 10. 347 | */ 348 | 349 | [hidden] { 350 | display: none; 351 | } 352 | -------------------------------------------------------------------------------- /public/js/treenee.js: -------------------------------------------------------------------------------- 1 | (function (global) { 2 | const app = {}; 3 | global.Treenee = app; 4 | })(window); 5 | -------------------------------------------------------------------------------- /public/mermaid: -------------------------------------------------------------------------------- 1 | ../node_modules/mermaid/dist/ -------------------------------------------------------------------------------- /routes/404.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = { 4 | method: ['GET', 'POST'], 5 | path: '/{any*}', 6 | handler: (request, h) => { 7 | if (request.url.pathname === '/service-worker.js') { 8 | return ''; 9 | } 10 | return h.view('404').code(404); 11 | } 12 | }; 13 | -------------------------------------------------------------------------------- /routes/code.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const _ = require('lodash'); 4 | const Joi = require('@hapi/joi'); 5 | const crypto = require('crypto'); 6 | 7 | const handler = (req, h) => { 8 | const trees = req.server.app.trees; 9 | const code = req.payload.accessCode; 10 | if (code.length === 0) { 11 | return h.redirect('/?e=1'); 12 | } 13 | 14 | const tree = trees.find(tree => tree.accessCode === code); 15 | if (tree) { 16 | // Sets the accessCode in a cookie so to be able to use 17 | // it when opening the tree 18 | const hash = crypto.createHash('sha256').update(code).digest('base64'); 19 | 20 | req.yar.set('accessCode', hash); 21 | return h.redirect(`/tree/${tree._meta.slug}`); 22 | } 23 | 24 | return h.redirect('/?e=2'); 25 | }; 26 | 27 | module.exports = { 28 | method: 'POST', 29 | path: '/code', 30 | handler, 31 | options: { 32 | validate: { 33 | payload: { 34 | accessCode: Joi.string().min(1).max(80).trim().allow('') 35 | } 36 | } 37 | } 38 | }; 39 | -------------------------------------------------------------------------------- /routes/graphs/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const validateCode = require('../../lib/validate-code'); 4 | const isURLLike = require('../../lib/is-url-like'); 5 | 6 | module.exports = [ 7 | { 8 | method: 'GET', 9 | path: '/graphs', 10 | handler: graphsIndex 11 | }, 12 | { 13 | method: 'GET', 14 | path: '/graphs/{param*}', 15 | handler: graphsTree 16 | } 17 | ]; 18 | 19 | function graphsIndex(req, h) { 20 | if (!req.server.settings.app.trees.showGraph) { 21 | return h.response('Not found').code(404); 22 | } 23 | return h.view('graphs/index'); 24 | } 25 | 26 | function graphsTree(req, h) { 27 | if (!req.server.settings.app.trees.showGraph) { 28 | return h.response('Not found').code(404); 29 | } 30 | 31 | // Find the tree by its slug (the param) 32 | const slug = req.params.param; 33 | const tree = req.server.app.trees.find(tree => { 34 | return slug === tree._meta.slug; 35 | }); 36 | 37 | if (!tree) { 38 | return h.response('Not found').code(404); 39 | } 40 | 41 | if (!validateCode(tree, req.yar.get('accessCode') || '')) { 42 | req.yar.clear('accessCode'); 43 | return h.response('Forbidden').code(401); 44 | } 45 | 46 | const graphDefinition = ['graph TD']; 47 | const rootNode = tree.nodes[0]; 48 | const label = makeLabel(tree, rootNode.id, rootNode.title); 49 | 50 | graphDefinition.push(`intro>"${clean(tree.title)}"] --- |Start| ${rootNode.id}(${label})`); 51 | 52 | // Convert the json defition to mmd 53 | tree.nodes.forEach(node => { 54 | if (!node.options) { 55 | return; 56 | } 57 | 58 | node.options.forEach(option => { 59 | const label = makeLabel(tree, option.onSelect); 60 | const target = isURLLike(option.onSelect) ? 'https://…' : option.onSelect; 61 | graphDefinition.push(`${node.id} --> |"${clean(option.text)}"| ${target}(${label})`); 62 | }); 63 | }); 64 | 65 | tree.nodes.forEach(node => { 66 | graphDefinition.push(`click ${node.id} Treenee_graphClickHandler`); 67 | }); 68 | 69 | return h.view('graphs/tree', { 70 | graphDefinition: graphDefinition.join('\n'), 71 | tree 72 | }); 73 | } 74 | 75 | function makeLabel(tree, onSelect, title) { 76 | if (isURLLike(onSelect)) { 77 | return 'https://…'; 78 | } 79 | const cleanTitle = clean(title ? title : findNodeTitle(tree, onSelect)); 80 | return `"${onSelect}  ${cleanTitle} "`; 81 | } 82 | 83 | function findNodeTitle(tree, nodeId) { 84 | const node = tree.nodes.find(node => node.id === nodeId); 85 | return node ? node.title : `No title for node ${nodeId}`; 86 | } 87 | 88 | function clean(text) { 89 | return text.replace(/"/g, '"'); 90 | //return text.replace(/&/g, '&'); 91 | // return entities.encode(text) 92 | // // Fixes issue with double encond 93 | // .replace(/&/g, '&'); 94 | } 95 | -------------------------------------------------------------------------------- /routes/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const _ = require('lodash'); 4 | const errors = require('../lib/errors'); 5 | const Joi = require('@hapi/joi'); 6 | 7 | const handler = (req, h) => { 8 | const error = errors.get(req.query.e || 0); 9 | return h.view('index', { 10 | error 11 | }); 12 | }; 13 | 14 | module.exports = { 15 | method: 'GET', 16 | path: '/', 17 | handler, 18 | options: { 19 | validate: { 20 | query: { 21 | e: Joi.number() 22 | .integer() 23 | .min(1) 24 | .max(errors.size - 1) 25 | .default(0) 26 | } 27 | } 28 | } 29 | }; 30 | -------------------------------------------------------------------------------- /routes/node.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const _ = require('lodash'); 4 | const validateCode = require('../lib/validate-code'); 5 | const Joi = require('@hapi/joi'); 6 | const Boom = require('@hapi/boom'); 7 | const treeTools = require('../lib/tree-tools'); 8 | const markdown = require('markdown-it')({ 9 | html: false, 10 | typographer: true 11 | }); 12 | 13 | const handler = (req, h) => { 14 | const tree = treeTools.findTreeBySlug(req.server.app.trees, req.params.slug); 15 | 16 | if (!tree) { 17 | return h.response('Not found (tree)').code(404); 18 | } 19 | 20 | const node = treeTools.findNodeById(tree, req.params.nodeId); 21 | if (!node) { 22 | return h.response('Not found (node)').code(404); 23 | } 24 | 25 | if (!validateCode(tree, req.yar.get('accessCode') || '')) { 26 | req.yar.clear('accessCode'); 27 | return h.redirect(`/?e=3`); 28 | } 29 | 30 | // Definition: a visited node is a node whose prompt's option 31 | // has been selected and not just shown 32 | 33 | let score; 34 | 35 | // Save the parentNode as a visited node, if needed 36 | const visits = req.yar.get('visits') || {}; 37 | const treeVisits = visits[tree.name] || []; 38 | 39 | let previousVisit = false; 40 | if (tree.trackVisits) { 41 | // Before presenting the node we need to know if this node has already 42 | // a selected option (if it's already visited). 43 | previousVisit = treeVisits.find(visit => visit.nodeId === node.id); 44 | } 45 | 46 | if (req.query.p) { 47 | const parentNode = tree.nodes.find(node => node.id == req.query.p); 48 | if (!parentNode) { 49 | return Boom.badRequest('Unrecognized parent node.'); 50 | } 51 | 52 | const option = parentNode.options.find(option => option._id == req.query.o); 53 | if (!option) { 54 | return Boom.badRequest(`Unrecognized option ${req.query.o}.`); 55 | } 56 | 57 | if (!treeVisits.find(visit => visit.nodeId === parentNode.id)) { 58 | // The parent node was never visited, and we can save the visit 59 | treeVisits.push({ 60 | nodeId: parentNode.id, 61 | optionId: option._id, 62 | value: parseInt(option.value, 10) || 0, 63 | time: Date.now() 64 | }); 65 | visits[tree.name] = treeVisits; 66 | req.yar.set('visits', visits); 67 | } 68 | 69 | score = treeVisits.reduce((previous, current) => { 70 | return previous + current.value; 71 | }, 0); 72 | } 73 | 74 | let body = node.body; 75 | if (tree.bodyFormat === 'markdown') { 76 | body = markdown.render(body); 77 | } 78 | 79 | return ( 80 | h 81 | .view('node', { 82 | previousVisit, 83 | score, 84 | node, 85 | body, 86 | tree 87 | }) 88 | // Instruct browsers to not cache these pages since we need to 89 | // check if they have been already scored. 90 | // @TODO consider to add these headers only for "scored" nodes. 91 | .header('Pragma', 'no-cache') 92 | .header( 93 | 'Cache-Control', 94 | 'private, no-store, max-age=0, no-cache, must-revalidate, post-check=0, pre-check=0' 95 | ) 96 | .header('Expires', 'Fri, 01 Jan 1990 00:00:00 GMT') 97 | ); 98 | }; 99 | 100 | module.exports = { 101 | method: 'GET', 102 | path: `/tree/{slug}/{nodeId}`, 103 | handler, 104 | options: { 105 | validate: { 106 | query: { 107 | // Parent node id 108 | p: Joi.string(), 109 | // Option 110 | o: Joi.string().min(7).max(10) 111 | } 112 | } 113 | } 114 | }; 115 | -------------------------------------------------------------------------------- /routes/public.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = { 4 | method: 'GET', 5 | path: '/public/{param*}', 6 | handler: { 7 | directory: { 8 | path: '.', 9 | redirectToSlash: true, 10 | index: true 11 | } 12 | } 13 | }; 14 | -------------------------------------------------------------------------------- /routes/tree.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const _ = require('lodash'); 4 | const validateCode = require('../lib/validate-code'); 5 | const treeTools = require('../lib/tree-tools'); 6 | 7 | const handler = (req, h) => { 8 | const tree = treeTools.findTreeBySlug(req.server.app.trees, req.params.slug); 9 | 10 | if (!tree) { 11 | return h.response('Not found').code(404); 12 | } 13 | 14 | if (!validateCode(tree, req.yar.get('accessCode') || '')) { 15 | req.yar.clear('accessCode'); 16 | return h.redirect(`/?e=3`); 17 | } 18 | 19 | req.yar.clear('visits'); 20 | 21 | const startUrl = `/tree/${tree._meta.slug}/${tree.startNodeId}`; 22 | if (tree.showIntro) { 23 | return h.view('tree', { 24 | tree, 25 | startUrl, 26 | showGraph: req.server.settings.app.trees.showGraph 27 | }); 28 | } else { 29 | return h.redirect(startUrl); 30 | } 31 | 32 | return h.view('tree', { 33 | tree, 34 | showGraph: req.server.settings.app.trees.showGraph 35 | }); 36 | }; 37 | 38 | const handlerJSON = (req, h) => { 39 | const tree = treeTools.findTreeBySlug(req.server.app.trees, req.params.param); 40 | 41 | if (!validateCode(tree, req.yar.get('accessCode') || '')) { 42 | req.yar.clear('accessCode'); 43 | return h.response('Forbidden').code(401); 44 | } 45 | 46 | return tree; 47 | }; 48 | 49 | module.exports = [ 50 | { 51 | method: 'GET', 52 | path: `/tree/{slug}`, 53 | handler 54 | }, 55 | { 56 | method: 'GET', 57 | path: `/tree/{slug}.json`, 58 | handler: handlerJSON 59 | } 60 | ]; 61 | -------------------------------------------------------------------------------- /scripts/task-build.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | ### 4 | # This script automates the generation of a static version of a treenee 5 | # installation. It won't work out-of-the-box because it uses directory names 6 | # that are not present (it's part of another project) but it is left here 7 | # as an "inspiration" in case you have a similar need. 8 | # 9 | # Cheers :) 10 | ### 11 | 12 | # Remove this line once you know what you're doing. 13 | exit 0 14 | 15 | treenee_port=3067 16 | 17 | type wget >/dev/null 2>&1 || { echo >&2 "😟 `wget` is apparently not installed."; exit 1; } 18 | 19 | type node >/dev/null 2>&1 || { echo >&2 "😟 Can't find any version of Nodejs."; exit 1; } 20 | 21 | # Ensure treenee is not listening already 22 | # Also, we want to make sure we start it because we don't trust any 23 | # other service running on that port anyway. 24 | nc -z localhost ${treenee_port} 2> /dev/null && { echo >&2 "😟 There is already something listening on port ${treenee_port}. Stop it and retry."; exit 1; } 25 | 26 | echo Starting Treenee… 27 | node index -s ../treenee-settings.json > /dev/null & 28 | pid=$(echo $!) 29 | 30 | # Wait until treenee is there (it should take a couple of seconds top) 31 | while ! echo exit | nc -z localhost ${treenee_port} 2> /dev/null; do sleep 1; done 32 | 33 | echo Creating the website… 34 | 35 | wget -q --mirror --convert-links --adjust-extension --page-requisites --no-parent http://localhost:${treenee_port} -P ../website 36 | 37 | echo Stopping Treenee… 38 | kill ${pid} 39 | wait ${pid} 2>/dev/null 40 | 41 | mv ../website/localhost:${treenee_port}/* ../website 42 | rm -rf ../website/localhost:${treenee_port} 43 | 44 | open $(ls -1 ../website/tree/*.html | head -1) 45 | 46 | echo All done. 47 | -------------------------------------------------------------------------------- /settings-schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-07/schema#", 3 | "type": "object", 4 | "additionalProperties": false, 5 | "properties": { 6 | "port": { 7 | "type": "integer" 8 | }, 9 | "cookiePassword": { 10 | "type": "string" 11 | }, 12 | "logRequests": { 13 | "description": "Wether you want full logging capability or not (default is false)", 14 | "type": "boolean" 15 | }, 16 | "trees": { 17 | "properties": { 18 | "location": { 19 | "type": "string" 20 | }, 21 | "exclude": { 22 | "items": { 23 | "type": "string" 24 | }, 25 | "type": "array" 26 | }, 27 | "showGraph": { 28 | "type": "boolean" 29 | } 30 | }, 31 | "type": "object", 32 | "additionalProperties": false 33 | }, 34 | "strings": { 35 | "properties": {}, 36 | "type": "object", 37 | "additionalProperties": true 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /templates/404.html: -------------------------------------------------------------------------------- 1 |

{{t "404_1"}}

2 |

{{t "404_2"}}

3 | {{t "shared_1"}} 4 | -------------------------------------------------------------------------------- /templates/graphs/index.html: -------------------------------------------------------------------------------- 1 | Nothing to see here, go home. 2 | -------------------------------------------------------------------------------- /templates/graphs/tree.html: -------------------------------------------------------------------------------- 1 | 39 | 40 |
41 |
{{graphDefinition}}
42 |
43 | 44 |
45 |
46 | 47 | 48 | 124 |
125 | -------------------------------------------------------------------------------- /templates/helpers/eq.js: -------------------------------------------------------------------------------- 1 | module.exports = (option1, option2, context) => { 2 | return option1 == option2; 3 | }; 4 | -------------------------------------------------------------------------------- /templates/helpers/is-url.js: -------------------------------------------------------------------------------- 1 | const isURLLike = require('../../lib/is-url-like'); 2 | 3 | module.exports = (options, context) => { 4 | return isURLLike(options); 5 | }; 6 | -------------------------------------------------------------------------------- /templates/helpers/not.js: -------------------------------------------------------------------------------- 1 | module.exports = (options, context) => { 2 | return !!options === false; 3 | }; 4 | -------------------------------------------------------------------------------- /templates/helpers/t.js: -------------------------------------------------------------------------------- 1 | const Handlebars = require('hbs'); 2 | 3 | module.exports = (options, context) => { 4 | let key = options; 5 | let withSpan = true; 6 | let strings; 7 | if (options.hash) { 8 | key = options.hash.key; 9 | withSpan = options.hash.withSpan === undefined ? withSpan : !!options.hash.withSpan; 10 | strings = options.data.root.c_settings.strings 11 | } else { 12 | strings = context.data.root.c_settings.strings 13 | } 14 | const string = strings ? (strings[key] !== undefined ? strings[key] : key) : key; 15 | const output = withSpan ? `${string}` : string; 16 | return new Handlebars.SafeString(output); 17 | }; 18 | -------------------------------------------------------------------------------- /templates/index.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |

{{t "index_1"}}

4 |
{{t "index_2"}}
5 |
    6 | {{#each c_trees}} 7 | {{#if _meta.isPublic}} 8 |
  • 9 | {{ title }} 10 |
  • 11 | {{/if}} 12 | {{/each}} 13 |
14 |
15 |
16 | {{#if error}} 17 |

{{ error }}

18 | {{/if}} 19 | {{#if c_trees._meta.privateCount}} 20 |

{{t "index_3"}}

21 |
22 |
23 |
24 |
25 | {{/if}} 26 |
27 |
28 | -------------------------------------------------------------------------------- /templates/layouts/layout.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | {{t key="layout_1" withSpan=false}} 10 | 11 | 12 | 13 |
{{t "layout_2"}}
14 |
{{{content}}}
15 |
16 | 17 | 18 | -------------------------------------------------------------------------------- /templates/node.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |

{{ tree.title }}

4 |

{{ node.title }}

5 |
6 | {{#if (eq tree.bodyFormat "text")}} 7 | {{ body }} 8 | {{else}} 9 | {{{ body }}} 10 | {{/if}} 11 |
12 | {{#if previousVisit}} 13 |

{{t "node_1"}}

14 | {{/if}} 15 | {{#if node.options}} 16 |
17 |

{{ node.prompt }}

18 |
    19 | {{#each node.options}} 20 | {{#if ../previousVisit}} 21 | {{#if (eq _id ../previousVisit.optionId)}} 22 |
  • 23 | {{#if (is-url onSelect)}} 24 | {{{ text }}} 25 | {{else}} 26 | {{{ text }}} 27 | {{/if}} 28 |
  • 29 | {{else}} 30 |
  • {{{ text }}}
  • 31 | {{/if}} 32 | {{else}} 33 |
  • 34 | {{#if (is-url onSelect)}} 35 | {{{ text }}} 36 | {{else}} 37 | {{{ text }}} 38 | {{/if}} 39 |
  • 40 | {{/if}} 41 | {{/each}} 42 |
43 |
44 | {{else}} 45 |

46 | {{t "node_2"}} {{t "node_3"}} 47 |

48 | {{/if}} 49 |
50 | 51 | 60 | {{#if score}} 61 |
62 |

{{t "node_4"}} {{ score }}

63 | {{/if}} 64 |
65 | -------------------------------------------------------------------------------- /templates/tree.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |

{{ tree.title }}

4 |

{{{ tree.description }}}

5 |
6 | {{t "tree_1"}} 7 |
8 |
9 | 17 |
18 | -------------------------------------------------------------------------------- /tests/lib/is-url-like.test.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const test = require('ava'); 4 | const isURLLike = require('../../lib/is-url-like'); 5 | 6 | test('Should test if a sting is a url or not', async t => { 7 | let str = ''; 8 | t.is(isURLLike(str), false); 9 | str = undefined; 10 | t.is(isURLLike(str), false); 11 | str = 'aaa'; 12 | t.is(isURLLike(str), false); 13 | str = 123; 14 | t.is(isURLLike(str), false); 15 | str = 'https'; 16 | t.is(isURLLike(str), false); 17 | str = 'mailto://claudio@xyz.com'; 18 | t.is(isURLLike(str), true); 19 | str = 'https://claudio@xyz.com some'; 20 | t.is(isURLLike(str), false); 21 | }); 22 | -------------------------------------------------------------------------------- /tests/lib/normalize-tree.test.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const test = require('ava'); 4 | const path = require('path'); 5 | const findTrees = require('../../lib/find-trees'); 6 | const normalizeTree = require('../../lib/normalize-tree'); 7 | 8 | test('Should normalize node ids', async t => { 9 | const trees = await findTrees(path.join(__dirname, '../trees')); 10 | trees[0].nodes[0].id = 123; 11 | const tree = normalizeTree(trees[0]); 12 | t.is(tree.nodes[0].id, '123'); 13 | }); 14 | 15 | test('Should normalize options onSelect', async t => { 16 | const trees = await findTrees(path.join(__dirname, '../trees')); 17 | const tree = normalizeTree(trees[0]); 18 | t.is(tree.nodes[0].options[0].onSelect, 'start'); 19 | t.is(tree.nodes[0].options[0].nextNodeId, 'start'); 20 | t.is(tree.nodes[0].options[1].onSelect, '2'); 21 | }); 22 | -------------------------------------------------------------------------------- /tests/lib/read-trees.test.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const test = require('ava'); 4 | const path = require('path'); 5 | const readTrees = require('../../lib/read-trees'); 6 | 7 | test('Should throw if the directory does not exist', async t => { 8 | const err = await t.throwsAsync(async () => { 9 | await readTrees('foobar'); 10 | }); 11 | }); 12 | 13 | test('Should read the trees', async t => { 14 | const trees = await readTrees(path.join(__dirname, '../trees')); 15 | t.is(trees.length, 1); 16 | t.is(trees[0].nodes.length, 2); 17 | t.is(trees[0].nodes[0].options.length, 2); 18 | t.is(trees[0].nodes[0].options[1].text, 'No'); 19 | }); 20 | 21 | test('Should exclude trees from settings', async t => { 22 | const trees = await readTrees(path.join(__dirname, '../trees'), ['test-tree']); 23 | t.is(trees.length, 0); 24 | }); 25 | -------------------------------------------------------------------------------- /tests/lib/settings.test.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const test = require('ava'); 4 | const settings = require('../../lib/settings'); 5 | 6 | test('Should load configuration from a passed object', async t => { 7 | const data = settings.loadFrom(); 8 | t.is(Object.keys(data).length, 5); 9 | }); 10 | 11 | test('Should change default values', async t => { 12 | const data = settings.loadFrom({ port: 666 }); 13 | t.is(data.port, 666); 14 | }); 15 | 16 | test('Should throw in case of aliens', async t => { 17 | const err = t.throws(() => { 18 | settings.loadFrom({ trees: { foobar: 123 } }); 19 | }); 20 | 21 | t.regex(err.message, /.*foobar/); 22 | }); 23 | -------------------------------------------------------------------------------- /tests/lib/slugify.test.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const test = require('ava'); 4 | const path = require('path'); 5 | const slugify = require('../../lib/slugify'); 6 | 7 | test('Should slugify some words', async t => { 8 | t.is(slugify('claudio'), 'claudio'); 9 | t.is(slugify('claudio '), 'claudio'); 10 | t.is(slugify('Claudio Cicali'), 'claudio-cicali'); 11 | t.is(slugify('B42'), 'b42'); 12 | t.is(slugify('B42 and more'), 'b42-and-more'); 13 | t.is(slugify('Very Special!'), 'very-special-'); 14 | }); 15 | -------------------------------------------------------------------------------- /tests/lib/tree-tools.test.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const test = require('ava'); 4 | const path = require('path'); 5 | const findTrees = require('../../lib/find-trees'); 6 | const treeTools = require('../../lib/tree-tools'); 7 | 8 | test('Should find a tree by slug', async t => { 9 | const trees = await findTrees(path.join(__dirname, '../trees')); 10 | const tree = treeTools.findTreeBySlug(trees, 'a-test-tree'); 11 | t.is(tree.nodes.length, 2); 12 | }); 13 | 14 | test('Should not find a tree by slug', async t => { 15 | const trees = await findTrees(path.join(__dirname, '../trees')); 16 | const tree = treeTools.findTreeBySlug(trees, 'foobar'); 17 | t.is(tree, undefined); 18 | }); 19 | 20 | test('Should find a node by id', async t => { 21 | const trees = await findTrees(path.join(__dirname, '../trees')); 22 | const node = treeTools.findNodeById(trees[0], 'start'); 23 | t.is(node.options.length, 2); 24 | }); 25 | 26 | test('Should not find a node by id', async t => { 27 | const trees = await findTrees(path.join(__dirname, '../trees')); 28 | const node = treeTools.findNodeById(trees[0], '1123'); 29 | t.is(node, undefined); 30 | }); 31 | -------------------------------------------------------------------------------- /tests/lib/validate-tree.test.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const test = require('ava'); 4 | const path = require('path'); 5 | const findTrees = require('../../lib/read-trees'); 6 | const validateTree = require('../../lib/validate-tree'); 7 | const _cloneDeep = require('lodash/cloneDeep'); 8 | const normalizeTree = require('../../lib/normalize-tree'); 9 | 10 | test('Should validate a valid tree', async t => { 11 | const tree = await getTestTree(); 12 | validateTree(tree); 13 | t.pass(); 14 | }); 15 | 16 | test('Should validate an invalid tree 1', async t => { 17 | const err = t.throws(() => { 18 | validateTree(123); 19 | }); 20 | t.regex(err.message, /.*an object./); 21 | }); 22 | 23 | test('Should validate an invalid tree 5', async t => { 24 | const tree = await getTestTree(); 25 | tree.nodes[1].id = '1'; 26 | const err = t.throws(() => { 27 | validateTree(tree); 28 | }); 29 | t.regex(err.message, /.*missing node \(2\)\.*/); 30 | }); 31 | 32 | test('Should validate an invalid tree 6', async t => { 33 | const tree = await getTestTree(); 34 | delete tree.nodes[1].title; 35 | const err = t.throws(() => { 36 | validateTree(tree); 37 | }); 38 | t.regex(err.message, /.*should have required property.*/); 39 | }); 40 | 41 | test('Should validate an invalid tree 7', async t => { 42 | const tree = await getTestTree(); 43 | delete tree.nodes[1].options; 44 | const err = t.throws(() => { 45 | validateTree(tree); 46 | }); 47 | t.regex(err.message, /.*for the prompt.*/); 48 | }); 49 | 50 | test('Should validate an invalid tree 8', async t => { 51 | const tree = await getTestTree(); 52 | delete tree.nodes[1].prompt; 53 | const err = t.throws(() => { 54 | validateTree(tree); 55 | }); 56 | t.regex(err.message, /.*for the option.*/); 57 | }); 58 | 59 | test('Should validate an invalid tree 12', async t => { 60 | const tree = await getTestTree(); 61 | tree.nodes[1].options[0].onSelect = '1000'; 62 | const err = t.throws(() => { 63 | validateTree(tree); 64 | }); 65 | t.regex(err.message, /.*missing node \(1000\).*/); 66 | }); 67 | 68 | async function getTestTree() { 69 | const trees = await findTrees(path.join(__dirname, '../trees')); 70 | return normalizeTree(_cloneDeep(trees[0])); 71 | } 72 | -------------------------------------------------------------------------------- /tests/trees/test-tree/tree.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | name: A test tree 3 | title: A test tree 4 | description: Some description for the test tree 5 | accessCode: secret! 6 | startNodeId: start 7 | nodes: 8 | - id: start 9 | title: You Ready? 10 | body: Let's go! 11 | prompt: Are you ready for this test? 12 | options: 13 | - text: Yes! 14 | nextNodeId: start-deprecated 15 | onSelect: start 16 | value: 0 17 | - text: 'No' 18 | nextNodeId: '2' 19 | value: 0 20 | - id: '2' 21 | title: You Goofy? 22 | body: Let's go! 23 | prompt: Are you goofy for this test? 24 | options: 25 | - text: Yes! 26 | nextNodeId: start 27 | value: 0 28 | - text: 'No' 29 | nextNodeId: '2' 30 | value: 0 31 | -------------------------------------------------------------------------------- /tree-schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-07/schema#", 3 | "description": "This is the schema for the Tree structure definition", 4 | "type": "object", 5 | "definitions": { 6 | "nodes": { 7 | "type": "array", 8 | "items": { 9 | "properties": { 10 | "id": { 11 | "type": "string" 12 | }, 13 | "title": { 14 | "type": "string" 15 | }, 16 | "body": { 17 | "type": "string" 18 | }, 19 | "prompt": { 20 | "type": "string" 21 | }, 22 | "options": { 23 | "$ref": "#/definitions/options" 24 | } 25 | }, 26 | "additionalProperties": false, 27 | "required": ["id", "title"] 28 | } 29 | }, 30 | "options": { 31 | "type": "array", 32 | "items": { 33 | "properties": { 34 | "_id": { 35 | "type": "string" 36 | }, 37 | "text": { 38 | "type": "string" 39 | }, 40 | "nextNodeId": { 41 | "description": "This is deprecated in favour of onSelect (Aug 2020)", 42 | "type": "string" 43 | }, 44 | "onSelect": { 45 | "description": "The id of another node or just a URL", 46 | "type": "string" 47 | }, 48 | "value": { 49 | "type": "number" 50 | } 51 | }, 52 | "additionalProperties": false, 53 | "required": ["text", "onSelect"] 54 | } 55 | } 56 | }, 57 | "properties": { 58 | "_meta": { 59 | "type": "object" 60 | }, 61 | "name": { 62 | "type": "string" 63 | }, 64 | "title": { 65 | "type": "string" 66 | }, 67 | "description": { 68 | "type": "string" 69 | }, 70 | "accessCode": { 71 | "type": "string" 72 | }, 73 | "startNodeId": { 74 | "description": "Identifier of the first node to show. Defaults to the first in the node list", 75 | "type": "string" 76 | }, 77 | "trackVisits": { 78 | "description": "Whether or not to detect if a node has already been visited. Using 'value' in this case would be pointless. Default true", 79 | "type": "boolean" 80 | }, 81 | "bodyFormat": { 82 | "description": "How to interpret the body: html (default), markdown or text", 83 | "type": "string", 84 | "enum": ["html", "markdown", "text"] 85 | }, 86 | "showIntro": { 87 | "description": "Whether or not to show the node description in a separate page before the very first node. Default true", 88 | "type": "boolean" 89 | }, 90 | "nodes": { 91 | "$ref": "#/definitions/nodes" 92 | }, 93 | "additionalProperties": false 94 | }, 95 | "additionalProperties": false, 96 | "required": ["name", "title", "nodes"] 97 | } 98 | -------------------------------------------------------------------------------- /trees/example/tree.yaml: -------------------------------------------------------------------------------- 1 | # This is the definition of the example tree (and this is 2 | # how you write comments) 3 | --- 4 | name: "An example decision tree" 5 | title: "How to select the best programming language" 6 | description: "Let me help you selecting the best programming language" 7 | accessCode: "" 8 | startNodeId: start 9 | trackVisits: true 10 | bodyFormat: html 11 | showIntro: true 12 | nodes: 13 | - id: "start" 14 | title: "Understanding your needs" 15 | # As you can see HTML is fully supported. 16 | # The symbol "|" next to "body" is very important and must be kept as it is 17 | body: | 18 | First of all it's important to specify the scope of your application. 19 |

20 | For example: 21 | prompt: "Is your application mission critical?" 22 | options: 23 | - text: "Yes it is!" 24 | onSelect: "mission-critical" 25 | - text: "No, it's just a demo" 26 | onSelect: "demo-application" 27 | - text: "Get me out of here!" 28 | onSelect: "https://reddit.com" 29 | 30 | - id: "mission-critical" 31 | title: "A mission critical application" 32 | body: "Since your application is mission critical, your need a powerful language!" 33 | prompt: "How much power do you need?" 34 | options: 35 | - text: "From 0 to 10" 36 | onSelect: "javascript" 37 | value: 0 38 | - text: "From 11 to 100" 39 | onSelect: "javascript" 40 | value: 0 41 | - text: "More than 100!" 42 | onSelect: "javascript" 43 | value: 0 44 | 45 | - id: "demo-application" 46 | title: "Just a demo application" 47 | body: "While building a demo application, we can consider experimenting some new skills." 48 | prompt: "Which skills do you want to learn better?" 49 | options: 50 | - text: "Functional programming" 51 | onSelect: "python" 52 | - text: "Object oriented programming" 53 | onSelect: "python" 54 | - text: "Magic with cards" 55 | onSelect: "python" 56 | 57 | - id: "javascript" 58 | title: "The perfect language is…" 59 | body: "The perfect language to use in your case is definitely JavaScript." 60 | 61 | - id: "python" 62 | title: "The perfect language is…" 63 | body: "The perfect language to use in your case is definitely Python." 64 | --------------------------------------------------------------------------------