├── .nvmrc ├── .npmignore ├── logo ├── logo.png └── logo.svg ├── .travis.yml ├── .gitignore ├── example01 ├── index.js ├── index.html ├── README.md ├── ArchivedTask.js ├── package.json ├── store.js ├── App.js ├── TaskInput.js ├── ActiveTask.js ├── ArchivedList.js ├── ActiveList.js └── reducer.js ├── example02 ├── index.js ├── README.md ├── package.json ├── components │ ├── App.js │ ├── BoxContainer.js │ └── Box.js ├── reducers │ ├── store.js │ └── reducer.js └── index.html ├── example03 ├── package.json ├── CharCounter.js ├── store.js ├── App.js ├── TextInput.js ├── index.html ├── README.md ├── reducer.js └── index.js ├── logger.js ├── sanitize.js ├── package.json ├── LICENSE ├── test ├── __setup.js ├── sanitize_test.js ├── connect_test.js ├── render_test.js └── html_test.js ├── CHANGELOG.md ├── index.js └── README.md /.nvmrc: -------------------------------------------------------------------------------- 1 | v8 2 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | example* 2 | test/ 3 | -------------------------------------------------------------------------------- /logo/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stasm/innerself/HEAD/logo/logo.png -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | dist: trusty 2 | language: node_js 3 | node_js: 8.5.0 4 | notifications: 5 | email: false 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | 3 | # esm needs this 4 | .esm-cache 5 | 6 | # Examples builds 7 | .cache 8 | dist 9 | -------------------------------------------------------------------------------- /example01/index.js: -------------------------------------------------------------------------------- 1 | import { attach } from "./store"; 2 | import App from "./App"; 3 | 4 | attach(App, document.querySelector("#root")); 5 | -------------------------------------------------------------------------------- /example02/index.js: -------------------------------------------------------------------------------- 1 | import { attach } from './reducers/store'; 2 | import App from './components/App'; 3 | 4 | attach(App, document.getElementById('app')); 5 | -------------------------------------------------------------------------------- /example02/README.md: -------------------------------------------------------------------------------- 1 | # innerself example 02 2 | 3 | A simple random square demo using `innerself`. 4 | 5 | To install the dependencies and serve the example locally run: 6 | 7 | $ npm install 8 | $ npm start 9 | -------------------------------------------------------------------------------- /example01/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | innerself example 01 4 | 5 | 6 |

innerself example 01

