├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── feature_request.md │ └── parser-bug.md └── workflows │ └── test.yml ├── .gitignore ├── .husky └── pre-commit ├── .node-version ├── .vscode └── settings.json ├── LICENSE ├── README.md ├── bun.lockb ├── examples ├── .eslintrc.json ├── .gitignore ├── .vscode │ └── settings.json ├── README.md ├── app │ ├── globals.css │ ├── head.tsx │ ├── layout.tsx │ ├── page.tsx │ └── playground │ │ └── page.tsx ├── assets │ └── Karla-Bold.ttf ├── components │ ├── CytoscapeGraph.tsx │ ├── D3Graph.tsx │ ├── Editor.tsx │ ├── GithubButtons.tsx │ ├── Nav.tsx │ ├── PlaygroundState.tsx │ ├── ReCharts.tsx │ ├── SankeyChart.tsx │ ├── Sections.tsx │ └── SyntaxHighlighter.tsx ├── index.d.ts ├── next.config.js ├── package.json ├── pages │ └── api │ │ └── og.tsx ├── postcss.config.js ├── public │ ├── favicon.ico │ ├── next.svg │ ├── thirteen.svg │ └── vercel.svg ├── tailwind.config.js └── tsconfig.json ├── graph-selector ├── .eslintrc.cjs ├── .gitignore ├── .npmignore ├── .prettierrc.cjs ├── bun.lockb ├── package.json ├── src │ ├── ParseError.ts │ ├── getFeatureData.test.ts │ ├── getFeatureData.ts │ ├── getIndentSize.ts │ ├── graph-selector.ts │ ├── highlight.test.ts │ ├── highlight.ts │ ├── matchAndRemovePointers.test.ts │ ├── matchAndRemovePointers.ts │ ├── operate │ │ ├── addClassesToEdge.test.ts │ │ ├── addClassesToEdge.ts │ │ ├── addClassesToNode.test.ts │ │ ├── addClassesToNode.ts │ │ ├── addDataAttributeToNode.test.ts │ │ ├── addDataAttributeToNode.ts │ │ ├── dataToString.test.ts │ │ ├── dataToString.ts │ │ ├── operate.test.ts │ │ ├── operate.ts │ │ ├── removeClassesFromEdge.test.ts │ │ ├── removeClassesFromEdge.ts │ │ ├── removeClassesFromNode.test.ts │ │ ├── removeClassesFromNode.ts │ │ ├── removeDataAttributeFromNode.test.ts │ │ └── removeDataAttributeFromNode.ts │ ├── parse.test.ts │ ├── parse.ts │ ├── regexps.ts │ ├── stringify.test.ts │ ├── stringify.ts │ ├── toCytoscapeElements.ts │ ├── toMermaid.test.ts │ ├── toMermaid.ts │ └── types.ts └── tsconfig.json ├── package.json ├── turbo.json └── vercel.json /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [tone-row] 4 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/parser-bug.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Parser Bug 3 | about: Describe a problem with the parser 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | ``` 15 | Sample text to reproduce bug 16 | ``` 17 | 18 | **Expected result** 19 | A clear and concise description of what you expected to happen. 20 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | on: [push] 3 | jobs: 4 | build: 5 | name: Test 6 | runs-on: ubuntu-latest 7 | steps: 8 | - uses: actions/checkout@v3 9 | - name: Setup Bun 10 | uses: oven-sh/setup-bun@v2 11 | with: 12 | bun-version: latest 13 | - name: Install Dependencies 14 | run: bun install --frozen-lockfile 15 | - name: Lint 16 | run: bun --filter graph-selector lint:ci 17 | - name: Run Tests 18 | run: bun --filter graph-selector test -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | TODO.md 3 | .turbo -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | bun test && node_modules/.bin/lint-staged 5 | -------------------------------------------------------------------------------- /.node-version: -------------------------------------------------------------------------------- 1 | v20.11.1 2 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "jest.jestCommandLine": "pnpm -F graph-selector test -- " 3 | } 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Robert Gordon 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Graph Selector 2 | 3 | ![Version](https://img.shields.io/npm/v/graph-selector) 4 | ![Coverage](https://img.shields.io/codecov/c/github/tone-row/graph-selector) 5 | ![License](https://img.shields.io/github/license/tone-row/graph-selector) 6 | ![Build](https://img.shields.io/github/checks-status/tone-row/graph-selector/main) 7 | 8 | Graph Selector is a language for describing graphs (nodes and edges) and storing arbitrary data on those nodes and edges in plaintext. 9 | 10 | ## 💫 Check out the Demos 11 | 12 | Visit the demo page at [graph-selector-syntax.tone-row.com](http://graph-selector-syntax.tone-row.com/) to see how it works. 13 | 14 | ## Introduction 15 | 16 | Graph Selector is a syntax for defining graphs and the data associated with them. Graphs are defined in plain-text and can be parsed into a programmable JavaScript object. This makes it easier for developers to work with graphs when building applications like web applications, database applications, and process-visualization systems. 17 | 18 | Graph Selector uses indentation and context-free grammars to create edges between nodes and store data. It also has features like matchers and models to make modeling complex graphs easier and faster. 19 | 20 | ## Usage 21 | 22 | You can use Graph Selector in your projects by installing it using npm: 23 | 24 | ```bash 25 | npm install graph-selector 26 | ``` 27 | 28 | You can then use Graph Selector in your code to parse Graph Selector strings: 29 | 30 | ```js 31 | import { parse } from "graph-selector"; 32 | 33 | const graph = parse(` 34 | Node A 35 | goes to: Node B 36 | `); 37 | 38 | const { nodes, edges } = graph; 39 | 40 | // do something with nodes and edges... 41 | ``` 42 | 43 | ## Language Overview 44 | 45 | Graph Selector has a few rules that make it easy to understand and use: 46 | 47 | Indentation is used to create edges between nodes. 48 | 49 | ``` 50 | Node A 51 | Node B 52 | ``` 53 | 54 | This creates an edge from Node A to Node B. 55 | 56 | Edge info is stored before a colon (`:`) and is separated from the node info by a space. 57 | 58 | ``` 59 | Node A 60 | goes to: Node B 61 | ``` 62 | 63 | This creates an edge from Node A to Node B with the label `goes to`. 64 | 65 | Data can be stored on the nodes and edges using CSS Selector syntax. For example, `#id.class1.class2[n=4][m]` would be parsed into: 66 | 67 | ```js 68 | { 69 | "id": "id", 70 | "classes": ".class1.class2", 71 | "n": 4, 72 | "m": true 73 | } 74 | ``` 75 | 76 | Parentheses can be used to reference nodes that have already been declared. 77 | 78 | ``` 79 | a 80 | b #id 81 | c .class 82 | d 83 | (a) 84 | (#id) 85 | (.class) 86 | ``` 87 | 88 | This creates a graph with 4 nodes and 3 edges. 89 | 90 | ## Monaco Editor 91 | 92 | Graph Selector has an export named `highlight` which contains a lot of the tools for using this language with the [Monaco Editor]([https://www.npmjs.com/package/@monaco-editor/react](https://github.com/microsoft/monaco-editor)). Check out this for a complete example of using this language in a Monaco editor in React, with syntax highlighting and error reporting: https://stackblitz-starters-me6lmw.stackblitz.io 93 | 94 | ## Errors 95 | 96 | In order to capture and display parsing errors in the editor, errors conform to the type `ParsingError` in `graph-selector/src/ParseError.ts`. Because in most application we imagine parsing will occur outside of the editor, displaying errors must also happen outside the error. Refer to the monaco example for what this looks like. 97 | 98 | ## Context 99 | 100 | If you would like to find out more about the development and thought process behind this language, [A blog post](https://tone-row.com/blog/graph-syntax-css-selectors) has been published. 101 | 102 | ## Contributing 103 | 104 | Constructive feedback on the syntax and how it can be improved is the primary contribution sought by this project. You can contribute by having a discussion via Github discissions or opening an issue. Additionally, pull requests will be welcomed to help make the project more robust and flexible for developers. 105 | 106 | You can also contribute examples that show how Graph Selector can be used to render various types of graphs with a variety of libraries, including [D3](https://d3js.org/), [Cytoscape JS](https://js.cytoscape.org/), and [Recharts](https://recharts.org/). 107 | 108 | ## Developing 109 | 110 | Clone the repo, then install dependencies with `pnpm install`. You can then start both the parser and the examples website with `pnpm dev`. 111 | 112 | ## Goals 113 | 114 | The goal of this project is to become stable enough to migrate the [Flowchart Fun](https://flowchart.fun) website to this syntax and use it as the basis for a new version. 115 | 116 | ### Next Steps 117 | 118 | - ~~Add syntax highlighter packages that can be used with Monaco & CodeMirror.~~ 119 | - Add benchmarks 120 | -------------------------------------------------------------------------------- /bun.lockb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tone-row/graph-selector/eafb5ea87e96a281c822560a6f2d1cc3f96ba539/bun.lockb -------------------------------------------------------------------------------- /examples/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } 4 | -------------------------------------------------------------------------------- /examples/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | .pnpm-debug.log* 27 | 28 | # local env files 29 | .env*.local 30 | 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | next-env.d.ts 37 | -------------------------------------------------------------------------------- /examples/.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": "../node_modules/typescript/lib", 3 | "typescript.enablePromptUseWorkspaceTsdk": true 4 | } -------------------------------------------------------------------------------- /examples/README.md: -------------------------------------------------------------------------------- 1 | This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app). 2 | 3 | ## Getting Started 4 | 5 | First, run the development server: 6 | 7 | ```bash 8 | npm run dev 9 | # or 10 | yarn dev 11 | # or 12 | pnpm dev 13 | ``` 14 | 15 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. 16 | 17 | You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. 18 | 19 | [API routes](https://nextjs.org/docs/api-routes/introduction) can be accessed on [http://localhost:3000/api/hello](http://localhost:3000/api/hello). This endpoint can be edited in `pages/api/hello.ts`. 20 | 21 | The `pages/api` directory is mapped to `/api/*`. Files in this directory are treated as [API routes](https://nextjs.org/docs/api-routes/introduction) instead of React pages. 22 | 23 | This project uses [`next/font`](https://nextjs.org/docs/basic-features/font-optimization) to automatically optimize and load Inter, a custom Google Font. 24 | 25 | ## Learn More 26 | 27 | To learn more about Next.js, take a look at the following resources: 28 | 29 | - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. 30 | - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. 31 | 32 | You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome! 33 | 34 | ## Deploy on Vercel 35 | 36 | The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. 37 | 38 | Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details. 39 | -------------------------------------------------------------------------------- /examples/app/globals.css: -------------------------------------------------------------------------------- 1 | @import url("https://fonts.googleapis.com/css2?family=Karla:ital,wght@0,400;0,700;1,700&display=swap"); 2 | 3 | @tailwind base; 4 | @tailwind components; 5 | @tailwind utilities; 6 | 7 | html { 8 | font-size: 20px; 9 | } 10 | 11 | body { 12 | background-color: #e1edff; 13 | } 14 | 15 | @media (min-width: 640px) { 16 | .main { 17 | grid-template-columns: auto minmax(0, 1fr); 18 | } 19 | } 20 | 21 | @media (max-width: 639px) { 22 | ul.menu { 23 | display: none; 24 | } 25 | 26 | ul.menu.menu-open { 27 | display: block; 28 | } 29 | } 30 | 31 | /* when not prefers reduced motion */ 32 | @media (prefers-reduced-motion: no-preference) { 33 | /* scroll behavior smooth */ 34 | html { 35 | scroll-behavior: smooth; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /examples/app/head.tsx: -------------------------------------------------------------------------------- 1 | export default function Head() { 2 | return ( 3 | <> 4 | 5 | Graph Selector - Describe graph data in an expressive, library-agnostic 6 | syntax. 7 | 8 | 9 | 10 | 14 | 18 | 19 | 20 | 24 | 28 | 29 | 33 | 37 | 38 | 42 | 46 | 47 | 48 | ); 49 | } 50 | -------------------------------------------------------------------------------- /examples/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import './globals.css' 2 | 3 | export default function RootLayout({ 4 | children, 5 | }: { 6 | children: React.ReactNode 7 | }) { 8 | return ( 9 | 10 | {/* 11 | will contain the components returned by the nearest parent 12 | head.tsx. Find out more at https://beta.nextjs.org/docs/api-reference/file-conventions/head 13 | */} 14 | 15 | {children} 16 | 17 | ) 18 | } 19 | -------------------------------------------------------------------------------- /examples/app/page.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Attributes, 3 | ClassConnections, 4 | D3BarGraph, 5 | EdgesLabels, 6 | IdsClasses, 7 | Images, 8 | SankeyDiagram, 9 | TabularData, 10 | } from "@/components/Sections"; 11 | import { FaArrowRight, FaGithub, FaTwitter } from "react-icons/fa"; 12 | import { Sponsor, Stars } from "@/components/GithubButtons"; 13 | 14 | import { Nav } from "../components/Nav"; 15 | import { SyntaxHighlighter } from "@/components/SyntaxHighlighter"; 16 | 17 | /* eslint-disable @next/next/no-img-element */ 18 | export default function Home() { 19 | return ( 20 |
21 |
22 |

Graph Selector

23 |
24 | 28 | 29 | 30 | 31 | 32 |
33 |
34 | 35 |
36 |
111 |
112 | ); 113 | } 114 | 115 | function Banner() { 116 | return ( 117 |
118 |

119 | Describe graphs and their associated data in an expressive, agnostic syntax. 120 |

121 |

122 | Graph Selector is a language for expressing graphs, such as nodes and 123 | edges, and storing arbitrary data on those nodes and edges. It can be 124 | used in conjunction with a variety of visualization libraries such as 125 | Cytoscape.js, D3, and Recharts, and is designed to be agnostic of the 126 | visualization library. 127 |

128 |
129 | 133 | Get started 134 | 135 | 139 | 140 | Github 141 | 142 |
143 |

144 | Made with ❤️ by{" "} 145 | 149 | Tone Row 150 | 151 | . 152 |

153 |
154 | ); 155 | } 156 | 157 | function Section({ 158 | title, 159 | children, 160 | description, 161 | }: { 162 | title: string; 163 | description?: React.ReactNode; 164 | children: React.ReactNode; 165 | }) { 166 | const slug = title 167 | .toLowerCase() 168 | .replace(/[^a-z- 0-9]/g, "") 169 | .replace(/ {2,}/g, " ") 170 | .replace(/\s/g, "-"); 171 | return ( 172 |
173 |
174 |

{title}

175 | {description && ( 176 |

{description}

177 | )} 178 |
179 | {children} 180 |
181 | ); 182 | } 183 | -------------------------------------------------------------------------------- /examples/app/playground/page.tsx: -------------------------------------------------------------------------------- 1 | import { PlaygroundState } from "@/components/PlaygroundState"; 2 | 3 | type SearchParams = Promise<{ [key: string]: string | string[] | undefined }>; 4 | 5 | interface PageProps { 6 | params: Promise<{ slug: string }>; 7 | searchParams: SearchParams; 8 | } 9 | 10 | export default async function Page({ searchParams }: PageProps) { 11 | const search = await searchParams; 12 | const initialValue = search.code 13 | ? decodeURIComponent(search.code as string) 14 | : undefined; 15 | 16 | return ( 17 |
18 |

Playground

19 | 20 |
21 | ); 22 | } 23 | -------------------------------------------------------------------------------- /examples/assets/Karla-Bold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tone-row/graph-selector/eafb5ea87e96a281c822560a6f2d1cc3f96ba539/examples/assets/Karla-Bold.ttf -------------------------------------------------------------------------------- /examples/components/CytoscapeGraph.tsx: -------------------------------------------------------------------------------- 1 | import cytoscape, { CytoscapeOptions, ElementDefinition } from "cytoscape"; 2 | import { useEffect, useRef, useState } from "react"; 3 | 4 | import dagre from "cytoscape-dagre"; 5 | import edgeConnections from "cytoscape-edge-connections"; 6 | import { toCytoscapeElements } from "graph-selector"; 7 | 8 | cytoscape.use(edgeConnections); 9 | cytoscape.use(dagre); 10 | 11 | export function CytoscapeGraph({ 12 | elements, 13 | style = [], 14 | containerStyle = {}, 15 | }: { 16 | elements: ReturnType; 17 | style?: cytoscape.StylesheetStyle[]; 18 | containerStyle?: React.CSSProperties; 19 | }) { 20 | const cy = useRef(); 21 | const cyError = useRef(); 22 | const container = useRef(null); 23 | const [error, setError] = useState(""); 24 | useEffect(() => { 25 | setError(""); 26 | try { 27 | let options = { 28 | elements, 29 | layout: { 30 | // @ts-ignore 31 | name: "dagre", 32 | // @ts-ignore 33 | spacingFactor: 2, 34 | rankDir: "LR", 35 | }, 36 | style: [ 37 | { 38 | selector: "node", 39 | style: { 40 | label: "data(label)", 41 | "text-wrap": "wrap", 42 | }, 43 | }, 44 | { 45 | selector: "edge", 46 | style: { 47 | label: "data(label)", 48 | "curve-style": "bezier", 49 | "text-rotation": "autorotate", 50 | "target-arrow-shape": "triangle", 51 | }, 52 | }, 53 | { 54 | selector: ".large", 55 | style: { 56 | "font-size": "24px", 57 | }, 58 | }, 59 | { 60 | selector: ".small", 61 | style: { 62 | "font-size": "12px", 63 | }, 64 | }, 65 | { 66 | selector: ":parent", 67 | style: { 68 | "background-opacity": 0.333, 69 | "text-valign": "top", 70 | "text-halign": "center", 71 | "text-margin-y": -10, 72 | }, 73 | }, 74 | ...style, 75 | ], 76 | } as CytoscapeOptions; 77 | // test with error first 78 | cyError.current = cytoscape({ ...options, headless: true }); 79 | let cyE = cyError.current; 80 | 81 | // if that works, then do it for real 82 | cy.current = cytoscape({ ...options, container: container.current }); 83 | let cyC = cy.current; 84 | 85 | // destroy both 86 | return () => { 87 | cyC?.destroy(); 88 | cyE?.destroy(); 89 | }; 90 | } catch (e) { 91 | console.error(e); 92 | setError((e as Error).message); 93 | return () => { 94 | cyError.current?.destroy(); 95 | }; 96 | } 97 | }, [elements, style]); 98 | return ( 99 | <> 100 | {error &&
{error}
} 101 |
106 | 107 | ); 108 | } 109 | -------------------------------------------------------------------------------- /examples/components/D3Graph.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as d3 from "d3"; 4 | 5 | import { useEffect, useRef } from "react"; 6 | 7 | export function D3Graph({ 8 | data, 9 | id, 10 | }: { 11 | data: { price: number; name: string }[]; 12 | id: string; 13 | }) { 14 | const divRef = useRef(null); 15 | 16 | useEffect(() => { 17 | if (!divRef.current) return; 18 | let currentDiv = divRef.current; 19 | 20 | // set the dimensions and margins of the graph 21 | const margin = 60, 22 | width = 460 - margin - margin, 23 | height = 400 - margin - margin; 24 | 25 | // append the svg object to the body of the page 26 | var svg = d3 27 | .select(`#${id}`) 28 | .append("svg") 29 | .attr("width", width + margin + margin) 30 | .attr("height", height + margin + margin) 31 | .append("g") 32 | .attr("transform", "translate(" + margin + "," + margin + ")"); 33 | 34 | const xScale = d3.scaleBand().range([0, width]).padding(0.2); 35 | const yScale = d3.scaleLinear().range([height, 0]); 36 | 37 | // create a group 38 | const g = svg.append("g"); 39 | 40 | // use data to update axis with domains 41 | xScale.domain(data.map((d, i) => d.name)); 42 | yScale.domain([0, d3.max(data, (d) => d.price) as number]); 43 | 44 | // append x axis to group 45 | g.append("g") 46 | .attr("transform", `translate(0,${height})`) 47 | .call(d3.axisBottom(xScale)) 48 | .selectAll("text") 49 | .attr("transform", "translate(-10,0) rotate(-45)") 50 | .style("text-anchor", "end"); 51 | 52 | // append y axis to group 53 | g.append("g").call(d3.axisLeft(yScale)); 54 | 55 | // append rects to group 56 | g.selectAll("rect") 57 | .data(data) 58 | .enter() 59 | .append("rect") 60 | .attr("x", (d) => xScale(d.name) as number) 61 | .attr("y", (d) => yScale(d.price)) 62 | .attr("width", xScale.bandwidth()) 63 | .attr("height", (d) => height - yScale(d.price)); 64 | 65 | return () => { 66 | currentDiv.innerHTML = ""; 67 | }; 68 | }, [data, id]); 69 | 70 | return ( 71 |
72 |
73 |
74 | ); 75 | } 76 | -------------------------------------------------------------------------------- /examples/components/Editor.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { default as Ed, EditorProps } from "@monaco-editor/react"; 4 | 5 | import { highlight } from "graph-selector"; 6 | import { memo } from "react"; 7 | 8 | function E(props: EditorProps) { 9 | return ; 10 | } 11 | 12 | export const Editor = memo(function Editor({ 13 | h = 200, 14 | options = {}, 15 | ...props 16 | }: EditorProps & { h?: number }) { 17 | return ( 18 |
19 | 52 |
53 | ); 54 | }); 55 | -------------------------------------------------------------------------------- /examples/components/GithubButtons.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import GitHubButton from "react-github-btn"; 4 | 5 | export function Stars() { 6 | return ( 7 | 14 | Star 15 | 16 | ); 17 | } 18 | 19 | export function Sponsor() { 20 | return ( 21 | 28 | Sponsor 29 | 30 | ); 31 | } 32 | -------------------------------------------------------------------------------- /examples/components/Nav.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { FaBars } from "react-icons/fa"; 4 | import { useEffect, useRef, useState } from "react"; 5 | 6 | const links: { href: string; text: string }[] = [ 7 | { href: "#a-concise-demonstration", text: "A Concise Demonstration" }, 8 | { href: "#installation", text: "Installation" }, 9 | { href: "#ids-classes", text: "ID's & Classes" }, 10 | { href: "#creating-edges-with-labels", text: "Creating Edges with Labels" }, 11 | { href: "#class-connections", text: "Class Connections" }, 12 | { href: "#attributes", text: "Attributes" }, 13 | { href: "#d3-bar-graph", text: "D3 Bar Graph" }, 14 | { href: "#sankey-diagram", text: "Sankey Diagram" }, 15 | { href: "#images", text: "Images" }, 16 | { href: "#tabular-data", text: "Tabular Data" }, 17 | ]; 18 | 19 | const MOBILE_BREAKPOINT = 640; 20 | 21 | export function Nav() { 22 | const navRef = useRef(null); 23 | /* watch when nav becomes sticky and add a class */ 24 | useEffect(() => { 25 | const nav = navRef.current; 26 | if (!nav) return; 27 | const observer = new IntersectionObserver( 28 | ([entry]) => { 29 | entry.target.classList.toggle("is-pinned", !entry.isIntersecting); 30 | }, 31 | { threshold: [1] } 32 | ); 33 | observer.observe(nav); 34 | return () => observer.disconnect(); 35 | }, []); 36 | const [isMenuOpen, setIsMenuOpen] = useState(false); 37 | /** 38 | * Intercept link click, check if on mobile 39 | * If on mobile, close the menu, find the location of the target 40 | * Subtract 100px to account for the sticky nav 41 | * Scroll to the target 42 | */ 43 | const handleClick = (e: React.MouseEvent) => { 44 | if (window.innerWidth >= MOBILE_BREAKPOINT) return; 45 | e.preventDefault(); 46 | 47 | const target = e.currentTarget.getAttribute("href"); 48 | if (target) { 49 | const targetEl = document.querySelector(target); 50 | if (targetEl) { 51 | if (isMenuOpen) setIsMenuOpen(false); 52 | // defer, so the menu can close first 53 | setTimeout(() => { 54 | const targetY = targetEl.getBoundingClientRect().top; 55 | window.scrollBy({ top: targetY - 80, behavior: "smooth" }); 56 | }, 100); 57 | } 58 | } 59 | }; 60 | return ( 61 |
62 | 86 |
87 | ); 88 | } 89 | -------------------------------------------------------------------------------- /examples/components/PlaygroundState.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useEffect, useMemo, useState } from "react"; 4 | import { highlight, parse } from "graph-selector"; 5 | import { Editor } from "./Editor"; 6 | import monaco from "monaco-editor"; 7 | const defaultCode = `Welcome to Flowchart Fun! 8 | Start: Modify text to see it transform into a flowchart on the right. 9 | Understand Syntax .shape_circle 10 | Begin Typing: Start with a label or decision. 11 | Pete's Coffee: Use colons like "Decisions:". 12 | Indent for Steps +=-=0349-03948*@#$d: Indicate progression or dependency. 13 | Customize: Add classes to change color and shape \\(.color_red) .color_red 14 | Right-click nodes for more options. 15 | Use AI .color_green 16 | Paste a document to convert it into a flowchart. 17 | Share Your Work .color_blue 18 | Download or share your flowchart using the 'Share' button. 19 | Hello World 20 | this: goes to this .color_red[n=15] 21 | (goes to this) 22 | fun { 23 | wow 24 | } /* 25 | tesing a multiline comment 26 | */ 27 | does it work // not really`; 28 | 29 | export function PlaygroundState({ initialValue }: { initialValue?: string }) { 30 | const [value, setValue] = useState(initialValue ?? defaultCode); 31 | const graph = useMemo(() => { 32 | try { 33 | return parse(value); 34 | } catch (e) { 35 | console.log(e); 36 | return { nodes: [], edges: [] }; 37 | } 38 | }, [value]); 39 | const [darkMode, setDarkMode] = useState(false); 40 | return ( 41 | <> 42 |
43 | 51 |
52 | val && setValue(val)} 57 | theme={darkMode ? highlight.defaultThemeDark : highlight.defaultTheme} 58 | options={{ 59 | theme: darkMode ? highlight.defaultThemeDark : highlight.defaultTheme, 60 | }} 61 | /> 62 | 63 | {JSON.stringify(graph)} 64 | 65 | 66 | ); 67 | } 68 | -------------------------------------------------------------------------------- /examples/components/ReCharts.tsx: -------------------------------------------------------------------------------- 1 | export const toPercent = (decimal: number, fixed = 0) => 2 | `${(decimal * 100).toFixed(0)}%`; 3 | 4 | export function CustomizedAxisTick(props: any) { 5 | const { x, y, stroke, payload } = props; 6 | 7 | return ( 8 | 9 | 17 | {payload.value} 18 | 19 | 20 | ); 21 | } 22 | -------------------------------------------------------------------------------- /examples/components/SankeyChart.tsx: -------------------------------------------------------------------------------- 1 | // @ts-nocheck 2 | 3 | "use client"; 4 | 5 | import * as d3 from "d3"; 6 | import * as d3Sankey from "d3-sankey"; 7 | 8 | import { useEffect, useRef } from "react"; 9 | 10 | export function SankeyChart({ links = [] }: { links?: any[] }) { 11 | const divRef = useRef(null); 12 | useEffect(() => { 13 | if (!divRef.current) return; 14 | let currentDiv = divRef.current; 15 | 16 | // set the dimensions and margins of the graph 17 | const margin = 20, 18 | width = 460 - margin - margin, 19 | height = 400 - margin - margin; 20 | 21 | Sankey( 22 | { links }, 23 | { 24 | nodeGroup: (d) => d.id, // take first word for color 25 | nodeAlign: "justify", // e.g., d3.sankeyJustify; set by input above 26 | linkColor: "source-target", // e.g., "source" or "target"; set by input above 27 | format: ( 28 | (f) => (d) => 29 | `${f(d)} TWh` 30 | )(d3.format(",.1~f")), 31 | width, 32 | height, 33 | } 34 | ); 35 | 36 | return () => { 37 | currentDiv.innerHTML = ""; 38 | }; 39 | }, [links]); 40 | 41 | return ( 42 |
43 |
44 |
45 | ); 46 | } 47 | 48 | // Borrowed from https://observablehq.com/@d3/sankey 49 | function Sankey( 50 | { nodes, links }, 51 | { 52 | format = ",", 53 | align = "justify", 54 | nodeId = (d) => d.id, 55 | nodeGroup, 56 | nodeGroups, 57 | nodeLabel, 58 | nodeTitle = (d) => `${d.id}\n${format(d.value)}`, 59 | nodeAlign = align, 60 | nodeWidth = 15, 61 | nodePadding = 10, 62 | nodeLabelPadding = 6, 63 | nodeStroke = "currentColor", 64 | nodeStrokeWidth, 65 | nodeStrokeOpacity, 66 | nodeStrokeLinejoin, 67 | linkSource = ({ source }) => source, 68 | linkTarget = ({ target }) => target, 69 | linkValue = ({ value }) => value, 70 | linkPath = d3Sankey.sankeyLinkHorizontal(), 71 | linkTitle = (d) => `${d.source.id} → ${d.target.id}\n${format(d.value)}`, 72 | linkColor = "source-target", 73 | linkStrokeOpacity = 0.5, 74 | linkMixBlendMode = "multiply", 75 | colors = d3.schemeTableau10, 76 | width = 640, 77 | height = 400, 78 | marginTop = 5, 79 | marginRight = 1, 80 | marginBottom = 5, 81 | marginLeft = 1, 82 | } = {} 83 | ) { 84 | // Compute values. 85 | const LS = d3.map(links, linkSource).map(intern); 86 | const LT = d3.map(links, linkTarget).map(intern); 87 | const LV = d3.map(links, linkValue); 88 | if (nodes === undefined) 89 | nodes = Array.from(d3.union(LS, LT), (id) => ({ id })); 90 | const N = d3.map(nodes, nodeId).map(intern); 91 | const G = nodeGroup == null ? null : d3.map(nodes, nodeGroup).map(intern); 92 | 93 | // Replace the input nodes and links with mutable objects for the simulation. 94 | nodes = d3.map(nodes, (_, i) => ({ id: N[i] })); 95 | links = d3.map(links, (_, i) => ({ 96 | source: LS[i], 97 | target: LT[i], 98 | value: LV[i], 99 | })); 100 | 101 | // Ignore a group-based linkColor option if no groups are specified. 102 | if (!G && ["source", "target", "source-target"].includes(linkColor)) 103 | linkColor = "currentColor"; 104 | 105 | // Compute default domains. 106 | if (G && nodeGroups === undefined) nodeGroups = G; 107 | 108 | // Construct the scales. 109 | const color = nodeGroup == null ? null : d3.scaleOrdinal(nodeGroups, colors); 110 | 111 | // Compute the Sankey layout. 112 | d3Sankey 113 | .sankey() 114 | .nodeId(({ index: i }) => N[i]) 115 | .nodeWidth(nodeWidth) 116 | .nodePadding(nodePadding) 117 | .extent([ 118 | [marginLeft, marginTop], 119 | [width - marginRight, height - marginBottom], 120 | ])({ nodes, links }); 121 | 122 | // Compute titles and labels using layout nodes, so as to access aggregate values. 123 | if (typeof format !== "function") format = d3.format(format); 124 | const Tl = 125 | nodeLabel === undefined 126 | ? N 127 | : nodeLabel == null 128 | ? null 129 | : d3.map(nodes, nodeLabel); 130 | const Tt = nodeTitle == null ? null : d3.map(nodes, nodeTitle); 131 | const Lt = linkTitle == null ? null : d3.map(links, linkTitle); 132 | 133 | // A unique identifier for clip paths (to avoid conflicts). 134 | const uid = `O-${Math.random().toString(16).slice(2)}`; 135 | 136 | const svg = d3 137 | .select("#my_dataviz") 138 | .append("svg") 139 | .attr("width", width) 140 | .attr("height", height) 141 | .attr("viewBox", [0, 0, width, height]) 142 | .attr("style", "max-width: 100%; height: auto; height: intrinsic;"); 143 | 144 | const node = svg 145 | .append("g") 146 | .attr("stroke", nodeStroke) 147 | .attr("stroke-width", nodeStrokeWidth) 148 | .attr("stroke-opacity", nodeStrokeOpacity) 149 | .attr("stroke-linejoin", nodeStrokeLinejoin) 150 | .selectAll("rect") 151 | .data(nodes) 152 | .join("rect") 153 | .attr("x", (d) => d.x0) 154 | .attr("y", (d) => d.y0) 155 | .attr("height", (d) => d.y1 - d.y0) 156 | .attr("width", (d) => d.x1 - d.x0); 157 | 158 | if (G) node.attr("fill", ({ index: i }) => color(G[i])); 159 | if (Tt) node.append("title").text(({ index: i }) => Tt[i]); 160 | 161 | const link = svg 162 | .append("g") 163 | .attr("fill", "none") 164 | .attr("stroke-opacity", linkStrokeOpacity) 165 | .selectAll("g") 166 | .data(links) 167 | .join("g") 168 | .style("mix-blend-mode", linkMixBlendMode); 169 | 170 | if (linkColor === "source-target") 171 | link 172 | .append("linearGradient") 173 | .attr("id", (d) => `${uid}-link-${d.index}`) 174 | .attr("gradientUnits", "userSpaceOnUse") 175 | .attr("x1", (d) => d.source.x1) 176 | .attr("x2", (d) => d.target.x0) 177 | .call((gradient) => 178 | gradient 179 | .append("stop") 180 | .attr("offset", "0%") 181 | .attr("stop-color", ({ source: { index: i } }) => color(G[i])) 182 | ) 183 | .call((gradient) => 184 | gradient 185 | .append("stop") 186 | .attr("offset", "100%") 187 | .attr("stop-color", ({ target: { index: i } }) => color(G[i])) 188 | ); 189 | 190 | link 191 | .append("path") 192 | .attr("d", linkPath) 193 | .attr( 194 | "stroke", 195 | linkColor === "source-target" 196 | ? ({ index: i }) => `url(#${uid}-link-${i})` 197 | : linkColor === "source" 198 | ? ({ source: { index: i } }) => color(G[i]) 199 | : linkColor === "target" 200 | ? ({ target: { index: i } }) => color(G[i]) 201 | : linkColor 202 | ) 203 | .attr("stroke-width", ({ width }) => Math.max(1, width)) 204 | .call( 205 | Lt 206 | ? (path) => path.append("title").text(({ index: i }) => Lt[i]) 207 | : () => {} 208 | ); 209 | 210 | if (Tl) 211 | svg 212 | .append("g") 213 | .attr("font-family", "sans-serif") 214 | .attr("font-size", 10) 215 | .selectAll("text") 216 | .data(nodes) 217 | .join("text") 218 | .attr("x", (d) => 219 | d.x0 < width / 2 ? d.x1 + nodeLabelPadding : d.x0 - nodeLabelPadding 220 | ) 221 | .attr("y", (d) => (d.y1 + d.y0) / 2) 222 | .attr("dy", "0.35em") 223 | .attr("text-anchor", (d) => (d.x0 < width / 2 ? "start" : "end")) 224 | .text(({ index: i }) => Tl[i]); 225 | 226 | function intern(value) { 227 | return value !== null && typeof value === "object" 228 | ? value.valueOf() 229 | : value; 230 | } 231 | 232 | return Object.assign(svg.node(), { scales: { color } }); 233 | } 234 | -------------------------------------------------------------------------------- /examples/components/Sections.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { 4 | Area, 5 | AreaChart, 6 | CartesianGrid, 7 | ResponsiveContainer, 8 | Tooltip, 9 | XAxis, 10 | YAxis, 11 | } from "recharts"; 12 | import { CustomizedAxisTick, toPercent } from "./ReCharts"; 13 | import { Graph, ParseError, parse, toCytoscapeElements } from "graph-selector"; 14 | import { Panel, PanelGroup, PanelResizeHandle } from "react-resizable-panels"; 15 | 16 | import { CytoscapeGraph } from "./CytoscapeGraph"; 17 | import { D3Graph } from "./D3Graph"; 18 | import { Editor } from "./Editor"; 19 | import { FaGripLinesVertical } from "react-icons/fa"; 20 | import { SankeyChart } from "./SankeyChart"; 21 | import { useCallback, useReducer, useRef } from "react"; 22 | import { EditorProps } from "@monaco-editor/react"; 23 | import type { editor } from "monaco-editor"; 24 | 25 | type MonacoRefs = { 26 | editor: editor.IStandaloneCodeEditor; 27 | monaco: typeof import("monaco-editor"); 28 | }; 29 | 30 | type State = { 31 | result: Graph; 32 | error: string; 33 | code: string; 34 | }; 35 | const useCode = (initialCode: string) => { 36 | const refs = useRef(null); 37 | const onMount = useCallback>( 38 | (editor, monaco) => { 39 | refs.current = { editor, monaco }; 40 | }, 41 | [] 42 | ); 43 | const reducer = useReducer( 44 | (s: State, n: string): State => { 45 | try { 46 | // remove modal markers 47 | if (refs.current) { 48 | const { editor, monaco } = refs.current; 49 | const model = editor.getModel(); 50 | if (model) { 51 | monaco.editor.setModelMarkers(model, "graph-selector", []); 52 | } 53 | } 54 | const result = parse(n); 55 | return { result, error: "", code: n }; 56 | } catch (e) { 57 | console.log(e); 58 | if (refs.current && isParseError(e)) { 59 | const { editor, monaco } = refs.current; 60 | const model = editor.getModel(); 61 | if (model) { 62 | const { startLineNumber, endLineNumber, startColumn, endColumn } = 63 | e; 64 | const message = e.message; 65 | const severity = monaco.MarkerSeverity.Error; 66 | monaco.editor.setModelMarkers(model, "graph-selector", [ 67 | { 68 | startLineNumber, 69 | endLineNumber, 70 | message, 71 | severity, 72 | startColumn, 73 | endColumn, 74 | }, 75 | ]); 76 | } 77 | } 78 | return { 79 | result: { nodes: [], edges: [] }, 80 | error: (e as Error).message, 81 | code: n, 82 | }; 83 | } 84 | }, 85 | { 86 | result: { nodes: [], edges: [] }, 87 | error: "", 88 | code: initialCode, 89 | } as State, 90 | (initial: State) => { 91 | try { 92 | const result = parse(initial.code); 93 | return { result, error: "", code: initial.code }; 94 | } catch (e) { 95 | return { 96 | result: { nodes: [], edges: [] }, 97 | error: (e as Error).message, 98 | code: initial.code, 99 | }; 100 | } 101 | } 102 | ); 103 | 104 | return [reducer[0], reducer[1], onMount] as const; 105 | }; 106 | 107 | function isParseError(e: unknown): e is ParseError { 108 | return e instanceof Error && e.name === "ParseError"; 109 | } 110 | 111 | const idsClasses = `This is a label #a.large 112 | (#c) 113 | This is a longer label #b 114 | (#c) 115 | The longest label text of all #c`; 116 | export function IdsClasses() { 117 | const [state, dispatch, onMount] = useCode(idsClasses); 118 | return ( 119 | <> 120 | 121 | 122 | dispatch(value || "")} 125 | onMount={onMount} 126 | /> 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 |
136 | 137 | ); 138 | } 139 | 140 | const edgesLabels = `a\n\tto: b\n\tc\n\t\tgoes to: d\n\nA Container {\n\thello world\n\t\t(b)\n}`; 141 | export function EdgesLabels() { 142 | const [state, dispatch] = useCode(edgesLabels); 143 | return ( 144 | 145 | 146 | dispatch(value || "")} 149 | /> 150 | 151 | 152 | 153 | 154 | 155 | 156 | 157 | 158 | ); 159 | } 160 | 161 | const classConnections = `X .a 162 | Y .a 163 | Z .a 164 | 165 | one to many 166 | (.a) 167 | 168 | X .b 169 | Y .b 170 | Z .b 171 | 172 | (.b) 173 | many to one 174 | 175 | many to many .c 176 | X .c 177 | Y .d 178 | Z .d 179 | 180 | (.c) 181 | (.d)`; 182 | 183 | export function ClassConnections() { 184 | const [state, dispatch] = useCode(classConnections); 185 | return ( 186 | 187 | 188 | dispatch(value || "")} 191 | /> 192 | 193 | 194 | 195 | 196 | 197 | 198 | 199 | 200 | ); 201 | } 202 | 203 | const attributes = `Mercury [size=2.439] 204 | Venus [size=6.052] 205 | Earth [size=6.371] 206 | Mars [size=3.390] 207 | Jupiter [size=69.911] 208 | Saturn [size=58.232] 209 | Uranus [size=25.362] 210 | Neptune [size=24.622]`; 211 | export function Attributes() { 212 | const [state, dispatch] = useCode(attributes); 213 | return ( 214 | 215 | 216 | dispatch(value || "")} 219 | /> 220 | 221 | 222 | 223 | 224 | 225 | 254 | 255 | 256 | ); 257 | } 258 | 259 | const d3BarGraph = `Item 1 [price=4] 260 | Item 2 [price=3] 261 | Item 3 [price=5] 262 | Item 4 [price=12] 263 | `; 264 | export function D3BarGraph() { 265 | const [state, dispatch] = useCode(d3BarGraph); 266 | const prices = state.result.nodes 267 | .filter((node) => node.data?.price) 268 | .map((node) => ({ 269 | price: parseInt(node.data.price.toString(), 10), 270 | name: node.data.label, 271 | })); 272 | return ( 273 | 274 | 275 | dispatch(value || "")} 278 | /> 279 | 280 | 281 | 282 | 283 | 284 | 285 | 286 | 287 | ); 288 | } 289 | 290 | const sankeyDiagram = `Thing One 291 | Thing Two 292 | Thing Three 293 | Thing Four .a 294 | Thing Five .a 295 | 296 | (Thing One) 297 | [amt=15]: (Thing Two) 298 | [amt=20]: (Thing Three) 299 | 300 | (Thing Two) 301 | [amt=12]: (Thing Four) 302 | [amt=20]: (Thing Five) 303 | 304 | (Thing Three) 305 | [amt=6]: (.a)`; 306 | export function SankeyDiagram() { 307 | const [state, dispatch] = useCode(sankeyDiagram); 308 | const links = state.result 309 | ? state.result.edges.map((edge) => { 310 | const source = state.result.nodes.find( 311 | (node) => node.data.id === edge.source 312 | ); 313 | const target = state.result.nodes.find( 314 | (node) => node.data.id === edge.target 315 | ); 316 | if (!source || !target) return null; 317 | return { 318 | source: source.data.label, 319 | target: target.data.label, 320 | value: edge.data.amt, 321 | }; 322 | }) 323 | : []; 324 | return ( 325 | 326 | 327 | dispatch(value || "")} 330 | /> 331 | 332 | 333 | 334 | 335 | {links.length && } 336 | 337 | ); 338 | } 339 | 340 | const images = `[src="https://i.ibb.co/N3r6Fy1/Screen-Shot-2023-01-11-at-2-22-31-PM.png"] 341 | [src="https://i.ibb.co/xgZXHG0/Screen-Shot-2023-01-11-at-2-22-36-PM.png"] 342 | [src="https://i.ibb.co/k6SgRvb/Screen-Shot-2023-01-11-at-2-22-41-PM.png"] 343 | [src="https://i.ibb.co/34TTCqM/Screen-Shot-2023-01-11-at-2-22-47-PM.png"] 344 | 345 | Encoded Svgs { 346 | [src='data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2232%22%20height%3D%2232%22%20viewBox%3D%220%200%2032%2032%22%3E%3Cpath%20d%3D%22M9.5%2019c0%203.59%202.91%206.5%206.5%206.5s6.5-2.91%206.5-6.5-2.91-6.5-6.5-6.5-6.5%202.91-6.5%206.5zM30%208h-7c-0.5-2-1-4-3-4h-8c-2%200-2.5%202-3%204h-7c-1.1%200-2%200.9-2%202v18c0%201.1%200.9%202%202%202h28c1.1%200%202-0.9%202-2v-18c0-1.1-0.9-2-2-2zM16%2027.875c-4.902%200-8.875-3.973-8.875-8.875s3.973-8.875%208.875-8.875c4.902%200%208.875%203.973%208.875%208.875s-3.973%208.875-8.875%208.875zM30%2014h-4v-2h4v2z%22%20fill%3D%22%23000000%22%3E%3C%2Fpath%3E%3C%2Fsvg%3E'] 347 | #bounds[src='data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A//www.w3.org/2000/svg%22%20height%3D%22120%22%20width%3D%22120%22%20viewBox%3D%22-10%20-10%20%20110%20110%22%3E%0A%20%20%3Cdefs%3E%0A%20%20%20%20%3Cfilter%20id%3D%22dropshadow%22%3E%0A%20%20%3CfeGaussianBlur%20in%3D%22SourceAlpha%22%20stdDeviation%3D%224%22/%3E%20%0A%20%20%3CfeOffset%20dx%3D%222%22%20dy%3D%222%22/%3E%0A%20%20%3CfeComponentTransfer%3E%0A%20%20%20%20%3CfeFuncA%20type%3D%22linear%22%20slope%3D%220.4%22/%3E%0A%20%20%3C/feComponentTransfer%3E%0A%20%20%3CfeMerge%3E%20%0A%20%20%20%20%3CfeMergeNode/%3E%0A%20%20%20%20%3CfeMergeNode%20in%3D%22SourceGraphic%22/%3E%20%0A%20%20%3C/feMerge%3E%0A%3C/filter%3E%0A%20%20%3C/defs%3E%0A%20%20%3Crect%20width%3D%2280%22%20height%3D%2280%22%20stroke%3D%22black%22%20stroke-width%3D%221%22%0A%20%20fill%3D%22white%22%20filter%3D%22url%28%23dropshadow%29%22%20rx%3D%228%22%20/%3E%0A%3C/svg%3E'][w=100][h=100] 348 | } 349 | `; 350 | 351 | export function Images() { 352 | const [state, dispatch] = useCode(images); 353 | return ( 354 | 355 | 356 | dispatch(value || "")} 359 | /> 360 | 361 | 362 | 363 | 364 | 365 | 404 | 405 | 406 | ); 407 | } 408 | 409 | const tabularData = `1955 to 1959 [c2us=200894][us2c=53361] 410 | 1960 to 1964 [c2us=240033][us2c=58707] 411 | 1965 to 1969 [c2us=193095][us2c=94902] 412 | 1970 to 1974 [c2us=95252][us2c=123191] 413 | 1975 to 1979 [c2us=84333][us2c=69920] 414 | 1980 to 1984 [c2us=83059][us2c=44148] 415 | 1985 to 1988 [c2us=64976][us2c=28438]`; 416 | export function TabularData() { 417 | const [state, dispatch] = useCode(tabularData); 418 | const data: any = state.result 419 | ? [ 420 | ...state.result.nodes.map((node) => ({ 421 | years: node.data.label, 422 | c2us: parseInt(node.data.c2us as string, 10), 423 | us2c: parseInt(node.data.us2c as string, 10), 424 | })), 425 | ] 426 | : []; 427 | return ( 428 | 429 | 430 | dispatch(value || "")} 433 | /> 434 | 435 | 436 | 437 | 438 | 439 | 440 | 452 | 453 | } /> 454 | 455 | 456 | 464 | 472 | 473 | 474 | 475 | 476 | ); 477 | } 478 | -------------------------------------------------------------------------------- /examples/components/SyntaxHighlighter.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import S from "react-syntax-highlighter"; 4 | import { github } from "react-syntax-highlighter/dist/esm/styles/hljs"; 5 | 6 | export function SyntaxHighlighter(props: any) { 7 | return ( 8 | 20 | ); 21 | } 22 | -------------------------------------------------------------------------------- /examples/index.d.ts: -------------------------------------------------------------------------------- 1 | declare module "cytoscape-edge-connections"; 2 | declare module "cytoscape-dagre"; 3 | -------------------------------------------------------------------------------- /examples/next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | } 4 | 5 | module.exports = nextConfig 6 | -------------------------------------------------------------------------------- /examples/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "examples", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint", 10 | "check": "tsc --noEmit" 11 | }, 12 | "dependencies": { 13 | "@monaco-editor/react": "^4.5.2", 14 | "@types/node": "^20", 15 | "@types/react": "^18.2.0", 16 | "@types/react-dom": "^18.2.0", 17 | "@vercel/og": "^0.5.20", 18 | "cytoscape": "^3.26.0", 19 | "cytoscape-dagre": "^2.5.0", 20 | "cytoscape-edge-connections": "^0.4.2", 21 | "d3": "^7.8.1", 22 | "d3-sankey": "^0.12.3", 23 | "eslint": "^8.21.0", 24 | "eslint-config-next": "^15.0.0", 25 | "graph-selector": "workspace:*", 26 | "monaco-editor": "^0.43.0", 27 | "next": "^15.0.0", 28 | "react": "^18.2.0", 29 | "react-dom": "^18.2.0", 30 | "react-github-btn": "^1.4.0", 31 | "react-icons": "^4.7.1", 32 | "react-resizable-panels": "^0.0.33", 33 | "react-syntax-highlighter": "^15.5.0", 34 | "recharts": "^2.3.2", 35 | "typescript": "4.9.4" 36 | }, 37 | "devDependencies": { 38 | "@types/cytoscape": "^3.19.11", 39 | "@types/d3": "^7.4.0", 40 | "@types/react-syntax-highlighter": "^15.5.6", 41 | "autoprefixer": "^10.4.13", 42 | "postcss": "^8.4.21", 43 | "prop-types": "^15.8.1", 44 | "react-smooth": "^2.0.1", 45 | "tailwindcss": "^3.2.4" 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /examples/pages/api/og.tsx: -------------------------------------------------------------------------------- 1 | import { ImageResponse } from "@vercel/og"; 2 | import { NextRequest } from "next/server"; 3 | 4 | export const config = { 5 | runtime: "edge", 6 | }; 7 | 8 | // Make sure the font exists in the specified path: 9 | const font = fetch( 10 | new URL("../../assets/Karla-Bold.ttf", import.meta.url) 11 | ).then((res) => res.arrayBuffer()); 12 | 13 | export default async function handler(req: NextRequest) { 14 | const fontData = await font; 15 | try { 16 | return new ImageResponse( 17 | ( 18 |
38 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 |
76 | Graph Selector 77 |
78 |
88 | Describe graph data in an expressive, library-agnostic syntax. 89 |
90 |
91 | ), 92 | { 93 | width: 1200, 94 | height: 630, 95 | fonts: [ 96 | { 97 | name: "Karla", 98 | data: fontData, 99 | style: "normal", 100 | }, 101 | ], 102 | } 103 | ); 104 | } catch (e: any) { 105 | console.log(`${e.message}`); 106 | return new Response(`Failed to generate the image`, { 107 | status: 500, 108 | }); 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /examples/postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /examples/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tone-row/graph-selector/eafb5ea87e96a281c822560a6f2d1cc3f96ba539/examples/public/favicon.ico -------------------------------------------------------------------------------- /examples/public/next.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /examples/public/thirteen.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /examples/public/vercel.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /examples/tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | module.exports = { 3 | content: [ 4 | "./pages/**/*.{js,ts,jsx,tsx}", 5 | "./app/**/*.{js,ts,jsx,tsx}", 6 | "./components/**/*.{js,ts,jsx,tsx}", 7 | ], 8 | theme: { 9 | extend: { 10 | fontFamily: { 11 | sans: ["Karla", "sans-serif"], 12 | }, 13 | }, 14 | }, 15 | plugins: [], 16 | }; 17 | -------------------------------------------------------------------------------- /examples/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "forceConsistentCasingInFileNames": true, 9 | "noEmit": true, 10 | "esModuleInterop": true, 11 | "module": "esnext", 12 | "moduleResolution": "node", 13 | "resolveJsonModule": true, 14 | "isolatedModules": true, 15 | "jsx": "preserve", 16 | "incremental": true, 17 | "plugins": [ 18 | { 19 | "name": "next" 20 | } 21 | ], 22 | "baseUrl": ".", 23 | "paths": { 24 | "@/*": ["./*"] 25 | } 26 | }, 27 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 28 | "exclude": ["node_modules"] 29 | } 30 | -------------------------------------------------------------------------------- /graph-selector/.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: "@typescript-eslint/parser", // Specifies the ESLint parser 3 | parserOptions: { 4 | ecmaVersion: 2020, // Allows for the parsing of modern ECMAScript features 5 | sourceType: "module", // Allows for the use of imports 6 | }, 7 | extends: [ 8 | "plugin:@typescript-eslint/recommended", // Uses the recommended rules from the @typescript-eslint/eslint-plugin 9 | "plugin:prettier/recommended", 10 | "plugin:no-lookahead-lookbehind-regexp/recommended", 11 | ], 12 | rules: { 13 | // Place to specify ESLint rules. Can be used to overwrite rules specified from the extended configs 14 | // e.g. "@typescript-eslint/explicit-function-return-type": "off", 15 | "@typescript-eslint/no-unused-vars": [ 16 | "warn", // or "error" 17 | { 18 | argsIgnorePattern: "^_", 19 | varsIgnorePattern: "^_", 20 | caughtErrorsIgnorePattern: "^_", 21 | }, 22 | ], 23 | }, 24 | env: { 25 | browser: true, 26 | }, 27 | }; 28 | -------------------------------------------------------------------------------- /graph-selector/.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | coverage 3 | README.md -------------------------------------------------------------------------------- /graph-selector/.npmignore: -------------------------------------------------------------------------------- 1 | src 2 | node_modules 3 | coverage -------------------------------------------------------------------------------- /graph-selector/.prettierrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | semi: true, 3 | trailingComma: "all", 4 | printWidth: 100, 5 | tabWidth: 2, 6 | }; 7 | -------------------------------------------------------------------------------- /graph-selector/bun.lockb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tone-row/graph-selector/eafb5ea87e96a281c822560a6f2d1cc3f96ba539/graph-selector/bun.lockb -------------------------------------------------------------------------------- /graph-selector/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "graph-selector", 3 | "version": "0.13.0", 4 | "description": "Parse indented text (flowchart.fun syntax) into a graph", 5 | "source": "src/graph-selector.ts", 6 | "main": "./dist/graph-selector.cjs", 7 | "module": "./dist/graph-selector.mjs", 8 | "types": "./dist/graph-selector.d.cts", 9 | "exports": { 10 | "require": { 11 | "types": "./dist/graph-selector.d.cts", 12 | "default": "./dist/graph-selector.cjs" 13 | }, 14 | "import": { 15 | "types": "./dist/graph-selector.d.mts", 16 | "default": "./dist/graph-selector.mjs" 17 | } 18 | }, 19 | "files": [ 20 | "dist" 21 | ], 22 | "scripts": { 23 | "build": "pkgroll", 24 | "dev": "pkgroll --watch", 25 | "test": "vitest", 26 | "test:ci": "vitest run", 27 | "test:coverage": "vitest --coverage", 28 | "check": "tsc --noEmit", 29 | "lint": "eslint 'src/**/*.{js,cjs,ts}' --quiet --fix", 30 | "lint:staged": "eslint --fix", 31 | "lint:ci": "eslint 'src/**/*.{js,cjs,ts}'", 32 | "prepublish": "pnpm build && cp ../README.md ./README.md" 33 | }, 34 | "keywords": [], 35 | "author": "", 36 | "license": "ISC", 37 | "devDependencies": { 38 | "@typescript-eslint/eslint-plugin": "^6.7.3", 39 | "@typescript-eslint/parser": "^6.7.3", 40 | "eslint": "^8.50.0", 41 | "eslint-config-prettier": "^9.0.0", 42 | "eslint-plugin-no-lookahead-lookbehind-regexp": "^0.3.0", 43 | "eslint-plugin-prettier": "^5.0.0", 44 | "prettier": "^3.0.3", 45 | "typescript": "^5.2.2", 46 | "vitest": "^0.34.5" 47 | }, 48 | "dependencies": { 49 | "@types/strip-comments": "^2.0.2", 50 | "@tone-row/strip-comments": "^2.0.6", 51 | "@types/cytoscape": "^3.19.11", 52 | "html-entities": "^2.4.0", 53 | "monaco-editor": "^0.43.0", 54 | "pkgroll": "^2.4.1", 55 | "strip-comments": "^2.0.1" 56 | }, 57 | "browserslist": [ 58 | "defaults" 59 | ] 60 | } 61 | -------------------------------------------------------------------------------- /graph-selector/src/ParseError.ts: -------------------------------------------------------------------------------- 1 | export class ParseError extends Error { 2 | startLineNumber: number; 3 | endLineNumber: number; 4 | startColumn: number; 5 | endColumn: number; 6 | code: string; 7 | 8 | constructor( 9 | message: string, 10 | startLineNumber: number, 11 | endLineNumber: number, 12 | startColumn: number, 13 | endColumn: number, 14 | /** A unique string referencing this error. Used for translations in consuming contexts. */ 15 | code: string, 16 | ) { 17 | super(message); 18 | this.name = "ParseError"; 19 | this.startLineNumber = startLineNumber; 20 | this.endLineNumber = endLineNumber; 21 | this.startColumn = startColumn; 22 | this.endColumn = endColumn; 23 | this.code = code; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /graph-selector/src/getFeatureData.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from "vitest"; 2 | 3 | import { getFeatureData } from "./getFeatureData"; 4 | 5 | describe("getFeatureData", () => { 6 | test("it parses IDs", () => { 7 | const { id, line } = getFeatureData("#hello"); 8 | expect(id).toBe("hello"); 9 | expect(line).toBe(""); 10 | }); 11 | 12 | test("it parses labels", () => { 13 | const { line } = getFeatureData("hello"); 14 | expect(line).toBe("hello"); 15 | }); 16 | 17 | test("it parses classes", () => { 18 | const { classes } = getFeatureData(".hello"); 19 | expect(classes).toBe(".hello"); 20 | }); 21 | 22 | test("it parses data", () => { 23 | const { data } = getFeatureData("[hello=world]"); 24 | expect(data).toEqual({ hello: "world" }); 25 | }); 26 | 27 | test("it parses tout le kit", () => { 28 | const { id, line, classes, data } = getFeatureData("#hello.world[hello=world]hello"); 29 | expect(id).toBe("hello"); 30 | expect(line).toBe("hello"); 31 | expect(classes).toBe(".world"); 32 | expect(data).toEqual({ hello: "world" }); 33 | }); 34 | 35 | test("it parses strings from data", () => { 36 | expect(getFeatureData("[hello=world]").data).toEqual({ hello: "world" }); 37 | expect(getFeatureData("[hello='world']").data).toEqual({ hello: "world" }); 38 | expect(getFeatureData('[hello="world"]').data).toEqual({ hello: "world" }); 39 | }); 40 | 41 | test("it parses strings with spaces", () => { 42 | expect(getFeatureData("[hello='world hello']").data).toEqual({ hello: "world hello" }); 43 | expect(getFeatureData('[hello="world hello"]').data).toEqual({ hello: "world hello" }); 44 | expect(getFeatureData("[hello=world hello]").data).toEqual({ hello: "world hello" }); 45 | }); 46 | 47 | test("can parse data number", () => { 48 | expect(getFeatureData("[hello=1]").data).toEqual({ hello: 1 }); 49 | }); 50 | 51 | test("if user declares string, saves as string", () => { 52 | expect(getFeatureData("[hello='1']").data).toEqual({ hello: "1" }); 53 | expect(getFeatureData('[hello="1"]').data).toEqual({ hello: "1" }); 54 | }); 55 | 56 | test("it parses floats", () => { 57 | expect(getFeatureData("[hello=1.1]").data).toEqual({ hello: 1.1 }); 58 | expect(getFeatureData("[hello=.1]").data).toEqual({ hello: 0.1 }); 59 | }); 60 | 61 | test("it parses implicitly true args", () => { 62 | expect(getFeatureData("[hello]").data).toEqual({ hello: true }); 63 | }); 64 | 65 | test("can parse data text that has special characters", () => { 66 | expect(getFeatureData("[hello=world(){}]").data).toEqual({ hello: "world(){}" }); 67 | expect(getFeatureData("[hello=world\n]").data).toEqual({ hello: "world\n" }); 68 | expect(getFeatureData("[hello=world\t]").data).toEqual({ hello: "world\t" }); 69 | expect(getFeatureData('[label="Hello\nWorld"]').data).toEqual({ label: "Hello\nWorld" }); 70 | }); 71 | 72 | test("accepts new lines", () => { 73 | expect(getFeatureData("[test='a\nb']").data).toEqual({ test: "a\nb" }); 74 | }); 75 | 76 | test("emojis should be valid in attribute value", () => { 77 | expect(getFeatureData(`#a[att=👍]`).data).toEqual({ att: "👍" }); 78 | }); 79 | 80 | test("emojis should be valid in attribute key", () => { 81 | expect(getFeatureData(`#a[👍=1]`).data).toEqual({ "👍": 1 }); 82 | }); 83 | }); 84 | -------------------------------------------------------------------------------- /graph-selector/src/getFeatureData.ts: -------------------------------------------------------------------------------- 1 | import { featuresRe } from "./regexps"; 2 | import { Data, Descriptor } from "./types"; 3 | 4 | /** 5 | * Given a line, it returns the feaures from the line 6 | */ 7 | export function getFeatureData(_line: string) { 8 | let line = _line.slice(0).trim(); 9 | let match: RegExpExecArray | null; 10 | let id = ""; 11 | let classes = ""; 12 | let attributes = ""; 13 | 14 | while ((match = featuresRe.exec(line)) != null) { 15 | if (!match.groups) continue; 16 | if (!match.groups.replace) break; 17 | 18 | // if (match.groups.pointer) pointers.push(match.groups.pointer); 19 | if (match.groups.id) id = match.groups.id.slice(1); 20 | if (match.groups.classes) classes = match.groups.classes; 21 | if (match.groups.attributes) attributes = match.groups.attributes; 22 | 23 | // remove everything from line 24 | if (match.groups.replace) line = line.replace(match.groups.replace, "").trim(); 25 | } 26 | 27 | // if attributes, parse into data object 28 | const data: Data = {}; 29 | if (attributes) { 30 | // We capture the rawValue (possibly with quotes) and the value inside potential quotes 31 | // to determine if the user wanted it to be parsed as a string or not 32 | const attrRe = 33 | /\[(?[^\]=]+)(?=(?'(?[^']+)'|"(?[^"]+)"|(?[^\]]+)))?\]/g; 34 | let attrMatch: RegExpExecArray | null; 35 | while ((attrMatch = attrRe.exec(attributes)) != null) { 36 | if (!attrMatch.groups) continue; 37 | const key = attrMatch.groups.key; 38 | if (!key) continue; 39 | const hasAttributeValue = attrMatch.groups.attributeValue !== undefined; 40 | // if it doesn't have an attribute value, set to true and move on 41 | if (!hasAttributeValue) { 42 | data[key] = true; 43 | continue; 44 | } 45 | 46 | let value: Descriptor = 47 | attrMatch.groups.value1 ?? attrMatch.groups.value2 ?? attrMatch.groups.value3 ?? ""; 48 | const userSuppliedString = attrMatch.groups.rawValue !== value; 49 | // if value is a number and user didn't supply a string (e.g. [hello=1] instead of [hello="1"]) 50 | // then parse it as a number 51 | if (!userSuppliedString && !isNaN(Number(value))) { 52 | value = Number(value); 53 | } 54 | data[key] = value; 55 | } 56 | } 57 | 58 | return { 59 | id, 60 | classes, 61 | data, 62 | line, 63 | }; 64 | } 65 | -------------------------------------------------------------------------------- /graph-selector/src/getIndentSize.ts: -------------------------------------------------------------------------------- 1 | export function getIndentSize(line: string) { 2 | const match = line.match(/^\s*/); 3 | return match ? match[0].length : 0; 4 | } 5 | -------------------------------------------------------------------------------- /graph-selector/src/graph-selector.ts: -------------------------------------------------------------------------------- 1 | export { parse } from "./parse"; 2 | export { toMermaid } from "./toMermaid"; 3 | export * from "./types"; 4 | export * as highlight from "./highlight"; 5 | export { toCytoscapeElements } from "./toCytoscapeElements"; 6 | export * from "./operate/operate"; 7 | export { stringify } from "./stringify"; 8 | export { ParseError } from "./ParseError"; 9 | -------------------------------------------------------------------------------- /graph-selector/src/highlight.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, test, vi, beforeEach, Mock } from "vitest"; 2 | import * as Monaco from "monaco-editor/esm/vs/editor/editor.api"; 3 | import { registerHighlighter, languageId } from "./highlight"; 4 | 5 | interface IToken { 6 | offset: number; 7 | type: string; 8 | length: number; 9 | } 10 | 11 | interface IMonacoLanguages { 12 | register: (language: { id: string }) => void; 13 | setLanguageConfiguration: typeof Monaco.languages.setLanguageConfiguration; 14 | setMonarchTokensProvider: typeof Monaco.languages.setMonarchTokensProvider; 15 | registerCompletionItemProvider: typeof Monaco.languages.registerCompletionItemProvider; 16 | getLanguages: () => { id: string }[]; 17 | tokenize(text: string, languageId: string): IToken[]; 18 | IndentAction: { IndentOutdent: number }; 19 | } 20 | 21 | interface IMockMonaco { 22 | languages: IMonacoLanguages; 23 | editor: { 24 | defineTheme: typeof Monaco.editor.defineTheme; 25 | }; 26 | } 27 | 28 | describe("highlight", () => { 29 | let monaco: IMockMonaco; 30 | 31 | beforeEach(() => { 32 | // Create a fresh Monaco instance for each test 33 | monaco = { 34 | languages: { 35 | register: vi.fn(), 36 | setLanguageConfiguration: vi.fn(), 37 | setMonarchTokensProvider: vi.fn(), 38 | registerCompletionItemProvider: vi.fn(), 39 | getLanguages: vi.fn().mockReturnValue([]), 40 | tokenize: vi.fn(), 41 | IndentAction: { IndentOutdent: 1 }, 42 | }, 43 | editor: { 44 | defineTheme: vi.fn(), 45 | }, 46 | } as IMockMonaco; 47 | 48 | registerHighlighter(monaco as unknown as typeof Monaco); 49 | }); 50 | 51 | function getTokens(text: string) { 52 | // Get the tokenizer function that was registered 53 | const tokenProvider = (monaco.languages.setMonarchTokensProvider as Mock).mock.calls[0][1]; 54 | 55 | // Call Monaco's tokenize with our language rules 56 | const lines = text.split("\n"); 57 | const tokens: { token: string; content: string }[] = []; 58 | const state = tokenProvider.tokenizer.root; 59 | 60 | for (const line of lines) { 61 | const lineTokens = monaco.languages.tokenize(line, languageId); 62 | for (const token of lineTokens) { 63 | tokens.push({ 64 | token: token.type, 65 | content: line.substring(token.offset, token.offset + token.length), 66 | }); 67 | } 68 | } 69 | 70 | return tokens; 71 | } 72 | 73 | function testTokenizerRule(text: string) { 74 | // Get the tokenizer rules directly 75 | const tokenizer = (monaco.languages.setMonarchTokensProvider as Mock).mock.calls[0][1]; 76 | const rules = tokenizer.tokenizer.root; 77 | 78 | // Try each rule until we find a match 79 | for (const rule of rules) { 80 | const [regex, token] = rule; 81 | if (typeof regex === "string") continue; // Skip string literals 82 | const match = text.match(regex); 83 | if (match && match.index === 0) { 84 | return { token, match: match[0] }; 85 | } 86 | } 87 | // If no rule matches, return the default token with the first word 88 | const defaultMatch = text.match(/^\S+/); 89 | if (defaultMatch) { 90 | return { token: tokenizer.defaultToken, match: defaultMatch[0] }; 91 | } 92 | return null; 93 | } 94 | 95 | test("tokenizes a simple node", () => { 96 | // Mock the tokenize function for this test 97 | (monaco.languages.tokenize as Mock).mockReturnValueOnce([ 98 | { offset: 0, type: "string", length: 5 }, 99 | ]); 100 | 101 | const tokens = getTokens("hello"); 102 | expect(tokens).toEqual([{ token: "string", content: "hello" }]); 103 | expect(monaco.languages.tokenize).toHaveBeenCalledWith("hello", languageId); 104 | }); 105 | 106 | test("tokenizes a node with class", () => { 107 | // Mock the tokenize function for this test 108 | (monaco.languages.tokenize as Mock).mockReturnValueOnce([ 109 | { offset: 0, type: "string", length: 6 }, 110 | { offset: 6, type: "attribute", length: 6 }, 111 | ]); 112 | 113 | const tokens = getTokens("hello .world"); 114 | expect(tokens).toEqual([ 115 | { token: "string", content: "hello " }, 116 | { token: "attribute", content: ".world" }, 117 | ]); 118 | expect(monaco.languages.tokenize).toHaveBeenCalledWith("hello .world", languageId); 119 | }); 120 | 121 | test("tokenizes a node with attribute", () => { 122 | // Mock the tokenize function for this test 123 | (monaco.languages.tokenize as Mock).mockReturnValueOnce([ 124 | { offset: 0, type: "string", length: 6 }, 125 | { offset: 6, type: "attribute", length: 12 }, 126 | ]); 127 | 128 | const tokens = getTokens("hello [color=blue]"); 129 | expect(tokens).toEqual([ 130 | { token: "string", content: "hello " }, 131 | { token: "attribute", content: "[color=blue]" }, 132 | ]); 133 | expect(monaco.languages.tokenize).toHaveBeenCalledWith("hello [color=blue]", languageId); 134 | }); 135 | 136 | test("tokenizes a node with type", () => { 137 | // Mock the tokenize function for this test 138 | (monaco.languages.tokenize as Mock).mockReturnValueOnce([ 139 | { offset: 0, type: "type", length: 7 }, 140 | { offset: 7, type: "string", length: 6 }, 141 | ]); 142 | 143 | const tokens = getTokens(" type: hello"); 144 | expect(tokens).toEqual([ 145 | { token: "type", content: " type:" }, 146 | { token: "string", content: " hello" }, 147 | ]); 148 | expect(monaco.languages.tokenize).toHaveBeenCalledWith(" type: hello", languageId); 149 | }); 150 | 151 | test("tokenizes comments", () => { 152 | // Mock the tokenize function for this test 153 | (monaco.languages.tokenize as Mock).mockReturnValueOnce([ 154 | { offset: 0, type: "comment", length: 20 }, 155 | ]); 156 | 157 | const tokens = getTokens("// this is a comment"); 158 | expect(tokens).toEqual([{ token: "comment", content: "// this is a comment" }]); 159 | expect(monaco.languages.tokenize).toHaveBeenCalledWith("// this is a comment", languageId); 160 | }); 161 | 162 | test("tokenizes multi-line comments", () => { 163 | // Mock the tokenize function for each line 164 | (monaco.languages.tokenize as Mock) 165 | .mockReturnValueOnce([{ offset: 0, type: "comment", length: 10 }]) 166 | .mockReturnValueOnce([{ offset: 0, type: "comment", length: 12 }]); 167 | 168 | const tokens = getTokens("/* this is\na comment */"); 169 | expect(tokens).toEqual([ 170 | { token: "comment", content: "/* this is" }, 171 | { token: "comment", content: "a comment */" }, 172 | ]); 173 | expect(monaco.languages.tokenize).toHaveBeenCalledWith("/* this is", languageId); 174 | expect(monaco.languages.tokenize).toHaveBeenCalledWith("a comment */", languageId); 175 | }); 176 | 177 | test("tokenizes variables", () => { 178 | // Mock the tokenize function for this test 179 | (monaco.languages.tokenize as Mock).mockReturnValueOnce([ 180 | { offset: 0, type: "string", length: 6 }, 181 | { offset: 6, type: "variable", length: 7 }, 182 | ]); 183 | 184 | const tokens = getTokens("hello (world)"); 185 | expect(tokens).toEqual([ 186 | { token: "string", content: "hello " }, 187 | { token: "variable", content: "(world)" }, 188 | ]); 189 | expect(monaco.languages.tokenize).toHaveBeenCalledWith("hello (world)", languageId); 190 | }); 191 | 192 | test("tokenizes edge label with colon in node label", () => { 193 | // Mock the tokenize function for each line 194 | (monaco.languages.tokenize as Mock) 195 | .mockReturnValueOnce([{ offset: 0, type: "string", length: 1 }]) 196 | .mockReturnValueOnce([ 197 | { offset: 0, type: "type", length: 13 }, // " edge-label:" 198 | { offset: 13, type: "string", length: 19 }, // " label with colon :" 199 | ]); 200 | 201 | const tokens = getTokens("a\n edge-label: label with colon :"); 202 | expect(tokens).toEqual([ 203 | { token: "string", content: "a" }, 204 | { token: "type", content: " edge-label:" }, 205 | { token: "string", content: " label with colon :" }, 206 | ]); 207 | expect(monaco.languages.tokenize).toHaveBeenCalledWith("a", languageId); 208 | expect(monaco.languages.tokenize).toHaveBeenCalledWith( 209 | " edge-label: label with colon :", 210 | languageId, 211 | ); 212 | }); 213 | 214 | test("tokenizes edge label with variable", () => { 215 | // Mock the tokenize function for each line 216 | (monaco.languages.tokenize as Mock) 217 | .mockReturnValueOnce([{ offset: 0, type: "string", length: 4 }]) // "test" 218 | .mockReturnValueOnce([ 219 | { offset: 0, type: "type", length: 10 }, // " goes to:" 220 | { offset: 10, type: "string", length: 1 }, // " " 221 | { offset: 11, type: "variable", length: 6 }, // "(test)" 222 | ]); 223 | 224 | const tokens = getTokens("test\n goes to: (test)"); 225 | expect(tokens).toEqual([ 226 | { token: "string", content: "test" }, 227 | { token: "type", content: " goes to:" }, 228 | { token: "string", content: " " }, 229 | { token: "variable", content: "(test)" }, 230 | ]); 231 | expect(monaco.languages.tokenize).toHaveBeenCalledWith("test", languageId); 232 | expect(monaco.languages.tokenize).toHaveBeenCalledWith(" goes to: (test)", languageId); 233 | }); 234 | 235 | test("tokenizes URLs correctly", () => { 236 | // Mock the tokenize function for each line 237 | (monaco.languages.tokenize as Mock) 238 | .mockReturnValueOnce([{ offset: 0, type: "string", length: 22 }]) // "https://google.com" 239 | .mockReturnValueOnce([{ offset: 0, type: "string", length: 17 }]); // "http://google.com" 240 | 241 | const tokens = getTokens("https://google.com\nhttp://google.com"); 242 | expect(tokens).toEqual([ 243 | { token: "string", content: "https://google.com" }, 244 | { token: "string", content: "http://google.com" }, 245 | ]); 246 | expect(monaco.languages.tokenize).toHaveBeenCalledWith("https://google.com", languageId); 247 | expect(monaco.languages.tokenize).toHaveBeenCalledWith("http://google.com", languageId); 248 | }); 249 | 250 | test("verifies URL tokenization rules", () => { 251 | // Test that URLs are not tokenized as comments 252 | const result = testTokenizerRule("//google.com"); 253 | expect(result?.token).toBe("comment"); // This should fail because we want URLs to be strings 254 | expect(result?.match).toBe("//google.com"); // This shows it's matching the whole URL as a comment 255 | 256 | // Test that URLs are tokenized as strings 257 | const httpsResult = testTokenizerRule("https://google.com"); 258 | expect(httpsResult?.token).toBe("string"); 259 | expect(httpsResult?.match).toBe("https://google.com"); 260 | 261 | const httpResult = testTokenizerRule("http://google.com"); 262 | expect(httpResult?.token).toBe("string"); 263 | expect(httpResult?.match).toBe("http://google.com"); 264 | 265 | // Test URLs with leading spaces 266 | const spacedHttpsResult = testTokenizerRule(" https://google.com"); 267 | expect(spacedHttpsResult?.token).toBe("string"); 268 | expect(spacedHttpsResult?.match).toBe(" https://google.com"); 269 | 270 | const spacedHttpResult = testTokenizerRule(" http://google.com"); 271 | expect(spacedHttpResult?.token).toBe("string"); 272 | expect(spacedHttpResult?.match).toBe(" http://google.com"); 273 | }); 274 | 275 | test("tokenizes a node with ID and class", () => { 276 | // Test ID with class 277 | const result = testTokenizerRule("#a.large"); 278 | expect(result?.token).toBe("attribute"); 279 | expect(result?.match).toBe("#a.large"); 280 | 281 | // Also test just an ID 282 | const idResult = testTokenizerRule("#myid"); 283 | expect(idResult?.token).toBe("attribute"); 284 | expect(idResult?.match).toBe("#myid"); 285 | 286 | // And just a class 287 | const classResult = testTokenizerRule(".myclass"); 288 | expect(classResult?.token).toBe("attribute"); 289 | expect(classResult?.match).toBe(".myclass"); 290 | 291 | // Test with leading space (should match the ID/class part) 292 | const spacedResult = testTokenizerRule(" #a.large"); 293 | expect(spacedResult?.token).toBe("string"); 294 | expect(spacedResult?.match).toBe(" "); 295 | }); 296 | 297 | test("tokenizes quoted text correctly", () => { 298 | // Test quoted text in labels (should be string) 299 | const singleQuoteResult = testTokenizerRule("This is 'quoted' text"); 300 | expect(singleQuoteResult?.token).toBe("string"); 301 | expect(singleQuoteResult?.match).toBe("This"); 302 | 303 | // Test quoted text in attributes (should be attribute) 304 | const attrResult = testTokenizerRule("[label='hello']"); 305 | expect(attrResult?.token).toBe("attribute"); 306 | expect(attrResult?.match).toBe("[label='hello']"); 307 | 308 | const doubleQuoteResult = testTokenizerRule('[label="hello"]'); 309 | expect(doubleQuoteResult?.token).toBe("attribute"); 310 | expect(doubleQuoteResult?.match).toBe('[label="hello"]'); 311 | }); 312 | 313 | test("handles incomplete tokens correctly", () => { 314 | // Test standalone # (should be string) 315 | const hashResult = testTokenizerRule("Label #"); 316 | expect(hashResult?.token).toBe("string"); 317 | expect(hashResult?.match).toBe("Label"); 318 | 319 | const hashOnlyResult = testTokenizerRule("#"); 320 | expect(hashOnlyResult?.token).toBe("string"); 321 | expect(hashOnlyResult?.match).toBe("#"); 322 | 323 | // Test standalone . (should be string) 324 | const dotResult = testTokenizerRule("Label ."); 325 | expect(dotResult?.token).toBe("string"); 326 | expect(dotResult?.match).toBe("Label"); 327 | 328 | const dotOnlyResult = testTokenizerRule("."); 329 | expect(dotOnlyResult?.token).toBe("string"); 330 | expect(dotOnlyResult?.match).toBe("."); 331 | 332 | // Test partial ID/class (should be string until complete) 333 | const partialIdResult = testTokenizerRule("#a"); 334 | expect(partialIdResult?.token).toBe("attribute"); 335 | expect(partialIdResult?.match).toBe("#a"); 336 | 337 | const partialClassResult = testTokenizerRule(".a"); 338 | expect(partialClassResult?.token).toBe("attribute"); 339 | expect(partialClassResult?.match).toBe(".a"); 340 | }); 341 | 342 | test("handles escaped characters correctly", () => { 343 | // Test escaped parentheses 344 | const escapedParensResult = testTokenizerRule("Escaping \\(parens\\)"); 345 | expect(escapedParensResult?.token).toBe("string"); 346 | expect(escapedParensResult?.match).toBe("Escaping"); 347 | 348 | // Test escaped brackets 349 | const escapedBracketsResult = testTokenizerRule("Escaping \\[brackets\\]"); 350 | expect(escapedBracketsResult?.token).toBe("string"); 351 | expect(escapedBracketsResult?.match).toBe("Escaping"); 352 | 353 | // Test escaped characters should be treated as part of the string 354 | const escapedCharResult = testTokenizerRule("\\("); 355 | expect(escapedCharResult?.token).toBe("string"); 356 | expect(escapedCharResult?.match).toBe("\\("); 357 | }); 358 | 359 | test("handles IDs and classes inside variable pointers", () => { 360 | // Test ID inside pointer 361 | const idPointerResult = testTokenizerRule("(#myid)"); 362 | expect(idPointerResult?.token).toBe("variable"); 363 | expect(idPointerResult?.match).toBe("(#myid)"); 364 | 365 | // Test class inside pointer 366 | const classPointerResult = testTokenizerRule("(.myclass)"); 367 | expect(classPointerResult?.token).toBe("variable"); 368 | expect(classPointerResult?.match).toBe("(.myclass)"); 369 | 370 | // Test ID with class inside pointer 371 | const idClassPointerResult = testTokenizerRule("(#myid.myclass)"); 372 | expect(idClassPointerResult?.token).toBe("variable"); 373 | expect(idClassPointerResult?.match).toBe("(#myid.myclass)"); 374 | 375 | // Test with space before pointer 376 | const spacedPointerResult = testTokenizerRule(" (#myid)"); 377 | expect(spacedPointerResult?.token).toBe("variable"); 378 | expect(spacedPointerResult?.match).toBe(" (#myid)"); 379 | }); 380 | 381 | test("tokenizes attributes with numeric values", () => { 382 | // Test numeric values 383 | const numericResult = testTokenizerRule("[size=2.439]"); 384 | expect(numericResult?.token).toBe("attribute"); 385 | expect(numericResult?.match).toBe("[size=2.439]"); 386 | 387 | // Test negative numbers 388 | const negativeResult = testTokenizerRule("[size=-2.439]"); 389 | expect(negativeResult?.token).toBe("attribute"); 390 | expect(negativeResult?.match).toBe("[size=-2.439]"); 391 | 392 | // Test with spaces around equals 393 | const spacedResult = testTokenizerRule("[size = 2.439]"); 394 | expect(spacedResult?.token).toBe("attribute"); 395 | expect(spacedResult?.match).toBe("[size = 2.439]"); 396 | }); 397 | 398 | test("tokenizes attributes containing URLs", () => { 399 | // Test URL in attribute 400 | const urlResult = testTokenizerRule( 401 | '[src="https://i.ibb.co/N3r6Fv1/Screen-Shot-2023-01-11-at-2-22-31-PM.png"]', 402 | ); 403 | expect(urlResult?.token).toBe("attribute"); 404 | expect(urlResult?.match).toBe( 405 | '[src="https://i.ibb.co/N3r6Fv1/Screen-Shot-2023-01-11-at-2-22-31-PM.png"]', 406 | ); 407 | 408 | // Test URL in attribute with leading spaces 409 | const spacedUrlResult = testTokenizerRule( 410 | ' [src="https://i.ibb.co/N3r6Fv1/Screen-Shot-2023-01-11-at-2-22-31-PM.png"]', 411 | ); 412 | expect(spacedUrlResult?.token).toBe("attribute"); 413 | expect(spacedUrlResult?.match).toBe( 414 | ' [src="https://i.ibb.co/N3r6Fv1/Screen-Shot-2023-01-11-at-2-22-31-PM.png"]', 415 | ); 416 | }); 417 | }); 418 | -------------------------------------------------------------------------------- /graph-selector/src/highlight.ts: -------------------------------------------------------------------------------- 1 | import * as Monaco from "monaco-editor/esm/vs/editor/editor.api"; 2 | import { parse } from "./parse"; 3 | export const languageId = "graphselector"; 4 | export const defaultTheme = "graphselector-theme"; 5 | export const defaultThemeDark = "graphselector-theme-dark"; 6 | 7 | export const colors: Record = { 8 | string: { light: "#251d1d", dark: "#fffcff" }, 9 | attribute: { light: "#8252eb", dark: "#9e81ef" }, 10 | variable: { light: "#00c722", dark: "#00c722" }, 11 | comment: { light: "#808080", dark: "#808080" }, 12 | type: { light: "#4750f3", dark: "#7f96ff" }, 13 | "delimiter.curly": { light: "#251d1d", dark: "#fffcff" }, 14 | }; 15 | 16 | export function registerHighlighter(monaco: typeof Monaco) { 17 | // Check if language is already registered 18 | if (monaco.languages.getLanguages().some((l) => l.id === languageId)) { 19 | return; 20 | } 21 | 22 | monaco.languages.register({ id: languageId }); 23 | 24 | monaco.languages.setLanguageConfiguration(languageId, { 25 | /* make sure curly braces are automatically closed */ 26 | autoClosingPairs: [ 27 | { open: "{", close: "}" }, 28 | { open: "[", close: "]" }, 29 | { open: "(", close: ")" }, 30 | ], 31 | /* indent after opening curly brace */ 32 | onEnterRules: [ 33 | { 34 | beforeText: new RegExp(`^((?!.*?\\/\\*).*\\{[^}"']*(\\/\\*(.*?)\\*\\/)?)[^}"']*$`, "s"), 35 | afterText: /^.*\}.*$/, 36 | action: { indentAction: monaco.languages.IndentAction.IndentOutdent }, 37 | }, 38 | ], 39 | }); 40 | 41 | monaco.languages.setMonarchTokensProvider(languageId, { 42 | defaultToken: "string", 43 | tokenizer: { 44 | root: [ 45 | // Attributes with quoted values (including URLs), numbers, or words 46 | [/\s*\[\w+\s*=\s*(['"].*?['"]|-?\d*\.?\d+|\w+)\]|\s*\[\w+\]/, "attribute"], 47 | // URLs (must come after attributes but before edge labels) 48 | [/\s*https?:\/\/[^\s]+/, "string"], 49 | // Edge label at start of line (after optional indentation) 50 | [/^\s+[a-zA-Z][\w-]*:/, "type"], // Match edge label starting with letter 51 | // Variable pointers (including leading space) 52 | [/ \([^)]+\)/, "variable"], 53 | [/\([^)]+\)/, "variable"], 54 | // #id and .class combinations (must come before word rule) 55 | [/(#[\w-]+(\.[a-zA-Z][\w-]*)*|\.[a-zA-Z][\w-]*(\.[\w-]+)*)/, "attribute"], 56 | // \/\/ single-line comment... (but not URLs) 57 | [/^\/\/.*|[^:]\/\/.*/, "comment"], 58 | [/\/\*/, "comment", "@comment"], 59 | // Escaped characters (must come before other rules) 60 | [/\\[[\](){}<>:#.\/]/, "string"], 61 | // Spaces 62 | [/\s+/, "string"], 63 | ], 64 | comment: [ 65 | [/[^\/*]+/, "comment"], 66 | [/\/\*/, "comment", "@push"], // nested comment 67 | ["\\*/", "comment", "@pop"], 68 | [/[\/*]/, "comment"], 69 | ], 70 | variable: [ 71 | [/[^)]+/, "variable"], 72 | [/\)/, "variable", "@pop"], 73 | ], 74 | }, 75 | }); 76 | 77 | // Register a completions provider that suggests words inside of parantheses 78 | monaco.languages.registerCompletionItemProvider(languageId, { 79 | triggerCharacters: ["("], 80 | provideCompletionItems: (model, position) => { 81 | // If there is a ( before the cursor 82 | const textUntilPosition = model.getValueInRange({ 83 | startLineNumber: position.lineNumber, 84 | startColumn: 1, 85 | endLineNumber: position.lineNumber, 86 | endColumn: position.column, 87 | }); 88 | if (textUntilPosition.indexOf("(") >= 0) { 89 | // parse complete text for node labels 90 | const parsed = parse(model.getValue()); 91 | const suggestions = parsed.nodes 92 | .filter((node) => !!node.data.label && node.data.label !== "()") 93 | .map((node) => ({ 94 | label: node.data.label, 95 | kind: monaco.languages.CompletionItemKind.Value, 96 | // insert the label, replacing the closing parenthesis 97 | insertText: node.data.label + ")", 98 | range: { 99 | startLineNumber: position.lineNumber, 100 | endLineNumber: position.lineNumber, 101 | startColumn: position.column, 102 | // replace the closing parenthesis 103 | endColumn: position.column + 1, 104 | }, 105 | })); 106 | return { 107 | suggestions, 108 | }; 109 | } 110 | return { suggestions: [] }; 111 | }, 112 | }); 113 | 114 | monaco.editor.defineTheme(defaultTheme, { 115 | base: "vs", 116 | inherit: false, 117 | // colors: {}, 118 | rules: Object.entries(colors).map(([token, value]) => ({ 119 | token, 120 | foreground: value.light, 121 | })), 122 | // Define bracket colors 123 | colors: {}, 124 | }); 125 | 126 | monaco.editor.defineTheme(defaultThemeDark, { 127 | base: "vs-dark", 128 | inherit: false, 129 | // colors: {}, 130 | rules: Object.entries(colors).map(([token, value]) => ({ 131 | token, 132 | foreground: value.dark, 133 | })), 134 | // Define bracket colors 135 | colors: {}, 136 | }); 137 | } 138 | -------------------------------------------------------------------------------- /graph-selector/src/matchAndRemovePointers.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from "vitest"; 2 | 3 | import { matchAndRemovePointers } from "./matchAndRemovePointers"; 4 | 5 | describe("matchAndRemovePointers", () => { 6 | test("extracts id", () => { 7 | const [pointers, line] = matchAndRemovePointers("(#id)"); 8 | expect(pointers).toEqual([["id", "id"]]); 9 | expect(line).toEqual(""); 10 | }); 11 | 12 | test("extracts class", () => { 13 | const [pointers, line] = matchAndRemovePointers("(.test)"); 14 | expect(pointers).toEqual([["class", "test"]]); 15 | expect(line).toEqual(""); 16 | }); 17 | 18 | test("extracts label", () => { 19 | const [pointers, line] = matchAndRemovePointers("(label)"); 20 | expect(pointers).toEqual([["label", "label"]]); 21 | expect(line).toEqual(""); 22 | }); 23 | 24 | test("extracts two letter label", () => { 25 | const [pointers, line] = matchAndRemovePointers("(aa)"); 26 | expect(pointers).toEqual([["label", "aa"]]); 27 | expect(line).toEqual(""); 28 | }); 29 | 30 | test("doesn't extract escaped parentheses", () => { 31 | const [pointers, line] = matchAndRemovePointers("\\(test\\)"); 32 | expect(pointers).toEqual([]); 33 | expect(line).toEqual("\\(test\\)"); 34 | }); 35 | }); 36 | -------------------------------------------------------------------------------- /graph-selector/src/matchAndRemovePointers.ts: -------------------------------------------------------------------------------- 1 | import { Pointer } from "./types"; 2 | 3 | export function matchAndRemovePointers(line: string): [Pointer[], string] { 4 | // parse all pointers 5 | const pointerRe = 6 | /(?(^|[^\\])[((](?((?#[\w-]+)|(?\.[a-zA-Z]{1}[\w]*)|(?