├── .gitignore ├── website ├── .gitignore ├── src │ ├── sitemap.txt │ ├── components │ │ ├── ImageZoom.module.css │ │ ├── Output.jsx │ │ ├── Errors.jsx │ │ ├── EditorToolbar.jsx │ │ ├── Resize.jsx │ │ ├── OutputToolbar.jsx │ │ ├── Editor.jsx │ │ ├── App.jsx │ │ └── ImageZoom.jsx │ ├── worker.js │ ├── index.jsx │ ├── styles │ │ ├── api.css │ │ ├── site.css │ │ └── app.css │ ├── index.html │ ├── links.js │ ├── examples.js │ ├── reloadable-promise-worker.js │ ├── images │ │ └── logo.svg │ └── api │ │ └── index.html ├── .parcelrc ├── package.json └── test │ └── links.test.js ├── packages ├── lang-dot │ ├── .gitignore │ ├── types │ │ └── index.d.ts │ ├── test │ │ ├── types │ │ │ ├── dot.ts │ │ │ ├── package.json │ │ │ └── package-lock.json │ │ ├── test-dot.js │ │ └── graph.txt │ ├── README.md │ ├── src │ │ ├── tokens.js │ │ ├── index.js │ │ └── dot.grammar │ ├── rollup.config.js │ ├── CHANGELOG.md │ └── package.json └── viz │ ├── .gitignore │ ├── test │ ├── browser │ │ ├── .gitignore │ │ ├── package.json │ │ └── index.html │ ├── types │ │ ├── namespace.ts │ │ ├── render-svg-element.ts │ │ ├── top-level.ts │ │ ├── package.json │ │ ├── render-result.ts │ │ ├── render-formats.ts │ │ ├── render-options.ts │ │ ├── viz.ts │ │ └── graph-objects.ts │ ├── module-import │ │ ├── package.json │ │ └── index.js │ ├── commonjs-require │ │ ├── index.js │ │ └── package.json │ ├── manual │ │ ├── performance-timing.js │ │ ├── performance-object.js │ │ ├── performance-multiple.js │ │ ├── instance-reuse.js │ │ └── utils.js │ ├── context-info.test.js │ ├── index.test.js │ ├── render-formats.test.js │ ├── render-unwrapped.test.js │ ├── graph-objects.test.js │ └── render.test.js │ ├── babel.config.json │ ├── backend │ ├── pre.js │ ├── Dockerfile │ └── viz.c │ ├── typedoc.json │ ├── src │ ├── index.js │ ├── errors.js │ ├── viz.js │ └── wrapper.js │ ├── README.md │ ├── scripts │ └── generate-metadata.js │ ├── site │ └── index.md │ ├── Makefile │ ├── package.json │ ├── rollup.config.js │ ├── CHANGELOG.md │ └── types │ └── index.d.ts ├── package.json ├── .github └── workflows │ ├── website-test.yml │ ├── lang-dot-publish.yml │ ├── website-deploy.yml │ ├── viz-publish.yml │ ├── lang-dot-build.yml │ └── viz-build.yml ├── README.md ├── LICENSE └── scripts └── collect-release-info.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | .parcel-cache/ 3 | -------------------------------------------------------------------------------- /website/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ 3 | .parcel-cache/ 4 | -------------------------------------------------------------------------------- /packages/lang-dot/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | src/parser.* 3 | dist/ 4 | -------------------------------------------------------------------------------- /packages/viz/.gitignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | lib/ 3 | node_modules/ 4 | docs/ 5 | -------------------------------------------------------------------------------- /website/src/sitemap.txt: -------------------------------------------------------------------------------- 1 | https://viz-js.com/ 2 | https://viz-js.com/api/ 3 | -------------------------------------------------------------------------------- /packages/viz/test/browser/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ 3 | .parcel-cache/ 4 | -------------------------------------------------------------------------------- /packages/viz/babel.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["@babel/env"], 3 | "targets": "defaults" 4 | } 5 | -------------------------------------------------------------------------------- /packages/lang-dot/types/index.d.ts: -------------------------------------------------------------------------------- 1 | import { LanguageSupport } from "@codemirror/language"; 2 | 3 | export function dot(): LanguageSupport; 4 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "type": "module", 4 | "workspaces": [ 5 | "packages/*", 6 | "packages/viz/test/*" 7 | ] 8 | } 9 | -------------------------------------------------------------------------------- /packages/viz/backend/pre.js: -------------------------------------------------------------------------------- 1 | Module["agerrMessages"] = []; 2 | Module["stderrMessages"] = []; 3 | err = text => Module["stderrMessages"].push(text); 4 | -------------------------------------------------------------------------------- /website/.parcelrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@parcel/config-default", 3 | "transformers": { 4 | "sitemap.txt": ["@parcel/transformer-raw"] 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /packages/viz/test/types/namespace.ts: -------------------------------------------------------------------------------- 1 | import * as Viz from "@viz-js/viz"; 2 | 3 | Viz.instance().then(viz => { 4 | viz.render("digraph { a -> b }"); 5 | }); 6 | -------------------------------------------------------------------------------- /packages/viz/test/module-import/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "type": "module", 4 | "dependencies": { 5 | "@viz-js/viz": "../.." 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /packages/viz/test/commonjs-require/index.js: -------------------------------------------------------------------------------- 1 | const Viz = require("@viz-js/viz"); 2 | 3 | Viz.instance().then(viz => console.log(viz.renderString("digraph { a -> b }"))); 4 | -------------------------------------------------------------------------------- /packages/viz/test/commonjs-require/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "type": "commonjs", 4 | "dependencies": { 5 | "@viz-js/viz": "../.." 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /packages/viz/test/module-import/index.js: -------------------------------------------------------------------------------- 1 | import * as Viz from "@viz-js/viz"; 2 | 3 | Viz.instance().then(viz => console.log(viz.renderString("digraph { a -> b }"))); 4 | -------------------------------------------------------------------------------- /packages/lang-dot/test/types/dot.ts: -------------------------------------------------------------------------------- 1 | import { dot } from "@viz-js/lang-dot"; 2 | 3 | let languageSupport = dot(); 4 | 5 | let name: string = languageSupport.language.name; 6 | -------------------------------------------------------------------------------- /packages/viz/typedoc.json: -------------------------------------------------------------------------------- 1 | { 2 | "excludeInternal": true, 3 | "plugin": [ 4 | "typedoc-plugin-mdn-links" 5 | ], 6 | "sort": ["kind", "source-order"], 7 | "readme": "site/index.md" 8 | } 9 | -------------------------------------------------------------------------------- /packages/viz/src/index.js: -------------------------------------------------------------------------------- 1 | import Module from "../lib/backend.js"; 2 | import Viz from "./viz.js"; 3 | 4 | export { graphvizVersion, formats, engines } from "../lib/metadata.js"; 5 | 6 | export function instance() { 7 | return Module().then(m => new Viz(m)); 8 | } 9 | -------------------------------------------------------------------------------- /packages/lang-dot/test/types/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "dependencies": { 4 | "@viz-js/lang-dot": "../..", 5 | "typescript": "^5.1.3" 6 | }, 7 | "scripts": { 8 | "check-types": "tsc --strict --lib es2015,dom --noEmit *.ts" 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /packages/viz/test/types/render-svg-element.ts: -------------------------------------------------------------------------------- 1 | import { instance, type SVGRenderOptions } from "@viz-js/viz"; 2 | 3 | instance().then(viz => { 4 | const trustedTypePolicy = { 5 | createHTML: (input: string) => input 6 | }; 7 | 8 | const result: SVGSVGElement = viz.renderSVGElement("digraph { a -> b }", { trustedTypePolicy }); 9 | }); 10 | -------------------------------------------------------------------------------- /packages/viz/README.md: -------------------------------------------------------------------------------- 1 | # Viz.js 2 | 3 | This project builds [Graphviz](http://www.graphviz.org) with [Emscripten](https://emscripten.org) and provides a simple wrapper for using it on the web. 4 | 5 | ## Install 6 | 7 | Viz.js is published on NPM as [`@viz-js/viz`](https://www.npmjs.com/package/@viz-js/viz). 8 | 9 | ## API 10 | 11 | [API Reference](https://viz-js.com/api/#viz) 12 | -------------------------------------------------------------------------------- /packages/lang-dot/README.md: -------------------------------------------------------------------------------- 1 | # lang-dot 2 | 3 | [Graphviz DOT](https://www.graphviz.org/doc/info/lang.html) language support for the [CodeMirror](https://codemirror.net/) code editor. 4 | 5 | ## Install 6 | 7 | lang-dot is published on NPM as [`@viz-js/lang-dot`](https://www.npmjs.com/package/@viz-js/lang-dot). 8 | 9 | ## API 10 | 11 | [API Reference](https://viz-js.com/api/#lang-dot) 12 | -------------------------------------------------------------------------------- /packages/lang-dot/src/tokens.js: -------------------------------------------------------------------------------- 1 | import { 2 | strict, 3 | graph, 4 | digraph, 5 | subgraph, 6 | node, 7 | edge 8 | } from "./parser.terms.js"; 9 | 10 | const keywordMap = { 11 | strict, 12 | graph, 13 | digraph, 14 | subgraph, 15 | node, 16 | edge 17 | }; 18 | 19 | export function keywords(name) { 20 | let found = keywordMap[name.toLowerCase()]; 21 | return found == null ? -1 : found; 22 | } 23 | -------------------------------------------------------------------------------- /packages/viz/test/types/top-level.ts: -------------------------------------------------------------------------------- 1 | import { instance, graphvizVersion, formats, engines, type RenderOptions, type RenderResult, type RenderError, type Viz } from "@viz-js/viz"; 2 | 3 | let version: string = graphvizVersion; 4 | 5 | let supportedEngines: Array = engines; 6 | 7 | let supportedFormats: Array = formats; 8 | 9 | instance().then(viz => { 10 | viz.render("digraph { a -> b }"); 11 | }); 12 | -------------------------------------------------------------------------------- /website/src/components/ImageZoom.module.css: -------------------------------------------------------------------------------- 1 | .container { 2 | display: grid; 3 | justify-content: center; 4 | align-items: center; 5 | overflow: auto; 6 | grid-template-columns: minmax(0, max-content); 7 | } 8 | 9 | .container.fit { 10 | overflow: hidden; 11 | grid-template-rows: minmax(0, 100%); 12 | grid-template-columns: auto; 13 | } 14 | 15 | .container.fit img { 16 | max-width: 100%; 17 | max-height: 100%; 18 | } 19 | -------------------------------------------------------------------------------- /packages/lang-dot/rollup.config.js: -------------------------------------------------------------------------------- 1 | import { nodeResolve } from "@rollup/plugin-node-resolve"; 2 | 3 | export default { 4 | input: "./src/index.js", 5 | output: [ 6 | { 7 | format: "cjs", 8 | file: "./dist/index.cjs" 9 | }, 10 | { 11 | format: "es", 12 | file: "./dist/index.js" 13 | } 14 | ], 15 | external(id) { return !/^[\.\/]/.test(id) }, 16 | plugins: [ 17 | nodeResolve() 18 | ] 19 | } 20 | -------------------------------------------------------------------------------- /packages/lang-dot/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## Unreleased 4 | 5 | ## 1.0.5 6 | 7 | * Add @lezer/lr as a dependency 8 | 9 | ## 1.0.4 10 | 11 | * Fix that types condition should be listed first 12 | 13 | ## 1.0.3 14 | 15 | * Include types condition in exports 16 | 17 | ## 1.0.2 18 | 19 | * Add TypeScript declaration file to package 20 | 21 | ## 1.0.1 22 | 23 | * Add TypeScript declaration file 24 | 25 | ## 1.0.0 26 | 27 | * Initial release 28 | -------------------------------------------------------------------------------- /website/src/worker.js: -------------------------------------------------------------------------------- 1 | import { instance } from "@viz-js/viz"; 2 | 3 | const vizPromise = instance(); 4 | 5 | async function render({ src, options }) { 6 | const viz = await vizPromise; 7 | 8 | try { 9 | postMessage({ status: "fulfilled", value: viz.render(src, options) }); 10 | } catch (error) { 11 | postMessage({ status: "rejected", reason: error.toString() }); 12 | } 13 | } 14 | 15 | self.onmessage = function(event) { 16 | render(event.data); 17 | } 18 | -------------------------------------------------------------------------------- /packages/viz/test/types/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "type": "module", 4 | "dependencies": { 5 | "@viz-js/viz": "../..", 6 | "typescript": "^5.1.3" 7 | }, 8 | "scripts": { 9 | "check-types": "tsc --strict --lib es2015,dom --module esnext --moduleResolution bundler --verbatimModuleSyntax --noEmit *.ts", 10 | "check-types-node": "tsc --strict --lib es2015,dom --module es2020 --moduleResolution node --verbatimModuleSyntax --noEmit *.ts" 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /packages/viz/scripts/generate-metadata.js: -------------------------------------------------------------------------------- 1 | import { writeFileSync } from "node:fs"; 2 | import Module from "../lib/backend.js"; 3 | import Viz from "../src/viz.js"; 4 | 5 | const args = process.argv.slice(2); 6 | 7 | const viz = new Viz(await Module()); 8 | 9 | const code = `export const graphvizVersion = ${JSON.stringify(viz.graphvizVersion)}; 10 | export const formats = ${JSON.stringify(viz.formats)}; 11 | export const engines = ${JSON.stringify(viz.engines)}; 12 | `; 13 | 14 | writeFileSync(args[0], code); 15 | -------------------------------------------------------------------------------- /website/src/index.jsx: -------------------------------------------------------------------------------- 1 | import { createRoot } from "react-dom/client"; 2 | import { StrictMode } from "react"; 3 | import { App } from "./components/App.jsx"; 4 | import { getInputFromSearch } from "./links.js"; 5 | import { getExample, defaultExampleName } from "./examples.js"; 6 | 7 | const initialSrc = getInputFromSearch(window.location.search, getExample(defaultExampleName)); 8 | 9 | const root = createRoot(document.getElementById("root")); 10 | root.render( 11 | 12 | 13 | 14 | ); 15 | -------------------------------------------------------------------------------- /packages/viz/test/browser/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "type": "module", 4 | "scripts": { 5 | "serve": "parcel serve --no-cache --no-autoinstall index.html" 6 | }, 7 | "dependencies": { 8 | "@viz-js/viz": "../..", 9 | "dompurify": "^3.3.0" 10 | }, 11 | "devDependencies": { 12 | "assert": "^2.1.0", 13 | "buffer": "^6.0.3", 14 | "events": "^3.3.0", 15 | "mocha": "^11.1.0", 16 | "parcel": "^2.11.0", 17 | "path-browserify": "^1.0.1", 18 | "process": "^0.11.10", 19 | "stream-browserify": "^3.0.0" 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /website/src/components/Output.jsx: -------------------------------------------------------------------------------- 1 | import { ImageZoom } from "./ImageZoom.jsx"; 2 | 3 | export function Output({ result, zoom, imageZoomRef, onZoomChange, isValid }) { 4 | let content; 5 | 6 | if (result) { 7 | if (result.format == "svg-image") { 8 | content = ; 9 | } else { 10 | content =
{result.output}
; 11 | } 12 | } 13 | 14 | return ( 15 |
16 | {content} 17 |
18 | ); 19 | } 20 | -------------------------------------------------------------------------------- /website/src/components/Errors.jsx: -------------------------------------------------------------------------------- 1 | export function Errors({ errors }) { 2 | if (errors.length > 0) { 3 | return ( 4 |
5 | 6 | 7 | {errors.map((e, i) => { 8 | return ( 9 | 10 | 11 | 12 | 13 | ) 14 | })} 15 | 16 |
{e.level ? {e.level} : null}{e.message}
17 |
18 | ); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /website/src/styles/api.css: -------------------------------------------------------------------------------- 1 | @import "./site.css"; 2 | 3 | .header-inner-wrap { 4 | width: 640px; 5 | display: flex; 6 | align-items: center; 7 | justify-content: space-between; 8 | gap: 12px; 9 | margin: 0 auto; 10 | } 11 | 12 | a { 13 | color: #000; 14 | } 15 | 16 | h2 a, 17 | code b a { 18 | text-decoration: none; 19 | } 20 | 21 | pre { 22 | background: #f8f8f8; 23 | padding: 8px; 24 | overflow: auto; 25 | } 26 | 27 | code { 28 | font-size: 13px; 29 | } 30 | 31 | main { 32 | padding: 0 16px; 33 | margin: 16px auto 32px auto; 34 | max-width: 640px; 35 | } 36 | 37 | dd { 38 | margin-left: 24px; 39 | } 40 | 41 | dl { 42 | margin-bottom: 2em; 43 | } 44 | -------------------------------------------------------------------------------- /.github/workflows/website-test.yml: -------------------------------------------------------------------------------- 1 | name: Test website package 2 | 3 | on: 4 | workflow_dispatch: {} 5 | push: 6 | branches-ignore: 7 | - v2 8 | - gh-pages 9 | paths: 10 | - "website/**" 11 | workflow_call: {} 12 | 13 | permissions: 14 | contents: read 15 | 16 | jobs: 17 | build: 18 | name: Test website package 19 | runs-on: ubuntu-latest 20 | steps: 21 | - uses: actions/checkout@v5 22 | 23 | - uses: actions/setup-node@v6 24 | with: 25 | node-version: "24.x" 26 | 27 | - name: "Run tests" 28 | run: | 29 | npm clean-install --ignore-scripts 30 | npm run test 31 | working-directory: website 32 | -------------------------------------------------------------------------------- /packages/viz/site/index.md: -------------------------------------------------------------------------------- 1 | Viz.js is a WebAssembly build of Graphviz with a simple JavaScript wrapper. 2 | 3 | ## Usage 4 | 5 | Viz.js exports a function, {@link instance}, which encapsulates decoding the WebAssembly code and instantiating the WebAssembly and Emscripten modules. This returns a promise that resolves to an instance of the {@link Viz} class, which provides methods for {@link Viz.render | rendering graphs}. 6 | 7 | ```js 8 | import * as Viz from "@viz-js/viz"; 9 | 10 | Viz.instance().then(viz => { 11 | const svg = viz.renderSVGElement("digraph { a -> b }"); 12 | 13 | document.getElementById("graph").appendChild(svg); 14 | }); 15 | ``` 16 | 17 | The instance can be used to render multiple graphs. 18 | -------------------------------------------------------------------------------- /packages/viz/Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: all clean 2 | 3 | all: lib/backend.js lib/metadata.js dist/viz.js dist/viz.cjs dist/viz-global.js 4 | 5 | dist/viz.js dist/viz.cjs dist/viz-global.js: lib/backend.js lib/metadata.js lib/backend.js src/viz.js src/index.js rollup.config.js 6 | npm exec -- rollup -c rollup.config.js 7 | 8 | lib/metadata.js: lib/backend.js src/viz.js 9 | node scripts/generate-metadata.js lib/metadata.js 10 | 11 | lib/backend.js: backend/Dockerfile backend/viz.c backend/pre.js 12 | docker build --progress=plain --build-arg DEBUG="${DEBUG}" -o lib backend 13 | @test -f lib/backend.js && touch lib/backend.js 14 | 15 | clean: 16 | rm -f lib/backend.js lib/encoded.js lib/metadata.js dist/viz.js dist/viz.cjs dist/viz-global.js 17 | -------------------------------------------------------------------------------- /website/src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Viz.js 7 | 8 | 9 | 10 | 11 | 12 |
13 |
14 | 15 | 16 | 22 |
23 | 24 |
25 |
26 |
27 | 28 | 29 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /website/src/styles/site.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | font: 14px/1.3 helvetica, sans-serif; 4 | } 5 | 6 | header { 7 | grid-area: header; 8 | display: flex; 9 | align-items: center; 10 | gap: 12px; 11 | padding: 8px; 12 | 13 | background: #111; 14 | color: #fff; 15 | } 16 | 17 | header a { 18 | color: #fff; 19 | } 20 | 21 | header .logo { 22 | display: block; 23 | } 24 | 25 | header p { 26 | margin: 0; 27 | } 28 | 29 | header nav { 30 | flex-grow: 1; 31 | } 32 | 33 | header nav ul { 34 | list-style: none; 35 | margin: 0; 36 | padding: 0; 37 | display: flex; 38 | justify-content: flex-end; 39 | gap: 8px; 40 | } 41 | 42 | header nav li a { 43 | padding: 6px 8px; 44 | border-radius: 4px; 45 | text-decoration: none; 46 | background: #333; 47 | } 48 | 49 | header nav li a:hover { 50 | background: #444; 51 | } 52 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Viz.js 2 | 3 | This is a collection of packages for working with Graphviz in JavaScript. The main package, [viz](./packages/viz), is a WebAssembly build of Graphviz with a simple JavaScript wrapper. 4 | 5 | With Viz.js, you can easily render a graph diagram as an SVG element to display it in a webpage: 6 | 7 | ```js 8 | import * as Viz from "@viz-js/viz"; 9 | 10 | Viz.instance().then(viz => { 11 | document.body.appendChild(viz.renderSVGElement("digraph { a -> b }")) 12 | }); 13 | ``` 14 | 15 | Other packages: 16 | 17 | - [lang-dot](./packages/lang-dot) — CodeMirror language support for the Graphviz DOT language. 18 | 19 | ## Install 20 | 21 | - Viz.js is published on NPM as [`@viz-js/viz`](https://www.npmjs.com/package/@viz-js/viz). 22 | - lang-dot is published on NPM as [`@viz-js/lang-dot`](https://www.npmjs.com/package/@viz-js/lang-dot). 23 | 24 | ## API 25 | 26 | [API Reference](https://viz-js.com/api/) 27 | -------------------------------------------------------------------------------- /website/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@viz-js/website", 3 | "version": "0.0.0", 4 | "private": true, 5 | "type": "module", 6 | "devDependencies": { 7 | "mocha": "^11.1.0", 8 | "parcel": "^2.12.0", 9 | "plugins": "^0.4.2", 10 | "process": "^0.11.10" 11 | }, 12 | "dependencies": { 13 | "@codemirror/language": "^6.8.0", 14 | "@codemirror/lint": "^6.4.0", 15 | "@codemirror/state": "^6.2.1", 16 | "@lezer/common": "^1.0.3", 17 | "@viz-js/lang-dot": "1.0.5", 18 | "@viz-js/viz": "3.16.0", 19 | "codemirror": "^6.0.1", 20 | "lodash-es": "^4.17.21", 21 | "react": "^19.1.0", 22 | "react-dom": "^19.1.0", 23 | "utf8": "^3.0.0" 24 | }, 25 | "source": [ 26 | "src/index.html", 27 | "src/api/index.html", 28 | "src/sitemap.txt" 29 | ], 30 | "scripts": { 31 | "build": "parcel build --no-cache --no-autoinstall", 32 | "serve": "parcel serve --no-cache --no-autoinstall", 33 | "test": "mocha --include \"test/*.test.js\"" 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /packages/viz/test/types/render-result.ts: -------------------------------------------------------------------------------- 1 | import { instance, type RenderResult, type RenderError } from "@viz-js/viz"; 2 | 3 | instance().then(viz => { 4 | let result: RenderResult = viz.render("digraph { a -> b }"); 5 | 6 | switch (result.status) { 7 | case "success": 8 | { 9 | let output: string = result.output; 10 | break; 11 | } 12 | 13 | case "failure": 14 | { 15 | // @ts-expect-error 16 | let output: string = result.output; 17 | break; 18 | } 19 | 20 | // @ts-expect-error 21 | case "invalid": 22 | break; 23 | } 24 | 25 | let error: RenderError | undefined = result.errors[0]; 26 | 27 | if (typeof error !== "undefined") { 28 | let message: string = error.message; 29 | 30 | switch (error.level) { 31 | case "error": 32 | break; 33 | 34 | case "warning": 35 | break; 36 | 37 | case undefined: 38 | break; 39 | 40 | // @ts-expect-error 41 | case "invalid": 42 | break; 43 | } 44 | } 45 | }); 46 | -------------------------------------------------------------------------------- /packages/viz/test/manual/performance-timing.js: -------------------------------------------------------------------------------- 1 | import { instance } from "../../src/index.js"; 2 | import { measure, randomGraph, dotStringify } from "./utils.js"; 3 | 4 | const tests = [ 5 | { nodeCount: 100, randomEdgeCount: 0 }, 6 | { nodeCount: 1000, randomEdgeCount: 0 }, 7 | { nodeCount: 5000, randomEdgeCount: 0 }, 8 | { nodeCount: 100, randomEdgeCount: 50 }, 9 | { nodeCount: 1000, randomEdgeCount: 500 }, 10 | { nodeCount: 5000, randomEdgeCount: 1000 }, 11 | { nodeCount: 100, randomEdgeCount: 100 }, 12 | { nodeCount: 100, randomEdgeCount: 200 }, 13 | { nodeCount: 100, randomEdgeCount: 300 } 14 | ]; 15 | 16 | tests.forEach(test => { 17 | test.input = dotStringify(randomGraph(test.nodeCount, test.randomEdgeCount)); 18 | }); 19 | 20 | const timeLimit = 5000; 21 | 22 | for (const { input, nodeCount, randomEdgeCount } of tests) { 23 | const viz = await instance(); 24 | const result = measure(() => viz.render(input), timeLimit); 25 | console.log(`${nodeCount} nodes, ${randomEdgeCount} edges: ${result}`); 26 | } 27 | -------------------------------------------------------------------------------- /packages/viz/test/types/render-formats.ts: -------------------------------------------------------------------------------- 1 | import { instance, type MultipleRenderResult, type RenderError } from "@viz-js/viz"; 2 | 3 | instance().then(viz => { 4 | let result: MultipleRenderResult = viz.renderFormats("digraph { a -> b }", ["svg", "cmapx"]); 5 | 6 | switch (result.status) { 7 | case "success": 8 | { 9 | let output: string = result.output["svg"]; 10 | break; 11 | } 12 | 13 | case "failure": 14 | { 15 | // @ts-expect-error 16 | let output: string = result.output; 17 | break; 18 | } 19 | 20 | // @ts-expect-error 21 | case "invalid": 22 | break; 23 | } 24 | 25 | let error: RenderError | undefined = result.errors[0]; 26 | 27 | if (typeof error !== "undefined") { 28 | let message: string = error.message; 29 | 30 | switch (error.level) { 31 | case "error": 32 | break; 33 | 34 | case "warning": 35 | break; 36 | 37 | case undefined: 38 | break; 39 | 40 | // @ts-expect-error 41 | case "invalid": 42 | break; 43 | } 44 | } 45 | }); 46 | -------------------------------------------------------------------------------- /website/src/links.js: -------------------------------------------------------------------------------- 1 | import utf8 from "utf8"; 2 | 3 | const PARAM = "dot"; 4 | 5 | export function encode(stringToEncode) { 6 | return btoa( 7 | utf8.encode( 8 | stringToEncode 9 | ) 10 | ) 11 | .replaceAll("+", "-") 12 | .replaceAll("/", "_") 13 | .replaceAll("=", "~"); 14 | } 15 | 16 | export function decode(stringToDecode) { 17 | return utf8.decode( 18 | atob( 19 | stringToDecode 20 | .replaceAll("-", "+") 21 | .replaceAll("_", "/") 22 | .replaceAll("~", "=") 23 | ) 24 | ); 25 | } 26 | 27 | export function getInputFromSearch(search, defaultValue = "") { 28 | const param = new URLSearchParams(search).get(PARAM); 29 | 30 | if (param) { 31 | try { 32 | return decode(param); 33 | } catch (error) { 34 | console.error(error); 35 | } 36 | } 37 | 38 | return defaultValue; 39 | } 40 | 41 | export function copyLink(str) { 42 | const url = `${window.location.origin}${window.location.pathname}?${PARAM}=${encode(str)}`; 43 | 44 | return navigator.clipboard.writeText(url); 45 | } 46 | -------------------------------------------------------------------------------- /packages/viz/test/manual/performance-object.js: -------------------------------------------------------------------------------- 1 | import { instance } from "../../src/index.js"; 2 | import { measure, randomGraph, dotStringify } from "./utils.js"; 3 | 4 | const tests = [ 5 | { nodeCount: 100, randomEdgeCount: 10 }, 6 | { nodeCount: 1000, randomEdgeCount: 50 }, 7 | { nodeCount: 1000, randomEdgeCount: 500 }, 8 | { nodeCount: 1000, randomEdgeCount: 1000 } 9 | ]; 10 | 11 | tests.forEach(test => { 12 | test.input = randomGraph(test.nodeCount, test.randomEdgeCount); 13 | }); 14 | 15 | const timeLimit = 5000; 16 | 17 | for (const { input, nodeCount, randomEdgeCount } of tests) { 18 | const viz = await instance(); 19 | const result = measure(() => viz.render(dotStringify(input)), timeLimit); 20 | console.log(`stringify, ${nodeCount} nodes, ${randomEdgeCount} edges: ${result}`); 21 | } 22 | 23 | for (const { input, nodeCount, randomEdgeCount } of tests) { 24 | const viz = await instance(); 25 | const result = measure(() => viz.render(input), timeLimit); 26 | console.log(`map, ${nodeCount} nodes, ${randomEdgeCount} edges: ${result}`); 27 | } 28 | -------------------------------------------------------------------------------- /packages/viz/src/errors.js: -------------------------------------------------------------------------------- 1 | const errorPatterns = [ 2 | [/^Error: (.*)/, "error"], 3 | [/^Warning: (.*)/, "warning"] 4 | ]; 5 | 6 | export function parseStderrMessages(messages) { 7 | return messages.map(message => { 8 | for (let i = 0; i < errorPatterns.length; i++) { 9 | const [pattern, level] = errorPatterns[i]; 10 | 11 | let match; 12 | 13 | if ((match = pattern.exec(message)) !== null) { 14 | return { message: match[1].trimEnd(), level }; 15 | } 16 | } 17 | 18 | return { message: message.trimEnd() }; 19 | }); 20 | } 21 | 22 | export function parseAgerrMessages(messages) { 23 | const result = []; 24 | let level = undefined; 25 | 26 | for (let i = 0; i < messages.length; i++) { 27 | if (messages[i] == "Error" && messages[i+1] == ": ") { 28 | level = "error"; 29 | i += 1; 30 | } else if (messages[i] == "Warning" && messages[i+1] == ": ") { 31 | level = "warning"; 32 | i += 1; 33 | } else { 34 | result.push({ message: messages[i].trimEnd(), level }); 35 | } 36 | } 37 | 38 | return result; 39 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) Michael Daines 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 | -------------------------------------------------------------------------------- /website/src/components/EditorToolbar.jsx: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | import { getExampleNames, defaultExampleName, getExample } from "../examples.js"; 3 | 4 | export function EditorToolbar({ onLoadExample, onCopyLink }) { 5 | const [exampleName, setExampleName] = useState(defaultExampleName); 6 | 7 | function handleLoadExample() { 8 | onLoadExample(getExample(exampleName)); 9 | } 10 | 11 | return ( 12 |
13 |
14 |
15 | 18 | 19 |
20 | 21 |
22 | 23 |
24 |
25 | 26 |
27 | 28 |
29 |
30 | ); 31 | } 32 | -------------------------------------------------------------------------------- /packages/viz/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@viz-js/viz", 3 | "description": "WebAssembly build of Graphviz with a simple wrapper for using it on the web", 4 | "keywords": [ 5 | "graphviz", 6 | "emscripten" 7 | ], 8 | "version": "3.24.0", 9 | "repository": { 10 | "type": "git", 11 | "url": "git+https://github.com/mdaines/viz-js.git", 12 | "directory": "packages/viz" 13 | }, 14 | "license": "MIT", 15 | "type": "module", 16 | "main": "./dist/viz.js", 17 | "types": "./types/index.d.ts", 18 | "exports": { 19 | "types": "./types/index.d.ts", 20 | "require": "./dist/viz.cjs", 21 | "default": "./dist/viz.js" 22 | }, 23 | "files": [ 24 | "dist", 25 | "lib", 26 | "src", 27 | "types" 28 | ], 29 | "devDependencies": { 30 | "@babel/core": "^7.21.4", 31 | "@babel/preset-env": "^7.21.4", 32 | "@rollup/plugin-babel": "^6.0.3", 33 | "@rollup/plugin-terser": "^0.4.1", 34 | "jsdom": "^21.1.1", 35 | "mocha": "^11.7.0", 36 | "rollup": "^3.29.5", 37 | "typedoc": "^0.28.5", 38 | "typedoc-plugin-mdn-links": "^5.0.2" 39 | }, 40 | "scripts": { 41 | "test": "mocha", 42 | "docs": "typedoc types/index.d.ts" 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /packages/viz/test/manual/performance-multiple.js: -------------------------------------------------------------------------------- 1 | import { instance } from "../../src/index.js"; 2 | import { measure, randomGraph, dotStringify } from "./utils.js"; 3 | 4 | const tests = [ 5 | { nodeCount: 100, randomEdgeCount: 10 }, 6 | { nodeCount: 1000, randomEdgeCount: 50 }, 7 | { nodeCount: 1000, randomEdgeCount: 500 }, 8 | { nodeCount: 1000, randomEdgeCount: 1000 } 9 | ]; 10 | 11 | tests.forEach(test => { 12 | test.input = dotStringify(randomGraph(test.nodeCount, test.randomEdgeCount)); 13 | }); 14 | 15 | const timeLimit = 5000; 16 | 17 | for (const { input, nodeCount, randomEdgeCount } of tests) { 18 | const viz = await instance(); 19 | const result = measure(() => { 20 | viz.render(input, { format: "svg" }); 21 | viz.render(input, { format: "cmapx" }); 22 | }, timeLimit); 23 | console.log(`render, ${nodeCount} nodes, ${randomEdgeCount} edges: ${result}`); 24 | } 25 | 26 | for (const { input, nodeCount, randomEdgeCount } of tests) { 27 | const viz = await instance(); 28 | const result = measure(() => { 29 | viz.renderFormats(input, ["svg", "cmapx"]); 30 | }, timeLimit); 31 | console.log(`renderFormats, ${nodeCount} nodes, ${randomEdgeCount} edges: ${result}`); 32 | } 33 | -------------------------------------------------------------------------------- /website/src/components/Resize.jsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef } from "react"; 2 | 3 | let dragging = false; 4 | let dragOffset; 5 | 6 | export function Resize({ onResize }) { 7 | function handleMouseDown(e) { 8 | e.preventDefault(); 9 | 10 | const resizeRect = resizeRef.current.getBoundingClientRect(); 11 | 12 | dragging = true; 13 | dragOffset = Math.round(e.clientX - resizeRect.left); 14 | } 15 | 16 | function handleMouseMove(e) { 17 | if (dragging) { 18 | const width = Math.max(0, e.clientX - dragOffset); 19 | onResize(width); 20 | } 21 | } 22 | 23 | function handleMouseUp() { 24 | if (dragging) { 25 | const resizeRect = resizeRef.current.getBoundingClientRect(); 26 | onResize(resizeRect.left); 27 | } 28 | 29 | dragging = false; 30 | } 31 | 32 | useEffect(() => { 33 | window.addEventListener("mousemove", handleMouseMove); 34 | window.addEventListener("mouseup", handleMouseUp); 35 | }, []); 36 | 37 | let resizeRef = useRef(null); 38 | 39 | return ( 40 |
41 |
42 |
43 |
44 | ); 45 | } 46 | -------------------------------------------------------------------------------- /packages/lang-dot/test/types/package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "types", 3 | "lockfileVersion": 3, 4 | "requires": true, 5 | "packages": { 6 | "": { 7 | "dependencies": { 8 | "@viz-js/lang-dot": "../..", 9 | "typescript": "^5.1.3" 10 | } 11 | }, 12 | "../..": { 13 | "name": "@viz-js/lang-dot", 14 | "version": "1.0.4-dev", 15 | "license": "MIT", 16 | "dependencies": { 17 | "@codemirror/language": "^6.8.0", 18 | "@lezer/common": "^1.0.3", 19 | "@lezer/highlight": "^1.1.6", 20 | "@lezer/xml": "^1.0.2" 21 | }, 22 | "devDependencies": { 23 | "@lezer/generator": "^1.3.0", 24 | "@rollup/plugin-node-resolve": "^15.1.0", 25 | "mocha": "^11.1.0", 26 | "rollup": "^3.29.5" 27 | } 28 | }, 29 | "node_modules/@viz-js/lang-dot": { 30 | "resolved": "../..", 31 | "link": true 32 | }, 33 | "node_modules/typescript": { 34 | "version": "5.2.2", 35 | "license": "Apache-2.0", 36 | "bin": { 37 | "tsc": "bin/tsc", 38 | "tsserver": "bin/tsserver" 39 | }, 40 | "engines": { 41 | "node": ">=14.17" 42 | } 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /packages/lang-dot/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@viz-js/lang-dot", 3 | "version": "1.0.5-dev", 4 | "description": "Graphviz DOT language support for the CodeMirror code editor", 5 | "repository": { 6 | "type": "git", 7 | "url": "git+https://github.com/mdaines/viz-js.git", 8 | "directory": "packages/lang-dot" 9 | }, 10 | "license": "MIT", 11 | "type": "module", 12 | "exports": { 13 | "types": "./types/index.d.ts", 14 | "import": "./dist/index.js", 15 | "require": "./dist/index.cjs" 16 | }, 17 | "files": [ 18 | "dist/index.js", 19 | "dist/index.cjs", 20 | "types/index.d.ts" 21 | ], 22 | "main": "dist/index.cjs", 23 | "module": "dist/index.js", 24 | "types": "types/index.d.ts", 25 | "devDependencies": { 26 | "@lezer/generator": "^1.3.0", 27 | "@rollup/plugin-node-resolve": "^15.1.0", 28 | "mocha": "^11.1.0", 29 | "rollup": "^3.29.5" 30 | }, 31 | "dependencies": { 32 | "@codemirror/language": "^6.8.0", 33 | "@lezer/common": "^1.0.3", 34 | "@lezer/highlight": "^1.1.6", 35 | "@lezer/lr": "^1.4.2", 36 | "@lezer/xml": "^1.0.2" 37 | }, 38 | "scripts": { 39 | "build": "lezer-generator src/dot.grammar -o src/parser && rollup -c", 40 | "test": "mocha test/test-*.js" 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /packages/viz/test/types/render-options.ts: -------------------------------------------------------------------------------- 1 | import { type RenderOptions } from "@viz-js/viz"; 2 | 3 | let options: RenderOptions = {}; 4 | 5 | options.format = "svg"; 6 | 7 | options.engine = "dot"; 8 | 9 | options.yInvert = true; 10 | 11 | options.reduce = true; 12 | 13 | options.graphAttributes = { 14 | rankdir: "LR", 15 | width: 2, 16 | label: { html: "test" }, 17 | test: true 18 | }; 19 | 20 | options.nodeAttributes = { 21 | rankdir: "LR", 22 | width: 2, 23 | label: { html: "test" }, 24 | test: true 25 | }; 26 | 27 | options.edgeAttributes = { 28 | rankdir: "LR", 29 | width: 2, 30 | label: { html: "test" }, 31 | test: true 32 | }; 33 | 34 | options.images = [{ name: "test.png", width: 300, height: 200 }]; 35 | 36 | options.images = [{ name: "test.png", width: "1cm", height: "1cm" }]; 37 | 38 | // @ts-expect-error 39 | options.format = false; 40 | 41 | // @ts-expect-error 42 | options.engine = 123; 43 | 44 | // @ts-expect-error 45 | options.yInvert = 1; 46 | 47 | // @ts-expect-error 48 | options.whatever = 123; 49 | 50 | // @ts-expect-error 51 | options.graphAttributes = { something: { whatever: 123 } }; 52 | 53 | // @ts-expect-error 54 | options.images = [{ name: "test.png" }]; 55 | 56 | // @ts-expect-error 57 | options.images = [{ url: "test.png" }]; 58 | -------------------------------------------------------------------------------- /.github/workflows/lang-dot-publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish lang-dot 2 | 3 | on: workflow_dispatch 4 | 5 | jobs: 6 | build: 7 | uses: ./.github/workflows/lang-dot-build.yml 8 | 9 | publish: 10 | needs: build 11 | runs-on: ubuntu-latest 12 | permissions: 13 | contents: write 14 | id-token: write 15 | steps: 16 | - uses: actions/checkout@v5 17 | 18 | - uses: actions/download-artifact@v6 19 | with: 20 | name: dist 21 | path: packages/lang-dot/dist 22 | 23 | - uses: actions/setup-node@v6 24 | with: 25 | node-version: "24.x" 26 | registry-url: "https://registry.npmjs.org" 27 | 28 | - name: "Collect release info" 29 | run: | 30 | node scripts/collect-release-info.js packages/lang-dot >> "$GITHUB_ENV" 31 | 32 | - name: "Create GitHub release" 33 | env: 34 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 35 | run: | 36 | echo "$RELEASE_NOTES" > "${{ runner.temp }}/notes.md" 37 | gh release create $RELEASE_TAG --notes-file "${{ runner.temp }}/notes.md" --title "$RELEASE_TITLE" 38 | 39 | - run: npm install -g npm 40 | 41 | - run: npm publish --provenance --access public 42 | env: 43 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 44 | working-directory: packages/lang-dot 45 | -------------------------------------------------------------------------------- /website/test/links.test.js: -------------------------------------------------------------------------------- 1 | import { getInputFromSearch, encode, decode } from "../src/links.js"; 2 | import assert from "node:assert/strict"; 3 | 4 | describe("getInputFromSearch", function() { 5 | it("returns the expected input", function() { 6 | const search = "dot=ZGlncmFwaCB7IGEgLT4gYiB9"; 7 | const expected = "digraph { a -> b }"; 8 | 9 | assert.deepStrictEqual(getInputFromSearch(search), expected); 10 | }); 11 | 12 | it("handles characters outside of ASCII range", function() { 13 | const search = "dot=ZGlncmFwaCB7IOmfs-alvSAtPiDimasgfQ~~"; 14 | const expected = "digraph { 音楽 -> ♫ }"; 15 | 16 | assert.deepStrictEqual(getInputFromSearch(search), expected); 17 | }); 18 | 19 | it("returns the default value if the expected param isn't present", function() { 20 | assert.deepStrictEqual(getInputFromSearch("", "fake default value"), "fake default value"); 21 | }); 22 | }); 23 | 24 | describe("encode", function() { 25 | it("handles characters outside of ASCII range", function() { 26 | assert.deepStrictEqual(encode("digraph { 音楽 -> ♫ }"), "ZGlncmFwaCB7IOmfs-alvSAtPiDimasgfQ~~"); 27 | }); 28 | }); 29 | 30 | describe("decode", function() { 31 | it("handles characters outside of ASCII range", function() { 32 | assert.deepStrictEqual(decode("ZGlncmFwaCB7IOmfs-alvSAtPiDimasgfQ~~"), "digraph { 音楽 -> ♫ }"); 33 | }); 34 | }); 35 | -------------------------------------------------------------------------------- /.github/workflows/website-deploy.yml: -------------------------------------------------------------------------------- 1 | name: Deploy website to GitHub Pages 2 | 3 | on: 4 | workflow_dispatch 5 | 6 | permissions: 7 | contents: read 8 | pages: write 9 | id-token: write 10 | 11 | concurrency: 12 | group: "pages" 13 | cancel-in-progress: true 14 | 15 | jobs: 16 | build: 17 | runs-on: ubuntu-latest 18 | steps: 19 | - name: Checkout 20 | uses: actions/checkout@v5 21 | 22 | - name: Use Node.js 23 | uses: actions/setup-node@v6 24 | with: 25 | node-version: "24.x" 26 | 27 | - name: Install dependencies 28 | run: npm clean-install --ignore-scripts 29 | working-directory: website 30 | 31 | - name: Setup Pages 32 | id: pages 33 | uses: actions/configure-pages@v5 34 | 35 | - name: Build website 36 | run: npm run build -- --public-url "${{ steps.pages.outputs.base_path }}/" 37 | timeout-minutes: 1 38 | working-directory: website 39 | 40 | - name: Upload artifact 41 | uses: actions/upload-pages-artifact@v4 42 | with: 43 | path: website/dist 44 | 45 | deploy: 46 | environment: 47 | name: github-pages 48 | url: ${{ steps.deployment.outputs.page_url }} 49 | 50 | needs: build 51 | runs-on: ubuntu-latest 52 | steps: 53 | - name: Deploy to GitHub Pages 54 | id: deployment 55 | uses: actions/deploy-pages@v4 56 | -------------------------------------------------------------------------------- /packages/lang-dot/test/test-dot.js: -------------------------------------------------------------------------------- 1 | import { fileTests } from "@lezer/generator/test"; 2 | import { buildParser } from "@lezer/generator"; 3 | import * as fs from "fs"; 4 | import * as path from "path"; 5 | import { fileURLToPath } from "url"; 6 | 7 | let testDir = path.dirname(fileURLToPath(import.meta.url)); 8 | 9 | let parser = buildParser( 10 | fs.readFileSync(path.join(testDir, "../src/dot.grammar"), "utf8"), 11 | { 12 | externalSpecializer(name, terms) { 13 | if (name == "keywords") { 14 | let keywordMap = { 15 | strict: terms.strict, 16 | graph: terms.graph, 17 | digraph: terms.digraph, 18 | subgraph: terms.subgraph, 19 | node: terms.node, 20 | edge: terms.edge 21 | }; 22 | 23 | return function(value) { 24 | let found = keywordMap[value.toLowerCase()]; 25 | return found == null ? -1 : found; 26 | } 27 | } else { 28 | throw new Error(`Undefined external specializer: ${name}`); 29 | } 30 | } 31 | } 32 | ); 33 | 34 | for (let file of fs.readdirSync(testDir)) { 35 | if (!/\.txt$/.test(file)) { 36 | continue; 37 | } 38 | 39 | let name = /^[^\.]*/.exec(file)[0]; 40 | describe(name, function() { 41 | for (let { name, run } of fileTests(fs.readFileSync(path.join(testDir, file), "utf8"), file)) { 42 | it(name, function() { 43 | run(parser); 44 | }); 45 | } 46 | }); 47 | } 48 | -------------------------------------------------------------------------------- /.github/workflows/viz-publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish viz 2 | 3 | on: workflow_dispatch 4 | 5 | jobs: 6 | build: 7 | uses: ./.github/workflows/viz-build.yml 8 | 9 | publish: 10 | needs: build 11 | runs-on: ubuntu-latest 12 | permissions: 13 | contents: write 14 | id-token: write 15 | steps: 16 | - uses: actions/checkout@v5 17 | 18 | - uses: actions/download-artifact@v6 19 | with: 20 | name: lib 21 | path: packages/viz/lib 22 | 23 | - uses: actions/download-artifact@v6 24 | with: 25 | name: dist 26 | path: packages/viz/dist 27 | 28 | - uses: actions/setup-node@v6 29 | with: 30 | node-version: "24.x" 31 | registry-url: "https://registry.npmjs.org" 32 | 33 | - name: "Collect release info" 34 | run: | 35 | node scripts/collect-release-info.js packages/viz >> "$GITHUB_ENV" 36 | 37 | - name: "Create GitHub release" 38 | env: 39 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 40 | run: | 41 | echo "$RELEASE_NOTES" > "${{ runner.temp }}/notes.md" 42 | gh release create $RELEASE_TAG --notes-file "${{ runner.temp }}/notes.md" --title "$RELEASE_TITLE" packages/viz/dist/viz-global.js 43 | 44 | - run: npm install -g npm 45 | 46 | - run: npm publish --provenance --access public 47 | env: 48 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 49 | working-directory: packages/viz 50 | -------------------------------------------------------------------------------- /.github/workflows/lang-dot-build.yml: -------------------------------------------------------------------------------- 1 | name: Build and test lang-dot package 2 | 3 | on: 4 | workflow_dispatch: {} 5 | push: 6 | branches-ignore: 7 | - v2 8 | - gh-pages 9 | paths: 10 | - "packages/lang-dot/**" 11 | - "package-lock.json" 12 | workflow_call: {} 13 | 14 | permissions: 15 | contents: read 16 | 17 | jobs: 18 | build: 19 | name: Build and test lang-dot package 20 | runs-on: ubuntu-latest 21 | steps: 22 | - uses: actions/checkout@v5 23 | 24 | - uses: actions/setup-node@v6 25 | with: 26 | node-version: "24.x" 27 | 28 | - name: "Run build and test" 29 | run: | 30 | npm clean-install --ignore-scripts 31 | npm run build 32 | npm run test 33 | working-directory: packages/lang-dot 34 | 35 | - uses: actions/upload-artifact@v5 36 | with: 37 | name: dist 38 | path: packages/lang-dot/dist 39 | 40 | test-types: 41 | name: Test types 42 | runs-on: ubuntu-latest 43 | steps: 44 | - uses: actions/checkout@v5 45 | 46 | - uses: actions/setup-node@v6 47 | with: 48 | node-version: "24.x" 49 | 50 | - name: "Install in package directory" 51 | run: | 52 | npm clean-install --ignore-scripts 53 | working-directory: packages/lang-dot 54 | 55 | - name: "Run check-types" 56 | run: | 57 | npm clean-install --ignore-scripts 58 | npm run check-types 59 | working-directory: packages/lang-dot/test/types 60 | -------------------------------------------------------------------------------- /packages/viz/test/context-info.test.js: -------------------------------------------------------------------------------- 1 | import assert from "node:assert/strict"; 2 | import * as VizPackage from "../src/index.js"; 3 | 4 | describe("Viz", function() { 5 | let viz; 6 | 7 | beforeEach(async function() { 8 | viz = await VizPackage.instance(); 9 | }); 10 | 11 | describe("graphvizVersion", function() { 12 | it("returns a version string", function() { 13 | assert.match(viz.graphvizVersion, /^\d+\.\d+\.\d+$/); 14 | }); 15 | }); 16 | 17 | describe("formats", function() { 18 | it("returns the list of formats", function() { 19 | assert.deepStrictEqual(viz.formats, [ 20 | "canon", 21 | "cmap", 22 | "cmapx", 23 | "cmapx_np", 24 | "dot", 25 | "dot_json", 26 | "eps", 27 | "fig", 28 | "gv", 29 | "imap", 30 | "imap_np", 31 | "ismap", 32 | "json", 33 | "json0", 34 | "pic", 35 | "plain", 36 | "plain-ext", 37 | "pov", 38 | "ps", 39 | "ps2", 40 | "svg", 41 | "svg_inline", 42 | "tk", 43 | "xdot", 44 | "xdot1.2", 45 | "xdot1.4", 46 | "xdot_json" 47 | ]); 48 | }); 49 | }); 50 | 51 | describe("engines", function() { 52 | it("returns the list of layout engines", function() { 53 | assert.deepStrictEqual(viz.engines, [ 54 | "circo", 55 | "dot", 56 | "fdp", 57 | "neato", 58 | "nop", 59 | "nop1", 60 | "nop2", 61 | "osage", 62 | "patchwork", 63 | "sfdp", 64 | "twopi" 65 | ]); 66 | }); 67 | }); 68 | }); 69 | -------------------------------------------------------------------------------- /packages/viz/rollup.config.js: -------------------------------------------------------------------------------- 1 | import babel from "@rollup/plugin-babel"; 2 | import terser from "@rollup/plugin-terser"; 3 | import { readFile } from "node:fs/promises"; 4 | 5 | async function getBanner() { 6 | const filePath = new URL("./package.json", import.meta.url); 7 | const contents = await readFile(filePath, { encoding: "utf8" }); 8 | const packageVersion = JSON.parse(contents).version; 9 | 10 | return `/*! 11 | Viz.js ${packageVersion} 12 | Copyright (c) Michael Daines 13 | 14 | This distribution contains other software in object code form: 15 | Graphviz https://www.graphviz.org 16 | Expat https://libexpat.github.io 17 | */`; 18 | } 19 | 20 | export default [ 21 | { 22 | input: "src/index.js", 23 | output: { 24 | file: "dist/viz.js", 25 | format: "es", 26 | banner: getBanner 27 | }, 28 | plugins: [ 29 | babel({ 30 | babelHelpers: "bundled", 31 | ignore: ["./lib/backend.js"] 32 | }) 33 | ] 34 | }, 35 | { 36 | input: "src/index.js", 37 | output: { 38 | file: "dist/viz.cjs", 39 | format: "cjs", 40 | banner: getBanner 41 | }, 42 | plugins: [ 43 | babel({ 44 | babelHelpers: "bundled", 45 | ignore: ["./lib/backend.js"] 46 | }) 47 | ] 48 | }, 49 | { 50 | input: "src/index.js", 51 | output: { 52 | name: "Viz", 53 | file: "dist/viz-global.js", 54 | format: "umd", 55 | banner: getBanner, 56 | plugins: [ 57 | terser() 58 | ] 59 | }, 60 | plugins: [ 61 | babel({ 62 | babelHelpers: "bundled", 63 | ignore: ["./lib/backend.js"] 64 | }) 65 | ] 66 | } 67 | ]; 68 | -------------------------------------------------------------------------------- /packages/viz/test/index.test.js: -------------------------------------------------------------------------------- 1 | import assert from "node:assert/strict"; 2 | import * as VizPackage from "../src/index.js"; 3 | import Viz from "../src/viz.js"; 4 | 5 | describe("graphvizVersion", function() { 6 | it("returns a version string", function() { 7 | assert.match(VizPackage.graphvizVersion, /^\d+\.\d+\.\d+$/); 8 | }); 9 | }); 10 | 11 | describe("formats", function() { 12 | it("returns the list of formats", function() { 13 | assert.deepStrictEqual(VizPackage.formats, [ 14 | "canon", 15 | "cmap", 16 | "cmapx", 17 | "cmapx_np", 18 | "dot", 19 | "dot_json", 20 | "eps", 21 | "fig", 22 | "gv", 23 | "imap", 24 | "imap_np", 25 | "ismap", 26 | "json", 27 | "json0", 28 | "pic", 29 | "plain", 30 | "plain-ext", 31 | "pov", 32 | "ps", 33 | "ps2", 34 | "svg", 35 | "svg_inline", 36 | "tk", 37 | "xdot", 38 | "xdot1.2", 39 | "xdot1.4", 40 | "xdot_json" 41 | ]); 42 | }); 43 | }); 44 | 45 | describe("engines", function() { 46 | it("returns the list of layout engines", function() { 47 | assert.deepStrictEqual(VizPackage.engines, [ 48 | "circo", 49 | "dot", 50 | "fdp", 51 | "neato", 52 | "nop", 53 | "nop1", 54 | "nop2", 55 | "osage", 56 | "patchwork", 57 | "sfdp", 58 | "twopi" 59 | ]); 60 | }); 61 | }); 62 | 63 | describe("instance", function() { 64 | it("returns a promise that resolves to an instance of the Viz class", async function() { 65 | const viz = await VizPackage.instance(); 66 | 67 | assert.ok(viz instanceof Viz); 68 | }); 69 | }); 70 | -------------------------------------------------------------------------------- /packages/lang-dot/src/index.js: -------------------------------------------------------------------------------- 1 | import { parser } from "./parser.js"; 2 | import { LRLanguage, LanguageSupport, indentNodeProp, delimitedIndent, foldNodeProp, foldInside } from "@codemirror/language"; 3 | import { styleTags, tags as t } from "@lezer/highlight"; 4 | import { parser as xmlParser } from "@lezer/xml"; 5 | import { parseMixed } from "@lezer/common"; 6 | 7 | const language = LRLanguage.define({ 8 | parser: parser.configure({ 9 | props: [ 10 | styleTags({ 11 | "strict digraph graph subgraph node edge": t.keyword, 12 | "Name": t.literal, 13 | "String HTMLString < >": t.string, 14 | "Number": t.number, 15 | "Node/Name": t.definition(t.variableName), 16 | "Port/Name": t.variableName, 17 | "AttrName/Name": t.definition(t.propertyName), 18 | "[ ]": t.squareBracket, 19 | "{ }": t.brace, 20 | ", ;": t.separator, 21 | ":": t.punctuation, 22 | "-> -- = +": t.operator, 23 | LineComment: t.lineComment, 24 | BlockComment: t.blockComment 25 | }), 26 | indentNodeProp.add({ 27 | Body: delimitedIndent({ closing: "}" }), 28 | Attributes: delimitedIndent({ closing: "]" }) 29 | }), 30 | foldNodeProp.add({ 31 | "Body Attributes": foldInside, 32 | BlockComment(tree) { return { from: tree.from + 2, to: tree.to - 2 } } 33 | }) 34 | ], 35 | wrap: parseMixed(node => { 36 | return node.name == "HTMLStringContent" ? { parser: xmlParser } : null 37 | }) 38 | }), 39 | languageData: { 40 | closeBrackets: { brackets: ["[", "{", '"', "<"] }, 41 | commentTokens: { line: "#", block: { open: "/*", close: "*/" } } 42 | } 43 | }); 44 | 45 | export function dot() { 46 | return new LanguageSupport(language); 47 | } 48 | -------------------------------------------------------------------------------- /packages/viz/test/types/viz.ts: -------------------------------------------------------------------------------- 1 | import { instance, type Viz, type RenderResult, type MultipleRenderResult } from "@viz-js/viz"; 2 | 3 | export function myRender(viz: Viz, src: string): string { 4 | return viz.renderString(src, { graphAttributes: { label: "My graph" } }); 5 | } 6 | 7 | instance().then(viz => { 8 | viz.render("digraph { a -> b }"); 9 | 10 | viz.render("digraph { a -> b }", { format: "svg" }); 11 | 12 | viz.render("digraph { a -> b }", { format: "svg", engine: "dot", yInvert: false }); 13 | 14 | viz.renderFormats("digraph { a -> b }", ["svg", "cmapx"]); 15 | 16 | viz.renderFormats("digraph { a -> b }", ["svg", "cmapx"], { engine: "dot" }); 17 | 18 | viz.render("digraph { a -> b }", { nodeAttributes: { shape: "circle" } }); 19 | 20 | viz.render({ edges: [{ tail: "a", head: "b" }] }); 21 | 22 | myRender(viz, "digraph { a -> b }"); 23 | 24 | // @ts-expect-error 25 | viz.render("digraph { a -> b }", { format: false }); 26 | 27 | // @ts-expect-error 28 | viz.render("digraph { a -> b }", { engine: 123 }); 29 | 30 | // @ts-expect-error 31 | viz.render("digraph { a -> b }", { yInvert: 1 }); 32 | 33 | // @ts-expect-error 34 | viz.render("digraph { a -> b }", { whatever: 123 }); 35 | 36 | // @ts-expect-error 37 | viz.render("digraph { a -> b }", { format: ["svg"] }); 38 | 39 | let result: RenderResult = viz.render("digraph { a -> b }"); 40 | 41 | let formatsResult: MultipleRenderResult = viz.renderFormats("digraph { a -> b }", ["svg", "cmapx"]); 42 | 43 | let stringResult: string = viz.renderString("digraph { a -> b }"); 44 | 45 | let svgElementResult: SVGSVGElement = viz.renderSVGElement("digraph { a -> b }"); 46 | 47 | let version: string = viz.graphvizVersion; 48 | 49 | let supportedEngines: Array = viz.engines; 50 | 51 | let supportedFormats: Array = viz.formats; 52 | }); 53 | -------------------------------------------------------------------------------- /website/src/examples.js: -------------------------------------------------------------------------------- 1 | const EXAMPLES = { 2 | "Simple": `digraph { 3 | a -> b 4 | } 5 | `, 6 | "Clusters": `digraph { 7 | node [shape=rect]; 8 | subgraph { 9 | cluster=true; 10 | color=blue; 11 | node [ 12 | shape=ellipse, 13 | style=filled, 14 | color=blue, 15 | fontcolor=white 16 | ]; 17 | a1 -> a2 -> a3; 18 | } 19 | subgraph { 20 | cluster=true; 21 | bgcolor=lightgreen; 22 | node [ 23 | shape=ellipse, 24 | style=filled, 25 | color=yellow, 26 | fontcolor=black 27 | ]; 28 | b1 -> b2 -> b3; 29 | b2:e -> b1; 30 | b2 [shape=diamond]; 31 | } 32 | start -> a1; 33 | start -> b1; 34 | a3 -> end; 35 | b3 -> end; 36 | start [style=filled, color=green, fontcolor=white]; 37 | end [color=red]; 38 | } 39 | `, 40 | "LR(1) Automaton": `digraph { 41 | graph [rankdir=LR]; 42 | node [shape=record]; 43 | 0 [label="0 | [• S, $]\\n[S → • a S b, $]\\n[S → •, $]"]; 44 | 1 [label="1 | [S •, $]"]; 45 | 2 [label="2 | [S → a • S b, $]\\n[S → • a S b, b]\\n[S → •, b]"]; 46 | 3 [label="3 | [S → a S • b, $]"]; 47 | 4 [label="4 | [S → a • S b, b]\\n[S → • a S b, b]\\n[S → •, b]"]; 48 | 5 [label="5 | [S → a S b •, $]"]; 49 | 6 [label="6 | [S → a S • b, b]"]; 50 | 7 [label="7 | [S → a S b •, b]"]; 51 | 0 -> 1 [label=S]; 52 | 0 -> 2 [label=a]; 53 | 2 -> 3 [label=S]; 54 | 2 -> 4 [label=a]; 55 | 3 -> 5 [label=b]; 56 | 4 -> 6 [label=S]; 57 | 4 -> 4 [label=a]; 58 | 6 -> 7 [label=b]; 59 | } 60 | ` 61 | }; 62 | 63 | export function getExampleNames() { 64 | return Object.keys(EXAMPLES); 65 | } 66 | 67 | export function getExample(name) { 68 | return EXAMPLES[name]; 69 | } 70 | 71 | export const defaultExampleName = "Simple"; 72 | -------------------------------------------------------------------------------- /website/src/components/OutputToolbar.jsx: -------------------------------------------------------------------------------- 1 | import { formats, engines } from "@viz-js/viz"; 2 | import { zoomLevels } from "./ImageZoom.jsx"; 3 | 4 | export function OutputToolbar({ options, onOptionChange, zoomEnabled, zoom, onZoomChange, onZoomIn, onZoomOut }) { 5 | return ( 6 |
7 |
8 | 11 | 12 |
13 | 14 |
15 | 21 | 22 |
23 | 24 |
25 |
26 | 32 | 33 | 34 | 35 |
36 | 37 |
38 |
39 | ); 40 | } 41 | -------------------------------------------------------------------------------- /packages/viz/src/viz.js: -------------------------------------------------------------------------------- 1 | import { getGraphvizVersion, getPluginList, renderInput } from "./wrapper.js"; 2 | 3 | class Viz { 4 | constructor(module) { 5 | this.module = module; 6 | } 7 | 8 | get graphvizVersion() { 9 | return getGraphvizVersion(this.module); 10 | } 11 | 12 | get formats() { 13 | return getPluginList(this.module, "device"); 14 | } 15 | 16 | get engines() { 17 | return getPluginList(this.module, "layout"); 18 | } 19 | 20 | renderFormats(input, formats, options = {}) { 21 | return renderInput(this.module, input, formats, { engine: "dot", ...options }); 22 | } 23 | 24 | render(input, options = {}) { 25 | let format; 26 | 27 | if (options.format === void 0) { 28 | format = "dot"; 29 | } else { 30 | format = options.format; 31 | } 32 | 33 | let result = renderInput(this.module, input, [format], { engine: "dot", ...options }); 34 | 35 | if (result.status === "success") { 36 | result.output = result.output[format]; 37 | } 38 | 39 | return result; 40 | } 41 | 42 | renderString(src, options = {}) { 43 | const result = this.render(src, options); 44 | 45 | if (result.status !== "success") { 46 | throw new Error(result.errors.find(e => e.level == "error")?.message || "render failed"); 47 | } 48 | 49 | return result.output; 50 | } 51 | 52 | renderSVGElement(src, options = {}) { 53 | const str = this.renderString(src, { ...options, format: "svg" }); 54 | 55 | let input; 56 | 57 | if (typeof options.trustedTypePolicy !== "undefined") { 58 | input = options.trustedTypePolicy.createHTML(str); 59 | } else { 60 | input = str; 61 | } 62 | 63 | const parser = new DOMParser(); 64 | return parser.parseFromString(input, "image/svg+xml").documentElement; 65 | } 66 | 67 | renderJSON(src, options = {}) { 68 | const str = this.renderString(src, { ...options, format: "json" }); 69 | return JSON.parse(str); 70 | } 71 | } 72 | 73 | export default Viz; 74 | -------------------------------------------------------------------------------- /packages/viz/test/types/graph-objects.ts: -------------------------------------------------------------------------------- 1 | import { type Graph } from "@viz-js/viz"; 2 | 3 | let graph: Graph; 4 | 5 | graph = {}; 6 | 7 | graph = { 8 | edges: [ 9 | { tail: "a", head: "b" } 10 | ] 11 | }; 12 | 13 | graph = { 14 | directed: false, 15 | strict: false, 16 | name: "G", 17 | graphAttributes: { 18 | label: "Test" 19 | }, 20 | edgeAttributes: { 21 | color: "green" 22 | }, 23 | nodeAttributes: { 24 | shape: "circle" 25 | }, 26 | nodes: [ 27 | { name: "a", attributes: { label: "A" } } 28 | ], 29 | edges: [ 30 | { tail: "a", head: "b", attributes: { label: "test" } } 31 | ], 32 | subgraphs: [ 33 | { 34 | name: "cluster1", 35 | graphAttributes: { 36 | color: "green" 37 | }, 38 | edgeAttributes: { 39 | color: "blue" 40 | }, 41 | nodeAttributes: { 42 | color: "red" 43 | }, 44 | subgraphs: [ 45 | { 46 | nodes: [ 47 | { name: "b" } 48 | ] 49 | } 50 | ] 51 | } 52 | ] 53 | }; 54 | 55 | graph = { 56 | graphAttributes: { 57 | width: 2, 58 | abc: true, 59 | label: { html: "test" } 60 | }, 61 | nodes: [ 62 | { 63 | name: "a", 64 | attributes: { 65 | width: 2, 66 | abc: true, 67 | label: { html: "test" } 68 | } 69 | } 70 | ] 71 | }; 72 | 73 | graph = { 74 | graphAttributes: { 75 | // @ts-expect-error 76 | blah: null 77 | } 78 | }; 79 | 80 | graph = { 81 | graphAttributes: { 82 | // @ts-expect-error 83 | label: { stuff: "abc" } 84 | } 85 | }; 86 | 87 | graph = { 88 | subgraphs: [ 89 | { 90 | // @ts-expect-error 91 | directed: false 92 | } 93 | ] 94 | }; 95 | 96 | graph = { 97 | subgraphs: [ 98 | { 99 | // @ts-expect-error 100 | strict: true 101 | } 102 | ] 103 | }; 104 | 105 | // @ts-expect-error 106 | graph = { a: "b" }; 107 | -------------------------------------------------------------------------------- /website/src/reloadable-promise-worker.js: -------------------------------------------------------------------------------- 1 | export class TerminatedError extends Error { 2 | constructor(message) { 3 | super(message); 4 | this.name = "TerminatedError"; 5 | } 6 | } 7 | 8 | export class UnexpectedMessageError extends Error { 9 | constructor(message, data) { 10 | super(message); 11 | this.name = "UnexpectedMessageError"; 12 | this.data = data; 13 | } 14 | } 15 | 16 | export class ReloadablePromiseWorker { 17 | constructor(makeWorker) { 18 | this.makeWorker = makeWorker; 19 | this.reloadWorker(); 20 | } 21 | 22 | reloadWorker() { 23 | if (this.worker) { 24 | this.worker.terminate(); 25 | 26 | if (this.settleFns) { 27 | this.settleFns.reject(new TerminatedError("worker terminated")); 28 | } 29 | } 30 | 31 | this.worker = this.makeWorker(); 32 | 33 | delete this.settleFns; 34 | 35 | this.worker.addEventListener("message", (event) => { 36 | if (this.settleFns) { 37 | if (event.data.status == "fulfilled") { 38 | this.settleFns.resolve(event.data.value); 39 | } else if (event.data.status == "rejected") { 40 | this.settleFns.reject(event.data.reason); 41 | } else { 42 | this.settleFns.reject(new UnexpectedMessageError("unexpected message from worker", event.data)); 43 | } 44 | delete this.settleFns; 45 | } 46 | }); 47 | 48 | this.worker.addEventListener("error", (event) => { 49 | if (this.settleFns) { 50 | this.settleFns.reject(event); 51 | delete this.settleFns; 52 | } 53 | }); 54 | 55 | this.worker.addEventListener("messageerror", (event) => { 56 | if (this.settleFns) { 57 | this.settleFns.reject(event); 58 | delete this.settleFns; 59 | } 60 | }); 61 | } 62 | 63 | postMessage(message) { 64 | if (this.settleFns) { 65 | this.reloadWorker(); 66 | } 67 | 68 | return new Promise((resolve, reject) => { 69 | this.settleFns = { resolve, reject }; 70 | this.worker.postMessage(message); 71 | }); 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /packages/viz/backend/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM emscripten/emsdk:3.1.70 AS graphviz 2 | 3 | ENV PREFIX=/prefix 4 | 5 | ADD "https://github.com/libexpat/libexpat/releases/download/R_2_7_3/expat-2.7.3.tar.gz" ./expat.tar.gz 6 | 7 | RUN mkdir -p expat && tar -zxf ./expat.tar.gz --strip-components 1 --directory expat 8 | RUN cd expat && emconfigure ./configure \ 9 | --host=wasm32 \ 10 | --disable-shared \ 11 | --prefix="${PREFIX}" \ 12 | --libdir="${PREFIX}/lib" \ 13 | CFLAGS="-Oz" \ 14 | CXXFLAGS="-Oz" 15 | RUN cd expat/lib && emmake make all install 16 | 17 | ADD "https://gitlab.com/api/v4/projects/4207231/packages/generic/graphviz-releases/14.1.1/graphviz-14.1.1.tar.gz" ./graphviz.tar.gz 18 | 19 | RUN mkdir -p graphviz && tar -zxf ./graphviz.tar.gz --strip-components 1 --directory graphviz 20 | RUN cd graphviz && emconfigure ./configure \ 21 | --host=wasm32 \ 22 | --disable-ltdl \ 23 | --prefix="${PREFIX}" \ 24 | --libdir="${PREFIX}/lib" \ 25 | EXPAT_CFLAGS="-I${PREFIX}/include" \ 26 | EXPAT_LIBS="-L${PREFIX}/lib -lexpat" \ 27 | CFLAGS="-Oz" \ 28 | CXXFLAGS="-Oz" 29 | RUN cd graphviz/lib && emmake make install 30 | RUN cd graphviz/plugin && emmake make install 31 | 32 | 33 | FROM emscripten/emsdk:3.1.70 AS viz 34 | 35 | ARG DEBUG="" 36 | 37 | ENV PREFIX=/prefix 38 | ENV OUTPUT=/output 39 | 40 | COPY --from=graphviz "${PREFIX}" "${PREFIX}" 41 | COPY viz.c pre.js . 42 | 43 | RUN mkdir -p "${OUTPUT}" 44 | RUN emcc \ 45 | -I"${PREFIX}/include" \ 46 | -I"${PREFIX}/include/graphviz" \ 47 | -L"${PREFIX}/lib" \ 48 | -L"${PREFIX}/lib/graphviz" \ 49 | -lgvplugin_dot_layout \ 50 | -lgvplugin_neato_layout \ 51 | -lgvplugin_core \ 52 | -lgvc \ 53 | -lpathplan \ 54 | -lcgraph \ 55 | -lxdot \ 56 | -lcdt \ 57 | -lexpat \ 58 | ${DEBUG:+-g2} \ 59 | -Oz \ 60 | --no-entry \ 61 | -s MODULARIZE=1 \ 62 | -s EXPORT_ES6=1 \ 63 | -s SINGLE_FILE=1 \ 64 | -s ASSERTIONS=0 \ 65 | -s ALLOW_MEMORY_GROWTH=1 \ 66 | -s ENVIRONMENT=web \ 67 | -s EXPORT_KEEPALIVE=1 \ 68 | -s EXPORTED_FUNCTIONS="['_malloc', '_free']" \ 69 | -s EXPORTED_RUNTIME_METHODS="['ccall', 'UTF8ToString', 'lengthBytesUTF8', 'stringToUTF8', 'getValue', 'FS', 'PATH']" \ 70 | -s INCOMING_MODULE_JS_API="['wasm']" \ 71 | -s WASM_BIGINT=1 \ 72 | -o "${OUTPUT}/backend.js" \ 73 | --pre-js pre.js \ 74 | viz.c 75 | 76 | 77 | FROM scratch AS export 78 | 79 | ENV OUTPUT=/output 80 | 81 | COPY --from=viz "${OUTPUT}" / 82 | -------------------------------------------------------------------------------- /scripts/collect-release-info.js: -------------------------------------------------------------------------------- 1 | import { readFile } from "node:fs/promises"; 2 | import { resolve } from "node:path"; 3 | import { exit, argv } from "node:process"; 4 | 5 | async function readPackageInfo(directory) { 6 | const filePath = resolve(directory, "package.json"); 7 | const contents = await readFile(filePath, { encoding: "utf8" }); 8 | const object = JSON.parse(contents); 9 | 10 | const name = object["name"]; 11 | const version = object["version"]; 12 | 13 | const nameMatch = name.match(/^@viz-js\/(.+)$/); 14 | const versionMatch = version.match(/^\d+\.\d+\.\d+$/); 15 | 16 | if (!nameMatch) { 17 | throw new Error(`name didn't match expected pattern: ${name}`); 18 | } 19 | 20 | const shortName = nameMatch[1]; 21 | 22 | if (!versionMatch) { 23 | throw new Error(`version didn't match expected pattern: ${version}`); 24 | } 25 | 26 | return { 27 | name, 28 | shortName, 29 | version 30 | }; 31 | } 32 | 33 | function getTag({ shortName, version }) { 34 | return `release-${shortName}-${version}`; 35 | } 36 | 37 | function getTitle({ name, version }) { 38 | return `${name} ${version}`; 39 | } 40 | 41 | async function readNotes(directory, { version }) { 42 | const filePath = resolve(directory, "CHANGELOG.md"); 43 | const contents = await readFile(filePath, { encoding: "utf8" }); 44 | 45 | const headings = Array.from(contents.matchAll(/^##\s*(.*)$/gm)); 46 | const foundIndex = headings.findIndex(h => h[1] === version); 47 | 48 | if (foundIndex === -1) { 49 | throw new Error(`Couldn't find notes for version: ${version}`); 50 | } 51 | 52 | let notes; 53 | 54 | const startIndex = headings[foundIndex].index + headings[foundIndex][0].length; 55 | 56 | if (headings.length > foundIndex + 1) { 57 | notes = contents.substring(startIndex, headings[foundIndex + 1].index); 58 | } else { 59 | notes = contents.substring(startIndex); 60 | } 61 | 62 | return notes.trim(); 63 | } 64 | 65 | try { 66 | const directory = argv[2]; 67 | 68 | const info = await readPackageInfo(directory); 69 | const tag = getTag(info); 70 | const title = getTitle(info); 71 | const notes = await readNotes(directory, info); 72 | 73 | process.stdout.write(`RELEASE_TAG=${tag}\n`); 74 | process.stdout.write(`RELEASE_TITLE=${title}\n`); 75 | process.stdout.write(`RELEASE_NOTES< { 13 | node.attributes = { label: `${node.name}!` }; 14 | }); 15 | return graph; 16 | } 17 | 18 | function makeObjectWithHTMLLabels() { 19 | const graph = randomGraph(100, 10); 20 | graph.nodes.forEach(node => { 21 | node.attributes = { label: { html: `${node.name}` } }; 22 | }); 23 | return graph; 24 | } 25 | 26 | function makeMultiple() { 27 | return `${dotStringify(makeObject())}${dotStringify(makeObject())}`; 28 | } 29 | 30 | const tests = [ 31 | { label: "string", fn: viz => viz.render(dotStringify(makeObject())) }, 32 | { label: "string with labels", fn: viz => viz.render(dotStringify(makeObjectWithLabels())) }, 33 | { label: "string with HTML labels", fn: viz => viz.render(dotStringify(makeObjectWithHTMLLabels())) }, 34 | { label: "string with multiple formats", fn: viz => viz.renderFormats(dotStringify(makeObject()), ["svg", "cmapx"]) }, 35 | { label: "object", fn: viz => viz.render(makeObject()) }, 36 | { label: "object with labels", fn: viz => viz.render(makeObjectWithLabels()) }, 37 | { label: "object with HTML labels", fn: viz => viz.render(makeObjectWithHTMLLabels()) }, 38 | { label: "valid input containing multiple graphs", fn: viz => viz.render(makeMultiple()) }, 39 | { label: "invalid input", fn: viz => viz.render(invalidInput) }, 40 | { label: "invalid layout engine option", fn: viz => viz.render(dotStringify(makeObject()), { engine: "invalid" }) }, 41 | { label: "invalid format option", fn: viz => viz.render(dotStringify(makeObject()), { format: "invalid" }) }, 42 | { label: "list layout engines", fn: viz => viz.engines }, 43 | { label: "list formats", fn: viz => viz.formats } 44 | ]; 45 | 46 | for (const { label, fn } of tests) { 47 | const viz = await instance(); 48 | 49 | console.log(label); 50 | 51 | let previous = 0; 52 | 53 | for (let i = 0; i < 10000; i++) { 54 | const result = fn(viz); 55 | 56 | const current = process.memoryUsage.rss(); 57 | 58 | if (i % 1000 == 999) { 59 | console.log(`output length: ${result.output?.length}, count: ${i+1}`, `rss: ${current}`, previous > 0 ? `change: ${current - previous}` : ""); 60 | previous = current; 61 | } 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /packages/lang-dot/src/dot.grammar: -------------------------------------------------------------------------------- 1 | @top Graph { 2 | Header Body 3 | } 4 | 5 | Header { 6 | strict? Graphtype atom? 7 | } 8 | 9 | Graphtype { 10 | graph | digraph 11 | } 12 | 13 | Body { 14 | "{" (statement ";"?)* "}" 15 | } 16 | 17 | statement { 18 | SimpleStatement { simple attributeList? } | 19 | EdgeStatement { simple (Edgeop simple)+ attributeList? } | 20 | GraphAttributeStatement { Attribute } | 21 | AttributeStatement { attrtype attributeList } 22 | } 23 | 24 | simple { 25 | Node | 26 | NodeList | 27 | Subgraph 28 | } 29 | 30 | Edgeop { "--" | "->" } 31 | 32 | attrtype { 33 | graph | 34 | node | 35 | edge 36 | } 37 | 38 | Node { 39 | atom Port? 40 | } 41 | 42 | Port { 43 | ":" atom | 44 | ":" atom ":" atom 45 | } 46 | 47 | NodeList { 48 | Node ("," Node)+ 49 | } 50 | 51 | Subgraph { 52 | SubgraphHeader? Body 53 | } 54 | 55 | SubgraphHeader { 56 | subgraph atom | 57 | subgraph 58 | } 59 | 60 | attributeList { 61 | Attributes+ 62 | } 63 | 64 | Attributes { 65 | "[" attrs* "]" 66 | } 67 | 68 | attrs { 69 | Attribute (";" | ",")? 70 | } 71 | 72 | Attribute { 73 | AttributeName { atom } "=" AttributeValue { atom } 74 | } 75 | 76 | @external specialize {Name} keywords from "./tokens.js" { 77 | strict[@name=strict], 78 | graph[@name=graph], 79 | digraph[@name=digraph], 80 | subgraph[@name=subgraph], 81 | node[@name=node], 82 | edge[@name=edge] 83 | } 84 | 85 | atom { Name | Number | String | ConcatString | HTMLString } 86 | 87 | ConcatString { 88 | String ("+" String)+ 89 | } 90 | 91 | @skip {} { 92 | HTMLString { "<" HTMLStringContent htmlEnd } 93 | HTMLStringContent { htmlBody } 94 | htmlBody { (htmlStart htmlBody htmlEnd | htmlContent)* } 95 | } 96 | 97 | @local tokens { 98 | htmlStart { "<" } 99 | htmlEnd { ">" } 100 | @else htmlContent 101 | } 102 | 103 | @skip { space | LineComment | BlockComment } 104 | 105 | @tokens { 106 | space { $[ \t\n\r]+ } 107 | 108 | LineComment { ("//" | "#") ![\n]* } 109 | 110 | BlockComment { "/*" blockCommentRest } 111 | blockCommentRest { ![*] blockCommentRest | "*" blockCommentAfterStar } 112 | blockCommentAfterStar { "/" | "*" blockCommentAfterStar | ![/*] blockCommentRest } 113 | 114 | letter { $[A-Za-z_$\u{80}-\u{10ffff}] } 115 | 116 | digit { $[0-9] } 117 | 118 | Name { letter (letter | digit)* } 119 | 120 | Number { "-"? ("." digit+ | (digit+) ("." digit*)?) } 121 | 122 | String { '"' (![\\"] | "\\" _)* '"' } 123 | 124 | "--" "->" 125 | 126 | "{" "}" 127 | 128 | "[" "]" 129 | 130 | "<" ">" 131 | } 132 | -------------------------------------------------------------------------------- /packages/viz/test/browser/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 |
7 | 8 | 84 | -------------------------------------------------------------------------------- /packages/viz/test/render-formats.test.js: -------------------------------------------------------------------------------- 1 | import assert from "node:assert/strict"; 2 | import * as VizPackage from "../src/index.js"; 3 | 4 | describe("Viz", function() { 5 | let viz; 6 | 7 | beforeEach(async function() { 8 | viz = await VizPackage.instance(); 9 | }); 10 | 11 | describe("renderFormats", function() { 12 | it("renders multiple output formats", function() { 13 | const result = viz.renderFormats("graph a { }", ["dot", "cmapx"]); 14 | 15 | assert.deepStrictEqual(result, { 16 | status: "success", 17 | output: { 18 | dot: "graph a {\n\tgraph [bb=\"0,0,0,0\"];\n\tnode [label=\"\\N\"];\n}\n", 19 | cmapx: "\n\n" 20 | }, 21 | errors: [] 22 | }); 23 | }); 24 | 25 | it("renders with the same format twice", function() { 26 | const result = viz.renderFormats("graph a { }", ["dot", "dot"]); 27 | 28 | assert.deepStrictEqual(result, { 29 | status: "success", 30 | output: { 31 | dot: "graph a {\n\tgraph [bb=\"0,0,0,0\"];\n\tnode [label=\"\\N\"];\n}\n" 32 | }, 33 | errors: [] 34 | }); 35 | }); 36 | 37 | it("renders with an empty array of formats", function() { 38 | const result = viz.renderFormats("graph a { }", []); 39 | 40 | assert.deepStrictEqual(result, { 41 | status: "success", 42 | output: {}, 43 | errors: [] 44 | }); 45 | }); 46 | 47 | it("accepts options", function() { 48 | const result = viz.renderFormats("graph a { b }", ["dot", "cmapx"], { engine: "neato", reduce: true }); 49 | 50 | assert.deepStrictEqual(result, { 51 | status: "success", 52 | output: { 53 | dot: "graph a {\n\tgraph [bb=\"0,0,0,0\"];\n\tnode [label=\"\\N\"];\n}\n", 54 | cmapx: "\n\n" 55 | }, 56 | errors: [] 57 | }); 58 | }); 59 | 60 | it("returns error messages for invalid input", function() { 61 | const result = viz.renderFormats("invalid", ["dot", "cmapx"]); 62 | 63 | assert.deepStrictEqual(result, { 64 | status: "failure", 65 | output: undefined, 66 | errors: [ 67 | { level: "error", message: "syntax error in line 1 near 'invalid'" } 68 | ] 69 | }); 70 | }); 71 | 72 | it("returns error messages for invalid input and an empty array of formats", function() { 73 | const result = viz.renderFormats("invalid", []); 74 | 75 | assert.deepStrictEqual(result, { 76 | status: "failure", 77 | output: undefined, 78 | errors: [ 79 | { level: "error", message: "syntax error in line 1 near 'invalid'" } 80 | ] 81 | }); 82 | }); 83 | }); 84 | }); 85 | -------------------------------------------------------------------------------- /packages/viz/test/manual/utils.js: -------------------------------------------------------------------------------- 1 | export function measure(operation, timeLimit) { 2 | let callCount = 0; 3 | 4 | const startTime = performance.now(); 5 | 6 | while (performance.now() - startTime < timeLimit) { 7 | operation(); 8 | callCount++; 9 | } 10 | 11 | const stopTime = performance.now(); 12 | const duration = (stopTime - startTime) / 1000; 13 | const speed = callCount / duration; 14 | 15 | return `${callCount} in ${duration.toFixed(2)} s, ${speed.toFixed(2)} calls/s` 16 | } 17 | 18 | const skipQuotePattern = /^([A-Za-z_][A-Za-z_0-9]*|-?(\.[0-9]+|[0-9]+(\.[0-9]+)?))$/; 19 | 20 | function quote(value) { 21 | if (typeof value === "object" && "html" in value) { 22 | return "<" + value.html + ">"; 23 | } 24 | 25 | const str = String(value); 26 | 27 | if (skipQuotePattern.test(str)) { 28 | return str; 29 | } else { 30 | return "\"" + str.replaceAll("\"", "\\\"").replaceAll("\n", "\\n") + "\""; 31 | } 32 | } 33 | 34 | export function randomGraph(nodeCount, randomEdgeCount = 0) { 35 | const result = { 36 | nodes: [], 37 | edges: [] 38 | }; 39 | 40 | const prefix = Math.floor(Number.MAX_SAFE_INTEGER * Math.random()); 41 | 42 | for (let i = 0; i < nodeCount; i++) { 43 | result.nodes.push({ name: `${prefix}-node${i}` }); 44 | } 45 | 46 | for (let i = 0; i < randomEdgeCount; i++) { 47 | const t = Math.floor(nodeCount * Math.random()); 48 | const h = Math.floor(nodeCount * Math.random()); 49 | 50 | result.edges.push({ 51 | tail: result.nodes[t].name, 52 | head: result.nodes[h].name 53 | }); 54 | } 55 | 56 | return result; 57 | } 58 | 59 | export function dotStringify(obj) { 60 | const edges = Array.from(obj); 61 | const result = []; 62 | 63 | result.push("digraph {\n"); 64 | 65 | for (const node of obj.nodes) { 66 | result.push(quote(node.name)); 67 | 68 | if (node.attributes) { 69 | result.push(" ["); 70 | 71 | let sep = ""; 72 | for (const [key, value] of Object.entries(node.attributes)) { 73 | result.push(quote(key), "=", quote(value), sep); 74 | sep = ", "; 75 | } 76 | 77 | result.push("]"); 78 | } 79 | 80 | result.push(";\n"); 81 | } 82 | 83 | for (const edge of obj.edges) { 84 | result.push(quote(edge.tail), " -> ", quote(edge.head)); 85 | 86 | if (edge.attributes) { 87 | result.push(" ["); 88 | 89 | let sep = ""; 90 | for (const [key, value] of Object.entries(edge.attributes)) { 91 | result.push(quote(key), "=", quote(value), sep); 92 | sep = ", "; 93 | } 94 | 95 | result.push("]"); 96 | } 97 | 98 | result.push(";\n"); 99 | } 100 | 101 | result.push("}\n"); 102 | 103 | return result.join(""); 104 | } 105 | -------------------------------------------------------------------------------- /website/src/components/Editor.jsx: -------------------------------------------------------------------------------- 1 | import { useRef, useEffect, useState, useImperativeHandle } from "react"; 2 | import { EditorView, basicSetup } from "codemirror"; 3 | import { EditorState } from "@codemirror/state"; 4 | import { linter } from "@codemirror/lint"; 5 | import { syntaxTree } from "@codemirror/language"; 6 | import { dot as dotLanguageSupport } from "@viz-js/lang-dot"; 7 | import { Errors } from "./Errors.jsx"; 8 | 9 | const syntaxLinter = linter((view) => { 10 | let diagnostics = []; 11 | let graphtype; 12 | 13 | syntaxTree(view.state).cursor().iterate(node => { 14 | if (!graphtype && node.matchContext(["Graphtype"])) { 15 | graphtype = node.name; 16 | } 17 | 18 | if (node.type.is("--") && graphtype == "digraph") { 19 | diagnostics.push({ 20 | from: node.from, 21 | to: node.to, 22 | severity: "error", 23 | message: "Syntax error: undirected edge in directed graph", 24 | actions: [ 25 | { 26 | name: "Replace with directed edge", 27 | apply(view, from, to) { 28 | view.dispatch({ changes: { from, to, insert: "->" }}); 29 | } 30 | } 31 | ] 32 | }); 33 | } 34 | 35 | if (node.type.is("->") && graphtype == "graph") { 36 | diagnostics.push({ 37 | from: node.from, 38 | to: node.to, 39 | severity: "error", 40 | message: "Syntax error: directed edge in undirected graph", 41 | actions: [ 42 | { 43 | name: "Replace with undirected edge", 44 | apply(view, from, to) { 45 | view.dispatch({ changes: { from, to, insert: "--" }}); 46 | } 47 | } 48 | ] 49 | }); 50 | } 51 | 52 | if (node.type.isError) { 53 | diagnostics.push({ 54 | from: node.from, 55 | to: node.to, 56 | severity: "error", 57 | message: "Syntax error" 58 | }); 59 | } 60 | }); 61 | 62 | return diagnostics; 63 | }); 64 | 65 | export function Editor({ defaultValue = "", onChange, ref }) { 66 | let editorContainerRef = useRef(null); 67 | let editorViewRef = useRef(null); 68 | 69 | useEffect(() => { 70 | if (!editorViewRef.current) { 71 | let updateListener = EditorView.updateListener.of(function(update) { 72 | if (update.docChanged && onChange) { 73 | onChange(update.state.doc.toString()); 74 | } 75 | }); 76 | 77 | let state = EditorState.create({ 78 | doc: defaultValue, 79 | extensions: [ 80 | basicSetup, 81 | dotLanguageSupport(), 82 | syntaxLinter, 83 | updateListener, 84 | EditorView.lineWrapping 85 | ] 86 | }); 87 | 88 | editorViewRef.current = new EditorView({ 89 | state, 90 | parent: editorContainerRef.current 91 | }); 92 | } 93 | 94 | return () => { 95 | editorViewRef.current.destroy(); 96 | editorViewRef.current = null; 97 | }; 98 | }, []); 99 | 100 | useImperativeHandle(ref, () => { 101 | return { 102 | setValue(value) { 103 | let view = editorViewRef.current; 104 | 105 | if (view) { 106 | view.dispatch({ 107 | changes: { from: 0, to: view.state.doc.length, insert: value } 108 | }); 109 | } 110 | } 111 | } 112 | }); 113 | 114 | return ( 115 |
116 | ); 117 | } 118 | -------------------------------------------------------------------------------- /.github/workflows/viz-build.yml: -------------------------------------------------------------------------------- 1 | name: Build and test viz package 2 | 3 | on: 4 | workflow_dispatch: {} 5 | push: 6 | branches-ignore: 7 | - v2 8 | - gh-pages 9 | paths: 10 | - "packages/viz/**" 11 | - "package-lock.json" 12 | workflow_call: {} 13 | 14 | permissions: 15 | contents: read 16 | 17 | jobs: 18 | build: 19 | name: Build 20 | runs-on: ubuntu-latest 21 | steps: 22 | - uses: actions/checkout@v5 23 | 24 | - uses: docker/setup-buildx-action@v3 25 | 26 | - uses: docker/build-push-action@v6 27 | with: 28 | context: packages/viz/backend 29 | push: false 30 | outputs: type=local,dest=packages/viz/lib 31 | cache-from: type=gha 32 | cache-to: type=gha,mode=max 33 | 34 | - uses: actions/setup-node@v6 35 | with: 36 | node-version: "24.x" 37 | 38 | - name: "Build" 39 | run: | 40 | npm clean-install --ignore-scripts 41 | test -f lib/backend.js && touch lib/backend.js 42 | make 43 | working-directory: packages/viz 44 | 45 | - uses: actions/upload-artifact@v5 46 | with: 47 | name: lib 48 | path: packages/viz/lib 49 | 50 | - uses: actions/upload-artifact@v5 51 | with: 52 | name: dist 53 | path: packages/viz/dist 54 | 55 | test: 56 | name: Test 57 | needs: build 58 | runs-on: ubuntu-latest 59 | steps: 60 | - uses: actions/checkout@v5 61 | 62 | - uses: actions/download-artifact@v6 63 | with: 64 | name: lib 65 | path: packages/viz/lib 66 | 67 | - uses: actions/download-artifact@v6 68 | with: 69 | name: dist 70 | path: packages/viz/dist 71 | 72 | - uses: actions/setup-node@v6 73 | with: 74 | node-version: "24.x" 75 | 76 | - name: "Run tests" 77 | run: | 78 | npm clean-install --ignore-scripts 79 | npm run test 80 | working-directory: packages/viz 81 | 82 | test-consume: 83 | name: Test import and require 84 | needs: build 85 | runs-on: ubuntu-latest 86 | steps: 87 | - uses: actions/checkout@v5 88 | 89 | - uses: actions/download-artifact@v6 90 | with: 91 | name: lib 92 | path: packages/viz/lib 93 | 94 | - uses: actions/download-artifact@v6 95 | with: 96 | name: dist 97 | path: packages/viz/dist 98 | 99 | - uses: actions/setup-node@v6 100 | with: 101 | node-version: "24.x" 102 | 103 | - name: "Run module-import test" 104 | run: | 105 | npm clean-install --ignore-scripts 106 | node index.js 107 | working-directory: packages/viz/test/module-import 108 | 109 | - name: "Run commonjs-require test" 110 | run: | 111 | npm clean-install --ignore-scripts 112 | node index.js 113 | working-directory: packages/viz/test/commonjs-require 114 | 115 | test-types: 116 | name: Test types 117 | runs-on: ubuntu-latest 118 | steps: 119 | - uses: actions/checkout@v5 120 | 121 | - uses: actions/setup-node@v6 122 | with: 123 | node-version: "24.x" 124 | 125 | - name: "Install in package directory" 126 | run: | 127 | npm clean-install --ignore-scripts 128 | working-directory: packages/viz 129 | 130 | - name: "Run check-types" 131 | run: | 132 | npm clean-install --ignore-scripts 133 | npm run check-types 134 | npm run check-types-node 135 | working-directory: packages/viz/test/types 136 | -------------------------------------------------------------------------------- /website/src/components/App.jsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect, useMemo, useRef } from "react"; 2 | import { debounce } from "lodash-es"; 3 | import { ReloadablePromiseWorker, TerminatedError } from "../reloadable-promise-worker.js"; 4 | import { copyLink } from "../links.js"; 5 | import { EditorToolbar } from "./EditorToolbar.jsx"; 6 | import { Editor } from "./Editor.jsx"; 7 | import { OutputToolbar } from "./OutputToolbar.jsx"; 8 | import { Output } from "./Output.jsx"; 9 | import { Errors } from "./Errors.jsx"; 10 | import { Resize } from "./Resize.jsx"; 11 | 12 | const worker = new ReloadablePromiseWorker(() => new Worker(new URL("../worker.js", import.meta.url), { type: "module" })); 13 | 14 | function render(src, options) { 15 | let effectiveFormat = options.format == "svg-image" ? "svg" : options.format; 16 | 17 | return worker 18 | .postMessage({ src, options: { ...options, format: effectiveFormat } }) 19 | .then(result => { 20 | return { ...result, format: options.format }; 21 | }); 22 | } 23 | 24 | export function App({ initialSrc }) { 25 | const [src, setSrc] = useState(initialSrc); 26 | const [debouncedSrc, setDebouncedSrc] = useState(src); 27 | const [options, setOptions] = useState({ engine: "dot", format: "svg-image" }); 28 | const [result, setResult] = useState(null); 29 | const [errors, setErrors] = useState([]); 30 | const [zoom, setZoom] = useState("fit"); 31 | const [isValid, setValid] = useState(false); 32 | 33 | const appRef = useRef(null); 34 | const editorRef = useRef(null); 35 | const imageZoomRef = useRef(null); 36 | 37 | function handleCopyLink() { 38 | copyLink(src); 39 | } 40 | 41 | function handleSrcChange(newSrc) { 42 | setSrc(newSrc); 43 | handleSrcChangeDebounced(newSrc); 44 | } 45 | 46 | function handleOptionChange(k, v) { 47 | setOptions(o => ({ ...o, [k]: v })); 48 | } 49 | 50 | function handleLoadExample(example) { 51 | editorRef.current?.setValue(example); 52 | setSrc(example); 53 | setDebouncedSrc(example); 54 | } 55 | 56 | function handleResize(width) { 57 | appRef.current.style.setProperty("--editor-width", width + "px"); 58 | } 59 | 60 | const handleSrcChangeDebounced = useMemo(() => { 61 | return debounce(setDebouncedSrc, 750); 62 | }, []); 63 | 64 | useEffect(() => { 65 | let ignore = false; 66 | 67 | setValid(false); 68 | 69 | render(debouncedSrc, options) 70 | .then(nextResult => { 71 | if (ignore) { 72 | return; 73 | } 74 | 75 | if (nextResult.status == "success") { 76 | setResult(nextResult); 77 | setValid(true); 78 | } 79 | 80 | setErrors(nextResult.errors); 81 | }) 82 | .catch(error => { 83 | if (!(error instanceof TerminatedError)) { 84 | setErrors([ 85 | { level: "error", message: error.toString() } 86 | ]); 87 | } 88 | }); 89 | 90 | return () => { 91 | ignore = true; 92 | }; 93 | }, [debouncedSrc, options]); 94 | 95 | const zoomEnabled = result?.format == "svg-image"; 96 | 97 | return ( 98 |
99 | 100 | 101 | 102 | imageZoomRef.current?.zoomIn()} onZoomOut={() => imageZoomRef.current?.zoomOut()} /> 103 | 104 | 105 |
106 | ); 107 | } 108 | -------------------------------------------------------------------------------- /website/src/styles/app.css: -------------------------------------------------------------------------------- 1 | @import "./site.css"; 2 | 3 | html { 4 | height: 100%; 5 | } 6 | 7 | body { 8 | height: 100%; 9 | } 10 | 11 | .wrap { 12 | width: 100%; 13 | height: 100%; 14 | 15 | display: grid; 16 | grid-template-areas: "header" "app"; 17 | grid-template-rows: max-content minmax(0, 1fr); 18 | } 19 | 20 | #root { 21 | grid-area: app; 22 | } 23 | 24 | #app { 25 | --editor-width: 50%; 26 | 27 | height: 100%; 28 | display: grid; 29 | grid-template-areas: "editor-toolbar resize output-toolbar" "editor resize output" "editor resize errors"; 30 | grid-template-columns: minmax(300px, var(--editor-width)) 1px minmax(450px, 1fr); 31 | grid-template-rows: max-content 1fr fit-content(20%); 32 | } 33 | 34 | .editor { 35 | grid-area: editor; 36 | } 37 | 38 | .cm-editor { 39 | height: 100%; 40 | overflow: auto; 41 | background: #fff; 42 | } 43 | 44 | .cm-editor.cm-focused { 45 | outline: none; 46 | } 47 | 48 | .cm-editor .cm-scroller { 49 | overflow: auto; 50 | } 51 | 52 | 53 | .errors { 54 | grid-area: errors; 55 | background: #eee; 56 | border-top: 1px solid #ccc; 57 | margin: 0; 58 | overflow: auto; 59 | } 60 | 61 | .errors table { 62 | border-collapse: collapse; 63 | border-style: hidden; 64 | } 65 | 66 | .errors td { 67 | padding: 8px; 68 | border: 1px solid #ddd; 69 | } 70 | 71 | .errors .level { 72 | text-align: center; 73 | } 74 | 75 | .errors .message { 76 | width: 100%; 77 | } 78 | 79 | .errors .level span { 80 | padding: 3px 4px 2px 4px; 81 | border-radius: 2px; 82 | text-transform: capitalize; 83 | font-size: 12px; 84 | } 85 | 86 | .errors .level span.error { 87 | color: #fff; 88 | background: #f33; 89 | } 90 | 91 | .errors .level span.warning { 92 | color: #000; 93 | background: #fc3; 94 | } 95 | 96 | 97 | .editor-toolbar { 98 | grid-area: editor-toolbar; 99 | justify-content: center; 100 | } 101 | 102 | .output-toolbar { 103 | grid-area: output-toolbar; 104 | justify-content: center; 105 | } 106 | 107 | .toolbar { 108 | padding: 8px; 109 | background: #eee; 110 | border-bottom: 1px solid #ccc; 111 | display: flex; 112 | flex-wrap: wrap; 113 | align-items: center; 114 | gap: 12px; 115 | } 116 | 117 | .toolbar .toolbar-item { 118 | display: grid; 119 | gap: 4px; 120 | } 121 | 122 | .toolbar .toolbar-flexible-space { 123 | flex-grow: 1; 124 | } 125 | 126 | .toolbar .toolbar-item-group { 127 | display: flex; 128 | gap: 4px; 129 | } 130 | 131 | .toolbar-item label { 132 | text-align: center; 133 | font-size: 12px; 134 | } 135 | 136 | 137 | .output { 138 | grid-area: output; 139 | background: #ddd; 140 | display: grid; 141 | overflow: hidden; 142 | position: relative; 143 | grid-template-rows: 100%; 144 | } 145 | 146 | .output.invalid > * { 147 | opacity: 0.5; 148 | transition-delay: 0.2s; 149 | transition-duration: 0; 150 | transition-property: opacity; 151 | } 152 | 153 | .output .raw { 154 | padding: 8px; 155 | font-family: monospace; 156 | white-space: pre-wrap; 157 | overflow: auto; 158 | } 159 | 160 | @media (max-width: 750px) { 161 | #app { 162 | grid-template-areas: "editor-toolbar" "editor" "output-toolbar" "output" "errors"; 163 | grid-template-columns: auto; 164 | grid-template-rows: max-content 30% max-content 1fr fit-content(20%); 165 | } 166 | 167 | .output-toolbar { 168 | border-top: 1px solid #ccc; 169 | } 170 | } 171 | 172 | @media (max-width: 450px) { 173 | .editor { 174 | font-size: 16px; 175 | } 176 | } 177 | 178 | 179 | .resize { 180 | grid-area: resize; 181 | background: #ccc; 182 | position: relative; 183 | z-index: 1; 184 | } 185 | 186 | .resize-handle { 187 | width: 7px; 188 | left: -3px; 189 | position: absolute; 190 | top: 0; 191 | bottom: 0; 192 | cursor: ew-resize; 193 | } 194 | 195 | @media (max-width: 750px) { 196 | .resize { 197 | display: none; 198 | } 199 | } 200 | -------------------------------------------------------------------------------- /packages/viz/test/render-unwrapped.test.js: -------------------------------------------------------------------------------- 1 | import assert from "node:assert/strict"; 2 | import { JSDOM } from "jsdom"; 3 | import * as VizPackage from "../src/index.js"; 4 | 5 | describe("Viz", function() { 6 | let viz; 7 | 8 | beforeEach(async function() { 9 | viz = await VizPackage.instance(); 10 | }); 11 | 12 | describe("renderString", function() { 13 | it("returns the output for the first graph, even if subsequent graphs have errors", function() { 14 | const result = viz.renderString("graph a { } graph {"); 15 | 16 | assert.strictEqual(result, "graph a {\n\tgraph [bb=\"0,0,0,0\"];\n\tnode [label=\"\\N\"];\n}\n"); 17 | }); 18 | 19 | it("throws an error if the first graph has a syntax error", function() { 20 | assert.throws(() => { viz.renderString("graph {"); }, /^Error: syntax error/); 21 | }); 22 | 23 | it("throws an error for layout errors", function() { 24 | assert.throws(() => { viz.renderString("graph { layout=invalid }"); }, /^Error: Layout type: "invalid" not recognized/); 25 | }); 26 | 27 | it("throws an error if there are no graphs in the input", function() { 28 | assert.throws(() => { viz.renderString(""); }, /^Error: render failed/); 29 | }); 30 | 31 | it("throws an error with the first render error message", function() { 32 | assert.throws(() => { viz.renderString("graph { layout=invalid; x=1.2.3=y }"); }, /^Error: Layout type: "invalid" not recognized/); 33 | }); 34 | 35 | it("throws for invalid format option", function() { 36 | assert.throws(() => { viz.renderString("graph { }", { format: "invalid" }); }, /^Error: Format: "invalid" not recognized/); 37 | }); 38 | 39 | it("throws for invalid engine option", function() { 40 | assert.throws(() => { viz.renderString("graph { }", { engine: "invalid" }); }, /^Error: Layout type: "invalid" not recognized/); 41 | }); 42 | 43 | it("accepts a non-ASCII character", function() { 44 | assert.match(viz.renderString("digraph { a [label=図] }"), /label=図/); 45 | }); 46 | 47 | it("a graph with unterminated string followed by another call with a valid graph", function() { 48 | assert.throws(() => { viz.renderString("graph { a[label=\"blah"); }, /^Error: syntax error/); 49 | assert.ok(viz.renderString("graph { a }")); 50 | }); 51 | }); 52 | 53 | describe("renderSVGElement", function() { 54 | beforeEach(function() { 55 | const window = (new JSDOM()).window; 56 | global.DOMParser = window.DOMParser; 57 | }); 58 | 59 | afterEach(function() { 60 | delete global.DOMParser; 61 | }); 62 | 63 | it("returns an SVG element", function() { 64 | const svg = viz.renderSVGElement("digraph { a -> b }"); 65 | assert.deepStrictEqual(svg.querySelector(".node title").textContent, "a"); 66 | assert.deepStrictEqual(svg.querySelector(".edge title").textContent, "a->b"); 67 | }); 68 | 69 | it("uses a trusted type policy if present", function() { 70 | let wasFakeSanitized = false; 71 | 72 | const fakePolicy = { 73 | createHTML(input) { 74 | wasFakeSanitized = true; 75 | return input; 76 | } 77 | }; 78 | 79 | viz.renderSVGElement("digraph { a -> b }", { trustedTypePolicy: fakePolicy }); 80 | 81 | assert.ok(wasFakeSanitized); 82 | }); 83 | 84 | it("throws an error for syntax errors", function() { 85 | assert.throws(() => { viz.renderSVGElement(`graph {`); }, /^Error: syntax error/); 86 | }); 87 | 88 | it("throws an error if there are no graphs in the input", function() { 89 | assert.throws(() => { viz.renderSVGElement(""); }, /^Error: render failed/); 90 | }); 91 | }); 92 | 93 | describe("renderJSON", function() { 94 | it("returns an object", function() { 95 | assert.deepStrictEqual(viz.renderJSON("digraph a { }").name, "a"); 96 | }); 97 | 98 | it("throws an error for syntax errors", function() { 99 | assert.throws(() => { viz.renderJSON(`graph {`); }, /^Error: syntax error/); 100 | }); 101 | 102 | it("throws an error if there are no graphs in the input", function() { 103 | assert.throws(() => { viz.renderJSON(""); }, /^Error: render failed/); 104 | }); 105 | }); 106 | }); 107 | -------------------------------------------------------------------------------- /website/src/components/ImageZoom.jsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect, useRef, useImperativeHandle } from "react"; 2 | import * as imageZoomClasses from "./ImageZoom.module.css"; 3 | 4 | export const zoomLevels = [ 5 | 0.05, 0.1, 0.25, 0.5, 0.75, 1, 1.25, 1.5, 2, 3, 4 6 | ]; 7 | 8 | function updateZoom(container, image, dimensions, zoom) { 9 | if (zoom == "fit") { 10 | container.classList.add(imageZoomClasses.fit); 11 | 12 | image.width = dimensions.width; 13 | image.height = dimensions.height; 14 | } else { 15 | container.classList.remove(imageZoomClasses.fit); 16 | 17 | image.width = dimensions.width * zoom; 18 | image.height = dimensions.height * zoom; 19 | } 20 | } 21 | 22 | function adjustScroll(container, fn) { 23 | const ratioX = (container.scrollLeft + container.clientWidth / 2) / container.scrollWidth; 24 | const ratioY = (container.scrollTop + container.clientHeight / 2) / container.scrollHeight; 25 | 26 | fn(); 27 | 28 | container.scrollLeft = (ratioX * container.scrollWidth) - (container.clientWidth / 2); 29 | container.scrollTop = (ratioY * container.scrollHeight) - (container.clientHeight / 2); 30 | } 31 | 32 | function measureFitZoomLevel(container, dimensions) { 33 | if (dimensions.width < container.offsetWidth && dimensions.height < container.offsetHeight) { 34 | return 1; 35 | } 36 | 37 | const widthRatio = container.offsetWidth / dimensions.width; 38 | const heightRatio = container.offsetHeight / dimensions.height; 39 | 40 | return Math.min(widthRatio, heightRatio); 41 | } 42 | 43 | export function ImageZoom({ svg, zoom, onZoomChange, ref }) { 44 | const blobURLRef = useRef(null); 45 | const imageRef = useRef(null); 46 | const containerRef = useRef(null); 47 | const dimensionsRef = useRef(null); 48 | 49 | useImperativeHandle(ref, () => { 50 | return { 51 | zoomIn() { 52 | if (!containerRef.current) { 53 | return; 54 | } 55 | 56 | if (!dimensionsRef.current) { 57 | return; 58 | } 59 | 60 | const effectiveZoomLevel = zoom == "fit" ? measureFitZoomLevel(containerRef.current, dimensionsRef.current) : zoom; 61 | const index = Math.min(zoomLevels.length - 1, zoomLevels.findLastIndex(level => level <= effectiveZoomLevel) + 1); 62 | 63 | onZoomChange(zoomLevels[index]); 64 | }, 65 | 66 | zoomOut() { 67 | if (!containerRef.current) { 68 | return; 69 | } 70 | 71 | if (!dimensionsRef.current) { 72 | return; 73 | } 74 | 75 | const effectiveZoomLevel = zoom == "fit" ? measureFitZoomLevel(containerRef.current, dimensionsRef.current) : zoom; 76 | const index = Math.max(0, zoomLevels.findIndex(level => level >= effectiveZoomLevel) - 1); 77 | 78 | onZoomChange(zoomLevels[index]); 79 | } 80 | } 81 | }); 82 | 83 | useEffect(() => { 84 | if (!imageRef.current) { 85 | return; 86 | } 87 | 88 | if (!dimensionsRef.current) { 89 | return; 90 | } 91 | 92 | adjustScroll(containerRef.current, () => { 93 | updateZoom(containerRef.current, imageRef.current, dimensionsRef.current, zoom); 94 | }); 95 | }, [zoom]); 96 | 97 | useEffect(() => { 98 | let ignore = false; 99 | 100 | const image = new Image(); 101 | 102 | image.addEventListener("load", function() { 103 | if (ignore) { 104 | return; 105 | } 106 | 107 | dimensionsRef.current = { width: image.width, height: image.height }; 108 | 109 | imageRef.current = image; 110 | 111 | containerRef.current.innerHTML = ""; 112 | containerRef.current.appendChild(imageRef.current); 113 | 114 | updateZoom(containerRef.current, imageRef.current, dimensionsRef.current, zoom); 115 | }); 116 | 117 | if (blobURLRef.current) { 118 | URL.revokeObjectURL(blobURLRef.current); 119 | blobURLRef.current = null; 120 | } 121 | 122 | const blob = new Blob([svg], { type: "image/svg+xml" }); 123 | blobURLRef.current = URL.createObjectURL(blob); 124 | 125 | image.src = blobURLRef.current; 126 | 127 | return () => { 128 | ignore = true; 129 | 130 | if (blobURLRef.current) { 131 | URL.revokeObjectURL(blobURLRef.current); 132 | blobURLRef.current = null; 133 | } 134 | }; 135 | }, [svg]); 136 | 137 | return ( 138 |
139 |
140 | ); 141 | } 142 | -------------------------------------------------------------------------------- /packages/viz/backend/viz.c: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | #include 4 | 5 | extern int Y_invert; 6 | extern unsigned char Reduce; 7 | 8 | extern gvplugin_library_t gvplugin_core_LTX_library; 9 | extern gvplugin_library_t gvplugin_dot_layout_LTX_library; 10 | extern gvplugin_library_t gvplugin_neato_layout_LTX_library; 11 | 12 | lt_symlist_t lt_preloaded_symbols[] = { 13 | { "gvplugin_core_LTX_library", &gvplugin_core_LTX_library}, 14 | { "gvplugin_dot_layout_LTX_library", &gvplugin_dot_layout_LTX_library}, 15 | { "gvplugin_neato_layout_LTX_library", &gvplugin_neato_layout_LTX_library}, 16 | { 0, 0 } 17 | }; 18 | 19 | EMSCRIPTEN_KEEPALIVE 20 | void viz_set_y_invert(int value) { 21 | Y_invert = value; 22 | } 23 | 24 | EMSCRIPTEN_KEEPALIVE 25 | void viz_set_reduce(int value) { 26 | Reduce = value; 27 | } 28 | 29 | EMSCRIPTEN_KEEPALIVE 30 | char *viz_get_graphviz_version() { 31 | GVC_t *context = NULL; 32 | char *result = NULL; 33 | 34 | context = gvContextPlugins(lt_preloaded_symbols, 0); 35 | 36 | result = gvcVersion(context); 37 | 38 | gvFinalize(context); 39 | gvFreeContext(context); 40 | 41 | return result; 42 | } 43 | 44 | EMSCRIPTEN_KEEPALIVE 45 | char **viz_get_plugin_list(const char *kind) { 46 | GVC_t *context = NULL; 47 | char **list = NULL; 48 | int count = 0; 49 | 50 | context = gvContextPlugins(lt_preloaded_symbols, 0); 51 | 52 | list = gvPluginList(context, kind, &count); 53 | 54 | gvFinalize(context); 55 | gvFreeContext(context); 56 | 57 | return list; 58 | } 59 | 60 | EM_JS(int, viz_errorf, (char *text), { 61 | Module["agerrMessages"].push(UTF8ToString(text)); 62 | return 0; 63 | }); 64 | 65 | EMSCRIPTEN_KEEPALIVE 66 | Agraph_t *viz_create_graph(char *name, bool directed, bool strict) { 67 | Agdesc_t desc = { .directed = directed, .strict = strict }; 68 | 69 | return agopen(name, desc, NULL); 70 | } 71 | 72 | EMSCRIPTEN_KEEPALIVE 73 | Agraph_t *viz_read_one_graph(char *string) { 74 | Agraph_t *graph = NULL; 75 | Agraph_t *other_graph = NULL; 76 | 77 | // Workaround for #218. Set the global default node label. 78 | 79 | agattr(NULL, AGNODE, "label", "\\N"); 80 | 81 | // Reset errors 82 | 83 | agseterrf(viz_errorf); 84 | agseterr(AGWARN); 85 | agreseterrors(); 86 | 87 | // Try to read one graph 88 | 89 | graph = agmemread(string); 90 | 91 | // Consume the rest of the input 92 | 93 | do { 94 | other_graph = agmemread(NULL); 95 | if (other_graph) { 96 | agclose(other_graph); 97 | } 98 | } while (other_graph); 99 | 100 | return graph; 101 | } 102 | 103 | EMSCRIPTEN_KEEPALIVE 104 | char *viz_string_dup(Agraph_t *g, char *s) { 105 | return agstrdup(g, s); 106 | } 107 | 108 | EMSCRIPTEN_KEEPALIVE 109 | char *viz_string_dup_html(Agraph_t *g, char *s) { 110 | return agstrdup_html(g, s); 111 | } 112 | 113 | EMSCRIPTEN_KEEPALIVE 114 | int viz_string_free(Agraph_t * g, const char *s) { 115 | return agstrfree(g, s, false); 116 | } 117 | 118 | EMSCRIPTEN_KEEPALIVE 119 | int viz_string_free_html(Agraph_t * g, const char *s) { 120 | return agstrfree(g, s, true); 121 | } 122 | 123 | EMSCRIPTEN_KEEPALIVE 124 | Agnode_t *viz_add_node(Agraph_t *g, char *name) { 125 | return agnode(g, name, true); 126 | } 127 | 128 | EMSCRIPTEN_KEEPALIVE 129 | Agedge_t *viz_add_edge(Agraph_t *g, char *uname, char *vname) { 130 | Agnode_t *u = agnode(g, uname, true); 131 | Agnode_t *v = agnode(g, vname, true); 132 | return agedge(g, u, v, NULL, true); 133 | } 134 | 135 | EMSCRIPTEN_KEEPALIVE 136 | Agraph_t *viz_add_subgraph(Agraph_t *g, char *name) { 137 | return agsubg(g, name, true); 138 | } 139 | 140 | EMSCRIPTEN_KEEPALIVE 141 | void viz_set_default_graph_attribute(Agraph_t *graph, char *name, char *value) { 142 | if (agattr(graph, AGRAPH, name, NULL) == NULL) { 143 | agattr(graph, AGRAPH, name, ""); 144 | } 145 | agattr(graph, AGRAPH, name, value); 146 | } 147 | 148 | EMSCRIPTEN_KEEPALIVE 149 | void viz_set_default_node_attribute(Agraph_t *graph, char *name, char *value) { 150 | if (agattr(graph, AGNODE, name, NULL) == NULL) { 151 | agattr(graph, AGNODE, name, ""); 152 | } 153 | agattr(graph, AGNODE, name, value); 154 | } 155 | 156 | EMSCRIPTEN_KEEPALIVE 157 | void viz_set_default_edge_attribute(Agraph_t *graph, char *name, char *value) { 158 | if (agattr(graph, AGEDGE, name, NULL) == NULL) { 159 | agattr(graph, AGEDGE, name, ""); 160 | } 161 | agattr(graph, AGEDGE, name, value); 162 | } 163 | 164 | EMSCRIPTEN_KEEPALIVE 165 | void viz_set_attribute(void *object, char *name, char *value) { 166 | agsafeset(object, name, value, ""); 167 | } 168 | 169 | EMSCRIPTEN_KEEPALIVE 170 | void viz_free_graph(Agraph_t *g) { 171 | agclose(g); 172 | } 173 | 174 | EMSCRIPTEN_KEEPALIVE 175 | GVC_t *viz_create_context() { 176 | return gvContextPlugins(lt_preloaded_symbols, 0); 177 | } 178 | 179 | EMSCRIPTEN_KEEPALIVE 180 | void viz_free_context(GVC_t *context) { 181 | gvFinalize(context); 182 | gvFreeContext(context); 183 | } 184 | 185 | EMSCRIPTEN_KEEPALIVE 186 | int viz_layout(GVC_t *context, Agraph_t *graph, const char *engine) { 187 | return gvLayout(context, graph, engine); 188 | } 189 | 190 | EMSCRIPTEN_KEEPALIVE 191 | void viz_free_layout(GVC_t *context, Agraph_t *graph) { 192 | gvFreeLayout(context, graph); 193 | } 194 | 195 | EMSCRIPTEN_KEEPALIVE 196 | void viz_reset_errors() { 197 | agseterrf(viz_errorf); 198 | agseterr(AGWARN); 199 | agreseterrors(); 200 | } 201 | 202 | EMSCRIPTEN_KEEPALIVE 203 | char *viz_render(GVC_t *context, Agraph_t *graph, const char *format) { 204 | char *data = NULL; 205 | size_t length = 0; 206 | int render_error = 0; 207 | 208 | render_error = gvRenderData(context, graph, format, &data, &length); 209 | 210 | if (render_error) { 211 | gvFreeRenderData(data); 212 | data = NULL; 213 | } 214 | 215 | return data; 216 | } 217 | -------------------------------------------------------------------------------- /packages/viz/test/graph-objects.test.js: -------------------------------------------------------------------------------- 1 | import assert from "node:assert/strict"; 2 | import * as VizPackage from "../src/index.js"; 3 | 4 | describe("Viz", function() { 5 | let viz; 6 | 7 | beforeEach(async function() { 8 | viz = await VizPackage.instance(); 9 | }); 10 | 11 | describe("rendering graph objects", function() { 12 | it("empty graph", function() { 13 | const result = viz.render({}); 14 | 15 | assert.deepStrictEqual(result, { 16 | status: "success", 17 | output: `digraph { 18 | graph [bb="0,0,0,0"]; 19 | node [label="\\N"]; 20 | } 21 | `, 22 | errors: [] 23 | }); 24 | }); 25 | 26 | it("attributes in options override options in input", function() { 27 | const result = viz.render( 28 | { 29 | nodeAttributes: { 30 | shape: "rectangle" 31 | } 32 | }, 33 | { 34 | nodeAttributes: { 35 | shape: "circle" 36 | } 37 | } 38 | ); 39 | 40 | assert.deepStrictEqual(result, { 41 | status: "success", 42 | output: `digraph { 43 | graph [bb="0,0,0,0"]; 44 | node [label="\\N", 45 | shape=circle 46 | ]; 47 | } 48 | `, 49 | errors: [] 50 | }); 51 | }); 52 | 53 | it("just edges", function() { 54 | const result = viz.render({ 55 | edges: [ 56 | { tail: "a", head: "b" } 57 | ] 58 | }); 59 | 60 | assert.deepStrictEqual(result, { 61 | status: "success", 62 | output: `digraph { 63 | graph [bb="0,0,54,108"]; 64 | node [label="\\N"]; 65 | a [height=0.5, 66 | pos="27,90", 67 | width=0.75]; 68 | b [height=0.5, 69 | pos="27,18", 70 | width=0.75]; 71 | a -> b [pos="e,27,36.104 27,71.697 27,64.407 27,55.726 27,47.536"]; 72 | } 73 | `, 74 | errors: [] 75 | }); 76 | }); 77 | 78 | it("undirected graph", function() { 79 | const result = viz.render({ 80 | directed: false, 81 | edges: [ 82 | { tail: "a", head: "b" } 83 | ] 84 | }); 85 | 86 | assert.deepStrictEqual(result, { 87 | status: "success", 88 | output: `graph { 89 | graph [bb="0,0,54,108"]; 90 | node [label="\\N"]; 91 | a [height=0.5, 92 | pos="27,90", 93 | width=0.75]; 94 | b [height=0.5, 95 | pos="27,18", 96 | width=0.75]; 97 | a -- b [pos="27,71.697 27,60.846 27,46.917 27,36.104"]; 98 | } 99 | `, 100 | errors: [] 101 | }); 102 | }); 103 | 104 | it("html attributes", function() { 105 | const result = viz.render({ 106 | nodes: [ 107 | { 108 | name: "a", 109 | attributes: { 110 | label: { html: "A" } 111 | } 112 | } 113 | ] 114 | }); 115 | 116 | assert.deepStrictEqual(result, { 117 | status: "success", 118 | output: `digraph { 119 | graph [bb="0,0,54,36"]; 120 | a [height=0.5, 121 | label=<A>, 122 | pos="27,18", 123 | width=0.75]; 124 | } 125 | `, 126 | errors: [] 127 | }); 128 | }); 129 | 130 | it("default attributes, nodes, edges, and nested subgraphs", function() { 131 | const result = viz.render({ 132 | graphAttributes: { 133 | rankdir: "LR" 134 | }, 135 | nodeAttributes: { 136 | shape: "circle" 137 | }, 138 | nodes: [ 139 | { name: "a", attributes: { label: "A", color: "red" } }, 140 | { name: "b", attributes: { label: "B", color: "green" } } 141 | ], 142 | edges: [ 143 | { tail: "a", head: "b", attributes: { label: "1" } }, 144 | { tail: "b", head: "c", attributes: { label: "2" } } 145 | ], 146 | subgraphs: [ 147 | { 148 | name: "cluster_1", 149 | nodes: [ 150 | { name: "c", attributes: { label: "C", color: "blue" } } 151 | ], 152 | edges: [ 153 | { tail: "c", head: "d", attributes: { label: "3" } } 154 | ], 155 | subgraphs: [ 156 | { 157 | name: "cluster_2", 158 | nodes: [ 159 | { name: "d", attributes: { label: "D", color: "orange" } } 160 | ] 161 | } 162 | ] 163 | } 164 | ] 165 | }); 166 | 167 | assert.deepStrictEqual(result, { 168 | status: "success", 169 | output: `digraph { 170 | graph [bb="0,0,297.04,84", 171 | rankdir=LR 172 | ]; 173 | node [shape=circle]; 174 | subgraph cluster_1 { 175 | graph [bb="150.02,8,289.04,76"]; 176 | subgraph cluster_2 { 177 | graph [bb="229.02,16,281.04,68"]; 178 | d [color=orange, 179 | height=0.50029, 180 | label=D, 181 | pos="255.03,42", 182 | width=0.50029]; 183 | } 184 | c [color=blue, 185 | height=0.5, 186 | label=C, 187 | pos="176.02,42", 188 | width=0.5]; 189 | c -> d [label=3, 190 | lp="215.52,50.4", 191 | pos="e,236.63,42 194.5,42 203.55,42 214.85,42 225.17,42"]; 192 | } 193 | a [color=red, 194 | height=0.50029, 195 | label=A, 196 | pos="18.01,42", 197 | width=0.50029]; 198 | b [color=green, 199 | height=0.5, 200 | label=B, 201 | pos="97.021,42", 202 | width=0.5]; 203 | a -> b [label=1, 204 | lp="57.521,50.4", 205 | pos="e,78.615,42 36.485,42 45.544,42 56.842,42 67.155,42"]; 206 | b -> c [label=2, 207 | lp="136.52,50.4", 208 | pos="e,157.62,42 115.49,42 124.55,42 135.85,42 146.16,42"]; 209 | } 210 | `, 211 | errors: [] 212 | }); 213 | }); 214 | 215 | it("throws for a node without a name", function() { 216 | assert.throws(() => { 217 | viz.render({ 218 | nodes: [ 219 | {} 220 | ] 221 | }); 222 | }); 223 | }); 224 | 225 | it("throws for an edge without tail or head", function() { 226 | assert.throws(() => { 227 | viz.render({ 228 | edges: [ 229 | {} 230 | ] 231 | }); 232 | }); 233 | 234 | assert.throws(() => { 235 | viz.render({ 236 | edges: [ 237 | { tail: "a" } 238 | ] 239 | }); 240 | }); 241 | 242 | assert.throws(() => { 243 | viz.render({ 244 | edges: [ 245 | { head: "b" } 246 | ] 247 | }); 248 | }); 249 | }); 250 | 251 | it("accepts a subgraph without a name", function() { 252 | const result = viz.render({ 253 | subgraphs: [ 254 | { 255 | nodes: [ 256 | { name: "a" } 257 | ] 258 | }, 259 | { 260 | nodes: [ 261 | { name: "b" } 262 | ] 263 | } 264 | ] 265 | }); 266 | 267 | assert.deepStrictEqual(result, { 268 | status: "success", 269 | output: `digraph { 270 | graph [bb="0,0,126,36"]; 271 | node [label="\\N"]; 272 | { 273 | a [height=0.5, 274 | pos="27,18", 275 | width=0.75]; 276 | } 277 | { 278 | b [height=0.5, 279 | pos="99,18", 280 | width=0.75]; 281 | } 282 | } 283 | `, 284 | errors: [] 285 | }); 286 | }); 287 | 288 | it("applies subgraph attributes correctly", function() { 289 | const result = viz.render({ 290 | subgraphs: [ 291 | { 292 | graphAttributes: { 293 | color: "red" 294 | }, 295 | nodeAttributes: { 296 | color: "green" 297 | }, 298 | edgeAttributes: { 299 | color: "blue" 300 | }, 301 | nodes: [ 302 | { name: "a" } 303 | ] 304 | } 305 | ] 306 | }); 307 | 308 | assert.deepStrictEqual(result, { 309 | status: "success", 310 | output: `digraph { 311 | graph [bb="0,0,54,36"]; 312 | node [label="\\N"]; 313 | { 314 | graph [color=red]; 315 | node [color=green]; 316 | edge [color=blue]; 317 | a [height=0.5, 318 | pos="27,18", 319 | width=0.75]; 320 | } 321 | } 322 | `, 323 | errors: [] 324 | }); 325 | }); 326 | }); 327 | }); 328 | -------------------------------------------------------------------------------- /packages/lang-dot/test/graph.txt: -------------------------------------------------------------------------------- 1 | # Minimal 2 | 3 | graph {} 4 | 5 | ==> 6 | 7 | Graph(Header(Graphtype(graph)), Body) 8 | 9 | 10 | # Header 11 | 12 | strict digraph test {} 13 | 14 | ==> 15 | 16 | Graph(Header(strict, Graphtype(digraph), Name), Body) 17 | 18 | 19 | # Node 20 | 21 | graph { a } 22 | 23 | ==> 24 | 25 | Graph(Header(...), Body(SimpleStatement(Node(Name)))) 26 | 27 | 28 | # Ports 29 | 30 | graph { 31 | a:b 32 | a:b:c 33 | } 34 | 35 | ==> 36 | 37 | Graph(Header(...), Body( 38 | SimpleStatement(Node(Name, Port(Name))), 39 | SimpleStatement(Node(Name, Port(Name, Name))) 40 | )) 41 | 42 | 43 | # Optional semicolon for statements 44 | 45 | graph { 46 | a; 47 | b 48 | } 49 | 50 | ==> 51 | 52 | Graph(Header(...), Body(SimpleStatement(Node(Name)), SimpleStatement(Node(Name)))) 53 | 54 | 55 | # Node list 56 | 57 | graph { a, b:x:y; c } 58 | 59 | ==> 60 | 61 | Graph(Header(...), Body( 62 | SimpleStatement(NodeList(Node(Name), Node(Name, Port(Name, Name)))), 63 | SimpleStatement(Node(Name)) 64 | )) 65 | 66 | 67 | # Subgraph 68 | 69 | graph { 70 | { a b } 71 | subgraph another { c d } 72 | } 73 | 74 | ==> 75 | 76 | Graph(Header(...), Body( 77 | SimpleStatement(Subgraph(Body( 78 | SimpleStatement(Node(Name)), 79 | SimpleStatement(Node(Name)) 80 | ))) 81 | SimpleStatement(Subgraph(SubgraphHeader(subgraph, Name), Body( 82 | SimpleStatement(Node(Name)), 83 | SimpleStatement(Node(Name)) 84 | ))) 85 | )) 86 | 87 | 88 | # Edge statement 89 | 90 | graph { 91 | a -- b 92 | } 93 | 94 | ==> 95 | 96 | Graph(Header(...), Body( 97 | EdgeStatement(Node(Name), Edgeop("--"), Node(Name)) 98 | )) 99 | 100 | 101 | # Edges 102 | 103 | graph { 104 | a -- b:p:s 105 | c, d -- { e f } -- g 106 | } 107 | 108 | ==> 109 | 110 | Graph(Header(...), Body( 111 | EdgeStatement(Node(Name), Edgeop("--"), Node(Name, Port(Name, Name))), 112 | EdgeStatement( 113 | NodeList(Node(Name), Node(Name)), 114 | Edgeop("--"), 115 | Subgraph(Body(SimpleStatement(Node(Name)), SimpleStatement(Node(Name)))), 116 | Edgeop("--"), 117 | Node(Name) 118 | ) 119 | )) 120 | 121 | 122 | # More edges 123 | 124 | digraph { 125 | a -> b -> c -> d -> e 126 | } 127 | 128 | ==> 129 | 130 | Graph(Header(...), Body( 131 | EdgeStatement( 132 | Node(Name), 133 | Edgeop("->"), 134 | Node(Name), 135 | Edgeop("->"), 136 | Node(Name), 137 | Edgeop("->"), 138 | Node(Name), 139 | Edgeop("->"), 140 | Node(Name) 141 | ) 142 | )) 143 | 144 | 145 | # Attributes 146 | 147 | graph { 148 | a [x=y, z=w w=w; y=x] 149 | b -- c -- d [m=n] [n=o] 150 | } 151 | 152 | ==> 153 | 154 | Graph(Header(...), Body( 155 | SimpleStatement(Node(Name), Attributes(Attribute(AttributeName(Name), AttributeValue(Name)), Attribute(AttributeName(Name), AttributeValue(Name)), Attribute(AttributeName(Name), AttributeValue(Name)), Attribute(AttributeName(Name), AttributeValue(Name)))) 156 | EdgeStatement(Node(Name), Edgeop("--"), Node(Name), Edgeop("--"), Node(Name), Attributes(Attribute(AttributeName(Name), AttributeValue(Name))), Attributes(Attribute(AttributeName(Name), AttributeValue(Name)))), 157 | )) 158 | 159 | 160 | # Attribute statements 161 | 162 | graph { 163 | x=y 164 | graph [a=b] 165 | edge [c=d] 166 | node [e=f] 167 | } 168 | 169 | ==> 170 | 171 | Graph(Header(...), Body( 172 | GraphAttributeStatement(Attribute(AttributeName(Name), AttributeValue(Name))), 173 | AttributeStatement(graph, Attributes(Attribute(AttributeName(Name), AttributeValue(Name)))), 174 | AttributeStatement(edge, Attributes(Attribute(AttributeName(Name), AttributeValue(Name)))), 175 | AttributeStatement(node, Attributes(Attribute(AttributeName(Name), AttributeValue(Name)))) 176 | )) 177 | 178 | 179 | # Names 180 | 181 | graph { 182 | a 183 | A_1 184 | café 185 |   186 | 図 187 | } 188 | 189 | ==> 190 | 191 | Graph(Header(...), Body( 192 | SimpleStatement(Node(Name(...))), 193 | SimpleStatement(Node(Name(...))), 194 | SimpleStatement(Node(Name(...))), 195 | SimpleStatement(Node(Name(...))), 196 | SimpleStatement(Node(Name(...))) 197 | )) 198 | 199 | 200 | # Numbers 201 | 202 | graph 123 { 203 | 123=.99 204 | 123 [8=9] 205 | 1 -- -2 -- 3. 206 | subgraph 4 { } 207 | 5:6:7 208 | } 209 | 210 | ==> 211 | 212 | Graph(Header(Graphtype(graph), Number), Body( 213 | GraphAttributeStatement(Attribute(AttributeName(Number), AttributeValue(Number))), 214 | SimpleStatement(Node(Number), Attributes(Attribute(AttributeName(Number), AttributeValue(Number)))), 215 | EdgeStatement(Node(Number), Edgeop("--"), Node(Number), Edgeop("--"), Node(Number)), 216 | SimpleStatement(Subgraph(SubgraphHeader(subgraph, Number), Body())), 217 | SimpleStatement(Node(Number, Port(Number, Number))) 218 | )) 219 | 220 | 221 | # Strings 222 | 223 | graph "abc" { 224 | "a"="b" 225 | "test" ["x"="y"] 226 | "x" -- "y" -- "z" 227 | subgraph "\"test\"" { } 228 | "a":"b":"c" 229 | } 230 | 231 | ==> 232 | 233 | Graph(Header(Graphtype(graph), String), Body( 234 | GraphAttributeStatement(Attribute(AttributeName(String), AttributeValue(String))), 235 | SimpleStatement(Node(String), Attributes(Attribute(AttributeName(String), AttributeValue(String)))), 236 | EdgeStatement(Node(String), Edgeop("--"), Node(String), Edgeop("--"), Node(String)), 237 | SimpleStatement(Subgraph(SubgraphHeader(subgraph, String), Body())), 238 | SimpleStatement(Node(String, Port(String, String))) 239 | )) 240 | 241 | 242 | # HTML-like strings 243 | 244 | graph <> { 245 | =< 246 | 247 | 248 | 249 | 250 | 251 |
loremipsumdolor
> 252 | <test> [=] 253 | <
> -- <
> -- <