7 |
8 | 9 | 10 | -------------------------------------------------------------------------------- /example01/README.md: -------------------------------------------------------------------------------- 1 | # innerself example 01 2 | 3 | A simple (and ulgy) Task Manager written using `innerself`. 4 | 5 | To install the dependencies and serve the example locally run: 6 | 7 | $ npm install 8 | $ npm start 9 | -------------------------------------------------------------------------------- /logo/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /example01/ArchivedTask.js: -------------------------------------------------------------------------------- 1 | import html from "../index"; 2 | 3 | export default function ArchivedTask(text) { 4 | return html` 5 |
  • 6 | ${text} 7 |
  • 8 | `; 9 | } 10 | -------------------------------------------------------------------------------- /example01/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "example01", 3 | "private": true, 4 | "scripts": { 5 | "start": "parcel serve index.html", 6 | "build": "parcel build index.html" 7 | }, 8 | "devDependencies": { 9 | "parcel-bundler": "^1.9.7" 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /example02/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "example02", 3 | "private": true, 4 | "scripts": { 5 | "start": "parcel serve index.html", 6 | "build": "parcel build index.html" 7 | }, 8 | "devDependencies": { 9 | "parcel-bundler": "^1.9.7" 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /example03/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "example03", 3 | "private": true, 4 | "scripts": { 5 | "start": "parcel serve index.html", 6 | "build": "parcel build index.html" 7 | }, 8 | "devDependencies": { 9 | "parcel-bundler": "^1.9.7" 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /example03/CharCounter.js: -------------------------------------------------------------------------------- 1 | import html from "../index"; 2 | import { connect } from "./store"; 3 | 4 | function CharCounter({value}) { 5 | return html` 6 |
    Characters typed: ${value.length}
    7 | `; 8 | } 9 | 10 | export default connect(CharCounter); 11 | -------------------------------------------------------------------------------- /example01/store.js: -------------------------------------------------------------------------------- 1 | import {createStore} from "../index"; 2 | import withLogger from "../logger"; 3 | import reducer from "./reducer" 4 | 5 | 6 | const { attach, connect, dispatch } = 7 | createStore(withLogger(reducer)); 8 | 9 | window.dispatch = dispatch; 10 | 11 | export { attach, connect }; 12 | -------------------------------------------------------------------------------- /example03/store.js: -------------------------------------------------------------------------------- 1 | import {createStore} from "../index"; 2 | import withLogger from "../logger"; 3 | import reducer from "./reducer" 4 | 5 | 6 | const { attach, connect, dispatch } = 7 | createStore(withLogger(reducer)); 8 | 9 | window.dispatch = dispatch; 10 | 11 | export { attach, connect }; 12 | -------------------------------------------------------------------------------- /example02/components/App.js: -------------------------------------------------------------------------------- 1 | import html from '../../index'; // import innerself 2 | 3 | import BoxContainer from './BoxContainer'; 4 | 5 | export default function App() { 6 | return html` 7 | ${ BoxContainer() } 8 | 9 | `; 10 | } 11 | -------------------------------------------------------------------------------- /example03/App.js: -------------------------------------------------------------------------------- 1 | import html from "../index"; 2 | import { connect } from "./store"; 3 | 4 | import TextInput from "./TextInput"; 5 | import CharCounter from "./CharCounter"; 6 | 7 | export default function App(idx) { 8 | return html` 9 | ${TextInput(idx)} 10 | ${CharCounter()} 11 | `; 12 | } 13 | -------------------------------------------------------------------------------- /example01/App.js: -------------------------------------------------------------------------------- 1 | import html from "../index"; 2 | import { connect } from "./store"; 3 | 4 | import ActiveList from "./ActiveList"; 5 | import ArchivedList from "./ArchivedList"; 6 | 7 | export default function App(tasks) { 8 | return html` 9 | ${ActiveList()} 10 | ${ArchivedList()} 11 | `; 12 | } 13 | -------------------------------------------------------------------------------- /example01/TaskInput.js: -------------------------------------------------------------------------------- 1 | import html from "../index"; 2 | 3 | export default function TaskInput() { 4 | return html` 5 | 7 | 8 | `; 9 | } 10 | -------------------------------------------------------------------------------- /example01/ActiveTask.js: -------------------------------------------------------------------------------- 1 | import html from "../index"; 2 | 3 | export default function ActiveTask(text, index) { 4 | return html` 5 |
  • 6 | ${text} 7 | 10 |
  • 11 | `; 12 | } 13 | -------------------------------------------------------------------------------- /example02/reducers/store.js: -------------------------------------------------------------------------------- 1 | import {createStore} from '../../index'; // import innerself 2 | import withLogger from '../../logger'; // import innerself/logger 3 | import reducer from './reducer'; 4 | 5 | const { attach, connect, dispatch } = 6 | createStore(withLogger(reducer)); 7 | 8 | window.dispatch = dispatch; 9 | 10 | export { attach, connect }; 11 | -------------------------------------------------------------------------------- /example02/components/BoxContainer.js: -------------------------------------------------------------------------------- 1 | import html from "../../index"; // import innerself 2 | import { connect } from '../reducers/store'; 3 | 4 | import Box from './Box'; 5 | 6 | function BoxContainer(state) { 7 | const { boxes } = state; 8 | 9 | return html`
    10 | ${boxes.map(Box)} 11 |
    `; 12 | } 13 | 14 | export default connect(BoxContainer); 15 | -------------------------------------------------------------------------------- /example03/TextInput.js: -------------------------------------------------------------------------------- 1 | import html from "../index"; 2 | import { connect } from "./store"; 3 | 4 | function TextInput({value}, idx) { 5 | return html` 6 | 9 | `; 10 | } 11 | 12 | export default connect(TextInput); 13 | -------------------------------------------------------------------------------- /example01/ArchivedList.js: -------------------------------------------------------------------------------- 1 | import html from "../index"; 2 | import { connect } from "./store"; 3 | import ArchivedTask from "./ArchivedTask"; 4 | 5 | function ArchiveList(state) { 6 | const { archive } = state; 7 | return html` 8 |

    Completed Tasks

    9 | 12 | `; 13 | } 14 | 15 | export default connect(ArchiveList); 16 | -------------------------------------------------------------------------------- /logger.js: -------------------------------------------------------------------------------- 1 | export default function logger(reducer) { 2 | return function(prev_state, action, args) { 3 | console.group(action); 4 | console.log("Previous State", prev_state); 5 | console.log("Action Arguments", args); 6 | const next_state = reducer(prev_state, action, args); 7 | console.log("Next State", next_state); 8 | console.groupEnd(); 9 | return next_state; 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /example03/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | innerself example 03 4 | 5 | 6 |

    innerself example 03

    7 |

    Focus is lost after re-render by default

    8 |
    9 |

    Use the render event to restore focus

    10 |
    11 |

    Use the render event to restore focus and caret position

    12 |
    13 | 14 | 15 | -------------------------------------------------------------------------------- /example03/README.md: -------------------------------------------------------------------------------- 1 | # innerself example 03 2 | 3 | An example illustrating how much hand-holding innerself needs to handle 4 | form-based elements. Because the assignment to innerHTML completely re-creates 5 | the entire DOM tree under the root, all local state of the descendant elements 6 | is lost upop re-render. Focus and selection need to be restored manually. 7 | 8 | To install the dependencies and serve the example locally run: 9 | 10 | $ npm install 11 | $ npm start 12 | -------------------------------------------------------------------------------- /example01/ActiveList.js: -------------------------------------------------------------------------------- 1 | import html from "../index"; 2 | import { connect } from "./store"; 3 | import ActiveTask from "./ActiveTask"; 4 | import TaskInput from "./TaskInput"; 5 | 6 | function ActiveList(state) { 7 | const { tasks } = state; 8 | return html` 9 |

    My Active Tasks

    10 | 16 | `; 17 | } 18 | 19 | export default connect(ActiveList); 20 | -------------------------------------------------------------------------------- /example02/components/Box.js: -------------------------------------------------------------------------------- 1 | import html from '../../index'; // import innerself 2 | 3 | export default function Box(state, index) { 4 | const { bg, top, left, scale } = state; 5 | 6 | return html` 7 |
    Box #${ index }
    15 | `; 16 | } 17 | -------------------------------------------------------------------------------- /sanitize.js: -------------------------------------------------------------------------------- 1 | const TEMPLATE = document.createElement("template"); 2 | const ENTITIES = { 3 | "&": "&", 4 | "<": "<", 5 | ">": ">", 6 | '"': """, 7 | "'": "'", 8 | }; 9 | 10 | export default function sanitize(value) { 11 | // Parse the HTML to inert DOM. 12 | TEMPLATE.innerHTML = value; 13 | // Strip all markup. 14 | const text = TEMPLATE.content.textContent; 15 | // Any HTML entities present in the original value have been unescaped by 16 | // textContent. Sanitize the syntax-sensitive characters back to entities. 17 | return text.replace(/[&<>"']/g, ch => ENTITIES[ch]); 18 | } 19 | -------------------------------------------------------------------------------- /example03/reducer.js: -------------------------------------------------------------------------------- 1 | import sanitize from "../sanitize"; 2 | 3 | const init = { 4 | id: "", 5 | value: "", 6 | }; 7 | 8 | export default function reducer(state = init, action, args) { 9 | switch (action) { 10 | case "CHANGE_VALUE": { 11 | const [textarea] = args; 12 | return Object.assign({}, state, { 13 | id: textarea.id, 14 | value: textarea.value, 15 | selectionStart: textarea.selectionStart, 16 | selectionEnd: textarea.selectionEnd, 17 | }); 18 | } 19 | default: 20 | return state; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "innerself", 3 | "version": "0.1.1", 4 | "description": "A tiny view + state management solution using innerHTML", 5 | "main": "index.js", 6 | "repository": { 7 | "type": "git", 8 | "url": "git+https://github.com/stasm/innerself.git" 9 | }, 10 | "author": "Staś Małolepszy ", 11 | "license": "ISC", 12 | "bugs": { 13 | "url": "https://github.com/stasm/innerself/issues" 14 | }, 15 | "scripts": { 16 | "test": "mocha --ui tdd --require ./test/__setup", 17 | "deploy": "gh-pages -d ." 18 | }, 19 | "homepage": "https://github.com/stasm/innerself#readme", 20 | "devDependencies": { 21 | "esm": "^3.0.80", 22 | "gh-pages": "^1.2.0", 23 | "jsdom": "^12.0.0", 24 | "mocha": "^5.2.0" 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | ISC License 2 | 3 | Copyright 2017, Staś Małolepszy 4 | 5 | Permission to use, copy, modify, and/or distribute this software for any 6 | purpose with or without fee is hereby granted, provided that the above 7 | copyright notice and this permission notice appear in all copies. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 10 | WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 11 | MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 12 | ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 13 | WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 14 | ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 15 | OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 16 | 17 | -------------------------------------------------------------------------------- /example03/index.js: -------------------------------------------------------------------------------- 1 | import { attach } from "./store"; 2 | import App from "./App"; 3 | 4 | const root1 = document.querySelector("#root1"); 5 | const root2 = document.querySelector("#root2"); 6 | const root3 = document.querySelector("#root3"); 7 | 8 | attach(() => App(1), root1); 9 | attach(() => App(2), root2); 10 | attach(() => App(3), root3); 11 | 12 | root2.addEventListener("render", function(event) { 13 | // event.detail is the state that was rendered. 14 | const { id, selectionStart, selectionEnd } = event.detail; 15 | if (id) { 16 | const textarea = root2.querySelector("#" + id); 17 | textarea.focus(); 18 | } 19 | }); 20 | 21 | root3.addEventListener("render", function(event) { 22 | // event.detail is the state that was rendered. 23 | const { id, selectionStart, selectionEnd } = event.detail; 24 | if (id) { 25 | const textarea = root3.querySelector("#" + id); 26 | textarea.focus(); 27 | textarea.setSelectionRange(selectionStart, selectionEnd); 28 | } 29 | }); 30 | -------------------------------------------------------------------------------- /test/__setup.js: -------------------------------------------------------------------------------- 1 | const { JSDOM } = require("jsdom"); 2 | 3 | const { window } = new JSDOM("", { 4 | url: "http://localhost", 5 | }); 6 | Object.keys(window).forEach(property => { 7 | if (typeof global[property] === "undefined") { 8 | global[property] = window[property]; 9 | } 10 | }); 11 | global.window = window; 12 | global.CustomEvent = function CustomEvent(name, {detail}) { 13 | this.name = name; 14 | this.detail = detail; 15 | } 16 | 17 | global.spyFunction = function(orig = x => void x) { 18 | let _args = []; 19 | 20 | function fake(...args) { 21 | _args = args; 22 | return orig(...args); 23 | } 24 | 25 | fake.args = () => _args; 26 | return fake; 27 | } 28 | 29 | global.mockElement = function() { 30 | let _innerHTML = ""; 31 | 32 | return { 33 | _dirty: new Map(), 34 | set innerHTML(value) { 35 | this._dirty.set("innerHTML", true); 36 | _innerHTML = value; 37 | }, 38 | get innerHTML() { 39 | return _innerHTML; 40 | }, 41 | dispatchEvent: spyFunction() 42 | }; 43 | } 44 | -------------------------------------------------------------------------------- /example01/reducer.js: -------------------------------------------------------------------------------- 1 | import sanitize from "../sanitize"; 2 | 3 | const init = { 4 | input_value: "", 5 | tasks: [], 6 | archive: [] 7 | }; 8 | 9 | export default function reducer(state = init, action, args) { 10 | switch (action) { 11 | case "CHANGE_INPUT": { 12 | const [input_value] = args; 13 | return Object.assign({}, state, { 14 | input_value: sanitize(input_value) 15 | }); 16 | } 17 | case "ADD_TASK": { 18 | const {tasks, input_value} = state; 19 | return Object.assign({}, state, { 20 | tasks: [...tasks, input_value], 21 | input_value: "" 22 | }); 23 | } 24 | case "COMPLETE_TASK": { 25 | const {tasks, archive} = state; 26 | const [index] = args; 27 | const task = tasks[index]; 28 | return Object.assign({}, state, { 29 | tasks: [ 30 | ...tasks.slice(0, index), 31 | ...tasks.slice(index + 1) 32 | ], 33 | archive: [...archive, task] 34 | }); 35 | } 36 | default: 37 | return state; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /example02/reducers/reducer.js: -------------------------------------------------------------------------------- 1 | const init = { 2 | boxes: [] 3 | }; 4 | 5 | export default function reducer(state = init, action, args) { 6 | const random = Math.random; 7 | const floor = Math.floor; 8 | 9 | switch (action) { 10 | case "ADD_BOX": { 11 | const { boxes } = state; 12 | const BoxContainer = document.querySelector('#app > div'); 13 | return Object.assign({}, state, { 14 | boxes: [...boxes, { 15 | top: floor(random() * BoxContainer.clientHeight), 16 | left: floor(random() * BoxContainer.clientWidth), 17 | bg: `hsl(${ floor(random() * 360 )}, 70%, 70%)`, 18 | scale: .25 + random() * .75 // should add up to 1 at most (1/4 scale at least) 19 | }] 20 | }); 21 | } 22 | case "DESTROY_BOX": { 23 | const { boxes } = state; 24 | const [index] = args; 25 | return Object.assign({}, state, { 26 | boxes: [ 27 | ...boxes.slice(0, index), 28 | ...boxes.slice(index + 1) 29 | ] 30 | }); 31 | } 32 | default: 33 | return state; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /test/sanitize_test.js: -------------------------------------------------------------------------------- 1 | require = require("esm")(module); 2 | const assert = require("assert"); 3 | const sanitize = require("../sanitize").default; 4 | 5 | suite("innerself/sanitize", function() { 6 | test("default export", function() { 7 | assert.equal(typeof sanitize, "function"); 8 | }); 9 | 10 | test("safe string", function() { 11 | const output = sanitize("Foo"); 12 | assert.equal(output, "Foo"); 13 | }); 14 | 15 | test("markup", function() { 16 | const output = sanitize("Foo"); 17 | assert.equal(output, "Foo"); 18 | }); 19 | 20 | test("markup via HTML entities", function() { 21 | const output = sanitize("<em>Foo</em>"); 22 | assert.equal(output, "<em>Foo</em>"); 23 | }); 24 | 25 | test("HTML entities escaped with &", function() { 26 | const output = sanitize("&lt;em&gt;Foo&lt;/em&gt;"); 27 | assert.equal(output, "&lt;em&gt;Foo&lt;/em&gt;"); 28 | }); 29 | 30 | test("quotes", function() { 31 | const output = sanitize("\"Foo\""); 32 | assert.equal(output, ""Foo""); 33 | }); 34 | 35 | test("quotes via HTML entities", function() { 36 | const output = sanitize(""Foo""); 37 | assert.equal(output, ""Foo""); 38 | }); 39 | }); 40 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # CHANGELOG 2 | 3 | ## Unreleased 4 | 5 | - Filter out booleans in the html helper. 6 | 7 | Previously only `null` and `undefined` were filtered out. Now both `true` 8 | and `false` are never rendered. With this change it's now possible to use 9 | the `&&` operator for conditionals: 10 | 11 | html`${is_fetching && PleaseWait()}` 12 | 13 | Keep in mind that not all falsy values are filtered out. Most notably, `0` 14 | is still a valid interpolation value. As a consequence please be mindful 15 | when using numbers for the predicate. The following example will actually 16 | render `0`. 17 | 18 | html`${items.length && ItemList()}` 19 | 20 | You can fix this by explicitly using a comparison which returns a boolean 21 | which arguably also reads better: 22 | 23 | html`${items.length > 0 && ItemList()}` 24 | 25 | 26 | ## 0.1.1 (September 12, 2017) 27 | 28 | - Dispatch the render event on roots. (#15) 29 | 30 | The render event provides a hook to add custom logic after the render is 31 | complete. It allows to restore focus, selection and caret positions after 32 | render. 33 | 34 | - Filter out null and undefined interpolations in the html helper. (#8) 35 | 36 | - Ignore tests and examples in npm. (#11) 37 | 38 | 39 | ## 0.1.0 (September 8, 2017) 40 | 41 | This is the first release to be tracked in the changelog. 42 | -------------------------------------------------------------------------------- /test/connect_test.js: -------------------------------------------------------------------------------- 1 | require = require("esm")(module); 2 | const assert = require("assert"); 3 | const { createStore } = require("../index") 4 | 5 | function counter(state = 0, action) { 6 | switch (action) { 7 | case "INCREMENT": 8 | return state + 1; 9 | default: 10 | return state; 11 | } 12 | } 13 | 14 | suite("connect", function() { 15 | let store; 16 | let root; 17 | 18 | setup(function() { 19 | store = createStore(counter); 20 | root = document.createElement("div"); 21 | }); 22 | 23 | test("passes the current state as the first argument", function() { 24 | const { attach, connect, dispatch } = store; 25 | 26 | const TestApp = spyFunction(); 27 | const ConnectedTestApp = connect(TestApp); 28 | attach(ConnectedTestApp, root); 29 | dispatch("INCREMENT"); 30 | 31 | assert.deepEqual(TestApp.args(), [1]); 32 | }); 33 | 34 | test("passes other args after the state", function() { 35 | const { attach, connect, dispatch } = store; 36 | 37 | const TestComponent = spyFunction(); 38 | const ConnectedTestComponent = connect(TestComponent); 39 | 40 | function TestApp() { 41 | return ConnectedTestComponent("Foo"); 42 | } 43 | 44 | attach(TestApp, root); 45 | dispatch("INCREMENT"); 46 | 47 | assert.deepEqual(TestComponent.args(), [1, "Foo"]); 48 | }); 49 | }); 50 | -------------------------------------------------------------------------------- /example02/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | innerself example 02 5 | 44 | 45 | 46 | 47 |
    48 |

    innerself example 02

    49 | Click on a box to destroy it 50 |
    51 |
    52 | 53 | 54 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | export default function html([first, ...strings], ...values) { 2 | // Weave the literal strings and the interpolations. 3 | // We don't have to explicitly handle array-typed values 4 | // because concat will spread them flat for us. 5 | return values.reduce( 6 | (acc, cur) => acc.concat(cur, strings.shift()), 7 | [first]) 8 | 9 | // Filter out interpolations which are bools, null or undefined. 10 | .filter(x => x && x !== true || x === 0) 11 | .join(""); 12 | } 13 | 14 | export function createStore(reducer) { 15 | let state = reducer(); 16 | const roots = new Map(); 17 | const prevs = new Map(); 18 | 19 | function render() { 20 | for (const [root, component] of roots) { 21 | const output = component(); 22 | 23 | // Poor man's Virtual DOM implementation :) Compare the new output 24 | // with the last output for this root. Don't trust the current 25 | // value of root.innerHTML as it may have been changed by other 26 | // scripts or extensions. 27 | if (output !== prevs.get(root)) { 28 | prevs.set(root, root.innerHTML = output); 29 | 30 | // Dispatch an event on the root to give developers a chance to 31 | // do some housekeeping after the whole DOM is replaced under 32 | // the root. You can re-focus elements in the listener to this 33 | // event. See example03. 34 | root.dispatchEvent( 35 | new CustomEvent("render", {detail: state})); 36 | } 37 | } 38 | }; 39 | 40 | return { 41 | attach(component, root) { 42 | roots.set(root, component); 43 | render(); 44 | }, 45 | connect(component) { 46 | // Return a decorated component function. 47 | return (...args) => component(state, ...args); 48 | }, 49 | dispatch(action, ...args) { 50 | state = reducer(state, action, args); 51 | render(); 52 | }, 53 | }; 54 | } 55 | -------------------------------------------------------------------------------- /test/render_test.js: -------------------------------------------------------------------------------- 1 | require = require("esm")(module); 2 | const assert = require("assert"); 3 | const { createStore } = require("../index") 4 | 5 | function counter(state = 0, action) { 6 | switch (action) { 7 | case "INCREMENT": 8 | return state + 1; 9 | default: 10 | return state; 11 | } 12 | } 13 | 14 | suite("render", function() { 15 | let store; 16 | let root; 17 | 18 | setup(function() { 19 | store = createStore(counter); 20 | root = mockElement(); 21 | }); 22 | 23 | test("assigns innerHTML of the root", function() { 24 | const { attach, dispatch } = store; 25 | 26 | // Always returns the same output. 27 | const TestApp = () => "Foo"; 28 | attach(TestApp, root); 29 | 30 | dispatch("INCREMENT"); 31 | assert.equal(root.innerHTML, "Foo"); 32 | }); 33 | 34 | test("re-assigns when the output changes", function() { 35 | const { attach, connect, dispatch } = store; 36 | 37 | const TestApp = state => `Foo ${state}`; 38 | const ConnectedTestApp = connect(TestApp); 39 | 40 | attach(ConnectedTestApp, root); 41 | assert.equal(root.innerHTML, "Foo 0"); 42 | 43 | dispatch("INCREMENT"); 44 | assert.equal(root.innerHTML, "Foo 1"); 45 | 46 | dispatch("INCREMENT"); 47 | assert.equal(root.innerHTML, "Foo 2"); 48 | }); 49 | 50 | test("dispatches an event when the output changes", function() { 51 | const { attach, connect, dispatch } = store; 52 | 53 | const TestApp = state => `Foo ${state}`; 54 | const ConnectedTestApp = connect(TestApp); 55 | attach(ConnectedTestApp, root); 56 | assert.deepEqual(root.dispatchEvent.args(), [{ 57 | name: "render", 58 | detail: 0 59 | }]); 60 | 61 | dispatch("INCREMENT"); 62 | assert.deepEqual(root.dispatchEvent.args(), [{ 63 | name: "render", 64 | detail: 1 65 | }]); 66 | 67 | dispatch("INCREMENT"); 68 | assert.deepEqual(root.dispatchEvent.args(), [{ 69 | name: "render", 70 | detail: 2 71 | }]); 72 | }); 73 | 74 | test("avoids re-assignment if the output doesn't change", function() { 75 | const { attach, connect, dispatch } = store; 76 | 77 | // Always returns the same output. 78 | const TestApp = () => "Foo"; 79 | root._dirty.set("innerHTML", false); 80 | 81 | attach(TestApp, root); 82 | assert.equal(root._dirty.get("innerHTML"), true); 83 | 84 | root._dirty.set("innerHTML", false); 85 | dispatch("INCREMENT"); 86 | assert.equal(root._dirty.get("innerHTML"), false); 87 | 88 | root._dirty.set("innerHTML", false); 89 | dispatch("INCREMENT"); 90 | assert.equal(root._dirty.get("innerHTML"), false); 91 | }); 92 | }); 93 | -------------------------------------------------------------------------------- /test/html_test.js: -------------------------------------------------------------------------------- 1 | require = require("esm")(module); 2 | const assert = require("assert"); 3 | const { default: html } = require("../index") 4 | 5 | suite("html", function() { 6 | test("default export", function() { 7 | assert.equal(typeof html, "function"); 8 | }); 9 | 10 | test("no interpolations", function() { 11 | const output = html`Foo Bar`; 12 | assert.equal(output, "Foo Bar"); 13 | }); 14 | 15 | test("string interpolation", function() { 16 | const str = "Bar"; 17 | const output = html`Foo ${str} Baz`; 18 | assert.equal(output, "Foo Bar Baz"); 19 | }); 20 | 21 | test("number interpolation", function() { 22 | const num = 4; 23 | const output = html`Foo ${num}`; 24 | assert.equal(output, "Foo 4"); 25 | }); 26 | 27 | test("array interpolation", function() { 28 | const arr = ["Bar", "Baz"]; 29 | const output = html`Foo ${arr}`; 30 | assert.equal(output, "Foo BarBaz"); 31 | }); 32 | 33 | test("true interpolation", function() { 34 | const output = html`Foo ${true}`; 35 | assert.equal(output, "Foo "); 36 | }); 37 | 38 | test("false interpolation", function() { 39 | const output = html`Foo ${false}`; 40 | assert.equal(output, "Foo "); 41 | }); 42 | 43 | test("null interpolation", function() { 44 | const output = html`Foo ${null}`; 45 | assert.equal(output, "Foo "); 46 | }); 47 | 48 | test("undefined interpolation", function() { 49 | const output = html`Foo ${undefined}`; 50 | assert.equal(output, "Foo "); 51 | }); 52 | 53 | test("falsy string interpolation", function() { 54 | const str = ""; 55 | const output = html`Foo ${str} Baz`; 56 | assert.equal(output, "Foo Baz"); 57 | }); 58 | 59 | test("falsy number interpolation", function() { 60 | const num = 0; 61 | const output = html`Foo ${num}`; 62 | assert.equal(output, "Foo 0"); 63 | }); 64 | 65 | test("a true predicate", function() { 66 | const output = html`Foo ${true && "Bar"}`; 67 | assert.equal(output, "Foo Bar"); 68 | }); 69 | 70 | test("a false predicate", function() { 71 | const output = html`Foo ${false && "Bar"}`; 72 | assert.equal(output, "Foo "); 73 | }); 74 | 75 | test("a null predicate", function() { 76 | const output = html`Foo ${null && "Bar"}`; 77 | assert.equal(output, "Foo "); 78 | }); 79 | 80 | test("an undefined predicate", function() { 81 | const output = html`Foo ${undefined && "Bar"}`; 82 | assert.equal(output, "Foo "); 83 | }); 84 | 85 | test("a truthy number predicate", function() { 86 | const output = html`Foo ${1 && "Bar"}`; 87 | assert.equal(output, "Foo Bar"); 88 | }); 89 | 90 | test("a falsy number predicate", function() { 91 | const output = html`Foo ${0 && "Bar"}`; 92 | assert.equal(output, "Foo 0"); 93 | 94 | // A real-life example. 95 | const items = []; 96 | assert.equal(html`${items.length && "Has items"}`, "0"); 97 | items.push("Foo"); 98 | assert.equal(html`${items.length && "Has items"}`, "Has items"); 99 | }); 100 | 101 | test("work-around for a falsy number predicate", function() { 102 | const output = html`Foo ${0 > 0 && "Bar"}`; 103 | assert.equal(output, "Foo "); 104 | 105 | // A real-life example. 106 | const items = []; 107 | assert.equal(html`${items.length > 0 && "Has items"}`, ""); 108 | items.push("Foo"); 109 | assert.equal(html`${items.length && "Has items"}`, "Has items"); 110 | }); 111 | }); 112 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
    2 | 3 | 4 | 5 |

    innerself

    6 |

    7 | 8 | Build Status 9 | 10 |

    11 |

    A tiny view + state management solution using innerHTML.

    12 |
    13 | 14 | [`innerHTML` is fast][quirksmode]. It's not fast enough if you're a Fortune 500 company 15 | or even if your app has more than just a handful of views. But it might be 16 | just fast enough for you if you care about code size. 17 | 18 | I wrote _innerself_ because I needed to make sense of the UI for a game I wrote 19 | for the [js13kGames][] jam. The whole game had to fit into 13KB. I needed 20 | something extremely small which would not make me lose sanity. _innerself_ 21 | clocks in at under 50 lines of code. That's around 600 bytes minified, ~350 22 | gzipped. 23 | 24 | _innerself_ is inspired by React and Redux. It offers the following familiar 25 | concepts: 26 | 27 | - composable components, 28 | - a single store, 29 | - a `dispatch` function, 30 | - reducers, 31 | - and even an optional logging middleware for debugging! 32 | 33 | It does all of this by serializing your component tree to a string and 34 | assigning it to `innerHTML` of a root element. It even imitates Virtual DOM 35 | diffing by comparing last known output of components with the new one :) 36 | I know this sounds like I'm crazy but it actually works quite nice for small 37 | and simple UIs. 38 | 39 | If you don't care about size constraints, _innerself_ might not be for you. 40 | Real frameworks like React have much more to offer, don’t sacrifice safety, 41 | accessibility, nor performance, and you probably won’t notice their size 42 | footprint. 43 | 44 | _innerself_ was a fun weekend project for me. Let me know what you think! 45 | 46 | [Live demo]: https://stasm.github.io/innerself/example01/ 47 | [quirksmode]: https://www.quirksmode.org/dom/innerhtml.html 48 | [js13kGames]: http://js13kgames.com/ 49 | 50 | 51 | ## Caveats 52 | 53 | You need to know a few things before you jump right in. _innerself_ is 54 | a less-than-serious pet project and I don't recommend using it in production. 55 | 56 | It's a poor choice for form-heavy UIs. It tries to avoid unnecessary 57 | re-renders, but they still happen if the DOM needs even a tiniest update. Your 58 | form elements will keep losing focus because every re-render is essentially 59 | a new assignment to the root element's `innerHTML`. 60 | 61 | When dealing with user input in serious scenarios, any use of `innerHTML` 62 | requires sanitization. _innerself_ doesn't do anything to protect you or your 63 | users from XSS attacks. If you allow keyboard input or display data fetched 64 | from a database, please take special care to secure your app. The 65 | `innerself/sanitize` module provides a rudimentary sanitization function. 66 | 67 | Perhaps the best use-case for _innerself_ are simple mouse-only UIs with no 68 | keyboard input at all :) 69 | 70 | 71 | ## Showcase 72 | 73 | - [A moment lost in time.][moment-lost] - a first-person exploration puzzle 74 | game by [@michalbe][] and myself. I originally wrote _innerself_ for this. 75 | - [Innerself Hacker News Clone][innerself-hn] - a Hacker News single page app by [@bsouthga][] with 76 | _innerself_ as the only dependency. Also serves as an example of a [TypeScript][typescript] _innerself_ app. 77 | - [Reach/Steal Draft Tracker][reach-steal] - a fantasy football draft tracker by [@bcruddy][] that tests the rendering performance with 300+ table rows backed by an [expressjs][] server. 78 | - [TodoMVC][todomvc-innerself] - a [TodoMVC][todomvc] app based on _innerself_ by [@Cweili][@cweili]. 79 | 80 | 81 | [moment-lost]: https://github.com/piesku/moment-lost 82 | [@michalbe]: https://github.com/michalbe 83 | [innerself-hn]: https://github.com/bsouthga/innerself-hn 84 | [@bsouthga]: https://github.com/bsouthga 85 | [typescript]: https://github.com/Microsoft/TypeScript 86 | [reach-steal]: https://github.com/bcruddy/reach-steal 87 | [@bcruddy]: https://github.com/bcruddy 88 | [expressjs]: https://github.com/expressjs/express 89 | [todomvc-innerself]: https://codepen.io/Cweili/pen/ZXOeQa 90 | [todomvc]: http://todomvc.com/ 91 | [@cweili]: https://github.com/Cweili 92 | 93 | 94 | ## Install 95 | 96 | $ npm install innerself 97 | 98 | For a more structured approach [@bsouthga][] created [innerself-app][]. Use it 99 | to bootstrap new _innerself_ apps from a predefined template. 100 | 101 | [innerself-app]: https://github.com/bsouthga/innerself-app 102 | 103 | 104 | ## Usage 105 | 106 | _innerself_ expects you to build a serialized version of your DOM which will 107 | then be assigned to `innerHTML` of a root element. The `html` helper allows 108 | you to easily interpolate Arrays. 109 | 110 | ```javascript 111 | import html from "innerself"; 112 | import ActiveTask from "./ActiveTask"; 113 | 114 | export default function ActiveList(tasks) { 115 | return html` 116 |

    My Active Tasks

    117 | 120 | `; 121 | } 122 | ``` 123 | 124 | The state of your app lives in a store, which you create by passing the reducer 125 | function to `createStore`: 126 | 127 | ```javascript 128 | const { attach, connect, dispatch } = createStore(reducer); 129 | window.dispatch = dispatch; 130 | export { attach, connect }; 131 | ``` 132 | 133 | You need to make `dispatch` available globally in one way or another. You can 134 | rename it, namespace it or put it on a DOM Element. The reason why it needs to 135 | be global is that the entire structure of your app must be serializable to 136 | string at all times. This includes event handlers, too. 137 | 138 | ```javascript 139 | import html from "innerself"; 140 | 141 | export default function ActiveTask(text, index) { 142 | return html` 143 |
  • 144 | ${text} ${index} 145 | 148 |
  • 149 | `; 150 | } 151 | ``` 152 | 153 | You can put any JavaScript into the `on` attributes. [The browser will 154 | wrap it in a function][mdn-event] which takes the `event` as the first argument 155 | (in most cases) and in which `this` refers to the DOM Element on which the 156 | event has been registered. 157 | 158 | [mdn-event]: https://developer.mozilla.org/en-US/docs/Web/Guide/Events/Event_handlers#Event_handler's_parameters_this_binding_and_the_return_value 159 | 160 | The `dispatch` function takes an action name and a variable number of 161 | arguments. They are passed to the reducer which should return a new version of 162 | the state. 163 | 164 | ```javascript 165 | const init = { 166 | tasks: [], 167 | archive: [] 168 | }; 169 | 170 | export default function reducer(state = init, action, args) { 171 | switch (action) { 172 | case "ADD_TASK": { 173 | const {tasks} = state; 174 | const [value] = args; 175 | return Object.assign({}, state, { 176 | tasks: [...tasks, value], 177 | }); 178 | } 179 | case "COMPLETE_TASK": { 180 | const {tasks, archive} = state; 181 | const [index] = args; 182 | const task = tasks[index]; 183 | return Object.assign({}, state, { 184 | tasks: [ 185 | ...tasks.slice(0, index), 186 | ...tasks.slice(index + 1) 187 | ], 188 | archive: [...archive, task] 189 | }); 190 | } 191 | default: 192 | return state; 193 | } 194 | } 195 | ``` 196 | 197 | If you need side-effects, you have three choices: 198 | 199 | - Put them right in the `on` attributes. 200 | - Expose global action creators. 201 | - Put them in the reducer. (This is considered a bad practice in Redux 202 | because it makes the reducer unpredictable and harder to test.) 203 | 204 | The `dispatch` function will also re-render the entire top-level component if 205 | the state changes require it. In order to be able to do so, it needs to know 206 | where in the DOM to put the `innerHTML` the top-level component generated. 207 | This is what `attach` returned by `createStore` is for: 208 | 209 | ```javascript 210 | import { attach } from "./store"; 211 | import App from "./App"; 212 | 213 | attach(App, document.querySelector("#root")); 214 | ``` 215 | 216 | `createStore` also returns a `connect` function. Use it to avoid passing data 217 | from top-level components down to its children where it makes sense. In the 218 | first snippet above, `ActiveList` receives a `tasks` argument which must be 219 | passed by the top-level component. 220 | 221 | Instead you can do this: 222 | 223 | ```javascript 224 | import html from "innerself"; 225 | import { connect } from "./store"; 226 | import ActiveTask from "./ActiveTask"; 227 | import TaskInput from "./TaskInput"; 228 | 229 | function ActiveList(state) { 230 | const { tasks } = state; 231 | return html` 232 |

    My Active Tasks

    233 |
      234 | ${tasks.map(ActiveTask)} 235 |
    • 236 | ${TaskInput()} 237 |
    • 238 |
    239 | `; 240 | } 241 | 242 | export default connect(ActiveList); 243 | ``` 244 | 245 | You can then avoid passing the state explicitly in the top-level component: 246 | 247 | ```javascript 248 | 249 | import html from "innerself"; 250 | import { connect } from "./store"; 251 | 252 | import ActiveList from "./ActiveList"; 253 | import ArchivedList from "./ArchivedList"; 254 | 255 | export default function App(tasks) { 256 | return html` 257 | ${ActiveList()} 258 | ${ArchivedList()} 259 | `; 260 | } 261 | ``` 262 | 263 | Connected components always receive the current state as their first argument, 264 | and then any other arguments passed explicitly by the parent. 265 | 266 | 267 | ## Logging Middleware 268 | 269 | _innerself_ comes with an optional helper middleware which prints state 270 | changes to the console. To use it, simply decorate your reducer with the 271 | default export of the `innerself/logger` module: 272 | 273 | ```javascript 274 | import { createStore } from "innerself"; 275 | import withLogger from "innerself/logger"; 276 | import reducer from "./reducer" 277 | 278 | const { attach, connect, dispatch } = 279 | createStore(withLogger(reducer)); 280 | ``` 281 | 282 | 283 | ## Crazy, huh? 284 | 285 | I know, I know. But it works! Check out the examples: 286 | 287 | - [example01][] - an obligatory Todo App. 288 | - [example02][] by @flynnham. 289 | - [example03][] illustrates limitations of _innerself_ when dealing with text 290 | inputs and how to work around them. 291 | 292 | [example01]: https://stasm.github.io/innerself/example01/ 293 | [example02]: https://stasm.github.io/innerself/example02/ 294 | [example03]: https://stasm.github.io/innerself/example03/ 295 | 296 | 297 | ## How It Works 298 | 299 | The update cycle starts with the `dispatch` function which passes the action to 300 | the reducer and updates the state. 301 | 302 | When the state changes, the store [compares the entire string output][diff] of 303 | top-level components (the ones attached to a root element in the DOM) with the 304 | output they produced last. This means that most of the time, even a slightest 305 | change in output will re-render the entire root. 306 | 307 | It's possible to dispatch actions which change the state and don't trigger 308 | re-renders. For instance in `example01` the text input dispatches 309 | `CHANGE_INPUT` actions on `keyup` events. The current value of the input is 310 | then saved in the store. Crucially, this value is not used by the `TaskInput` 311 | component to populate the input element. The whole thing relies on the fact 312 | that the native HTML input element stores its own state when the user is typing 313 | into it. 314 | 315 | This limitation was fine for my use-case but it's worth pointing out that it 316 | badly hurts accessibility. Any change to the state which causes a re-render 317 | will make the currently focused element lose focus. 318 | 319 | React is of course much smarter: the Virtual DOM is a lightweight 320 | representation of the render tree and updates to components produce an actual 321 | diff. React maps the items in the Virtual DOM to the elements in the real DOM 322 | and is able to only update what has really changed, regardless of its position 323 | in the tree. 324 | 325 | Here's an interesting piece of trivia that I learned about while working on 326 | this project. React only re-renders components when their local state changes, 327 | as signaled by `this.setState()`. The fact that it also looks like components 328 | re-render when their props change derives from that as well. Something needs to 329 | pass those props in, after all, and this something is the parent component 330 | which first needs to decide to re-render itself. 331 | 332 | When you think about how you can `connect` components with _react-redux_ to 333 | avoid passing state to them from parents it becomes clear why behind the scenes 334 | it calls [`this.setState(dummyState)`][dummy] (which is an empty object) to 335 | trigger a re-render of the connected component :) It does this only when the 336 | sub-state as described by the selector (`mapStateToProps`) changes, which is 337 | easy to compute (and fast) if the reducers use immutability right. In the best 338 | case scenario it only needs to compare the identity of the sub-state to know 339 | that it's changed. 340 | 341 | [diff]: https://github.com/stasm/innerself/blob/7aa2e6857fd05cc7047dcd3bbdda6d3820b76f42/index.js#L20-L27 342 | [dummy]: https://github.com/reactjs/react-redux/blob/fd81f1812c2420aa72805b61f1d06754cb5bfb43/src/components/connectAdvanced.js#L218 343 | --------------------------------------------------------------------------------