├── .eslintignore ├── .gitignore ├── .nvmrc ├── LICENSE ├── README.md ├── _redirects ├── build ├── babel-preset.js └── build.js ├── demo ├── .babelrc ├── counter-child-function.mdx ├── counter.mdx ├── dataviz.mdx ├── helpers │ └── table.js ├── index.html ├── index.js └── toggle.mdx ├── package.json ├── rollup.config.js ├── src ├── .babelrc └── index.js └── yarn.lock /.eslintignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | lib/ 3 | cache/ 4 | es/ 5 | /index.js -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.cache 2 | /es 3 | /node_modules 4 | /umd 5 | /index.js 6 | /lib 7 | /compat 8 | /dist 9 | npm-debug.log* 10 | yarn-error.log* 11 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | lts/carbon 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT LICENSE 2 | 3 | Copyright 2018 Alex Krolick 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. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # MDX-Observable 2 | 3 | _**alpha project**, API may change significantly_ 4 | 5 | _0.2.0 does not actually use observables so the name may change 😬_ 6 | 7 | Interactive documents powered by Markdown, React, ~~and Observables~~ 8 | 9 | Share state between JSX blocks in a [MDX](https://mdxjs.com/) document 10 | 11 | - **Declarative** React automatically updates observers when data changes 12 | - **Write with Markdown** store documents in plain text that can be revision-controlled 13 | 14 | 15 | 16 | 17 | - [Examples](#examples) 18 | - [Dev Server](#dev-server) 19 | - [Static Build](#static-build) 20 | - [API](#api) 21 | - [State](#state) 22 | - [Using render prop](#using-render-prop) 23 | - [Using context to connect Observe components](#using-context-to-connect-observe-components) 24 | - [Observe](#observe) 25 | - [Alternatives](#alternatives) 26 | - [Notebooks](#notebooks) 27 | - [Other state management libraries for JS](#other-state-management-libraries-for-js) 28 | - [Roadmap](#roadmap) 29 | - [Potential Issues](#potential-issues) 30 | - [Usage outside MDX](#usage-outside-mdx) 31 | - [Warning about blank lines in JSX](#warning-about-blank-lines-in-jsx) 32 | - [License](#license) 33 | 34 | 35 | 36 | ## Examples 37 | 38 | See demos: https://mdx-observable.netlify.app/ 39 | 40 | - [Counter w/Observer](./demo/counter.mdx) 41 | - [Counter w/Render Prop](./demo/counter-child-function.mdx) 42 | - [Toggle](./demo/toggle.mdx) 43 | - [Dataviz](./demo/dataviz.mdx) 44 | 45 | ``` 46 | git clone git@github.com:alexkrolick/mdx-observable.git 47 | cd mdx-observable 48 | yarn install 49 | ``` 50 | 51 | ### Dev Server 52 | 53 | Start the dev server with live reloading 54 | 55 | ```sh 56 | yarn run demo:parcel:dev 57 | ``` 58 | 59 | ### Static Build 60 | 61 | The output files in `dist/` can be hosted on a static web server 62 | 63 | ``` 64 | yarn run build:parcel 65 | ``` 66 | 67 | ```jsx 68 | // notebook.mdx 69 | import { State, Observe } from 'mdx-observable'; 70 | 71 | # Counter 72 | 73 | 74 | 75 | 76 | {({ setState }) => ( 77 | 80 | )} 81 | 82 | 83 | The button has been clicked: 84 | 85 | 86 | { ({...state}) => ({state.count} times) } 87 | 88 | 89 | 90 | ``` 91 | 92 | Example with a form, table, and graph running in [OK-MDX](https://github.com/jxnblk/ok-mdx): 93 | 94 | screen shot 2018-08-25 at 11 33 32 pm 95 | 96 | ## API 97 | 98 | ### State 99 | 100 | State container component 101 | 102 | Props: 103 | 104 | - `initialState: Object` - initial state 105 | - `children: React.Children | function` Can either be: 106 | - React children: JSX or Markdown node(s) 107 | - A render prop: a single function that gets called with `{...state, setState}` as the argument 108 | 109 | #### Using render prop 110 | 111 | _Very similar to [React Powerplug's State](https://github.com/renatorib/react-powerplug/blob/master/docs/components/State.md)_ 112 | 113 | _Note: whitespace is sensitive in MDX, 114 | so the awkward spacing below is important._ 115 | 116 | ```mdx 117 | 118 | {({setState, ...state}) => 119 | 120 |

Hello, World!

