├── README.md ├── examples ├── annotations.cradle └── userFlows.cradle ├── grammar.pegjs ├── index.js ├── package-lock.json └── package.json /README.md: -------------------------------------------------------------------------------- 1 | # What is Cradle? 2 | 3 | Cradle is a DSL (domain specific language) for making flowcharts and graphs really easily. Interaction and UX design involve complex logic and structure, but our tools for designing experiences are often limited to drawing boxes+arrows manually in Figma/Sketch/Omnigraffle/whatever flavor of mind mapping or wireframing tool you use, using complex JavaScript libraries, learning DOT or UML (which are not intended for design), or just using natural language. Cradle's intent is to try formalize experience and interaction design as code. 4 | 5 | Cradle has an intuitive syntax that lets you write with whitespace, nest things in hierarchies easily, and create different kinds of links between nodes easily. It should feel like writing, but just a little bit more structured. 6 | 7 | It's not "feature complete" or intended to be a complete replacement for DOT (especially its renderer), but that's not the point. It's supposed to be easy to use and get the job done. It is also not a new concept to think of UIs and UX with state machines and graphs. But the emphasis in Cradle here is on syntax and ease of use. 8 | 9 | Cradle is opinionated and takes the stance that design specs should be: 10 | - Versionable 11 | - Portable - not stuck in any specific design program 12 | - Visualizable 13 | - Lintable 14 | - Testable - error handling is huge in UX! 15 | 16 | Prior art and references: 17 | - https://sketch.systems/ 18 | - https://pegjs.org/online 19 | - https://lezer.codemirror.net/ 20 | - https://www.antlr.org/ 21 | - https://js.cytoscape.org/ 22 | - https://github.com/mattrasto/phase 23 | - https://flowchart.js.org/ 24 | - https://github.com/davidkpiano/xstate 25 | - https://rsms.me/graphviz/ 26 | 27 | # How does it work? 28 | 29 | Your Cradle spec is just text. It can either live as a text file you read, or as a string in code. Here are the basics of the syntax 30 | 31 | ### Syntax 32 | #### Nodes 33 | Nodes are just text. They're basically any alphanumeric character, and they represent the boxes you'd draw in a graph. You connect Nodes together with various kinds of arrows to form sequences. 34 | ``` 35 | this is a node 36 | ``` 37 | ``` 38 | this is a node too and it includes spaces 39 | ``` 40 | 41 | #### Edges 42 | Edges are arrows between nodes that link them together. 43 | 44 | This is biased right now towards people who read/write LTR languages (because I speak English), but I'm thinking of making a config section for any spec where you can set the parsing to RTL, which would switch the symbols for Forward and backward edges. 45 | 46 | There are three kinds: 47 | - Forward `->` 48 | - Backward `<-` 49 | - Bidirectional `<->` 50 | 51 | If you want to add a label to an edge, wrap it in parentheses and write text before it. 52 | 53 | - `(on click ->)` 54 | - `(backwards baby <-)` 55 | - `(this goes both ways <->)` 56 | 57 | #### Sequences 58 | Sequences are the basic part of Cradle. Often when designing flows, we like to write things with arrows and events between them. For example: 59 | ``` 60 | step 1 -> step 2 -> step 3 61 | ``` 62 | If you want to label transitions, simply wrap the arrow in parentheses and write some text before it. 63 | ``` 64 | step 1 (transition label ->) step 2 -> step 3 65 | ``` 66 | If you want to indicate directionality, just adjust the direction of the edge as indicated above. 67 | ``` 68 | // bidirectional 69 | step 1 <-> step 2 <-> step 3 70 | ``` 71 | ``` 72 | // forward 73 | step 1 -> step 2 -> step 3 74 | ``` 75 | ``` 76 | // backward 77 | step 1 <- step 2 <- step 3 78 | 79 | ``` 80 | Let's get a practical example in here. If you're designing basic interactions for a web app, and you want to specify what happens when you click on a menu. You can define that like this: 81 | ``` 82 | menu (on click ->) open popover 83 | ``` 84 | 85 | Now, apps usually have tons of different flows/sequences grouped together. How do we group them in Cradle? Groups! 86 | 87 | #### Groups 88 | A group allows you to make lists of sequences and other groups. 89 | Groups are anything contained inside 2 curly braces. You write commas between sequences and groups to separate them. 90 | ``` 91 | group { 92 | step 1 -> step 2, 93 | step 3 -> step 4, 94 | subgroup { 95 | 96 | }, 97 | subgroup2 { 98 | 99 | } 100 | } 101 | ``` 102 | 103 | Let's take the interactions example from before and expand on it. Usually, we like to define what happens to UI elements with various interactions. Using groups we can easily organize them like this: 104 | 105 | (shorthand syntax still WIP) 106 | ``` 107 | menu interactions { 108 | menu (on click ->) open popover, 109 | menu (on hover ->) show tooltip, 110 | menu (on focus ->) ... 111 | } 112 | ``` 113 | 114 | 115 | # Open questions: 116 | - How to handle styling? 117 | - Should that be included in the graph itself (like DOT) or handled outside? 118 | - Parameterization? 119 | - Should this be owned by Cradle or left up to the host language? 120 | - Functions? 121 | - Should Cradle have its own simple version of functions that can take an input, parameterize it, and spit out an output? 122 | - Types? 123 | - Should nodes and edges have types? More specifically, can we easily allow users to make their own types or templates of Nodes and Edges? 124 | - Variables / references / aliases / node scope? 125 | - Can nodes have multiple names / references? If so, what's the easiest syntax to do that? 126 | - Should everything be in a global namespace? 127 | - What if you could refer to nodes by their parents using dot notation or array indexing? 128 | - `user flow.step 1`, `user flow.0`, `user flow.first` could all refer to the same node 129 | 130 | # In progress 131 | High priority: 132 | [x] A parser (which exports the AST) 133 | [x] A transpiler (which converts the AST into DOT) 134 | [ ] A renderer (which takes the AST and has an opinonated set of SVG objects and / or React components that will let you create interactive graphs). 135 | 136 | After: 137 | - An interactive CLUI app for specifying Cradle graphs and interacting with them through text and clicking 138 | - A Figma Cradle plugin that let's you automatically render 139 | -------------------------------------------------------------------------------- /examples/annotations.cradle: -------------------------------------------------------------------------------- 1 | annotations { 2 | // getting to annotations 3 | 4 | creating an annotation { 5 | on highlight -> context menu -> annotation ui, 6 | // this basically skips a step 7 | on margin click -> annotation ui 8 | }, 9 | 10 | viewing annotations { 11 | manually { 12 | go to an annotation (on click ->) annotation ui 13 | }, 14 | // not figured out yet 15 | automatically -> { 16 | if you are in an annotated region (go ->) annotation ui 17 | (annotation ui).questions 18 | } 19 | }, 20 | 21 | // actual ui for it 22 | 23 | annotation ui { 24 | actions { 25 | leave a message, 26 | resolve annotation, 27 | exit 28 | }, 29 | 30 | questions { 31 | should we update scroll position on every scroll event?, 32 | should all annotations be visible at once? 33 | should we leave an indicator in the margin? 34 | } 35 | } 36 | } -------------------------------------------------------------------------------- /examples/userFlows.cradle: -------------------------------------------------------------------------------- 1 | user flows { 2 | ways to sign up { 3 | get to landing page -> scroll down -> hit sign up bar, 4 | go to anon page -> see save in right corner -> get sign up pop up 5 | } 6 | onboarding {} 7 | log out {} 8 | } 9 | 10 | -------------------------------------------------------------------------------- /grammar.pegjs: -------------------------------------------------------------------------------- 1 | { 2 | // Used to arbitrarily flatten 3 | function flatten(arr) { 4 | return arr.reduce(function (flat, toFlatten) { 5 | return flat.concat(Array.isArray(toFlatten) ? flatten(toFlatten) : toFlatten); 6 | }, []); 7 | } 8 | 9 | function last(arr) { 10 | return arr[arr.length-1] 11 | } 12 | 13 | function first(arr) { 14 | return arr[0] 15 | } 16 | } 17 | 18 | // Entry point for the grammar 19 | start = Graph 20 | 21 | Graph "graph" = _ children: (Group / Sequence)+ _ { 22 | return { 23 | ast: children 24 | } 25 | } 26 | 27 | 28 | /******************* 29 | Group 30 | *******************/ 31 | // Either seprate them by commas or just have a regular unit 32 | Group "group" = children: (Node?_ (GroupOpener _ ( ((Group/Sequence)","_) / (Group/Sequence)_)* _ GroupCloser) _) { 33 | return { 34 | type: "group", 35 | start: children[0], 36 | children: flatten(children.slice(1)).filter(c => c 37 | && c.type !== 'whitespace' 38 | && c.type !== 'groupOpener' 39 | && c.type !== 'groupCloser' 40 | && c !== ',') 41 | } 42 | } 43 | 44 | GroupOpener = char: "{" { 45 | return { 46 | type: "groupOpener", 47 | content: char 48 | } 49 | } 50 | 51 | GroupCloser = char: "}" { 52 | return { 53 | type: "groupCloser", 54 | content: char 55 | } 56 | } 57 | 58 | /******************* 59 | Sequence 60 | *******************/ 61 | // TODO?: the last node in the sequence is getting it's first letter cut off 62 | Sequence "sequence" = children:(NodeEdgeUnit _)+ { 63 | return { 64 | type: "sequence", 65 | children: flatten(children.flat().filter(c => c.type !== 'whitespace').map(c => c.children)) 66 | } 67 | } 68 | 69 | // Combines nodes and edges into tuples, which are then combined into sequences 70 | NodeEdgeUnit "unit" = unit:(_ Node _ Edge*) { 71 | return { 72 | type: 'nodeEdgeUnit', 73 | children: unit.filter(n => n.type !== 'whitespace') 74 | } 75 | } 76 | 77 | 78 | /******************* 79 | Edges 80 | *******************/ 81 | Edge "edge" = edge:(LabeledEdge / UnlabeledEdge) { 82 | return edge 83 | } 84 | 85 | // Labeled edges wrap arrows in parens and let you write text 86 | LabeledEdge "labeled edge" = content:"("label:Node edge:UnlabeledEdge")" { 87 | return { 88 | type: "edge", 89 | direction: edge.direction, 90 | content: edge.content, 91 | label: label.content 92 | } 93 | } 94 | 95 | // This is basically a "regular" edge 96 | UnlabeledEdge "unlabeled edge" = edge:(BiEdge/FEdge/BEdge) { 97 | return edge 98 | } 99 | 100 | BiEdge "bidirectional edge" = content:"<->" { 101 | return { type: "edge", direction: "bi", content } 102 | } 103 | 104 | FEdge "forward edge" = content:"->" { 105 | return { type: "edge", direction: "forward", content } 106 | } 107 | 108 | BEdge "backward edge" = content:"<-" { 109 | return { type: "edge", direction: "backward", content } 110 | } 111 | 112 | /******************* 113 | Node 114 | *******************/ 115 | // Joins words and spaces together 116 | Node "node" = content:(Word InterwordWs)+ { 117 | return { 118 | type: "node", 119 | content: content.map(c => c[0] + c[1]).join('').trim() 120 | } 121 | } 122 | 123 | // Used for spaces within nodes themselves 124 | InterwordWs "interword whitespace" = ws:_ { 125 | return ws.content.join("") 126 | } 127 | 128 | // 129 | Word "word" = letters:letter+ { 130 | return letters.join("") 131 | } 132 | 133 | letter "letter" = [A-Za-z0-9] 134 | 135 | // We return an object so we can easily filter 136 | _ "whitespace" = content: [ \t\n\r]* { 137 | return { 138 | type: "whitespace", 139 | content 140 | } 141 | } -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | const fs = require("fs") 2 | const peg = require("pegjs") 3 | 4 | // Utils 5 | const walk = (tree, callback) => { 6 | tree.forEach(child => { 7 | callback && callback(child); 8 | if (child.children) { 9 | walk(child.children, callback) 10 | } 11 | }) 12 | } 13 | const log = (s) => console.log(s) 14 | const last = (arr) => arr[arr.length - 1] 15 | 16 | // https://repl.it/@tangert/more-flow-dsl-sketching#index.js 17 | const grammar = fs.readFileSync("grammar.pegjs", "utf8") 18 | const parser = peg.generate(grammar) 19 | 20 | // syntax highlighting? 21 | 22 | const sampleInputs = [ 23 | `step 1 -> step 2 (on click ->) step 3`, 24 | 25 | `root { 1 -> 2, 3 -> 4, subgraph { 1 -> 2 }}`, 26 | 27 | `sign up flows { 28 | sign up { 29 | if youre already signed up -> login, 30 | else { 31 | sign up with { 32 | google -> oauth, 33 | github -> oauth, 34 | facebook -> oauth, 35 | email 36 | } 37 | } 38 | }, 39 | login { 40 | choose provider { 41 | google, 42 | github, 43 | facebook, 44 | email -> enter in email and password 45 | } 46 | } 47 | }`, 48 | 49 | `root { 50 | 1 { 51 | a -> b -> c 52 | }, 53 | 2 { 54 | a -> b -> c 55 | } 56 | }`, 57 | 58 | `r{1{},2{3{}}}`, 59 | 60 | `{}`, 61 | ] 62 | 63 | 64 | /* 65 | this creates an adjancency list from the ast 66 | { 67 | 1: [2] 68 | 2: [3, 4, 5] 69 | } 70 | */ 71 | // assumes global. unique name space 72 | 73 | // Will take the ast and simple turn it into an object with string keys and values 74 | function createObject(ast) { 75 | } 76 | 77 | // sampleInputs.forEach(inp => { 78 | // const parsed = parser.parse(inp) 79 | // log("\n") 80 | // log("INPUT:") 81 | // log(inp) 82 | // log("AST:") 83 | // log(parsed) 84 | // walk(parsed.ast, function(child) { 85 | // log(child) 86 | // // if(child.type === 'group') { 87 | // // child.start && log(child.start.content) 88 | // // } else if (child.content) { 89 | // // log(child.content) 90 | // // } 91 | // }) 92 | // log("\n") 93 | // }) 94 | 95 | const userFlows = fs.readFileSync("examples/userFlows.cradle", "utf8") 96 | const userFlowAST = parser.parse(userFlows) 97 | log(userFlows) 98 | log(userFlowAST) 99 | 100 | const buildGraph = (ast) => { 101 | let graph = {} 102 | // To determine how to add edges into the list 103 | let lastNode; 104 | let lastEdge; 105 | // To keep track if you're inside a group 106 | let lastGroup; 107 | let lastSequenceLength = 0; 108 | let startOfSequence = false; 109 | 110 | // formats the node 111 | const Node = (content, label) => ({ 112 | node: content, 113 | // Add a label if there's one 114 | ...(label && { label }) 115 | }) 116 | 117 | // Walk over the nodes and build the graph. 118 | walk(ast, (child) => { 119 | // If you find a node, check what the last node was and the last edge to decide how to add it to the last 120 | if (child.type === 'node') { 121 | // Initialize the adjacency list if it's not there. 122 | if (!graph[child.content]) { 123 | graph[child.content] = [] 124 | } 125 | 126 | if (lastGroup && startOfSequence) { 127 | // You found the start of a sequence inside of a group. 128 | // Push the current one 129 | // It's implied that a group is a forward edge 130 | graph[lastGroup.start.content].push(Node(child.content)) 131 | startOfSequence = false; 132 | } 133 | 134 | if (lastEdge && lastNode) { 135 | // found a connection! 136 | // add it onto the last node's list :) 137 | let _to = Node(child.content, lastEdge.label) 138 | let _from = Node(lastNode.content, lastEdge.label) 139 | 140 | // Switch direction if it's backward 141 | if (lastEdge.direction === 'backward') { 142 | const tmp = _to 143 | _to = _from 144 | _from = tmp 145 | } 146 | 147 | // Add the edges 148 | if (!graph[_from.node].includes(_to.node)) { 149 | graph[_from.node].push(_to) 150 | } 151 | 152 | // If it's bidirectional, add the reverse as well. 153 | if (lastEdge.direction === 'bi' && !graph[_to.node].includes(_from.node)) { 154 | graph[_to.node].push(_from); 155 | } 156 | } 157 | 158 | lastSequenceLength--; 159 | // got to the end of a sequence 160 | if (lastSequenceLength === 0) { 161 | // once you reach the end of a sequence set the last node and edge to null so you have a fresh start 162 | lastNode = null 163 | lastEdge = null 164 | } else { 165 | lastNode = child 166 | } 167 | 168 | } 169 | else if (child.type === 'group') { 170 | // Initialize 171 | if (!graph[child.start.content]) { 172 | graph[child.start.content] = [] 173 | } 174 | if (lastGroup) { 175 | // add the edge if this is a subgroup 176 | graph[lastGroup.start.content].push(Node(child.start.content)) 177 | } 178 | lastGroup = child 179 | } 180 | 181 | else if (child.type === 'sequence') { 182 | startOfSequence = true 183 | lastSequenceLength = child.children.filter(c => c.type === 'node').length 184 | } 185 | // If you find an edge, store it to define how the next node you find gets added to the list 186 | else if (child.type === 'edge') { 187 | // check for labels where to store them. 188 | lastEdge = child 189 | } 190 | }) 191 | 192 | return graph 193 | } 194 | 195 | const flatternGraph = (graph) => { 196 | // goes through. the graph and gets rid of adjacency lists and makes everything single nodes and node transitions. 197 | } 198 | 199 | 200 | const cradleToDOT = (input) => { 201 | // parse the sequence 202 | log("INPUT: " + input) 203 | 204 | const parsed = parser.parse(input) 205 | // get the graph 206 | const graph = buildGraph(parsed.ast) 207 | // create the DOT string 208 | // TODO: maybe convert it directly into the graph object? 209 | 210 | log("GRAPH: ") 211 | log(graph) 212 | 213 | const all = [] 214 | Object.entries(graph).forEach(e => { 215 | // Handle groups 216 | const _from = e[0] 217 | const _to = e[1] 218 | const withoutLabels = [] 219 | const withLabels = [] 220 | _to.forEach(n => { 221 | if(n.label) { 222 | withLabels.push(n) 223 | } else { 224 | withoutLabels.push(n) 225 | } 226 | }) 227 | 228 | // TODO: get fancier and only quote things if they contain spaces 229 | if(withoutLabels.length > 1) { 230 | all.push(`"${_from}"->{${withoutLabels.map(n => `"${n.node}"`)}}`) 231 | } else if(withoutLabels.length === 1) { 232 | all.push(`"${_from}"->"${withoutLabels[0].node}"`) 233 | } 234 | if(withLabels.length > 0) { 235 | withLabels.forEach(n => { 236 | all.push(`"${_from}"->"${n.node}" [label="${n.label}"]`) 237 | }) 238 | } 239 | }) 240 | 241 | const dotString = `digraph G { 242 | ${all.join('\n')} 243 | }` 244 | 245 | return dotString 246 | } 247 | 248 | 249 | const sequence = ` 250 | test group { 251 | a <-> b <-> c (wow <->) d -> e -> f <-> g, 252 | cool <-> beans, 253 | awesome (on click ->) dude, 254 | back <- to back, 255 | subgroup { 256 | wow -> hi, 257 | another sub { 258 | niceee -> owwooow 259 | } 260 | } 261 | }`; 262 | 263 | 264 | // TODO: nested things are broken again... 265 | const interactions = ` 266 | draw { 267 | with { 268 | wow -> nice 269 | }, 270 | on, 271 | towards 272 | } 273 | ` 274 | 275 | // const interactions = ` 276 | // draw { 277 | // with { 278 | // brush { on, towards }, 279 | // finger { on, towards } 280 | // }, 281 | // on { canvas, screen }, 282 | // towards { edge, center } 283 | // } 284 | // ` 285 | 286 | const sequence2 = `step 1 -> step 2 (on click <->) step 3`; 287 | 288 | // rename "content" to "data" 289 | 290 | const buildNodeEdgeList = (ast) => { 291 | // want to build a thing of nodes and edges... 292 | // Nodes: [{ data: any }] 293 | // Edges: [{ source: Node, target: Node, direction: forward/backward}] 294 | 295 | // In memory dict for checking if ndoes have been added already 296 | const nodesAdded = {} 297 | // stringify the edges to quickly check. 298 | let edgesAdded = {} 299 | const graph = { 300 | nodes: [], 301 | edges: [] 302 | } 303 | 304 | let lastSequence; 305 | let lastGroup; 306 | let lastEdge; 307 | 308 | // formatter functions 309 | const Node = (data) => ({ data }) 310 | const Edge = (source, target, data) => ({ source, target, data }) 311 | const EdgeKey = (edge) => `${edge.source.data}-${edge.target.data}` 312 | 313 | walk(ast, (child) => { 314 | if(child.type === 'node') { 315 | 316 | // First, add the node 317 | if(!nodesAdded[child.content]) { 318 | graph.nodes = [...graph.nodes, Node(child.content)] 319 | } 320 | nodesAdded[child.content] = true 321 | 322 | // Check if there's a connection 323 | if (lastEdge && lastNode) { 324 | // found a connection! 325 | // add it onto the last node's list :) 326 | let target = Node(child.content) 327 | let source = Node(lastNode.content) 328 | 329 | log(lastEdge) 330 | 331 | // Switch direction if it's backward 332 | if (lastEdge.direction === 'backward') { 333 | const tmp = target 334 | target = source 335 | source = tmp 336 | } 337 | 338 | const edge = Edge(source, target, lastEdge.label) 339 | const edgeKey = EdgeKey(edge) 340 | 341 | if(!edgesAdded[edgeKey]){ 342 | graph.edges = [...graph.edges, edge] 343 | } 344 | 345 | // If it's bidirectional, add the reverse as well. 346 | if (lastEdge.direction === 'bi') { 347 | // create the reverse one 348 | const e = Edge(target, source, lastEdge.label) 349 | const ek = EdgeKey(e) 350 | if(!edgesAdded[ek]){ 351 | graph.edges = [...graph.edges, e] 352 | } 353 | edgesAdded[EdgeKey(e)] = true 354 | } 355 | 356 | 357 | // add the edge 358 | edgesAdded[EdgeKey(edge)] = true 359 | } 360 | 361 | lastNode = child 362 | 363 | } else if (child.type === 'group') { 364 | lastGroup = child; 365 | } else if(child.type === 'sequence') { 366 | lastSequence = child 367 | } else if (child.type === 'edge') { 368 | lastEdge = child 369 | } 370 | }) 371 | 372 | return graph 373 | } 374 | 375 | const neList = buildNodeEdgeList(parser.parse(sequence).ast) 376 | log(neList) 377 | // [userFlows, sequence, sequence2, interactions].forEach( s => { 378 | // log('\n') 379 | // // log(cradleToDOT(s)) 380 | // }) 381 | -------------------------------------------------------------------------------- /package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "basic-interpreter", 3 | "version": "1.0.0", 4 | "lockfileVersion": 1, 5 | "requires": true, 6 | "dependencies": { 7 | "pegjs": { 8 | "version": "0.10.0", 9 | "resolved": "https://registry.npmjs.org/pegjs/-/pegjs-0.10.0.tgz", 10 | "integrity": "sha1-z4uvrm7d/0tafvsYUmnqr0YQ3b0=" 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "basic-interpreter", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1" 8 | }, 9 | "keywords": [], 10 | "author": "", 11 | "license": "ISC", 12 | "dependencies": { 13 | "pegjs": "^0.10.0" 14 | } 15 | } 16 | --------------------------------------------------------------------------------