├── .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 | 
4 | 
5 | 
6 | 
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 | {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 |
33 |
34 |
35 |
36 |
37 |
38 |
47 |
51 |
52 | {`npm install graph-selector`}
53 |
54 |
55 |
61 |
67 |
73 |
79 |
85 |
91 |
97 |
103 |
109 |
110 |
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 |
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 |
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 |
63 | {
66 | setIsMenuOpen((s) => !s);
67 | }}
68 | >
69 | Menu
70 |
71 |
72 |
77 | {links.map(({ href, text }) => (
78 |
79 |
80 | {text}
81 |
82 |
83 | ))}
84 |
85 |
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 |
44 | setDarkMode(!darkMode)}
48 | />
49 | Dark / Light Mode
50 |
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 |
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]*)|(?[^))]+)))[))])/g;
7 | let pointerMatch: RegExpExecArray | null;
8 | const pointers: Pointer[] = [];
9 | let lineWithPointersRemoved = line.slice(0);
10 | while ((pointerMatch = pointerRe.exec(line)) != null) {
11 | if (!pointerMatch.groups) continue;
12 | if (pointerMatch.groups.pointer) {
13 | if (pointerMatch.groups.id) {
14 | pointers.push(["id", pointerMatch.groups.id.slice(1)]);
15 | } else if (pointerMatch.groups.class) {
16 | pointers.push(["class", pointerMatch.groups.class.slice(1)]);
17 | } else if (pointerMatch.groups.label) {
18 | pointers.push(["label", pointerMatch.groups.label]);
19 | }
20 | }
21 | // remove everything from line
22 | if (pointerMatch.groups.replace)
23 | lineWithPointersRemoved = lineWithPointersRemoved
24 | .replace(pointerMatch.groups.replace, "")
25 | .trim();
26 | }
27 | return [pointers, lineWithPointersRemoved];
28 | }
29 |
--------------------------------------------------------------------------------
/graph-selector/src/operate/addClassesToEdge.test.ts:
--------------------------------------------------------------------------------
1 | import { describe, expect, it, test } from "vitest";
2 |
3 | import { addClassesToEdge } from "./addClassesToEdge";
4 |
5 | describe("addClassesToEdge", () => {
6 | it("should add class to edge", () => {
7 | expect(addClassesToEdge({ line: " edge: foo", classNames: ["bar"] })).toBe(" .bar edge: foo");
8 | });
9 |
10 | it("should work if line starts container", () => {
11 | expect(addClassesToEdge({ line: " edge: foo {", classNames: ["bar"] })).toBe(
12 | " .bar edge: foo {",
13 | );
14 | });
15 |
16 | it("should work when an ID is present", () => {
17 | expect(addClassesToEdge({ line: " #baz edge: foo", classNames: ["bar"] })).toBe(
18 | " #baz.bar edge: foo",
19 | );
20 | });
21 |
22 | it("should work when edge has no label", () => {
23 | expect(addClassesToEdge({ line: " #baz: node", classNames: ["bar"] })).toBe(
24 | " #baz.bar: node",
25 | );
26 | });
27 |
28 | it("should work when an ID is present and line starts container", () => {
29 | expect(addClassesToEdge({ line: " #baz edge: foo {", classNames: ["bar"] })).toBe(
30 | " #baz.bar edge: foo {",
31 | );
32 | });
33 |
34 | it("should work when the line contains attributes only", () => {
35 | expect(addClassesToEdge({ line: " [foo=bar] edge: foo", classNames: ["bar"] })).toBe(
36 | ' .bar[foo="bar"] edge: foo',
37 | );
38 | });
39 |
40 | it("should work when the line contains attributes only and line starts container", () => {
41 | expect(addClassesToEdge({ line: " [foo=bar]: foo {", classNames: ["bar"] })).toBe(
42 | ' .bar[foo="bar"]: foo {',
43 | );
44 | });
45 |
46 | it("should work when the line contains attributes and an ID", () => {
47 | expect(addClassesToEdge({ line: " #baz[foo=bar] edge: foo", classNames: ["bar"] })).toBe(
48 | ' #baz.bar[foo="bar"] edge: foo',
49 | );
50 | });
51 |
52 | it("should work when the line contains attributes and an ID and line starts container", () => {
53 | expect(addClassesToEdge({ line: " #baz[foo=bar]: foo {", classNames: ["bar"] })).toBe(
54 | ' #baz.bar[foo="bar"]: foo {',
55 | );
56 | });
57 |
58 | it("should work when the line contains classes only", () => {
59 | expect(addClassesToEdge({ line: " .baz edge: foo", classNames: ["bar"] })).toBe(
60 | " .baz.bar edge: foo",
61 | );
62 | });
63 |
64 | it("should work when the line contains classes only and line starts container", () => {
65 | expect(addClassesToEdge({ line: " .baz: foo {", classNames: ["bar"] })).toBe(
66 | " .baz.bar: foo {",
67 | );
68 | });
69 |
70 | it("should work when the line contains classes and an ID", () => {
71 | expect(addClassesToEdge({ line: " #baz.baz edge: foo", classNames: ["bar"] })).toBe(
72 | " #baz.baz.bar edge: foo",
73 | );
74 | });
75 |
76 | it("should work when the line contains classes and an ID and line starts container", () => {
77 | expect(addClassesToEdge({ line: " #baz.baz: foo {", classNames: ["bar"] })).toBe(
78 | " #baz.baz.bar: foo {",
79 | );
80 | });
81 |
82 | it("should work when the line contains classes and attributes", () => {
83 | expect(addClassesToEdge({ line: " .baz[foo=bar] edge: foo", classNames: ["bar"] })).toBe(
84 | ' .baz.bar[foo="bar"] edge: foo',
85 | );
86 | });
87 |
88 | it("should work when the line contains classes and attributes and line starts container", () => {
89 | expect(addClassesToEdge({ line: " .baz[foo=bar]: foo {", classNames: ["bar"] })).toBe(
90 | ' .baz.bar[foo="bar"]: foo {',
91 | );
92 | });
93 |
94 | it("should work with multiple classes", () => {
95 | expect(addClassesToEdge({ line: " .baz.baz edge: foo", classNames: ["bar", "foo"] })).toBe(
96 | " .baz.baz.bar.foo edge: foo",
97 | );
98 | });
99 |
100 | it("should not add class if class already present", () => {
101 | expect(addClassesToEdge({ line: " .bar edge: foo", classNames: ["bar"] })).toBe(
102 | " .bar edge: foo",
103 | );
104 | });
105 |
106 | test("if there is no edge, it should add the colon", () => {
107 | expect(addClassesToEdge({ line: " foo", classNames: ["bar"] })).toBe(" .bar: foo");
108 | });
109 | });
110 |
--------------------------------------------------------------------------------
/graph-selector/src/operate/addClassesToEdge.ts:
--------------------------------------------------------------------------------
1 | import { featuresRe, getEdgeBreakIndex, getFeaturesIndex } from "../regexps";
2 |
3 | import { dataToString } from "./dataToString";
4 | import { getFeatureData } from "../getFeatureData";
5 |
6 | export function addClassesToEdge({ line, classNames }: { line: string; classNames: string[] }) {
7 | // remove initial indent
8 | const indent = line.match(/^\s*/)?.[0] || "";
9 | line = line.replace(/^\s*/, "");
10 |
11 | // remove container start ("{" as last character)
12 | let containerStart = "";
13 | if (line.endsWith(" {")) {
14 | containerStart = " {";
15 | line = line.slice(0, -2);
16 | }
17 |
18 | // pop off edge
19 | let edge = "";
20 | const edgeBreakIndex = getEdgeBreakIndex(line);
21 | if (edgeBreakIndex !== -1) {
22 | edge = line.slice(0, edgeBreakIndex + 1);
23 | line = line.slice(edgeBreakIndex + 1);
24 | }
25 |
26 | // if there's no edge, note it
27 | const noEdge = !edge;
28 |
29 | // separate features and label
30 | const startOfFeatures = getFeaturesIndex(edge);
31 | let features = "";
32 | if (startOfFeatures === 0) {
33 | features = featuresRe.exec(edge)?.[0] || "";
34 | edge = edge.slice(features.length);
35 |
36 | // reset regex
37 | featuresRe.lastIndex = 0;
38 | }
39 |
40 | const divider = noEdge ? ": " : "";
41 |
42 | if (!features) {
43 | const completeEdge = [`.${classNames.join(".")}`, edge.trim()].filter(Boolean).join(" ").trim();
44 | return indent + completeEdge + divider + line + containerStart;
45 | }
46 |
47 | // extract features from string
48 | const { classes, data, id = "" } = getFeatureData(features);
49 |
50 | let newFeatureString = " ";
51 | if (id) newFeatureString += `#${id}`;
52 | if (classes) newFeatureString += classes;
53 | let classNameString = "";
54 | // for each class in classNames, if it's not already in classes, add it to the new class string
55 | for (const className of classNames)
56 | if (!classes.includes(className)) classNameString += `.${className}`;
57 | newFeatureString += classNameString;
58 | if (Object.keys(data).length) newFeatureString += dataToString(data);
59 |
60 | const edgeWithFeatures = [newFeatureString.trim(), edge.trim()].filter(Boolean).join(" ").trim();
61 |
62 | return indent + edgeWithFeatures + line + containerStart;
63 | }
64 |
--------------------------------------------------------------------------------
/graph-selector/src/operate/addClassesToNode.test.ts:
--------------------------------------------------------------------------------
1 | import { describe, expect, it } from "vitest";
2 |
3 | import { addClassesToNode } from "./addClassesToNode";
4 |
5 | describe("addClassesToNode", () => {
6 | it("should add class to node", () => {
7 | const result = addClassesToNode({
8 | line: " to: my node",
9 | classNames: ["foo"],
10 | });
11 | expect(result).toEqual(" to: my node .foo");
12 | });
13 |
14 | it("should work if line starts container", () => {
15 | const result = addClassesToNode({
16 | line: "my node {",
17 | classNames: ["foo"],
18 | });
19 | expect(result).toEqual("my node .foo {");
20 | });
21 |
22 | it("should work when an ID is present", () => {
23 | const result = addClassesToNode({
24 | line: "my node #my-id",
25 | classNames: ["foo"],
26 | });
27 | expect(result).toEqual("my node #my-id.foo");
28 | });
29 |
30 | it("should work when an ID is present and line starts container", () => {
31 | const result = addClassesToNode({
32 | line: "my node #my-id {",
33 | classNames: ["foo"],
34 | });
35 | expect(result).toEqual("my node #my-id.foo {");
36 | });
37 |
38 | it("should work when the line contains attributes only", () => {
39 | const result = addClassesToNode({
40 | line: "my node [foo=bar]",
41 | classNames: ["foo"],
42 | });
43 | expect(result).toEqual('my node .foo[foo="bar"]');
44 | });
45 |
46 | it("should work when the line contains attributes only and line starts container", () => {
47 | const result = addClassesToNode({
48 | line: "my node [foo=bar] {",
49 | classNames: ["foo"],
50 | });
51 | expect(result).toEqual('my node .foo[foo="bar"] {');
52 | });
53 |
54 | it("should work when the line contains attributes and an ID", () => {
55 | const result = addClassesToNode({
56 | line: "my node #my-id[foo=bar]",
57 | classNames: ["foo"],
58 | });
59 | expect(result).toEqual('my node #my-id.foo[foo="bar"]');
60 | });
61 |
62 | it("should work when the line contains attributes and an ID and line starts container", () => {
63 | const result = addClassesToNode({
64 | line: "my node #my-id[foo=bar] {",
65 | classNames: ["foo"],
66 | });
67 | expect(result).toEqual('my node #my-id.foo[foo="bar"] {');
68 | });
69 |
70 | it("should work when the line contains classes only", () => {
71 | const result = addClassesToNode({
72 | line: "my node .my-class",
73 | classNames: ["foo"],
74 | });
75 | expect(result).toEqual("my node .my-class.foo");
76 | });
77 |
78 | it("should work when the line contains classes only and line starts container", () => {
79 | const result = addClassesToNode({
80 | line: "my node .my-class {",
81 | classNames: ["foo"],
82 | });
83 | expect(result).toEqual("my node .my-class.foo {");
84 | });
85 |
86 | it("should work when the line contains classes and an ID", () => {
87 | const result = addClassesToNode({
88 | line: "my node #my-id.my-class",
89 | classNames: ["foo"],
90 | });
91 | expect(result).toEqual("my node #my-id.my-class.foo");
92 | });
93 |
94 | it("should work when the line contains classes and an ID and line starts container", () => {
95 | const result = addClassesToNode({
96 | line: "my node #my-id.my-class {",
97 | classNames: ["foo"],
98 | });
99 | expect(result).toEqual("my node #my-id.my-class.foo {");
100 | });
101 |
102 | it("should work when the line contains classes and attributes", () => {
103 | const result = addClassesToNode({
104 | line: "my node .my-class[foo=bar]",
105 | classNames: ["foo"],
106 | });
107 | expect(result).toEqual('my node .my-class.foo[foo="bar"]');
108 | });
109 |
110 | it("should work when the line contains classes and attributes and line starts container", () => {
111 | const result = addClassesToNode({
112 | line: "my node .my-class[foo=bar] {",
113 | classNames: ["foo"],
114 | });
115 | expect(result).toEqual('my node .my-class.foo[foo="bar"] {');
116 | });
117 |
118 | it("should work with multiple classes", () => {
119 | const result = addClassesToNode({
120 | line: "my node",
121 | classNames: ["foo", "bar"],
122 | });
123 | expect(result).toEqual("my node .foo.bar");
124 | });
125 |
126 | it("should not add class if class already present", () => {
127 | const result = addClassesToNode({
128 | line: "my node .foo",
129 | classNames: ["foo"],
130 | });
131 | expect(result).toEqual("my node .foo");
132 | });
133 | });
134 |
--------------------------------------------------------------------------------
/graph-selector/src/operate/addClassesToNode.ts:
--------------------------------------------------------------------------------
1 | import { getEdgeBreakIndex, getFeaturesIndex } from "../regexps";
2 |
3 | import { dataToString } from "./dataToString";
4 | import { getFeatureData } from "../getFeatureData";
5 |
6 | export function addClassesToNode({ line, classNames }: { line: string; classNames: string[] }) {
7 | // remove initial indent
8 | const indent = line.match(/^\s*/)?.[0] || "";
9 | line = line.replace(/^\s*/, "");
10 |
11 | // remove container start ("{" as last character)
12 | let containerStart = "";
13 | if (line.endsWith(" {")) {
14 | containerStart = " {";
15 | line = line.slice(0, -2);
16 | }
17 |
18 | // remove edge
19 | let edge = "";
20 | const edgeBreakIndex = getEdgeBreakIndex(line);
21 | if (edgeBreakIndex !== -1) {
22 | edge = line.slice(0, edgeBreakIndex + 1);
23 | line = line.slice(edgeBreakIndex + 1);
24 | }
25 |
26 | const featuresIndex = getFeaturesIndex(line);
27 | let features = "";
28 | if (featuresIndex !== -1) {
29 | features = line.slice(featuresIndex);
30 | line = line.slice(0, featuresIndex);
31 | }
32 |
33 | if (!features) {
34 | return indent + edge + line + ` .${classNames.join(".")}` + containerStart;
35 | }
36 |
37 | const { classes, data, id = "" } = getFeatureData(features);
38 |
39 | let newFeatureString = " ";
40 | if (id) newFeatureString += `#${id}`;
41 | if (classes) newFeatureString += classes;
42 | let classNameString = "";
43 | // for each class in classNames, if it's not already in classes, add it to the new class string
44 | for (const className of classNames)
45 | if (!classes.includes(className)) classNameString += `.${className}`;
46 | newFeatureString += classNameString;
47 | if (Object.keys(data).length) newFeatureString += dataToString(data);
48 |
49 | return indent + edge + line + newFeatureString + containerStart;
50 | }
51 |
--------------------------------------------------------------------------------
/graph-selector/src/operate/addDataAttributeToNode.test.ts:
--------------------------------------------------------------------------------
1 | import { describe, expect, test } from "vitest";
2 |
3 | import { addDataAttributeToNode } from "./addDataAttributeToNode";
4 |
5 | describe("addDataAttributeToNode", () => {
6 | test("it adds a data attribute to a node", () => {
7 | expect(
8 | addDataAttributeToNode({
9 | line: "node",
10 | name: "test",
11 | value: "test",
12 | }),
13 | ).toEqual('node [test="test"]');
14 | });
15 |
16 | test("it adds a data attribute to a node that starts a collection", () => {
17 | expect(
18 | addDataAttributeToNode({
19 | line: "node {",
20 | name: "data-test",
21 | value: "test",
22 | }),
23 | ).toEqual('node [data-test="test"] {');
24 | });
25 |
26 | test("it can add a boolean attribute", () => {
27 | expect(
28 | addDataAttributeToNode({
29 | line: "node",
30 | name: "test",
31 | value: true,
32 | }),
33 | ).toEqual("node [test]");
34 | });
35 |
36 | test("it can add a number attribute", () => {
37 | expect(
38 | addDataAttributeToNode({
39 | line: "node",
40 | name: "test",
41 | value: 1,
42 | }),
43 | ).toEqual("node [test=1]");
44 | });
45 |
46 | test("it can add an attribute when the element has an edge label", () => {
47 | expect(
48 | addDataAttributeToNode({
49 | line: " edge: node",
50 | name: "test",
51 | value: "test",
52 | }),
53 | ).toEqual(' edge: node [test="test"]');
54 | });
55 |
56 | test("can add attribute when node has ID", () => {
57 | expect(
58 | addDataAttributeToNode({
59 | line: "node #foo",
60 | name: "test",
61 | value: "test",
62 | }),
63 | ).toEqual('node #foo[test="test"]');
64 | });
65 | });
66 |
--------------------------------------------------------------------------------
/graph-selector/src/operate/addDataAttributeToNode.ts:
--------------------------------------------------------------------------------
1 | import { getEdgeBreakIndex, getFeaturesIndex } from "../regexps";
2 |
3 | import { dataToString } from "./dataToString";
4 | import { getFeatureData } from "../getFeatureData";
5 | import { Descriptor } from "../types";
6 |
7 | /**
8 | * Adds a data attribute to a node
9 | * or updates the value of an existing data attribute
10 | */
11 | export function addDataAttributeToNode({
12 | line,
13 | name,
14 | value,
15 | }: {
16 | line: string;
17 | name: string;
18 | value: Descriptor;
19 | }) {
20 | // remove initial indent
21 | const indent = line.match(/^\s*/)?.[0] || "";
22 | line = line.replace(/^\s*/, "");
23 |
24 | // remove container start ("{" as last character)
25 | let containerStart = "";
26 | if (line.endsWith(" {")) {
27 | containerStart = " {";
28 | line = line.slice(0, -2);
29 | }
30 |
31 | // remove edge
32 | let edge = "";
33 | const edgeBreakIndex = getEdgeBreakIndex(line);
34 | if (edgeBreakIndex !== -1) {
35 | edge = line.slice(0, edgeBreakIndex + 1);
36 | line = line.slice(edgeBreakIndex + 1);
37 | }
38 |
39 | const featuresIndex = getFeaturesIndex(line);
40 | let features = "";
41 | if (featuresIndex !== -1) {
42 | features = line.slice(featuresIndex);
43 | line = line.slice(0, featuresIndex);
44 | }
45 |
46 | const dataStr = dataToString({ [name]: value });
47 |
48 | if (!features) {
49 | return indent + edge + line + ` ${dataStr}` + containerStart;
50 | }
51 |
52 | const { classes, data, id = "" } = getFeatureData(features);
53 |
54 | let newFeatureString = " ";
55 | if (id) newFeatureString += `#${id}`;
56 | if (classes) newFeatureString += classes;
57 | newFeatureString += dataToString({ ...data, [name]: value });
58 |
59 | return indent + edge + line + newFeatureString + containerStart;
60 | }
61 |
--------------------------------------------------------------------------------
/graph-selector/src/operate/dataToString.test.ts:
--------------------------------------------------------------------------------
1 | import { describe, expect, test } from "vitest";
2 |
3 | import { dataToString } from "./dataToString";
4 |
5 | describe("dataToString", () => {
6 | test("should convert true attributes to []", () => {
7 | const result = dataToString({ something: true });
8 | expect(result).toEqual("[something]");
9 | });
10 |
11 | test("should wrap strings with double quotes in single quotes", () => {
12 | const result = dataToString({ something: '"foo"' });
13 | expect(result).toEqual("[something='\"foo\"']");
14 | });
15 |
16 | test("should not wrap numbers in quotes", () => {
17 | const result = dataToString({ something: 123 });
18 | expect(result).toEqual("[something=123]");
19 | });
20 | });
21 |
--------------------------------------------------------------------------------
/graph-selector/src/operate/dataToString.ts:
--------------------------------------------------------------------------------
1 | import { Data } from "../types";
2 |
3 | /**
4 | * Converts data object back to string
5 | * Problems:
6 | * - Order not guaranteed
7 | * - Will wrap strings even if they weren't wrapped
8 | */
9 | export function dataToString(data: Data) {
10 | let dataString = "";
11 | for (const key in data) {
12 | const value = data[key];
13 | dataString += `[${key}`;
14 | if (value === true) {
15 | dataString += "]";
16 | continue;
17 | } else if (typeof value === "string") {
18 | // if includes '"', use single quotes
19 | if (value.includes('"')) {
20 | dataString += `='${value}']`;
21 | continue;
22 | }
23 | // Will auto-wrap in double quotes
24 | dataString += `="${value}"]`;
25 | continue;
26 | } else if (typeof value === "number") {
27 | dataString += `=${value}]`;
28 | continue;
29 | }
30 | }
31 | return dataString;
32 | }
33 |
--------------------------------------------------------------------------------
/graph-selector/src/operate/operate.test.ts:
--------------------------------------------------------------------------------
1 | import { describe, expect, test } from "vitest";
2 | import { operate } from "./operate";
3 |
4 | describe("operate", () => {
5 | test("should return a string", () => {
6 | expect(
7 | operate("hello .world", {
8 | lineNumber: 1,
9 | operation: [
10 | "removeClassesFromNode",
11 | {
12 | classNames: ["world"],
13 | },
14 | ],
15 | }),
16 | ).toBe("hello");
17 | });
18 |
19 | test("leaves comments intact", () => {
20 | expect(
21 | operate("// some comment\nhello .world\n// some other comment", {
22 | lineNumber: 2,
23 | operation: [
24 | "removeClassesFromNode",
25 | {
26 | classNames: ["world"],
27 | },
28 | ],
29 | }),
30 | ).toBe("// some comment\nhello\n// some other comment");
31 | });
32 |
33 | test("line number should be 1-indexed", () => {
34 | const run = () =>
35 | operate("hello .world", {
36 | lineNumber: 0,
37 | operation: ["addClassesToNode", { classNames: ["c"] }],
38 | });
39 | expect(run).toThrow("lineNumber must be 1-indexed");
40 | });
41 |
42 | test("should throw if line number is out of bounds", () => {
43 | const run = () =>
44 | operate("hello .world", {
45 | lineNumber: 2,
46 | operation: ["addClassesToNode", { classNames: ["c"] }],
47 | });
48 | expect(run).toThrow("lineNumber must be less than the number of lines");
49 | });
50 |
51 | test("can add/remove data attributes", () => {
52 | expect(
53 | operate("hello .world", {
54 | lineNumber: 1,
55 | operation: [
56 | "addDataAttributeToNode",
57 | {
58 | name: "foo",
59 | value: "bar",
60 | },
61 | ],
62 | }),
63 | ).toBe('hello .world[foo="bar"]');
64 |
65 | expect(
66 | operate('hello .world[foo="bar"]', {
67 | lineNumber: 1,
68 | operation: [
69 | "removeDataAttributeFromNode",
70 | {
71 | name: "foo",
72 | },
73 | ],
74 | }),
75 | ).toBe("hello .world");
76 | });
77 |
78 | /* This test is combatting a specific bug found in the wild. */
79 | test("can add/remove classes from edges with similar names", () => {
80 | const classes = ["triangle", "source-triangle-tee", "source-circle-triangle"];
81 |
82 | // removes all classes and then adds one
83 | function addClass(text: string, c: string) {
84 | let newText = operate(text, {
85 | lineNumber: 1,
86 | operation: ["removeClassesFromEdge", { classNames: classes }],
87 | });
88 | newText = operate(newText, {
89 | lineNumber: 1,
90 | operation: ["addClassesToEdge", { classNames: [c] }],
91 | });
92 | return newText;
93 | }
94 |
95 | let text = " foo";
96 | text = addClass(text, "source-triangle-tee");
97 | text = addClass(text, "source-circle-triangle");
98 | expect(text).toBe(" .source-circle-triangle: foo");
99 | });
100 | });
101 |
--------------------------------------------------------------------------------
/graph-selector/src/operate/operate.ts:
--------------------------------------------------------------------------------
1 | import { addClassesToEdge } from "./addClassesToEdge";
2 | import { addClassesToNode } from "./addClassesToNode";
3 | import { addDataAttributeToNode } from "./addDataAttributeToNode";
4 | import { removeClassesFromEdge } from "./removeClassesFromEdge";
5 | import { removeClassesFromNode } from "./removeClassesFromNode";
6 | import { removeDataAttributeFromNode } from "./removeDataAttributeFromNode";
7 |
8 | export const operations = {
9 | removeClassesFromNode,
10 | addClassesToNode,
11 | addDataAttributeToNode,
12 | removeDataAttributeFromNode,
13 | addClassesToEdge,
14 | removeClassesFromEdge,
15 | };
16 |
17 | export type OperationKey = keyof typeof operations;
18 |
19 | /** Create a type which is a tuple. First the opertion key, then the paramaters of the function */
20 | export type Operation = {
21 | [K in OperationKey]: [K, Omit[0], "line">];
22 | }[OperationKey];
23 |
24 | export type Instruction = {
25 | /** a **1-indexed** (not 0-indexed) line number */
26 | lineNumber: number;
27 | operation: Operation;
28 | };
29 |
30 | /**
31 | * Used to alter the text of a graph given an instruction.
32 | *
33 | * e.g. _"tell the node on line 14 to remove the class 'foo'"_
34 | *
35 | * **Note:** The line number is 1-indexed, not 0-indexed.
36 | */
37 | export function operate(graphText: string, instruction: Instruction): string {
38 | const lines = graphText.split("\n");
39 | const { operation } = instruction;
40 | if (instruction.lineNumber < 1) throw new Error("lineNumber must be 1-indexed");
41 | if (instruction.lineNumber > lines.length)
42 | throw new Error("lineNumber must be less than the number of lines");
43 | const lineNumber = instruction.lineNumber - 1;
44 | const [operationKey, operationParams] = operation;
45 | const operationFunction = operations[operationKey];
46 | const line = lines[lineNumber];
47 | // 'as never' because "Correspondence Problem", basically TS can't infer
48 | const newLine = operationFunction({ line, ...operationParams } as never);
49 | lines[lineNumber] = newLine;
50 | return lines.join("\n");
51 | }
52 |
--------------------------------------------------------------------------------
/graph-selector/src/operate/removeClassesFromEdge.test.ts:
--------------------------------------------------------------------------------
1 | import { describe, expect, it, test } from "vitest";
2 | import { removeClassesFromEdge } from "./removeClassesFromEdge";
3 |
4 | describe("removeClassesFromEdge", () => {
5 | it("removes a classf rom an edge", () => {
6 | const result = removeClassesFromEdge({
7 | line: ".some-class edge: my node",
8 | classNames: ["some-class"],
9 | });
10 | expect(result).toBe("edge: my node");
11 | });
12 |
13 | it("doesn't alter if class isn't present", () => {
14 | const result = removeClassesFromEdge({
15 | line: "edge: my node",
16 | classNames: ["some-class"],
17 | });
18 | expect(result).toBe("edge: my node");
19 | });
20 |
21 | it("removes a class from an edge with trailing classes", () => {
22 | const result = removeClassesFromEdge({
23 | line: ".some-class.another-class edge: my node",
24 | classNames: ["some-class"],
25 | });
26 | expect(result).toBe(".another-class edge: my node");
27 | });
28 |
29 | it("removes a class from an edge with leading classes", () => {
30 | const result = removeClassesFromEdge({
31 | line: ".some-class.another-class edge: my node",
32 | classNames: ["another-class"],
33 | });
34 | expect(result).toBe(".some-class edge: my node");
35 | });
36 |
37 | it("removes a class from an edge with leading and trailing classes", () => {
38 | const result = removeClassesFromEdge({
39 | line: ".some-class.another-class.yet-another-class edge: my node",
40 | classNames: ["another-class"],
41 | });
42 | expect(result).toBe(".some-class.yet-another-class edge: my node");
43 | });
44 |
45 | it("removes a class from an edge and doesn't affect indentation", () => {
46 | const result = removeClassesFromEdge({
47 | line: " .some-class edge: my node",
48 | classNames: ["some-class"],
49 | });
50 | expect(result).toBe(" edge: my node");
51 | });
52 |
53 | it("removes a class from an edge without affecting the node", () => {
54 | const result = removeClassesFromEdge({
55 | line: " .some-class edge: my node .some-class",
56 | classNames: ["some-class"],
57 | });
58 | expect(result).toBe(" edge: my node .some-class");
59 | });
60 |
61 | it("can remove multiple classes at once", () => {
62 | const result = removeClassesFromEdge({
63 | line: ".some-class.another-class edge: my node",
64 | classNames: ["some-class", "another-class"],
65 | });
66 | expect(result).toBe("edge: my node");
67 | });
68 |
69 | it("can remove class when no edge label", () => {
70 | const result = removeClassesFromEdge({
71 | line: " .some-class: my node",
72 | classNames: ["some-class"],
73 | });
74 | expect(result).toBe(" my node");
75 | });
76 |
77 | it("can remove edge class when starting container line", () => {
78 | const result = removeClassesFromEdge({
79 | line: " .some-class edge: container {",
80 | classNames: ["some-class"],
81 | });
82 | expect(result).toBe(" edge: container {");
83 | });
84 |
85 | it("should not remove partial class", () => {
86 | const result = removeClassesFromEdge({
87 | line: " .some-class edge: my node",
88 | classNames: ["some"],
89 | });
90 | expect(result).toBe(" .some-class edge: my node");
91 | });
92 |
93 | it("should not remove partial class when valid part of label", () => {
94 | const result = removeClassesFromEdge({
95 | line: " period.cool: my node",
96 | classNames: ["cool"],
97 | });
98 | expect(result).toBe(" period.cool: my node");
99 | });
100 |
101 | it("doesn't remove class from node obviously", () => {
102 | const result = removeClassesFromEdge({
103 | line: " my node .some-class",
104 | classNames: ["some-class"],
105 | });
106 | expect(result).toBe(" my node .some-class");
107 | });
108 |
109 | test("should remove class when it abuts data", () => {
110 | let line = " .e[multiple-words][x=1000] hello: hi";
111 | line = removeClassesFromEdge({
112 | line,
113 | classNames: ["e"],
114 | });
115 | expect(line).toBe(" [multiple-words][x=1000] hello: hi");
116 | });
117 |
118 | // bug directly from flowchart.fun
119 | test("shouldn't leave stray classes", () => {
120 | let line = " .roundrectangle[w=100][h=70] label: cool";
121 | line = removeClassesFromEdge({
122 | line,
123 | classNames: ["rectangle"],
124 | });
125 | expect(line).toBe(" .roundrectangle[w=100][h=70] label: cool");
126 | });
127 | });
128 |
--------------------------------------------------------------------------------
/graph-selector/src/operate/removeClassesFromEdge.ts:
--------------------------------------------------------------------------------
1 | import { getEdgeBreakIndex, getFeaturesIndex } from "../regexps";
2 |
3 | export function removeClassesFromEdge({
4 | line,
5 | classNames,
6 | }: {
7 | /** The line text */
8 | line: string;
9 | /** Array of string *without* the dot (.) */
10 | classNames: string[];
11 | }) {
12 | // remove initial indent and store
13 | const indent = line.match(/^\s*/)?.[0];
14 | line = line.replace(/^\s*/, "");
15 |
16 | // remove container start ("{" as last character)
17 | let containerStart = "";
18 | if (line.endsWith(" {")) {
19 | containerStart = " {";
20 | line = line.slice(0, -2);
21 | }
22 |
23 | // check for unescaped colon that's not at the start of the line
24 | // if it exists, we're dealing with an edge
25 | let edge = "";
26 | const edgeBreakIndex = getEdgeBreakIndex(line);
27 | if (edgeBreakIndex !== -1) {
28 | edge = line.slice(0, edgeBreakIndex + 1);
29 | line = line.slice(edgeBreakIndex + 1);
30 | }
31 |
32 | // need to check for start of label
33 | // if label start is before feature data start, then everything that
34 | // looks like a feature is actually in the label
35 | // and we don't have anything to change
36 | if (edge.trim()) {
37 | const labelStart = /\w/.exec(edge)?.index ?? -1;
38 | const featuresStart = getFeaturesIndex(edge);
39 | if (labelStart < featuresStart) {
40 | return indent + edge + line + containerStart;
41 | }
42 | }
43 |
44 | // remove class names from edge
45 | for (const className of classNames) {
46 | // match class and stop character
47 | const match = new RegExp(`\\.${className}(?\\.|$| |:|:|\\[)`).exec(edge);
48 | // if it's not there, continue
49 | if (!match) continue;
50 |
51 | // get stop character
52 | const stopCharacter = match.groups?.stopCharacter || "";
53 |
54 | // get the index of the match
55 | const index = match.index;
56 |
57 | // remove the match up to the stop character
58 | edge = edge.slice(0, index) + stopCharacter + edge.slice(index + match[0].length);
59 | }
60 |
61 | // remove leading whitespace before beginning of line if it exists
62 | edge = edge.replace(/^\s*/, "");
63 |
64 | // if the edge is empty and the line begins with a colon,
65 | if (edge === "" && line.startsWith(":")) {
66 | // remove the colon and whitespace
67 | line = line.replace(/^[::]\s*/, "");
68 | }
69 |
70 | return indent + edge + line + containerStart;
71 | }
72 |
--------------------------------------------------------------------------------
/graph-selector/src/operate/removeClassesFromNode.test.ts:
--------------------------------------------------------------------------------
1 | import { describe, expect, it, test } from "vitest";
2 | import { removeClassesFromNode } from "./removeClassesFromNode";
3 |
4 | describe("removeClassFromNode", () => {
5 | it("removes a class from a node", () => {
6 | const result = removeClassesFromNode({
7 | line: "my node .some-class",
8 | classNames: ["some-class"],
9 | });
10 | expect(result).toBe("my node");
11 | });
12 |
13 | it("doesn't alter if class isn't present", () => {
14 | const result = removeClassesFromNode({
15 | line: "my node",
16 | classNames: ["some-class"],
17 | });
18 | expect(result).toBe("my node");
19 | });
20 |
21 | it("removes a class from a node with trailing classes", () => {
22 | const result = removeClassesFromNode({
23 | line: "my node .some-class.another-class",
24 | classNames: ["some-class"],
25 | });
26 | expect(result).toBe("my node .another-class");
27 | });
28 |
29 | it("removes a class from a node with leading classes", () => {
30 | const result = removeClassesFromNode({
31 | line: "my node .some-class.another-class",
32 | classNames: ["another-class"],
33 | });
34 | expect(result).toBe("my node .some-class");
35 | });
36 |
37 | it("removes a class from a node with leading and trailing classes", () => {
38 | const result = removeClassesFromNode({
39 | line: "my node .some-class.another-class.yet-another-class",
40 | classNames: ["another-class"],
41 | });
42 | expect(result).toBe("my node .some-class.yet-another-class");
43 | });
44 |
45 | it("removes a class from a node and doesn't affect indentation", () => {
46 | const result = removeClassesFromNode({
47 | line: " my node .some-class",
48 | classNames: ["some-class"],
49 | });
50 | expect(result).toBe(" my node");
51 | });
52 |
53 | it("removes a class from a node without affecting the edge", () => {
54 | const result = removeClassesFromNode({
55 | line: " edge .some-class: my node .some-class",
56 | classNames: ["some-class"],
57 | });
58 | expect(result).toBe(" edge .some-class: my node");
59 | });
60 |
61 | it("can remove multiple classes at once", () => {
62 | const result = removeClassesFromNode({
63 | line: " edge .some-class: my node .some-class.another-class",
64 | classNames: ["some-class", "another-class"],
65 | });
66 | expect(result).toBe(" edge .some-class: my node");
67 | });
68 |
69 | test("shouldn't grow when removing class on containers", () => {
70 | let line = "hello .world {";
71 | line = removeClassesFromNode({
72 | line,
73 | classNames: ["world"],
74 | });
75 | expect(line).toBe("hello {");
76 | });
77 |
78 | test("doesn't remove a partial class", () => {
79 | let line = "hello .some-class";
80 | line = removeClassesFromNode({
81 | line,
82 | classNames: ["some"],
83 | });
84 | expect(line).toBe("hello .some-class");
85 | });
86 |
87 | test("should remove class when it abuts data", () => {
88 | let line = "hello .e[multiple-words][x=1000]";
89 | line = removeClassesFromNode({
90 | line,
91 | classNames: ["e"],
92 | });
93 | expect(line).toBe("hello [multiple-words][x=1000]");
94 | });
95 |
96 | // bug directly from flowchart.fun
97 | test("shouldn't leave stray classes", () => {
98 | let line = "label .roundrectangle[w=100][h=70]";
99 | line = removeClassesFromNode({
100 | line,
101 | classNames: ["rectangle"],
102 | });
103 | expect(line).toBe("label .roundrectangle[w=100][h=70]");
104 | });
105 | });
106 |
--------------------------------------------------------------------------------
/graph-selector/src/operate/removeClassesFromNode.ts:
--------------------------------------------------------------------------------
1 | import { getEdgeBreakIndex } from "../regexps";
2 |
3 | export function removeClassesFromNode({
4 | line,
5 | classNames,
6 | }: {
7 | /** The line text */
8 | line: string;
9 | /** Array of string *without* the dot (.) */
10 | classNames: string[];
11 | }) {
12 | // remove initial indent and store
13 | const indent = line.match(/^\s*/)?.[0];
14 | line = line.replace(/^\s*/, "");
15 |
16 | // remove container start ("{" as last character)
17 | let containerStart = "";
18 | if (line.endsWith(" {")) {
19 | containerStart = " {";
20 | line = line.slice(0, -2);
21 | }
22 |
23 | // check for unescaped colon that's not at the start of the line
24 | // if it exists, we're dealing with an edge
25 | let edge = "";
26 | const edgeBreakIndex = getEdgeBreakIndex(line);
27 | if (edgeBreakIndex !== -1) {
28 | edge = line.slice(0, edgeBreakIndex + 1);
29 | line = line.slice(edgeBreakIndex + 1);
30 | }
31 |
32 | // remove class names
33 | for (const className of classNames) {
34 | // need to match class and stop character to avoid partial matching
35 | const match = new RegExp(`\\.${className}(?\\.|$| |:|:|\\[)`).exec(line);
36 |
37 | // if no match, continue
38 | if (!match) continue;
39 |
40 | // get the stop character
41 | const stopCharacter = match.groups?.stopCharacter || "";
42 |
43 | // get the index of the match
44 | const index = match.index;
45 |
46 | // remove the match up to the stop character
47 | line = line.slice(0, index) + stopCharacter + line.slice(index + match[0].length);
48 | }
49 |
50 | // remove trailing whitespace before end of line if it exists
51 | line = line.replace(/\s*$/, "");
52 |
53 | return indent + edge + line + containerStart;
54 | }
55 |
--------------------------------------------------------------------------------
/graph-selector/src/operate/removeDataAttributeFromNode.test.ts:
--------------------------------------------------------------------------------
1 | import { describe, expect, test } from "vitest";
2 |
3 | import { removeDataAttributeFromNode } from "./removeDataAttributeFromNode";
4 |
5 | describe("removeDataAttributeFromNode", () => {
6 | test("it removes a data attribute from a node", () => {
7 | expect(
8 | removeDataAttributeFromNode({
9 | line: 'node [test="test"]',
10 | name: "test",
11 | }),
12 | ).toEqual("node");
13 | });
14 |
15 | test("it removes a data attribute from a node that starts a collection", () => {
16 | expect(
17 | removeDataAttributeFromNode({
18 | line: 'node [data-test="test"] {',
19 | name: "data-test",
20 | }),
21 | ).toEqual("node {");
22 | });
23 |
24 | test("it can remove a boolean attribute", () => {
25 | expect(
26 | removeDataAttributeFromNode({
27 | line: "node [test]",
28 | name: "test",
29 | }),
30 | ).toEqual("node");
31 | });
32 |
33 | test("it can remove a number attribute", () => {
34 | expect(
35 | removeDataAttributeFromNode({
36 | line: "node [test=1]",
37 | name: "test",
38 | }),
39 | ).toEqual("node");
40 | });
41 |
42 | test("can remove an attribute when line has edge label", () => {
43 | expect(
44 | removeDataAttributeFromNode({
45 | line: ' edge: node [test="test"]',
46 | name: "test",
47 | }),
48 | ).toEqual(" edge: node");
49 | });
50 |
51 | test("can remove an attribute when line has edge label and starts container", () => {
52 | expect(
53 | removeDataAttributeFromNode({
54 | line: ' edge: node [test="test"] {',
55 | name: "test",
56 | }),
57 | ).toEqual(" edge: node {");
58 | });
59 |
60 | test("can remove attribute when node has ID", () => {
61 | expect(
62 | removeDataAttributeFromNode({
63 | line: 'node #foo[test="test"]',
64 | name: "test",
65 | }),
66 | ).toEqual("node #foo");
67 | });
68 |
69 | test("does nothing if attribute not present", () => {
70 | expect(
71 | removeDataAttributeFromNode({
72 | line: "node",
73 | name: "test",
74 | }),
75 | ).toEqual("node");
76 | });
77 | });
78 |
--------------------------------------------------------------------------------
/graph-selector/src/operate/removeDataAttributeFromNode.ts:
--------------------------------------------------------------------------------
1 | import { getEdgeBreakIndex, getFeaturesIndex } from "../regexps";
2 |
3 | import { dataToString } from "./dataToString";
4 | import { getFeatureData } from "../getFeatureData";
5 |
6 | /**
7 | * Removes a data attribute from a node by name
8 | */
9 | export function removeDataAttributeFromNode({ line, name }: { line: string; name: string }) {
10 | // remove initial indent
11 | const indent = line.match(/^\s*/)?.[0] || "";
12 | line = line.replace(/^\s*/, "");
13 |
14 | // remove container start ("{" as last character)
15 | let containerStart = "";
16 | if (line.endsWith(" {")) {
17 | containerStart = " {";
18 | line = line.slice(0, -2);
19 | }
20 |
21 | // remove edge
22 | let edge = "";
23 | const edgeBreakIndex = getEdgeBreakIndex(line);
24 | if (edgeBreakIndex !== -1) {
25 | edge = line.slice(0, edgeBreakIndex + 1);
26 | line = line.slice(edgeBreakIndex + 1);
27 | }
28 |
29 | const featuresIndex = getFeaturesIndex(line);
30 | let features = "";
31 | if (featuresIndex !== -1) {
32 | features = line.slice(featuresIndex);
33 | line = line.slice(0, featuresIndex);
34 | }
35 |
36 | if (!features) {
37 | return indent + edge + line + containerStart;
38 | }
39 |
40 | const { classes, data, id = "" } = getFeatureData(features);
41 |
42 | let newFeatureString = "";
43 | if (id) newFeatureString += `#${id}`;
44 | if (classes) newFeatureString += classes;
45 | delete data[name];
46 | newFeatureString += dataToString(data);
47 |
48 | return (
49 | indent + (edge + [line, newFeatureString].filter(Boolean).join(" ") + containerStart).trim()
50 | );
51 | }
52 |
--------------------------------------------------------------------------------
/graph-selector/src/parse.test.ts:
--------------------------------------------------------------------------------
1 | import { describe, expect, test } from "vitest";
2 | import { parse } from "./parse";
3 |
4 | describe("parse", () => {
5 | test("it returns nodes and edges", () => {
6 | const result = parse(`a\n b`);
7 | expect(result).toHaveProperty("nodes");
8 | expect(result).toHaveProperty("edges");
9 | });
10 |
11 | /* Nodes */
12 | test("nodes have label", () => {
13 | const result = parse(`a\nb`);
14 | expect(result.nodes[0]?.data.label).toEqual("a");
15 | expect(result.nodes[1]?.data.label).toEqual("b");
16 | });
17 |
18 | test("nodes have line number", () => {
19 | const result = parse(`a\nb`);
20 | expect(result.nodes[0]?.parser?.lineNumber).toEqual(1);
21 | expect(result.nodes[1]?.parser?.lineNumber).toEqual(2);
22 | });
23 |
24 | test("nodes have unique IDs", () => {
25 | const result = parse(`a\na`);
26 | expect(result.nodes[0]?.data.id).toEqual("n1");
27 | expect(result.nodes[1]?.data.id).toEqual("n2");
28 | });
29 |
30 | test("allow custom ID", () => {
31 | const result = parse(`a #x\n b #y`);
32 | expect(result.nodes[0]?.data.id).toEqual("x");
33 | expect(result.nodes[1]?.data.id).toEqual("y");
34 | });
35 |
36 | test("custom id not included in label", () => {
37 | const result = parse(`a #x`);
38 | expect(result.nodes[0]?.data.id).toEqual("x");
39 | expect(result.nodes[0]?.data.label).toEqual("a");
40 | });
41 |
42 | test("allow percentages in label attribute", () => {
43 | const result = parse(`5%`);
44 | expect(result.nodes[0]?.data.label).toEqual("5%");
45 | });
46 |
47 | test("can read classes without id", () => {
48 | const result = parse(`a .class1.class2`);
49 | expect(result.nodes[0]?.data.classes).toEqual(".class1.class2");
50 | expect(result.nodes[0]?.data.label).toEqual("a");
51 | });
52 |
53 | test("can read classes with id", () => {
54 | const result = parse(`a #x.class1.class2`);
55 | expect(result.nodes[0]?.data.id).toEqual("x");
56 | expect(result.nodes[0]?.data.classes).toEqual(".class1.class2");
57 | expect(result.nodes[0]?.data.label).toEqual("a");
58 | });
59 |
60 | test("creates edge with indentation", () => {
61 | const result = parse(`a\n b`);
62 | expect(result.edges[0]?.source).toEqual("n1");
63 | expect(result.edges[0]?.target).toEqual("n2");
64 | expect(result.edges.length).toEqual(1);
65 | });
66 |
67 | test("create edge with label", () => {
68 | const result = parse(`a\n b: c`);
69 | expect(result.edges[0]?.source).toEqual("n1");
70 | expect(result.edges[0]?.target).toEqual("n2");
71 | expect(result.edges[0]?.data.label).toEqual("b");
72 | expect(result.edges.length).toEqual(1);
73 | });
74 |
75 | test("can parse id, classes when edge label", () => {
76 | const result = parse(`a\n b: c #x.class1.class2`);
77 | const edge = result.edges[0];
78 | const node = result.nodes[1];
79 | if (!edge || !node) {
80 | throw new Error("Expected edge and node to be defined");
81 | }
82 | expect(edge.source).toEqual("n1");
83 | expect(edge.target).toEqual("x");
84 | expect(edge.data.label).toEqual("b");
85 | expect(edge.data.id).toEqual("n1-x-1");
86 | expect(node.data.id).toEqual("x");
87 | expect(node.data.classes).toEqual(".class1.class2");
88 | });
89 |
90 | test("should preserve spaces in labels", () => {
91 | let result = parse(`a long label #b`);
92 | const node = result.nodes[0];
93 | if (!node) {
94 | throw new Error("Expected node to be defined");
95 | }
96 | expect(node.data.label).toEqual("a long label");
97 |
98 | result = parse(`another one`);
99 | const node2 = result.nodes[0];
100 | if (!node2) {
101 | throw new Error("Expected node to be defined");
102 | }
103 | expect(node2.data.label).toEqual("another one");
104 | });
105 |
106 | test("should parse attributes w/o quotes", () => {
107 | const result = parse(`[d=e][f=a] c`);
108 | expect(result.nodes[0]?.data.d).toEqual("e");
109 | expect(result.nodes[0]?.data.f).toEqual("a");
110 | });
111 |
112 | test("can parse all node qualities", () => {
113 | expect(parse(`c #long-id.class1.class2[d=e][f=a]`).nodes[0]).toEqual({
114 | data: {
115 | classes: ".class1.class2",
116 | d: "e",
117 | f: "a",
118 | id: "long-id",
119 | label: "c",
120 | },
121 | parser: {
122 | lineNumber: 1,
123 | },
124 | });
125 | });
126 |
127 | test("should create node with only data", () => {
128 | const result = parse(`[d=e]\n[f=a]`);
129 | expect(result.nodes.length).toEqual(2);
130 | });
131 |
132 | test("should create node with label in attribute", () => {
133 | const result = parse(`[label=a]`);
134 | expect(result.nodes[0]?.data.label).toEqual("a");
135 | });
136 |
137 | test("allows newline in raw label", () => {
138 | const result = parse(`a\\nb`);
139 | expect(result.nodes[0]?.data.label).toEqual("a\nb");
140 | });
141 |
142 | test("should allow newline in label attribute", () => {
143 | const result = parse(`[label="a\\nb"]`);
144 | expect(result.nodes[0]?.data.label).toEqual("a\nb");
145 | });
146 |
147 | test("should allow escaped parentheses in label attribute", () => {
148 | const result = parse(`Hello\\(World\\)`);
149 | expect(result.nodes[0]?.data.label).toEqual("Hello(World)");
150 | });
151 |
152 | test("allow escaped brackets in label attribute", () => {
153 | const result = parse(`Hello\\[World\\]`);
154 | expect(result.nodes[0]?.data.label).toEqual("Hello[World]");
155 | });
156 |
157 | test("allow escaped curly braces in label attribute", () => {
158 | const result = parse(`Hello\\{World\\}`);
159 | expect(result.nodes[0]?.data.label).toEqual("Hello{World}");
160 | });
161 |
162 | test("preserves URLs in labels", () => {
163 | const result = parse(`Check out http://example.com and https://test.com`);
164 | expect(result.nodes[0]?.data.label).toEqual(
165 | "Check out http://example.com and https://test.com",
166 | );
167 | });
168 |
169 | test("allow apostrophe in quoted attribute", () => {
170 | const result = parse(`[attr="Hello's World"]`);
171 | expect(result.nodes[0]?.data.attr).toEqual("Hello's World");
172 | });
173 |
174 | /* Pointers */
175 | test("can parse pointer to label", () => {
176 | const result = parse(`a\n (a)`);
177 | expect(result.edges[0]?.source).toEqual("n1");
178 | expect(result.edges[0]?.target).toEqual("n1");
179 | expect(result.edges.length).toEqual(1);
180 | });
181 |
182 | test("can parse pointer to id", () => {
183 | const result = parse(`b #b\na\n (#b)`);
184 | expect(result.edges[0]?.source).toEqual("n2");
185 | expect(result.edges[0]?.target).toEqual("b");
186 | expect(result.edges.length).toEqual(1);
187 | });
188 |
189 | test("can parse pointer to class", () => {
190 | const result = parse(`c .c\na\n (.c)`);
191 | expect(result.edges[0]?.source).toEqual("n2");
192 | expect(result.edges[0]?.target).toEqual("n1");
193 | expect(result.edges.length).toEqual(1);
194 | });
195 |
196 | test("can parse source pointer to raw node", () => {
197 | const result = parse(`a\n(a)\n c`);
198 | expect(result.edges[0]?.source).toEqual("n1");
199 | expect(result.edges[0]?.target).toEqual("n3");
200 | expect(result.edges.length).toEqual(1);
201 | });
202 |
203 | test("can parse source pointer to id", () => {
204 | const result = parse(`a\n(a)\n #myid c`);
205 | expect(result.edges[0]?.source).toEqual("n1");
206 | expect(result.edges[0]?.target).toEqual("myid");
207 | expect(result.edges.length).toEqual(1);
208 | });
209 |
210 | test("can parse source pointer to class", () => {
211 | const result = parse(`a\nsome color .red\n(a)\n (.red)`);
212 | expect(result.edges[0]?.source).toEqual("n1");
213 | expect(result.edges[0]?.target).toEqual("n2");
214 | expect(result.edges.length).toEqual(1);
215 | });
216 |
217 | test("can parse source pointer when using id", () => {
218 | const result = parse(`(#a)\n c\na #a`);
219 | expect(result.edges[0]?.source).toEqual("a");
220 | expect(result.edges[0]?.target).toEqual("n2");
221 | expect(result.edges.length).toEqual(1);
222 | });
223 |
224 | test("gets the right number of nodes", () => {
225 | const result = parse(`a\ne\n\t(a)\n\t(e)`);
226 | expect(result.nodes.length).toEqual(2);
227 | });
228 |
229 | test("should work with chinese colon and parentheses", () => {
230 | const result = parse(`中文\n to: (中文)`);
231 | expect(result.edges).toEqual([
232 | {
233 | source: "n1",
234 | target: "n1",
235 | data: {
236 | classes: "",
237 | id: "n1-n1-1",
238 | label: "to",
239 | },
240 | parser: {
241 | lineNumber: 2,
242 | },
243 | },
244 | ]);
245 | expect(result.nodes).toEqual([
246 | {
247 | data: {
248 | classes: "",
249 | id: "n1",
250 | label: "中文",
251 | },
252 | parser: {
253 | lineNumber: 1,
254 | },
255 | },
256 | ]);
257 | });
258 |
259 | test("Should work with chinese edge label and colon", () => {
260 | const result = parse(`之前什么都没有做盒子\n\t它应该可以工作:但是有一个错误🐛`);
261 | expect(result.edges).toEqual([
262 | {
263 | source: "n1",
264 | target: "n2",
265 | data: {
266 | classes: "",
267 | id: "n1-n2-1",
268 | label: "它应该可以工作",
269 | },
270 | parser: {
271 | lineNumber: 2,
272 | },
273 | },
274 | ]);
275 | expect(result.nodes[1]).toEqual({
276 | data: {
277 | classes: "",
278 | id: "n2",
279 | label: "但是有一个错误🐛",
280 | },
281 | parser: {
282 | lineNumber: 2,
283 | },
284 | });
285 | });
286 |
287 | test("works with two-letter label pointer", () => {
288 | const result = parse(`bb\nc\n\t(bb)`);
289 | expect(result.edges.length).toEqual(1);
290 | });
291 |
292 | test("can parse node with url in attribute", () => {
293 | expect(parse(`[url="http://www.google.com"]`).nodes[0]?.data.url).toEqual(
294 | "http:\\/\\/www.google.com",
295 | );
296 | });
297 |
298 | /* Edges */
299 |
300 | test("gets correct line number for edges", () => {
301 | const result = parse(`#a[size=4] label a
302 | #b[size=3] label b
303 | #c[size=5] label c
304 | #d[size=2] label d
305 |
306 | (#a)
307 | (#c)
308 | (#d)
309 | (#b)
310 | (#d)
311 | `);
312 | expect(result.edges[0]?.parser?.lineNumber).toEqual(7);
313 | expect(result.edges[1]?.parser?.lineNumber).toEqual(8);
314 | expect(result.edges[2]?.parser?.lineNumber).toEqual(10);
315 | });
316 |
317 | test("get correct edge label", () => {
318 | const result = parse(`a\nb\n\ttest: (a)`);
319 | expect(result.edges[0]?.data.label).toEqual("test");
320 | });
321 |
322 | test("allow dashes and numbers in classes and ids", () => {
323 | const result = parse(`#a-1.class-1.class-2[d=e][f=a] c`);
324 | expect(result.nodes[0]?.data.id).toEqual("a-1");
325 | expect(result.nodes[0]?.data.classes).toEqual(".class-1.class-2");
326 | });
327 |
328 | test("parse edge data", () => {
329 | const result = parse(`a\n #x.fun.fun-2[att=15] still the label: b`);
330 | expect(result.edges[0]?.data.id).toEqual("x");
331 | expect(result.edges[0]?.source).toEqual("n1");
332 | expect(result.edges[0]?.target).toEqual("n2");
333 | expect(result.edges[0]?.data.att).toEqual(15);
334 | expect(result.edges[0]?.data.classes).toEqual(".fun.fun-2");
335 | expect(result.edges[0]?.data.label).toEqual("still the label");
336 | expect(result.edges[0]?.data.id).toEqual("x");
337 | });
338 |
339 | test("self edge has id", () => {
340 | const result = parse(`#b longer label text\n\t#xxx edge label: (#b)`);
341 |
342 | expect(result.edges[0]?.data.id).toEqual("xxx");
343 | });
344 |
345 | test("shouldn't create node for empty line", () => {
346 | const result = parse(`
347 | `);
348 | expect(result.nodes.length).toEqual(0);
349 | });
350 |
351 | test("should allow edge to edge", () => {
352 | const result = parse(`a
353 | #c : cool
354 |
355 | to edge
356 | (#c)`);
357 | expect(result.edges.length).toEqual(2);
358 | expect(result.edges[1]?.target).toEqual("c");
359 | });
360 |
361 | test("unresolved edges also have unique edge ids", () => {
362 | const input = `a\n b\n(a)\n (b)\n(a)\n (b)`;
363 | expect(() => parse(input)).not.toThrow();
364 | const result = parse(input);
365 | expect(result.edges[0]?.data.id).toEqual("n1-n2-1");
366 | expect(result.edges[1]?.data.id).toEqual("n1-n2-2");
367 | expect(result.edges[2]?.data.id).toEqual("n1-n2-3");
368 | });
369 |
370 | test("should auto-increment edge ids", () => {
371 | const result = parse(`a\n (b)\n (b)\n b`);
372 | expect(result.edges[0]?.data.id).toEqual("n1-n4-1");
373 | expect(result.edges[1]?.data.id).toEqual("n1-n4-2");
374 | expect(result.edges[2]?.data.id).toEqual("n1-n4-3");
375 | });
376 |
377 | test("should find edges created later by label", () => {
378 | const result = parse(`
379 | a
380 | (b)
381 | (c)
382 | b
383 | c`);
384 | expect(result.edges[0]?.source).toEqual("n2");
385 | expect(result.edges[0]?.target).toEqual("n5");
386 | expect(result.edges[1]?.source).toEqual("n2");
387 | expect(result.edges[1]?.target).toEqual("n6");
388 | });
389 |
390 | test("indents under pointers should also produce edge to pointer", () => {
391 | const result = parse(`
392 | b
393 | a
394 | (b)
395 | c`);
396 | expect(result.edges[0]?.source).toEqual("n3");
397 | expect(result.edges[0]?.target).toEqual("n2");
398 | expect(result.edges[1]?.source).toEqual("n2");
399 | expect(result.edges[1]?.target).toEqual("n5");
400 | });
401 |
402 | /* Misc */
403 | test("should ignore empty lines", () => {
404 | const result = parse(`a\n\n\tb`);
405 | expect(result.nodes.length).toEqual(2);
406 | expect(result.edges.length).toEqual(1);
407 | });
408 |
409 | test("should allow escaped colons in labels", () => {
410 | const result = parse(`this line has a colon \\: in it`);
411 | expect(result.nodes[0]?.data.label).toEqual("this line has a colon : in it");
412 | });
413 |
414 | test("should allow escaped slashes in labels", () => {
415 | const result = parse(`this line has two slashes \\/\\/ in it`);
416 | expect(result.nodes[0]?.data.label).toEqual("this line has two slashes // in it");
417 | });
418 |
419 | test("emojis should be valid in labels", () => {
420 | const result = parse(`👍`);
421 | expect(result.nodes[0]?.data.label).toEqual("👍");
422 | });
423 |
424 | /* Errors */
425 | test("should error labeled edge width no indent", () => {
426 | const label = `A\ntest: B`;
427 | expect(() => parse(label)).toThrow("Line 2: Edge label without parent");
428 | });
429 |
430 | test("should error if node ID used more than once", () => {
431 | const getResult = () => parse(`#hello hi\n#hello hi`);
432 | expect(getResult).toThrow('Line 2: Duplicate node id "hello"');
433 | });
434 |
435 | test("should error if edge ID used more than once", () => {
436 | const getResult = () => parse(`#a hi\n #b: test\n #b: another one`);
437 | expect(getResult).toThrow('Line 3: Duplicate edge id "b"');
438 | });
439 |
440 | test("should error intentional duplicate edge Id", () => {
441 | const getResult = () => parse(`a\n b\n #n1-n2-1: (b)`);
442 | expect(getResult).toThrow('Line 3: Duplicate edge id "n1-n2-1"');
443 | });
444 |
445 | test("should error if user creates pointers and node on same line", () => {
446 | const getResult = () => parse(`a\n\t(b) c`);
447 | expect(getResult).toThrow("Line 2: Can't create node and pointer on same line");
448 | });
449 |
450 | test("should error if single line contains multiple pointers", () => {
451 | const getResult = () => parse(`b\nc\na\n\t(b) (c)`);
452 | expect(getResult).toThrow("Line 4: Can't create multiple pointers on same line");
453 | });
454 |
455 | test("should error if you try to open a container on a line with pointers", () => {
456 | const getResult = () => parse(`a\n\t(b) {\n\t\tc\n\t}`);
457 | expect(getResult).toThrow("Line 2: Can't create pointer and container on same line");
458 | });
459 |
460 | // Containers
461 | test("should create containers from curly brackets", () => {
462 | const result = parse(`a {\n\tb\n\tc\n}`);
463 | expect(result.nodes.length).toEqual(3);
464 | expect(result.nodes[1]?.data.parent).toEqual("n1");
465 | expect(result.nodes[2]?.data.parent).toEqual("n1");
466 | });
467 |
468 | test("should create edges inside of containers", () => {
469 | const result = parse(`a {\n\tb\n\t\tc\n}`);
470 | expect(result.edges.length).toEqual(1);
471 | expect(result.edges[0]?.source).toEqual("n2");
472 | expect(result.edges[0]?.target).toEqual("n3");
473 | expect(result.nodes.slice(1).every((n) => n.data.parent === "n1")).toEqual(true);
474 | });
475 |
476 | test("can create edges to a container", () => {
477 | const result = parse(`
478 | a {
479 | b
480 | }
481 | c
482 | (a)`);
483 | expect(result.edges.length).toEqual(1);
484 | expect(result.edges[0]?.source).toEqual("n5");
485 | expect(result.edges[0]?.target).toEqual("n2");
486 | expect(result.nodes[1]?.data.parent).toEqual("n2");
487 | });
488 |
489 | test("should allow nesting containers", () => {
490 | const result = parse(`
491 | a {
492 | b {
493 | c
494 | }
495 | }
496 | `);
497 | expect(result.nodes.length).toEqual(3);
498 | expect(result.nodes[1]?.data.parent).toEqual("n2");
499 | expect(result.nodes[2]?.data.parent).toEqual("n3");
500 | });
501 |
502 | test("should create a parent node with no label if none given", () => {
503 | const result = parse(`
504 | {
505 | a
506 | }`);
507 | expect(result.nodes.length).toEqual(2);
508 | expect(result.nodes[0]?.data.label).toEqual("");
509 | expect(result.nodes[1]?.data.parent).toEqual("n2");
510 | });
511 |
512 | test("nodes that are containers should have an isParent flag", () => {
513 | const result = parse(`
514 | a {
515 | b
516 | }`);
517 | expect(result.nodes[0]?.data.isParent).toEqual(true);
518 | });
519 | });
520 |
--------------------------------------------------------------------------------
/graph-selector/src/parse.ts:
--------------------------------------------------------------------------------
1 | import { Data, FeatureData, Graph, Pointer } from "./types";
2 | import { getEdgeBreakIndex, getFeaturesIndex } from "./regexps";
3 |
4 | import { getFeatureData } from "./getFeatureData";
5 | import { matchAndRemovePointers } from "./matchAndRemovePointers";
6 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment
7 | // @ts-ignore
8 | import { strip } from "@tone-row/strip-comments";
9 | import { ParseError } from "./ParseError";
10 | import { getIndentSize } from "./getIndentSize";
11 |
12 | // TODO: these types could probably be improved to match the target types (in ./types.ts) more closely
13 |
14 | type ID = string;
15 | type UnresolvedEdges = {
16 | source: ID | Pointer;
17 | target: ID | Pointer;
18 | lineNumber: number;
19 | label: string;
20 | classes: string;
21 | id: string;
22 | otherData: Data;
23 | }[];
24 | type Ancestor = Pointer[] | ID | null;
25 | type Ancestors = Ancestor[];
26 |
27 | export function parse(text: string): Graph {
28 | const nodes: Graph["nodes"] = [];
29 | const edges: Graph["edges"] = [];
30 |
31 | // add a \ before any http:// or https://
32 | text = text.replace(/(https?:)\/\//g, "$1\\/\\/");
33 |
34 | // break into lines
35 | let lines = strip(text, { preserveNewlines: true }).split(/\n/g);
36 |
37 | // unescape backslashes in newlines
38 | lines = lines.map((line: string) => line.replace(/\\n/g, "\n"));
39 |
40 | // start line number count
41 | let lineNumber = 0;
42 |
43 | // store Ids
44 | const nodeIds: string[] = [];
45 | const edgeIds: string[] = [];
46 |
47 | // store ancestors (ids at indent size)
48 | // Because of containers, we need to keep track of indents at different levels
49 | // So we will turn this into a stack
50 | // And always reference the top of the stack
51 | const allAncestors: Ancestors[] = [[]];
52 |
53 | // store pointer edges to be resolved when all nodes parsed
54 | const unresolvedEdges: UnresolvedEdges = [];
55 |
56 | // pop container state on and off stack
57 | const containerParentIds: ID[] = [];
58 |
59 | for (let line of lines) {
60 | ++lineNumber;
61 |
62 | const originalLine = line;
63 |
64 | // continue from empty line
65 | if (!line.trim()) continue;
66 |
67 | // if the very last character of the line is an open curly bracket
68 | // mark it as a container and remove the bracket
69 | let isContainerStart = false;
70 | let isContainerEnd = false;
71 | if (line[line.length - 1] === "{") {
72 | line = line.slice(0, -1);
73 | isContainerStart = true;
74 | }
75 |
76 | // if the first non-whitespace character of the line is a close curly bracket
77 | // mark it as a container and remove the bracket
78 | if (/^\s*\}/.test(line)) {
79 | // just replace the first closing curly bracket
80 | line = line.replace("}", "");
81 | isContainerEnd = true;
82 | containerParentIds.pop();
83 | }
84 |
85 | // get indent size
86 | const indentSize = getIndentSize(line);
87 |
88 | // get our relevant set of ancestors to work with indents
89 | let ancestors = allAncestors[allAncestors.length - 1] || [];
90 |
91 | // check if line is a "source-pointer" (i.e. a reference, like (x), with no indent)
92 | if (indentSize === 0 && line[0] === "(") {
93 | // parse pointers
94 | const [pointers] = matchAndRemovePointers(line);
95 | // Update array of ancestors
96 | allAncestors[allAncestors.length - 1] = [pointers];
97 | continue;
98 | }
99 |
100 | // get parent if exists
101 | const ancestor = findParent(indentSize, ancestors);
102 |
103 | // get edge label if parent
104 | let edgeLabel = "";
105 | const edgeBreakIndex = getEdgeBreakIndex(line);
106 | if (edgeBreakIndex > -1) {
107 | edgeLabel = line.slice(0, edgeBreakIndex + 1);
108 | line = line.slice(edgeBreakIndex + 2).trim();
109 | }
110 |
111 | // throw if edge label and no indent
112 | if (indentSize === 0 && edgeLabel) {
113 | throw new ParseError(
114 | `Line ${lineNumber}: Edge label without parent`,
115 | lineNumber,
116 | lineNumber,
117 | 0,
118 | edgeLabel.length + 1,
119 | "EDGE_LABEL_WITHOUT_PARENT",
120 | );
121 | }
122 |
123 | // remove indent from line
124 | line = line.trim();
125 |
126 | // get index where features (id, classes, data) start
127 | const featuresIndex = getFeaturesIndex(line);
128 | const { classes, data, ...rest } = getFeatureData(line.slice(featuresIndex));
129 | let id = rest.id;
130 | line = line.slice(0, featuresIndex);
131 |
132 | // parse all pointers
133 | const [pointers, lineWithPointersRemoved] = matchAndRemovePointers(line);
134 | line = lineWithPointersRemoved;
135 |
136 | // error if more than one pointer
137 | if (pointers.length > 1) {
138 | throw new ParseError(
139 | `Line ${lineNumber}: Can't create multiple pointers on same line`,
140 | lineNumber,
141 | lineNumber,
142 | 0,
143 | originalLine.length + 1,
144 | "MULTIPLE_POINTERS_ON_SAME_LINE",
145 | );
146 | }
147 |
148 | // the lable is what is left after everything is removed
149 | let label = line;
150 |
151 | // safe remove escape from characters now
152 | label = label.replace(/\\([::\(\)\(\){}\[\]])/g, "$1").replace(/\\([#\.\/])/g, "$1");
153 |
154 | let lineDeclaresNode = !!id || !!label || !!classes || Object.keys(data).length > 0;
155 |
156 | // Create a ghost node to be the container parent if none given
157 | if (!lineDeclaresNode && isContainerStart) {
158 | lineDeclaresNode = true;
159 | id = `n${lineNumber}`;
160 | }
161 |
162 | // Throw if line has pointers and also opens container
163 | if (pointers.length > 0 && isContainerStart) {
164 | throw new ParseError(
165 | `Line ${lineNumber}: Can't create pointer and container on same line`,
166 | lineNumber,
167 | lineNumber,
168 | originalLine.length,
169 | originalLine.length + 1,
170 | "POINTER_AND_CONTAINER_ON_SAME_LINE",
171 | );
172 | }
173 |
174 | // error if line declares node and pointers
175 | if (lineDeclaresNode && pointers.length > 0) {
176 | throw new ParseError(
177 | `Line ${lineNumber}: Can't create node and pointer on same line`,
178 | lineNumber,
179 | lineNumber,
180 | indentSize + 1,
181 | originalLine.length + 1,
182 | "NODE_AND_POINTER_ON_SAME_LINE",
183 | );
184 | }
185 |
186 | // create a unique ID from line number
187 | // if no user-supplied id
188 | if (lineDeclaresNode && !id) {
189 | id = `n${lineNumber}`;
190 | }
191 |
192 | // Throw if id already exists
193 | if (lineDeclaresNode && nodeIds.includes(id)) {
194 | throw new ParseError(
195 | `Line ${lineNumber}: Duplicate node id "${id}"`,
196 | lineNumber,
197 | lineNumber,
198 | indentSize + 1,
199 | originalLine.length + 1,
200 | "DUPLICATE_NODE_ID",
201 | );
202 | }
203 |
204 | // Store id
205 | nodeIds.push(id);
206 |
207 | // create node if line declares node
208 | if (lineDeclaresNode) {
209 | const _data: FeatureData = {
210 | label,
211 | id,
212 | classes,
213 | ...data,
214 | };
215 |
216 | // if parent, add isParent flag
217 | if (isContainerStart) {
218 | _data.isParent = true;
219 | }
220 |
221 | const node: Graph["nodes"][number] = {
222 | data: _data,
223 | parser: {
224 | lineNumber,
225 | },
226 | };
227 |
228 | // see if there is an active container parent
229 | const containerParentId = containerParentIds[containerParentIds.length - 1];
230 |
231 | // if container, add parent to data
232 | if (containerParentId) {
233 | node.data.parent = containerParentId;
234 | }
235 |
236 | nodes.push(node);
237 | }
238 |
239 | // if container, set parent id
240 | // and add to ancestors
241 | if (isContainerStart) {
242 | containerParentIds.push(id);
243 | allAncestors.push([]);
244 | }
245 |
246 | // if container end, pop ancestors
247 | if (isContainerEnd) {
248 | allAncestors.pop();
249 | }
250 |
251 | // If ancestor, create edge (or unresolvedEdge)
252 | if (ancestor) {
253 | // start by getting edge data
254 | const { line: newLabel, ...edgeData } = getFeatureData(edgeLabel);
255 | edgeLabel = newLabel;
256 | if (isId(ancestor)) {
257 | // Create Edge for the node on this line
258 | if (lineDeclaresNode) {
259 | let edgeId = edgeData.id;
260 | if (!edgeId) {
261 | edgeId = `${ancestor}-${id}-1`;
262 | }
263 | if (edgeIds.includes(edgeId)) {
264 | throw new ParseError(
265 | `Line ${lineNumber}: Duplicate edge id "${edgeId}"`,
266 | lineNumber,
267 | lineNumber,
268 | indentSize + 1,
269 | // get length of edge id
270 | indentSize + 1 + edgeId.length + 1,
271 | "DUPLICATE_EDGE_ID",
272 | );
273 | }
274 | edgeIds.push(edgeId);
275 | edges.push({
276 | source: ancestor,
277 | target: id,
278 | parser: {
279 | lineNumber,
280 | },
281 | data: {
282 | id: edgeId,
283 | label: edgeLabel,
284 | classes: edgeData.classes,
285 | ...edgeData.data,
286 | },
287 | });
288 | }
289 |
290 | // add all pointers to future edges
291 | for (const [pointerType, pointerId] of pointers) {
292 | unresolvedEdges.push({
293 | source: ancestor,
294 | target: [pointerType, pointerId],
295 | lineNumber,
296 | label: edgeLabel,
297 | id: edgeData.id,
298 | classes: edgeData.classes,
299 | otherData: edgeData.data,
300 | });
301 | }
302 | } else {
303 | // Ancestor is a pointer array
304 | // loop over ancestor pointers
305 | // and create unresolved edges for each
306 | for (const sourcePointerArray of ancestor) {
307 | if (lineDeclaresNode) {
308 | unresolvedEdges.push({
309 | source: sourcePointerArray,
310 | target: id,
311 | lineNumber,
312 | label: edgeLabel,
313 | id: edgeData.id,
314 | classes: edgeData.classes,
315 | otherData: edgeData.data,
316 | });
317 | }
318 |
319 | // add all pointers to future edges
320 | for (const targetPointerArray of pointers) {
321 | unresolvedEdges.push({
322 | source: sourcePointerArray,
323 | target: targetPointerArray,
324 | lineNumber,
325 | label: edgeLabel,
326 | id: edgeData.id,
327 | classes: edgeData.classes,
328 | otherData: edgeData.data,
329 | });
330 | }
331 | }
332 | }
333 | }
334 |
335 | // Update array of ancestors
336 | if (!lineDeclaresNode) {
337 | ancestors[indentSize] = pointers;
338 | } else {
339 | ancestors[indentSize] = id;
340 | }
341 | ancestors = ancestors.slice(0, indentSize + 1);
342 | }
343 |
344 | // resolve unresolved edges
345 | for (const { source, target, lineNumber, label, otherData, ...rest } of unresolvedEdges) {
346 | const sourceNodes = isPointerArray(source)
347 | ? getNodesFromPointerArray(nodes, edges, source)
348 | : nodes.filter((n) => n.data.id === source);
349 | const targetNodes = isPointerArray(target)
350 | ? getNodesFromPointerArray(nodes, edges, target)
351 | : nodes.filter((n) => n.data.id === target);
352 | if (sourceNodes.length === 0 || targetNodes.length === 0) continue;
353 | for (const sourceNode of sourceNodes) {
354 | for (const targetNode of targetNodes) {
355 | const source = sourceNode.data.id;
356 | const target = targetNode.data.id;
357 | const data = {
358 | ...rest,
359 | ...otherData,
360 | label,
361 | };
362 |
363 | // Create edge id if not user-supplied
364 | if (!data.id) {
365 | let inc = 1;
366 | let edgeId = `${source}-${target}-${inc}`;
367 | while (edgeIds.includes(edgeId)) {
368 | ++inc;
369 | edgeId = `${source}-${target}-${inc}`;
370 | }
371 | data.id = edgeId;
372 | }
373 |
374 | if (edgeIds.includes(data.id)) {
375 | const line = lines[lineNumber - 1];
376 | if (!line) continue;
377 | throw new ParseError(
378 | `Line ${lineNumber}: Duplicate edge id "${data.id}"`,
379 | lineNumber,
380 | lineNumber,
381 | 0,
382 | line.length + 1,
383 | "DUPLICATE_EDGE_ID",
384 | );
385 | }
386 |
387 | edgeIds.push(data.id);
388 | edges.push({
389 | source,
390 | target,
391 | parser: {
392 | lineNumber,
393 | },
394 | data,
395 | });
396 | }
397 | }
398 | }
399 |
400 | return {
401 | nodes,
402 | edges,
403 | };
404 | }
405 |
406 | function findParent(indentSize: number, ancestors: Ancestors): Ancestor {
407 | let parent: Ancestor = null;
408 | let i = indentSize - 1;
409 | while (!parent && i >= 0) {
410 | const ancestor = ancestors[i];
411 | if (ancestor) parent = ancestor;
412 | i--;
413 | }
414 | return parent;
415 | }
416 | /**
417 | * Returns the nodes or edges that match a given pointer array
418 | *
419 | * Note: Because we only resolve unresolved edges once, we can't
420 | * resolve pointers to edges that haven't been created yet. This is something
421 | * potentially worth fixing in the future.
422 | */
423 | function getNodesFromPointerArray(
424 | nodes: Graph["nodes"],
425 | edges: Graph["edges"],
426 | [pointerType, value]: Pointer,
427 | ) {
428 | const entities = [...nodes, ...edges];
429 | switch (pointerType) {
430 | case "id":
431 | return entities.filter((node) => node.data.id === value);
432 | case "class":
433 | return entities.filter(
434 | (node) =>
435 | typeof node.data.classes === "string" && node.data.classes.split(".").includes(value),
436 | );
437 | case "label":
438 | return entities.filter((node) => node.data.label === value);
439 | }
440 | }
441 |
442 | function isPointerArray(x: unknown): x is Pointer {
443 | return Array.isArray(x) && x.length === 2;
444 | }
445 |
446 | function isId(id: unknown): id is string {
447 | return typeof id === "string";
448 | }
449 |
--------------------------------------------------------------------------------
/graph-selector/src/regexps.ts:
--------------------------------------------------------------------------------
1 | /*
2 | The goal of this file is centralize the regular expressions
3 | that are being used for the same purpose. */
4 |
5 | export const getEdgeBreakIndex = (line: string) => line.search(/[^\\](: |:)/);
6 |
7 | export const getFeaturesIndex = (line: string) => {
8 | const m = /(^|\s)(#|\.|\[)/.exec(line);
9 | return m?.index ?? line.length;
10 | };
11 |
12 | /**
13 | * @description
14 | * This regular expression is used to match the features of a node or edge.
15 | */
16 | export const featuresRe =
17 | /(?(?#[\w-]+)?(?(\.[a-zA-Z]{1}[\w-]*)*)?(?(\[[^\]=]+(?=(".*"|'.*'|.*))?\])*))/gs;
18 |
--------------------------------------------------------------------------------
/graph-selector/src/stringify.test.ts:
--------------------------------------------------------------------------------
1 | import { describe, expect, it } from "vitest";
2 |
3 | import { stringify } from "./stringify";
4 |
5 | describe("stringify", () => {
6 | it("should return a string", () => {
7 | expect(
8 | stringify({
9 | nodes: [],
10 | edges: [],
11 | }),
12 | ).toBe("");
13 | });
14 |
15 | it("should stringify a node", () => {
16 | expect(
17 | stringify({
18 | nodes: [
19 | {
20 | data: {
21 | id: "a",
22 | label: "a",
23 | classes: "",
24 | },
25 | },
26 | ],
27 | edges: [],
28 | }),
29 | ).toBe("a");
30 | });
31 |
32 | it("should stringify a node with an id", () => {
33 | expect(
34 | stringify({
35 | nodes: [
36 | {
37 | data: {
38 | id: "custom-id",
39 | label: "a",
40 | classes: "",
41 | },
42 | },
43 | ],
44 | edges: [],
45 | }),
46 | ).toBe("a #custom-id");
47 | });
48 |
49 | it("should stringify a node with classes", () => {
50 | expect(
51 | stringify({
52 | nodes: [
53 | {
54 | data: {
55 | id: "a",
56 | label: "a",
57 | classes: "foo bar",
58 | },
59 | },
60 | ],
61 | edges: [],
62 | }),
63 | ).toBe("a .foo.bar");
64 | });
65 |
66 | it("should stringify a node with data", () => {
67 | expect(
68 | stringify({
69 | nodes: [
70 | {
71 | data: {
72 | id: "a",
73 | label: "a",
74 | classes: "",
75 | foo: "bar",
76 | baz: 1,
77 | fizz: true,
78 | },
79 | },
80 | ],
81 | edges: [],
82 | }),
83 | ).toBe('a [foo="bar"][baz=1][fizz]');
84 | });
85 |
86 | it("should stringify a node with an id and classes", () => {
87 | expect(
88 | stringify({
89 | nodes: [
90 | {
91 | data: {
92 | id: "custom-id",
93 | label: "a",
94 | classes: "foo bar",
95 | },
96 | },
97 | ],
98 | edges: [],
99 | }),
100 | ).toBe("a #custom-id.foo.bar");
101 | });
102 |
103 | it("should stringify a node with an id and data", () => {
104 | expect(
105 | stringify({
106 | nodes: [
107 | {
108 | data: {
109 | id: "custom-id",
110 | label: "a",
111 | classes: "",
112 | foo: "bar",
113 | baz: 1,
114 | fizz: true,
115 | },
116 | },
117 | ],
118 | edges: [],
119 | }),
120 | ).toBe('a #custom-id[foo="bar"][baz=1][fizz]');
121 | });
122 |
123 | it("should stringify a node with an id, classes, and data", () => {
124 | expect(
125 | stringify({
126 | nodes: [
127 | {
128 | data: {
129 | id: "custom-id",
130 | label: "a",
131 | classes: "foo bar",
132 | foo: "bar",
133 | baz: 1,
134 | fizz: true,
135 | },
136 | },
137 | ],
138 | edges: [],
139 | }),
140 | ).toBe('a #custom-id.foo.bar[foo="bar"][baz=1][fizz]');
141 | });
142 |
143 | it("should escape characters in the label", () => {
144 | expect(
145 | stringify({
146 | nodes: [
147 | {
148 | data: {
149 | id: "a",
150 | label: "a\nb:c[d]e(f)g",
151 | classes: "",
152 | },
153 | },
154 | ],
155 | edges: [],
156 | }),
157 | ).toBe("a\\nb\\:c\\[d\\]e\\(f\\)g #a");
158 | });
159 |
160 | it("should stringify an edge", () => {
161 | expect(
162 | stringify({
163 | nodes: [
164 | {
165 | data: {
166 | id: "a",
167 | label: "a",
168 | classes: "",
169 | },
170 | },
171 | {
172 | data: {
173 | id: "b",
174 | label: "b",
175 | classes: "",
176 | },
177 | },
178 | ],
179 | edges: [
180 | {
181 | source: "a",
182 | target: "b",
183 | data: {
184 | id: "",
185 | label: "",
186 | classes: "",
187 | },
188 | },
189 | ],
190 | }),
191 | ).toBe("a\n (b)\nb");
192 | });
193 |
194 | it("should stringify an edge with an id", () => {
195 | expect(
196 | stringify({
197 | nodes: [
198 | {
199 | data: {
200 | id: "a",
201 | label: "a",
202 | classes: "",
203 | },
204 | },
205 | {
206 | data: {
207 | id: "b",
208 | label: "b",
209 | classes: "",
210 | },
211 | },
212 | ],
213 | edges: [
214 | {
215 | source: "a",
216 | target: "b",
217 | data: {
218 | id: "custom-id",
219 | label: "",
220 | classes: "",
221 | },
222 | },
223 | ],
224 | }),
225 | ).toBe("a\n #custom-id: (b)\nb");
226 | });
227 |
228 | it("should not add id to edge if label only", () => {
229 | expect(
230 | stringify({
231 | nodes: [
232 | {
233 | data: {
234 | id: "a",
235 | label: "a",
236 | classes: "",
237 | },
238 | },
239 | {
240 | data: {
241 | id: "b",
242 | label: "b",
243 | classes: "",
244 | },
245 | },
246 | ],
247 | edges: [
248 | {
249 | source: "a",
250 | target: "b",
251 | data: {
252 | id: "",
253 | label: "custom label",
254 | classes: "",
255 | },
256 | },
257 | ],
258 | }),
259 | ).toBe("a\n custom label: (b)\nb");
260 | });
261 |
262 | it("should use id for edge if custom id", () => {
263 | expect(
264 | stringify({
265 | nodes: [
266 | {
267 | data: {
268 | id: "a",
269 | label: "a",
270 | classes: "",
271 | },
272 | },
273 | {
274 | data: {
275 | id: "x",
276 | label: "b",
277 | classes: "",
278 | },
279 | },
280 | ],
281 | edges: [
282 | {
283 | source: "a",
284 | target: "x",
285 | data: {
286 | id: "",
287 | label: "custom label",
288 | classes: "",
289 | },
290 | },
291 | ],
292 | }),
293 | ).toBe("a\n custom label: (#x)\nb #x");
294 | });
295 |
296 | it("should stringify an edge with classes", () => {
297 | expect(
298 | stringify({
299 | nodes: [
300 | {
301 | data: {
302 | id: "a",
303 | label: "a",
304 | classes: "",
305 | },
306 | },
307 | {
308 | data: {
309 | id: "b",
310 | label: "b",
311 | classes: "",
312 | },
313 | },
314 | ],
315 | edges: [
316 | {
317 | source: "a",
318 | target: "b",
319 | data: {
320 | id: "",
321 | label: "",
322 | classes: "foo bar",
323 | },
324 | },
325 | ],
326 | }),
327 | ).toBe("a\n .foo.bar: (b)\nb");
328 | });
329 |
330 | it("should stringify an edge with data", () => {
331 | expect(
332 | stringify({
333 | nodes: [
334 | {
335 | data: {
336 | id: "a",
337 | label: "a",
338 | classes: "",
339 | },
340 | },
341 | {
342 | data: {
343 | id: "b",
344 | label: "b",
345 | classes: "",
346 | },
347 | },
348 | ],
349 | edges: [
350 | {
351 | source: "a",
352 | target: "b",
353 | data: {
354 | id: "",
355 | label: "",
356 | classes: "",
357 | foo: "bar",
358 | baz: 1,
359 | fizz: true,
360 | },
361 | },
362 | ],
363 | }),
364 | ).toBe('a\n [foo="bar"][baz=1][fizz]: (b)\nb');
365 | });
366 |
367 | it("should ignore an edge with an invalid target", () => {
368 | expect(
369 | stringify({
370 | nodes: [
371 | {
372 | data: {
373 | id: "a",
374 | label: "a",
375 | classes: "",
376 | },
377 | },
378 | ],
379 | edges: [
380 | {
381 | source: "a",
382 | target: "b",
383 | data: {
384 | id: "",
385 | label: "",
386 | classes: "",
387 | },
388 | },
389 | ],
390 | }),
391 | ).toBe("a");
392 | });
393 |
394 | it("should throw an error if node data passed that isn't a valid type", () => {
395 | expect(() => {
396 | stringify({
397 | nodes: [
398 | {
399 | data: {
400 | id: "a",
401 | label: "a",
402 | classes: "",
403 | foo: "bar",
404 | baz: 1,
405 | fizz: true,
406 | // @ts-expect-error - invalid data type
407 | buzz: undefined,
408 | },
409 | },
410 | ],
411 | edges: [],
412 | });
413 | }).toThrowError('Invalid data type for property "buzz": undefined');
414 | });
415 |
416 | it("should return the corrent string when using ids in our scheme", () => {
417 | const result = stringify({
418 | edges: [
419 | {
420 | data: {
421 | classes: "",
422 | id: "",
423 | label: "",
424 | },
425 | source: "n1",
426 | target: "n2",
427 | },
428 | {
429 | data: {
430 | classes: "",
431 | id: "",
432 | label: "",
433 | },
434 | source: "n2",
435 | target: "n3",
436 | },
437 | {
438 | data: {
439 | classes: "",
440 | id: "",
441 | label: "label",
442 | },
443 | source: "n3",
444 | target: "n4",
445 | },
446 | {
447 | data: {
448 | classes: "",
449 | id: "",
450 | label: "",
451 | },
452 | source: "n3",
453 | target: "n6",
454 | },
455 | {
456 | data: {
457 | classes: "",
458 | id: "",
459 | label: "",
460 | },
461 | source: "n3",
462 | target: "n9",
463 | },
464 | {
465 | data: {
466 | classes: "",
467 | id: "",
468 | label: "",
469 | },
470 | source: "n4",
471 | target: "n10",
472 | },
473 | {
474 | data: {
475 | classes: "",
476 | id: "",
477 | label: "",
478 | },
479 | source: "n6",
480 | target: "n7",
481 | },
482 | {
483 | data: {
484 | classes: "",
485 | id: "",
486 | label: "",
487 | },
488 | source: "n7",
489 | target: "n8",
490 | },
491 | {
492 | data: {
493 | classes: "",
494 | id: "",
495 | label: "",
496 | },
497 | source: "n9",
498 | target: "n10",
499 | },
500 | {
501 | data: {
502 | classes: "",
503 | id: "",
504 | label: "",
505 | },
506 | source: "n10",
507 | target: "n8",
508 | },
509 | ],
510 | nodes: [
511 | {
512 | data: {
513 | classes: "",
514 | id: "n1",
515 | label: "Start",
516 | },
517 | },
518 | {
519 | data: {
520 | classes: "",
521 | id: "n2",
522 | label: "Step 1",
523 | },
524 | },
525 | {
526 | data: {
527 | classes: "",
528 | id: "n3",
529 | label: "Step 2",
530 | },
531 | },
532 | {
533 | data: {
534 | classes: "",
535 | id: "n4",
536 | label: "Step 3",
537 | },
538 | },
539 | {
540 | data: {
541 | classes: "",
542 | id: "n6",
543 | label: "Step 4",
544 | },
545 | },
546 | {
547 | data: {
548 | classes: "",
549 | id: "n7",
550 | label: "Step 6",
551 | },
552 | },
553 | {
554 | data: {
555 | classes: "",
556 | id: "n8",
557 | label: "Finish",
558 | },
559 | },
560 | {
561 | data: {
562 | classes: "",
563 | id: "n9",
564 | label: "Step 5",
565 | },
566 | },
567 | {
568 | data: {
569 | classes: "",
570 | id: "n10",
571 | label: "Step 7",
572 | },
573 | },
574 | ],
575 | });
576 |
577 | expect(result).toBe(`Start #n1
578 | (#n2)
579 | Step 1 #n2
580 | (#n3)
581 | Step 2 #n3
582 | label: (#n4)
583 | (#n6)
584 | (#n9)
585 | Step 3 #n4
586 | (#n10)
587 | Step 4 #n6
588 | (#n7)
589 | Step 6 #n7
590 | (#n8)
591 | Finish #n8
592 | Step 5 #n9
593 | (#n10)
594 | Step 7 #n10
595 | (#n8)`);
596 | });
597 |
598 | it("can also return a compact version", () => {
599 | const result = stringify(
600 | {
601 | edges: [
602 | {
603 | data: {
604 | classes: "",
605 | id: "",
606 | label: "",
607 | },
608 | source: "n1",
609 | target: "n2",
610 | },
611 | {
612 | data: {
613 | classes: "",
614 | id: "",
615 | label: "",
616 | },
617 | source: "n2",
618 | target: "n3",
619 | },
620 | {
621 | data: {
622 | classes: "",
623 | id: "",
624 | label: "label",
625 | },
626 | source: "n3",
627 | target: "n4",
628 | },
629 | {
630 | data: {
631 | classes: "",
632 | id: "",
633 | label: "",
634 | },
635 | source: "n3",
636 | target: "n6",
637 | },
638 | {
639 | data: {
640 | classes: "",
641 | id: "",
642 | label: "",
643 | },
644 | source: "n3",
645 | target: "n9",
646 | },
647 | {
648 | data: {
649 | classes: "",
650 | id: "",
651 | label: "",
652 | },
653 | source: "n4",
654 | target: "n10",
655 | },
656 | {
657 | data: {
658 | classes: "",
659 | id: "",
660 | label: "",
661 | },
662 | source: "n6",
663 | target: "n7",
664 | },
665 | {
666 | data: {
667 | classes: "",
668 | id: "",
669 | label: "",
670 | },
671 | source: "n7",
672 | target: "n8",
673 | },
674 | {
675 | data: {
676 | classes: "",
677 | id: "",
678 | label: "",
679 | },
680 | source: "n9",
681 | target: "n10",
682 | },
683 | {
684 | data: {
685 | classes: "",
686 | id: "",
687 | label: "",
688 | },
689 | source: "n10",
690 | target: "n8",
691 | },
692 | ],
693 | nodes: [
694 | {
695 | data: {
696 | classes: "",
697 | id: "n1",
698 | label: "Start",
699 | },
700 | },
701 | {
702 | data: {
703 | classes: "",
704 | id: "n2",
705 | label: "Step 1",
706 | },
707 | },
708 | {
709 | data: {
710 | classes: "",
711 | id: "n3",
712 | label: "Step 2",
713 | },
714 | },
715 | {
716 | data: {
717 | classes: "",
718 | id: "n4",
719 | label: "Step 3",
720 | },
721 | },
722 | {
723 | data: {
724 | classes: "",
725 | id: "n6",
726 | label: "Step 4",
727 | },
728 | },
729 | {
730 | data: {
731 | classes: "",
732 | id: "n7",
733 | label: "Step 6",
734 | },
735 | },
736 | {
737 | data: {
738 | classes: "",
739 | id: "n8",
740 | label: "Finish",
741 | },
742 | },
743 | {
744 | data: {
745 | classes: "",
746 | id: "n9",
747 | label: "Step 5",
748 | },
749 | },
750 | {
751 | data: {
752 | classes: "",
753 | id: "n10",
754 | label: "Step 7",
755 | },
756 | },
757 | ],
758 | },
759 | {
760 | compact: true,
761 | },
762 | );
763 |
764 | expect(result).toBe(`Start #n1
765 | Step 1 #n2
766 | Step 2 #n3
767 | label: Step 3 #n4
768 | Step 7 #n10
769 | (#n8)
770 | Step 4 #n6
771 | Step 6 #n7
772 | Finish #n8
773 | Step 5 #n9
774 | (#n10)`);
775 | });
776 | });
777 |
--------------------------------------------------------------------------------
/graph-selector/src/stringify.ts:
--------------------------------------------------------------------------------
1 | import { getIndentSize } from "./getIndentSize";
2 | import { Graph } from "./types";
3 |
4 | type StringifyOptions = {
5 | /**
6 | * Whether to compact the output by removing newlines and spaces
7 | */
8 | compact?: boolean;
9 | };
10 |
11 | const defaultOptions: StringifyOptions = {
12 | compact: false,
13 | };
14 |
15 | /**
16 | * Convets a graph to graph-selector DSL
17 | */
18 | export function stringify(graph: Graph, options: StringifyOptions = defaultOptions) {
19 | const lines: string[] = [];
20 |
21 | // In compact mode, we will store where edges should be added
22 |
23 | // Loop over nodes
24 | for (const { data: node } of graph.nodes) {
25 | // Escape label
26 | const labelStr = escapeLabel(node.label);
27 |
28 | // Only include ID if it's not the same as the label
29 | let idStr = "";
30 | if (node.id !== node.label) {
31 | idStr = `#${node.id}`;
32 | }
33 |
34 | // Only include classes if there are any
35 | let classesStr = "";
36 | if (node.classes)
37 | classesStr = node.classes
38 | .split(" ")
39 | .map((c) => `.${c}`)
40 | .join("");
41 |
42 | // Only include data if there is any
43 | const data = stringifyData(node);
44 |
45 | // build the line
46 | const features = [idStr, classesStr, data].filter(Boolean).join("");
47 | const line = [labelStr, features].filter(Boolean).join(" ");
48 |
49 | // Store how indented the parent is for use in compact mode
50 | let parentIndent = 0;
51 | let insertEdgeAt: number | undefined = undefined;
52 |
53 | if (options.compact) {
54 | // check if there is an edge to this id alread in the list of lines
55 | const targetStr = idStr ? `(${idStr})` : `(${labelStr})`;
56 | const existingLineIndex = lines.findIndex((line) => line.includes(targetStr));
57 |
58 | // If this node has not been referenced yet, add it to the list of lines
59 | if (existingLineIndex === -1) {
60 | lines.push(line);
61 | } else {
62 | // replace the targetStr with the line
63 | lines[existingLineIndex] = lines[existingLineIndex]!.replace(targetStr, line);
64 |
65 | // set the insertEdgeAt to the index of the next line
66 | insertEdgeAt = existingLineIndex + 1;
67 |
68 | // set the parentIndent to the indent of the existing line
69 | parentIndent = getIndentSize(lines[existingLineIndex]!);
70 | }
71 | } else {
72 | lines.push(line);
73 | }
74 |
75 | // get all edges whose source is this node
76 | const edges = graph.edges.filter((edge) => edge.source === node.id);
77 | for (const { target, data: edge } of edges) {
78 | // get target node
79 | const targetNode = graph.nodes.find((node) => node.data.id === target);
80 | if (!targetNode) continue;
81 |
82 | let label = escapeLabel(edge.label);
83 |
84 | let id = edge.id;
85 | if (id && id !== edge.label) {
86 | id = `#${edge.id}`;
87 | }
88 |
89 | let classes = "";
90 | if (edge.classes) {
91 | classes = edge.classes
92 | .split(" ")
93 | .map((c) => `.${c}`)
94 | .join("");
95 | }
96 |
97 | const data = stringifyData(edge);
98 |
99 | const features = [id, classes, data].filter(Boolean).join("");
100 | label = [features, label].filter(Boolean).join(" ");
101 |
102 | // Add a colon if there is a label
103 | if (label) label = `${label}: `;
104 |
105 | // link by id, if id is not the same as label, else label
106 | const link =
107 | targetNode.data.id !== targetNode.data.label
108 | ? `#${targetNode.data.id}`
109 | : targetNode.data.label;
110 |
111 | // wrap link in ()
112 | const wrappedLink = `(${link})`;
113 |
114 | const parentIndentStr = " ".repeat(parentIndent);
115 |
116 | const line = [parentIndentStr, " ", label, wrappedLink].filter(Boolean).join("");
117 |
118 | if (options.compact && insertEdgeAt !== undefined) {
119 | lines.splice(insertEdgeAt, 0, line);
120 | insertEdgeAt++;
121 | } else {
122 | lines.push(line);
123 | }
124 | }
125 | }
126 |
127 | // no empty functions
128 | return lines.join("\n");
129 | }
130 |
131 | /**
132 | * Convert data in the format Record to
133 | * CSS selector style string, e.g. { foo: "bar", baz: 1, fizz: true } => "[foo=bar][baz=1][fizz]"
134 | */
135 | export function stringifyData(data: Record) {
136 | return Object.entries(data)
137 | .map(([key, value]) => {
138 | if (["id", "label", "classes"].includes(key)) return "";
139 | if (typeof value === "boolean") return `[${key}]`;
140 | if (typeof value === "number") return `[${key}=${value}]`;
141 | if (typeof value === "string") return `[${key}="${value.replace(/"/g, '\\"')}"]`;
142 | throw new Error(`Invalid data type for property "${key}": ${typeof value}`);
143 | })
144 | .filter(Boolean)
145 | .join("");
146 | }
147 |
148 | function escapeLabel(label: string) {
149 | return (
150 | label
151 | // Escape these characters in the label using backslash
152 | // :, [, ], (, )
153 | .replace(/[:\[\]\(\)]/g, (c) => `\\${c}`)
154 |
155 | // make sure any newlines are escaped
156 | .replace(/\n/g, "\\n")
157 | );
158 | }
159 |
--------------------------------------------------------------------------------
/graph-selector/src/toCytoscapeElements.ts:
--------------------------------------------------------------------------------
1 | import { ElementDefinition } from "cytoscape";
2 | import { Graph } from "./types";
3 |
4 | export function toCytoscapeElements(parsed: Graph | null): ElementDefinition[] {
5 | const elements: ElementDefinition[] = [];
6 | if (!parsed) return elements;
7 | for (const { data, parser = {} } of parsed.nodes) {
8 | const { classes: _classes = "", ...rest } = data;
9 | const classes = _classes.split(".").join(" ");
10 | elements.push({
11 | classes,
12 | data: {
13 | ...parser,
14 | ...rest,
15 | },
16 | });
17 | }
18 | for (const { source, target, data, parser = {} } of parsed.edges) {
19 | const { classes: _classes = "", ...rest } = data;
20 | const classes = _classes.split(".").join(" ");
21 | elements.push({
22 | classes,
23 | data: {
24 | ...parser,
25 | source,
26 | target,
27 | ...rest,
28 | },
29 | });
30 | }
31 | return elements;
32 | }
33 |
--------------------------------------------------------------------------------
/graph-selector/src/toMermaid.test.ts:
--------------------------------------------------------------------------------
1 | import { describe, expect, test } from "vitest";
2 |
3 | import { parse } from "./parse";
4 | import { toMermaid } from "./toMermaid";
5 |
6 | describe("toMermaid", () => {
7 | test("should create nodes", () => {
8 | const graph = parse(`a\nb`);
9 | const mermaid = toMermaid(graph);
10 | expect(mermaid).toEqual(`flowchart\n\tn1["a"]\n\tn2["b"]`);
11 | });
12 |
13 | test("should create edges", () => {
14 | const graph = parse(`a\n b`);
15 | const mermaid = toMermaid(graph);
16 | expect(mermaid).toEqual(`flowchart\n\tn1["a"]\n\tn2["b"]\n\tn1 --> n2`);
17 | });
18 |
19 | test("renders edge labels", () => {
20 | const graph = parse(`a\n label: b`);
21 | const mermaid = toMermaid(graph);
22 | expect(mermaid).toEqual(`flowchart\n\tn1["a"]\n\tn2["b"]\n\tn1 -- "label" --> n2`);
23 | });
24 |
25 | test("renders nodes with no label", () => {
26 | const graph = parse(`.classonly`);
27 | const mermaid = toMermaid(graph);
28 | expect(mermaid).toEqual(`flowchart\n\tn1[" "]`);
29 | });
30 |
31 | test("supports shapes classes", () => {
32 | const graph = parse(
33 | `ellipse .ellipse\ncircle .circle\ndiamond .diamond\nrounded-rectangle .rounded-rectangle\nroundedrectangle .roundedrectangle\nhexagon .hexagon\nrhomboid .rhomboid`,
34 | );
35 | const mermaid = toMermaid(graph);
36 | expect(mermaid).toEqual(
37 | `flowchart\n\tn1(["ellipse"])\n\tn2(("circle"))\n\tn3{"diamond"}\n\tn4("rounded-rectangle")\n\tn5("roundedrectangle")\n\tn6{{"hexagon"}}\n\tn7[\\"rhomboid"\\]`,
38 | );
39 | });
40 |
41 | test("Escapes characters in labels", () => {
42 | const graph = parse(`a\n b & c < d > e \" f it's a label`);
43 | const mermaid = toMermaid(graph);
44 | expect(mermaid).toEqual(
45 | `flowchart\n\tn1["a"]\n\tn2["b & c < d > e " f it's a label"]\n\tn1 --> n2`,
46 | );
47 | });
48 |
49 | test("Supports containers / subgraphs", () => {
50 | const graph = parse(`a {\n b\n}`);
51 | const mermaid = toMermaid(graph);
52 | expect(mermaid).toEqual(`flowchart\n\tsubgraph n1 ["a"]\n\t\tn2["b"]\n\tend`);
53 | });
54 |
55 | test("Supports containers with edges within them", () => {
56 | const graph = parse(`a {\n b\n c\n}`);
57 | const mermaid = toMermaid(graph);
58 | expect(mermaid).toEqual(
59 | `flowchart\n\tsubgraph n1 ["a"]\n\t\tn2["b"]\n\t\tn3["c"]\n\tend\n\tn2 --> n3`,
60 | );
61 | });
62 |
63 | test("Supports adjacent containers", () => {
64 | const graph = parse(`a {\n b\n}\nc {\n d\n}`);
65 | const mermaid = toMermaid(graph);
66 | expect(mermaid).toEqual(
67 | `flowchart\n\tsubgraph n1 ["a"]\n\t\tn2["b"]\n\tend\n\tsubgraph n4 ["c"]\n\t\tn5["d"]\n\tend`,
68 | );
69 | });
70 |
71 | test("Supports linking between containers", () => {
72 | const graph = parse(`a {\n b\n}\n\t(c)\nc {\n d\n (b)\n}`);
73 | const mermaid = toMermaid(graph);
74 | expect(mermaid).toEqual(
75 | `flowchart\n\tsubgraph n1 ["a"]\n\t\tn2["b"]\n\tend\n\tsubgraph n5 ["c"]\n\t\tn6["d"]\n\tend\n\tn1 --> n5\n\tn6 --> n2`,
76 | );
77 | });
78 |
79 | test("Ignores nodes with no id", () => {
80 | const mermaid = toMermaid({
81 | nodes: [{ data: { label: "a", classes: "", id: "" } }],
82 | edges: [],
83 | });
84 | expect(mermaid).toEqual(`flowchart`);
85 | });
86 |
87 | test("Creates the correct rhomboid", () => {
88 | const mermaid = toMermaid({
89 | nodes: [{ data: { label: "a", classes: "rhomboid", id: "a" } }],
90 | edges: [],
91 | });
92 | expect(mermaid).toEqual(`flowchart\n\ta[\\"a"\\]`);
93 | });
94 | });
95 |
--------------------------------------------------------------------------------
/graph-selector/src/toMermaid.ts:
--------------------------------------------------------------------------------
1 | import { Graph } from "./types";
2 | import { encode } from "html-entities";
3 |
4 | export function toMermaid({ nodes, edges }: Graph) {
5 | const styleLines: string[] = [];
6 | const lines = ["flowchart"];
7 |
8 | function getSafe(s: string) {
9 | return s.replace(/\s+/g, "_");
10 | }
11 |
12 | /**
13 | * Push and pop container parents as we traverse the graph
14 | */
15 | const parents: string[] = [];
16 |
17 | for (const node of nodes) {
18 | const { id, label, classes, ...rest } = node.data;
19 | if (!id) continue;
20 | const safeId = getSafe(id);
21 |
22 | // end subgraph
23 | if (parents.length) {
24 | if (rest.parent !== parents[parents.length - 1]) {
25 | parents.pop();
26 | lines.push(`${whitespace()}end`);
27 | }
28 | }
29 |
30 | // start subgraph
31 | if (rest.isParent) {
32 | lines.push(`${whitespace()}subgraph ${safeId} ["${getSafeLabel(label)}"]`);
33 | parents.push(safeId);
34 | continue;
35 | }
36 |
37 | let before = "[";
38 | let after = "]";
39 |
40 | // Support shape classes
41 | if (classes.includes("rounded-rectangle") || classes.includes("roundedrectangle")) {
42 | before = "(";
43 | after = ")";
44 | } else if (classes.includes("ellipse")) {
45 | before = "([";
46 | after = "])";
47 | } else if (classes.includes("circle")) {
48 | before = "((";
49 | after = "))";
50 | } else if (classes.includes("diamond")) {
51 | before = "{";
52 | after = "}";
53 | } else if (classes.includes("hexagon")) {
54 | before = "{{";
55 | after = "}}";
56 | } else if (classes.includes("right-rhomboid")) {
57 | before = "[/";
58 | after = "/]";
59 | } else if (classes.includes("rhomboid")) {
60 | before = "[\\";
61 | after = "\\]";
62 | }
63 |
64 | lines.push(`${whitespace()}${safeId}${before}"${getSafeLabel(label) || " "}"${after}`);
65 | }
66 |
67 | // Close any open subgraphs
68 | while (parents.length) {
69 | parents.pop();
70 | lines.push(`${whitespace()}end`);
71 | }
72 |
73 | for (const edge of edges) {
74 | const { source, target } = edge;
75 | const { label } = edge.data;
76 | const safeSource = getSafe(source);
77 | const safeTarget = getSafe(target);
78 | const safeLabel = getSafeLabel(label);
79 | lines.push(`\t${safeSource} -${safeLabel ? `- "${safeLabel}" -` : ""}-> ${safeTarget}`);
80 | }
81 |
82 | return lines.concat(styleLines).join("\n");
83 |
84 | function whitespace() {
85 | return Array(parents.length + 1)
86 | .fill("\t")
87 | .join("");
88 | }
89 | }
90 |
91 | function getSafeLabel(unsafe: string) {
92 | return encode(unsafe);
93 | }
94 |
--------------------------------------------------------------------------------
/graph-selector/src/types.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Label, ID, Classes, or Data Attribute Value
3 | *
4 | * *Note:* The word "descriptor" refers to it being a sort of inverse (CSS) Selector, in that we're describing
5 | * an element rather than selecting one that may exist
6 | */
7 | export type Descriptor = string | number | boolean;
8 |
9 | /**
10 | * These are the things stored in the document with text (as opposed to edges, which are stored via indentation, etc.)
11 | *
12 | * They are stored under "data" in the document
13 | * e.g., label #id.class1.class2[x=14][cool]
14 | */
15 | export interface Data {
16 | [key: string]: Descriptor;
17 | }
18 |
19 | /**
20 | * The more specific version of attributes with a guarateed id, classes, and label
21 | *
22 | * A "Feature" refers to a node or an edge in the graph, both of which are gauranteed to at least have this data
23 | */
24 | export interface FeatureData extends Data {
25 | id: string;
26 | classes: string;
27 | label: string;
28 | }
29 |
30 | /**
31 | * Data that is a result of parsing the document
32 | */
33 | export type Parser = {
34 | lineNumber: number;
35 | };
36 |
37 | export type Node = {
38 | data: FeatureData;
39 | parser?: Parser;
40 | };
41 |
42 | export type Edge = {
43 | source: string;
44 | target: string;
45 | data: FeatureData;
46 | parser?: Parser;
47 | };
48 |
49 | export type Graph = {
50 | nodes: Node[];
51 | edges: Edge[];
52 | };
53 |
54 | export type PointerType = "id" | "class" | "label";
55 | export type Pointer = [PointerType, string];
56 |
--------------------------------------------------------------------------------
/graph-selector/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "esModuleInterop": true,
4 | "skipLibCheck": true,
5 | "moduleResolution": "Bundler",
6 | "module": "ESNext",
7 | "target": "ESNext",
8 | "allowJs": true,
9 | "resolveJsonModule": true,
10 | "moduleDetection": "force",
11 | "strict": true,
12 | "noUncheckedIndexedAccess": true,
13 | "outDir": "dist",
14 | "lib": ["es2022", "dom", "dom.iterable"],
15 | "declaration": true
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "graph-selector-syntax",
3 | "version": "1.0.0",
4 | "description": "",
5 | "main": "index.js",
6 | "private": true,
7 | "scripts": {
8 | "build": "bun --filter graph-selector build; bun --filter examples build",
9 | "dev": "bun --filter '*' dev",
10 | "test": "bun --filter '*' test",
11 | "test:watch": "bun --filter '*' test:watch",
12 | "lint": "bun --filter graph-selector lint",
13 | "prepare": "husky install",
14 | "browsers": "bun --filter graph-selector up -r caniuse-lite"
15 | },
16 | "workspaces": [
17 | "graph-selector",
18 | "examples"
19 | ],
20 | "keywords": [],
21 | "author": "",
22 | "license": "ISC",
23 | "devDependencies": {
24 | "husky": "^8.0.0",
25 | "lint-staged": "^13.0.3"
26 | },
27 | "packageManager": "bun@1.1.21",
28 | "lint-staged": {
29 | "graph-selector/**/*.{js,ts}": [
30 | "bun --filter graph-selector lint:staged"
31 | ]
32 | },
33 | "dependencies": {
34 | "@types/cytoscape": "^3.21.9",
35 | "cytoscape": "^3.31.0"
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/turbo.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://turbo.build/schema.json",
3 | "pipeline": {
4 | "dev": {
5 | "cache": false
6 | },
7 | "build": {
8 | "dependsOn": ["^build"],
9 | "outputs": ["graph-selector/dist", "examples/.next"]
10 | },
11 | "lint": {},
12 | "test:ci": {
13 | "dependsOn": ["^build"],
14 | "outputs": ["coverage/**"]
15 | }
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/vercel.json:
--------------------------------------------------------------------------------
1 | {
2 | "rewrites": [{ "source": "/(.*)", "destination": "/" }]
3 | }
4 |
--------------------------------------------------------------------------------
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 |