121 | 122 | Some markdown 123 | 124 | ## Some header 125 | 126 | - item a 127 | - item b 128 | 129 |
} 130 |
131 | ``` 132 | 133 | #### Using context to connect Observe components 134 | 135 | ```mdx 136 | 137 | 138 | ...child nodes... 139 | 140 | 141 | {({ ...state}) =>

Hello, World!

} 142 |
143 | 144 | ...more child nodes... 145 | 146 |
147 | ``` 148 | 149 | ### Observe 150 | 151 | Component that re-renders when the global state changes. 152 | 153 | Props: 154 | 155 | - `children: ({...state, setState}) => React.Node` 156 | function that accepts an object with: 157 | - `setState`: function like React `setState`, can take an object or an updater function (`state => patch`); result is _shallow merged_ with current state 158 | - the rest of the global state 159 | 160 | ```js 161 | 162 | {({ setState, ...state }) => { 163 | return
{state.something}
; 164 | }} 165 |
166 | 167 | 168 | {({ setState, something }) => { 169 | return
{something
; 170 | }} 171 |
172 | ``` 173 | 174 | ## Alternatives 175 | 176 | ### Notebooks 177 | 178 | Advantages of MDX-Observable over [Jupyter](https://jupyter.org/) or [ObservableHQ](https://beta.observablehq.com/scratchpad): 179 | 180 | - No cells to run; entire document is live 181 | - Interactivity powered by predictable one-way data flow 182 | - Use standard JS imports and any React component 183 | - Produces static bundles 184 | - Edit using preferred JS tooling 185 | - Bundle with anything that supports [MDX](https://mdxjs.com/getting-started/), like Webpack, Gatsby, Parcel, etc. 186 | 187 | ### Other state management libraries for JS 188 | 189 | Most state management libraries don't work with MDX because you can't define variables, meaning APIs like `const myStore = createStore();` are inaccessible. You can work around this by doing this work in another JS file and importing it, but the logic is hard to follow. 190 | 191 | Some renderless/headless libraries thatwork fully inline are: 192 | 193 | - https://github.com/renatorib/react-powerplug 194 | - https://github.com/ianstormtaylor/react-values 195 | 196 | However the whitespace sensitivity may make them difficult to use. 197 | 198 | ## Roadmap 199 | 200 | - [x] See if `` could work as a wrapper instead of sibling of ``. This would allow better scoping and safer setup/teardown. 201 | 202 | - [ ] Some way to define functions inline. This might map well to the concept of "selectors" from Redux. Currently you can work around this gap by defining utilities in external JS files, but this makes it hard to write self-contained notebooks. 203 | 204 | Possible API: 205 | 206 | ```js 207 | {/* compute */} }}> 208 | ``` 209 | 210 | - [x] Better live-reload support. MDX utils like `ok-mdx` do a full remount when the live editor changes or navigation occures; we could add a `restoreKey` to persist a namespaced cache within the module. 211 | 212 | - [ ] **Add tests** 213 | 214 | ## Potential Issues 215 | 216 | ### Usage outside MDX 217 | 218 | ~~Technically `mdx-observable` doesn't depend on MDX for anything, but since it uses a singleton for a cache, it is not a good fit for state management in an app.~~ Fixed 219 | 220 | ### Warning about blank lines in JSX 221 | 222 | Currently (Aug 2018) the MDX parser doesn't allow putting blank lines inside of JSX blocks. If you see an error about "adjacent elements", this is probably why. 223 | 224 | ## License 225 | 226 | See [LICENSE](./LICENSE) 227 | -------------------------------------------------------------------------------- /_redirects: -------------------------------------------------------------------------------- 1 | /* /index.html 200 -------------------------------------------------------------------------------- /build/babel-preset.js: -------------------------------------------------------------------------------- 1 | // This file modified from @reach/router build script: 2 | // https://github.com/reach/router/blob/master/build/babel-preset.js 3 | // Copyright (c) 2018-present, Ryan Florence 4 | 5 | const BABEL_ENV = process.env.BABEL_ENV; 6 | const building = BABEL_ENV !== undefined && BABEL_ENV !== "cjs"; 7 | 8 | const plugins = [ 9 | "transform-object-rest-spread", 10 | "transform-class-properties", 11 | "dev-expression", 12 | [ 13 | "transform-react-remove-prop-types", 14 | { 15 | mode: "unsafe-wrap" 16 | } 17 | ], 18 | [ 19 | "transform-inline-environment-variables", 20 | { 21 | include: ["COMPAT"] 22 | } 23 | ] 24 | ]; 25 | 26 | if (BABEL_ENV === "umd") { 27 | plugins.push("external-helpers"); 28 | } 29 | 30 | module.exports = { 31 | presets: [ 32 | [ 33 | "env", 34 | { 35 | loose: true, 36 | modules: building ? false : "commonjs" 37 | } 38 | ], 39 | "react" 40 | ], 41 | plugins: plugins 42 | }; 43 | -------------------------------------------------------------------------------- /build/build.js: -------------------------------------------------------------------------------- 1 | // This file modified from @reach/router build script: 2 | // https://github.com/reach/router/blob/master/build/build.js 3 | // Copyright (c) 2018-present, Ryan Florence 4 | 5 | const fs = require("fs"); 6 | const execSync = require("child_process").execSync; 7 | const prettyBytes = require("pretty-bytes"); 8 | const gzipSize = require("gzip-size"); 9 | 10 | const exec = (command, extraEnv) => 11 | execSync(command, { 12 | stdio: "inherit", 13 | env: Object.assign({}, process.env, extraEnv) 14 | }); 15 | 16 | console.log("\nBuilding ES modules ..."); 17 | exec("babel src -d es --ignore *.test.js", { 18 | BABEL_ENV: "es" 19 | }); 20 | 21 | console.log("Building CommonJS modules ..."); 22 | exec("babel src -d . --ignore *.test.js", { 23 | BABEL_ENV: "cjs" 24 | }); 25 | 26 | console.log("\nBuilding UMD ..."); 27 | exec("rollup -c -f umd -o umd/mdx-observable.js", { 28 | BABEL_ENV: "umd", 29 | NODE_ENV: "development" 30 | }); 31 | 32 | console.log("\nBuilding UMD min.js ..."); 33 | exec("rollup -c -f umd -o umd/mdx-observable.min.js", { 34 | BABEL_ENV: "umd", 35 | NODE_ENV: "production" 36 | }); 37 | 38 | const size = gzipSize.sync(fs.readFileSync("umd/mdx-observable.min.js")); 39 | console.log("\ngzipped, the UMD build is %s", prettyBytes(size)); 40 | -------------------------------------------------------------------------------- /demo/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["env", "react-app/prod"], 3 | "plugins": ["transform-class-properties"] 4 | } -------------------------------------------------------------------------------- /demo/counter-child-function.mdx: -------------------------------------------------------------------------------- 1 | import { State, Observe } from '../es'; 2 | 3 | # Counter 4 | 5 | 6 | {({ setState, ...state }) => { 7 | // cannot leave blank lines here 8 | // but at least we can use JS variables because we are inside a function! 9 | const increment = 1; 10 | // no blank lines here either 11 | // you can use comments or a single ; 12 | return ( 13 | // the body of the document can be put inside the render function 14 | // this has to be wrapped in a fragment if it has multiple nodes 15 | // 16 | // code inside the block cannot be indented >= 4 spaces or it will be 17 | // rendered as text instead of parsed 18 | 19 | 20 | 23 | 24 | The button has been clicked: 25 | 26 | {state.count} times 27 | 28 | 29 | )}} 30 | 31 | -------------------------------------------------------------------------------- /demo/counter.mdx: -------------------------------------------------------------------------------- 1 | import { State, Observe } from '../es'; 2 | import { Link } from "@reach/router"; 3 | 4 | # Counter 5 | 6 | 7 | 8 | 9 | {({ setState }) => ( 10 | 13 | )} 14 | 15 | 16 | The button has been clicked: 17 | 18 | 19 | { ({...state}) => (`${state.count} times`) } 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /demo/dataviz.mdx: -------------------------------------------------------------------------------- 1 | import { State, Observe } from '../es'; 2 | import Table from './helpers/table'; 3 | import { Form } from 'react-powerplug'; 4 | import { VictoryBar, VictoryChart, VictoryAxis, VictoryTheme } from 'victory'; 5 | import 'katex/dist/katex.min.css'; 6 | import { BlockMath, InlineMath } from 'react-katex'; 7 | 8 | 9 | # Data Visualization 10 | 11 | _An example linking a dataset to a table, form, and dynamic values 12 | displayed in a text and chart, plus some LateX math._ 13 | 14 | # Which Car Should You Buy? 15 | 16 | There a number of factors to consider when buying a car. Use our special formula to help you decide! 17 | 18 | {` 19 | \\text{Score} = 100 \\times \\frac{(reliability \\times 3 + luxury \\times 2)}{price}` 20 | } 21 | 22 | Here are some cars to get you started with your comparison: 23 | 24 | 36 | 37 | 40 | 41 | 42 | {({ vehicles }) => ( 43 | Object.values(v))]} 45 | /> 46 | )} 47 | 48 | 49 | 50 | 53 | 54 | 55 | {({ vehicles, setState }) => { 56 | return ( 57 |
58 | {({ input, values }) => ( 59 | { 61 | e.preventDefault(); 62 | const newCar = { 63 | name: values.name, 64 | price: parseInt(values.price), 65 | reliability: parseInt(values.reliability), 66 | luxury: parseInt(values.luxury) 67 | }; 68 | setState(s => ({ ...s, vehicles: [...s.vehicles, newCar] })); 69 | }} 70 | > 71 | 72 |
73 | 78 |
79 | 84 |
85 | 90 |
91 | 92 | 93 | )} 94 | 95 | ); 96 | }} 97 |
98 | 99 | ## Results 100 | 101 | ### Scores 102 | 103 | 104 | { 105 | ({ vehicles, setState }) => { 106 | const cheapest = [...vehicles].sort((a, b) => a.price - b.price)[0]; 107 | const mostReliable = [...vehicles].sort( 108 | (a, b) => b.reliability - a.reliability 109 | )[0]; 110 | const mostLuxurious = [...vehicles].sort( 111 | (a, b) => b.luxury - a.luxury 112 | )[0]; 113 | const specialFormula = ({ price, reliability, luxury }) => 114 | (reliability * 3 + luxury * 2) / price * 100; 115 | const byFormula = [...vehicles] 116 | .map(v => ({ ...v, score: specialFormula(v) })) 117 | .sort((a, b) => b.score - a.score)[0]; 118 | return ( 119 |
120 |
121 | The cheapest car is: {cheapest.name} 122 |
123 |
124 | The most reliable car is: {mostReliable.name} 125 |
126 |
127 | The most luxurious car is: {mostLuxurious.name} 128 |
129 |
130 | 131 | Our special formula says: Buy {byFormula.name} 132 | 133 |
134 |
135 | ); 136 | } 137 | } 138 |
139 | 140 | 141 | 142 | { 143 | ({ vehicles, setState }) => { 144 | const specialFormula = ({ price, reliability, luxury }) => 145 | (reliability * 3 + luxury * 2) / price * 100; 146 | const byFormula = [...vehicles] 147 | .map(v => ({ ...v, score: specialFormula(v) })) 148 | .sort((a, b) => b.score - a.score) 149 | .map((v, i) => ({...v, fill: `#999`})) 150 | return ( 151 |
152 | 157 | (`${x * 1000}`)} 160 | /> 161 | 162 | 163 | 164 |
165 | ) 166 | } 167 | } 168 |
169 | 170 | 171 | -------------------------------------------------------------------------------- /demo/helpers/table.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import key from "weak-key"; 3 | 4 | function Table({ data, className = "" }) { 5 | const [headers, ...rows] = data; 6 | return ( 7 |
8 | 9 | 10 | {headers.map((h, i) => ( 11 | 15 | ))} 16 | 17 | 18 | 19 | {rows.map(row => ( 20 | 21 | {row.map((d, i) => ( 22 | 23 | ))} 24 | 25 | ))} 26 | 27 |
12 | {h.charAt(0).toUpperCase()} 13 | {h.slice(1)} 14 |
{d}
28 | ); 29 | } 30 | 31 | export default Table; 32 | -------------------------------------------------------------------------------- /demo/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | MDX-Observable 8 | 9 | 25 | 26 | 27 |
28 | 29 | 30 | -------------------------------------------------------------------------------- /demo/index.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom"; 3 | import Dataviz from "./dataviz.mdx"; 4 | import Counter from "./counter.mdx"; 5 | import CounterRender from "./counter-child-function.mdx"; 6 | import Toggle from "./toggle.mdx"; 7 | import { Router, Link, Redirect } from "@reach/router"; 8 | 9 | const NavLink = props => ( 10 | { 13 | return { 14 | style: { 15 | fontWeight: isCurrent ? "bold" : undefined 16 | } 17 | }; 18 | }} 19 | /> 20 | ); 21 | 22 | const NotFound = () => ( 23 |
24 |

404 - Not Found

25 | Go Home 26 |
27 | ); 28 | 29 | const Nav = () => ( 30 | 39 | ); 40 | 41 | const Demo = ( 42 | 43 |