Co-authored-by: Anmol Sethi <hi@nhooyr.io>
This commit is contained in:
Alexander Wang 2022-11-03 06:54:49 -07:00
commit 524c089a74
594 changed files with 176172 additions and 0 deletions

2
.github/issue_request_template.md vendored Normal file
View file

@ -0,0 +1,2 @@
<!-- Please title the issue with a scope prefix like cli: improve performance. -->
<!-- Please add screenshots or screencasts for ui/autolayout issues. -->

2
.github/pull_request_template.md vendored Normal file
View file

@ -0,0 +1,2 @@
<!-- Please title the PR with a scope prefix like cli: performance improvements. -->
<!-- Please add screenshots or screencasts for ui/autolayout changes. -->

97
.github/workflows/ci.yml vendored Normal file
View file

@ -0,0 +1,97 @@
name: ci
on: [push]
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
assert-linear:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
with:
submodules: recursive
- run: ./make.sh assert-linear
env:
GITHUB_TOKEN: ${{ secrets._GITHUB_TOKEN }}
DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }}
fmt:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
with:
submodules: recursive
- uses: actions/setup-go@v3
with:
go-version-file: ./go.mod
cache: true
- run: ./make.sh fmt
env:
GITHUB_TOKEN: ${{ secrets._GITHUB_TOKEN }}
DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }}
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
with:
submodules: recursive
- uses: actions/setup-go@v3
with:
go-version-file: ./go.mod
cache: true
- run: ./make.sh lint
env:
GITHUB_TOKEN: ${{ secrets._GITHUB_TOKEN }}
DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }}
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
with:
submodules: recursive
- uses: actions/setup-go@v3
with:
go-version-file: ./go.mod
cache: true
- run: ./make.sh build
env:
GITHUB_TOKEN: ${{ secrets._GITHUB_TOKEN }}
DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }}
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
with:
submodules: recursive
- uses: actions/setup-go@v3
with:
go-version-file: ./go.mod
cache: true
- run: ./make.sh test
env:
GITHUB_TOKEN: ${{ secrets._GITHUB_TOKEN }}
DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }}
- uses: actions/upload-artifact@v3
if: always()
with:
name: d2chaos-test
path: ./d2chaos/out
race:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
with:
submodules: recursive
- uses: actions/setup-go@v3
with:
go-version-file: ./go.mod
cache: true
- run: ./make.sh race
env:
GITHUB_TOKEN: ${{ secrets._GITHUB_TOKEN }}
DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }}
- uses: actions/upload-artifact@v3
if: always()
with:
name: d2chaos-race
path: ./d2chaos/out

26
.github/workflows/daily.yml vendored Normal file
View file

@ -0,0 +1,26 @@
name: daily
on:
workflow_dispatch:
schedule:
# daily at 00:42 to avoid hourly loads in GitHub actions
# https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#schedule
- cron: '42 0 * * *'
concurrency:
group: ${{ github.workflow }}
cancel-in-progress: true
jobs:
all:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
with:
submodules: recursive
- uses: actions/setup-go@v3
with:
go-version-file: ./go.mod
cache: true
- run: CI_ALL=1 ./make.sh
env:
GITHUB_TOKEN: ${{ secrets._GITHUB_TOKEN }}
DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }}

6
.gitignore vendored Normal file
View file

@ -0,0 +1,6 @@
.make-log
.changed-files
.make-log.txt
*.got.json
*.got.svg
e2e_report.html

3
.gitmodules vendored Normal file
View file

@ -0,0 +1,3 @@
[submodule "ci/sub"]
path = ci/sub
url = https://github.com/terrastruct/ci

375
LICENSE.txt Normal file
View file

@ -0,0 +1,375 @@
Copyright 2022 Terrastruct Inc.
Mozilla Public License Version 2.0
==================================
1. Definitions
--------------
1.1. "Contributor"
means each individual or legal entity that creates, contributes to
the creation of, or owns Covered Software.
1.2. "Contributor Version"
means the combination of the Contributions of others (if any) used
by a Contributor and that particular Contributor's Contribution.
1.3. "Contribution"
means Covered Software of a particular Contributor.
1.4. "Covered Software"
means Source Code Form to which the initial Contributor has attached
the notice in Exhibit A, the Executable Form of such Source Code
Form, and Modifications of such Source Code Form, in each case
including portions thereof.
1.5. "Incompatible With Secondary Licenses"
means
(a) that the initial Contributor has attached the notice described
in Exhibit B to the Covered Software; or
(b) that the Covered Software was made available under the terms of
version 1.1 or earlier of the License, but not also under the
terms of a Secondary License.
1.6. "Executable Form"
means any form of the work other than Source Code Form.
1.7. "Larger Work"
means a work that combines Covered Software with other material, in
a separate file or files, that is not Covered Software.
1.8. "License"
means this document.
1.9. "Licensable"
means having the right to grant, to the maximum extent possible,
whether at the time of the initial grant or subsequently, any and
all of the rights conveyed by this License.
1.10. "Modifications"
means any of the following:
(a) any file in Source Code Form that results from an addition to,
deletion from, or modification of the contents of Covered
Software; or
(b) any new file in Source Code Form that contains any Covered
Software.
1.11. "Patent Claims" of a Contributor
means any patent claim(s), including without limitation, method,
process, and apparatus claims, in any patent Licensable by such
Contributor that would be infringed, but for the grant of the
License, by the making, using, selling, offering for sale, having
made, import, or transfer of either its Contributions or its
Contributor Version.
1.12. "Secondary License"
means either the GNU General Public License, Version 2.0, the GNU
Lesser General Public License, Version 2.1, the GNU Affero General
Public License, Version 3.0, or any later versions of those
licenses.
1.13. "Source Code Form"
means the form of the work preferred for making modifications.
1.14. "You" (or "Your")
means an individual or a legal entity exercising rights under this
License. For legal entities, "You" includes any entity that
controls, is controlled by, or is under common control with You. For
purposes of this definition, "control" means (a) the power, direct
or indirect, to cause the direction or management of such entity,
whether by contract or otherwise, or (b) ownership of more than
fifty percent (50%) of the outstanding shares or beneficial
ownership of such entity.
2. License Grants and Conditions
--------------------------------
2.1. Grants
Each Contributor hereby grants You a world-wide, royalty-free,
non-exclusive license:
(a) under intellectual property rights (other than patent or trademark)
Licensable by such Contributor to use, reproduce, make available,
modify, display, perform, distribute, and otherwise exploit its
Contributions, either on an unmodified basis, with Modifications, or
as part of a Larger Work; and
(b) under Patent Claims of such Contributor to make, use, sell, offer
for sale, have made, import, and otherwise transfer either its
Contributions or its Contributor Version.
2.2. Effective Date
The licenses granted in Section 2.1 with respect to any Contribution
become effective for each Contribution on the date the Contributor first
distributes such Contribution.
2.3. Limitations on Grant Scope
The licenses granted in this Section 2 are the only rights granted under
this License. No additional rights or licenses will be implied from the
distribution or licensing of Covered Software under this License.
Notwithstanding Section 2.1(b) above, no patent license is granted by a
Contributor:
(a) for any code that a Contributor has removed from Covered Software;
or
(b) for infringements caused by: (i) Your and any other third party's
modifications of Covered Software, or (ii) the combination of its
Contributions with other software (except as part of its Contributor
Version); or
(c) under Patent Claims infringed by Covered Software in the absence of
its Contributions.
This License does not grant any rights in the trademarks, service marks,
or logos of any Contributor (except as may be necessary to comply with
the notice requirements in Section 3.4).
2.4. Subsequent Licenses
No Contributor makes additional grants as a result of Your choice to
distribute the Covered Software under a subsequent version of this
License (see Section 10.2) or under the terms of a Secondary License (if
permitted under the terms of Section 3.3).
2.5. Representation
Each Contributor represents that the Contributor believes its
Contributions are its original creation(s) or it has sufficient rights
to grant the rights to its Contributions conveyed by this License.
2.6. Fair Use
This License is not intended to limit any rights You have under
applicable copyright doctrines of fair use, fair dealing, or other
equivalents.
2.7. Conditions
Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted
in Section 2.1.
3. Responsibilities
-------------------
3.1. Distribution of Source Form
All distribution of Covered Software in Source Code Form, including any
Modifications that You create or to which You contribute, must be under
the terms of this License. You must inform recipients that the Source
Code Form of the Covered Software is governed by the terms of this
License, and how they can obtain a copy of this License. You may not
attempt to alter or restrict the recipients' rights in the Source Code
Form.
3.2. Distribution of Executable Form
If You distribute Covered Software in Executable Form then:
(a) such Covered Software must also be made available in Source Code
Form, as described in Section 3.1, and You must inform recipients of
the Executable Form how they can obtain a copy of such Source Code
Form by reasonable means in a timely manner, at a charge no more
than the cost of distribution to the recipient; and
(b) You may distribute such Executable Form under the terms of this
License, or sublicense it under different terms, provided that the
license for the Executable Form does not attempt to limit or alter
the recipients' rights in the Source Code Form under this License.
3.3. Distribution of a Larger Work
You may create and distribute a Larger Work under terms of Your choice,
provided that You also comply with the requirements of this License for
the Covered Software. If the Larger Work is a combination of Covered
Software with a work governed by one or more Secondary Licenses, and the
Covered Software is not Incompatible With Secondary Licenses, this
License permits You to additionally distribute such Covered Software
under the terms of such Secondary License(s), so that the recipient of
the Larger Work may, at their option, further distribute the Covered
Software under the terms of either this License or such Secondary
License(s).
3.4. Notices
You may not remove or alter the substance of any license notices
(including copyright notices, patent notices, disclaimers of warranty,
or limitations of liability) contained within the Source Code Form of
the Covered Software, except that You may alter any license notices to
the extent required to remedy known factual inaccuracies.
3.5. Application of Additional Terms
You may choose to offer, and to charge a fee for, warranty, support,
indemnity or liability obligations to one or more recipients of Covered
Software. However, You may do so only on Your own behalf, and not on
behalf of any Contributor. You must make it absolutely clear that any
such warranty, support, indemnity, or liability obligation is offered by
You alone, and You hereby agree to indemnify every Contributor for any
liability incurred by such Contributor as a result of warranty, support,
indemnity or liability terms You offer. You may include additional
disclaimers of warranty and limitations of liability specific to any
jurisdiction.
4. Inability to Comply Due to Statute or Regulation
---------------------------------------------------
If it is impossible for You to comply with any of the terms of this
License with respect to some or all of the Covered Software due to
statute, judicial order, or regulation then You must: (a) comply with
the terms of this License to the maximum extent possible; and (b)
describe the limitations and the code they affect. Such description must
be placed in a text file included with all distributions of the Covered
Software under this License. Except to the extent prohibited by statute
or regulation, such description must be sufficiently detailed for a
recipient of ordinary skill to be able to understand it.
5. Termination
--------------
5.1. The rights granted under this License will terminate automatically
if You fail to comply with any of its terms. However, if You become
compliant, then the rights granted under this License from a particular
Contributor are reinstated (a) provisionally, unless and until such
Contributor explicitly and finally terminates Your grants, and (b) on an
ongoing basis, if such Contributor fails to notify You of the
non-compliance by some reasonable means prior to 60 days after You have
come back into compliance. Moreover, Your grants from a particular
Contributor are reinstated on an ongoing basis if such Contributor
notifies You of the non-compliance by some reasonable means, this is the
first time You have received notice of non-compliance with this License
from such Contributor, and You become compliant prior to 30 days after
Your receipt of the notice.
5.2. If You initiate litigation against any entity by asserting a patent
infringement claim (excluding declaratory judgment actions,
counter-claims, and cross-claims) alleging that a Contributor Version
directly or indirectly infringes any patent, then the rights granted to
You by any and all Contributors for the Covered Software under Section
2.1 of this License shall terminate.
5.3. In the event of termination under Sections 5.1 or 5.2 above, all
end user license agreements (excluding distributors and resellers) which
have been validly granted by You or Your distributors under this License
prior to termination shall survive termination.
************************************************************************
* *
* 6. Disclaimer of Warranty *
* ------------------------- *
* *
* Covered Software is provided under this License on an "as is" *
* basis, without warranty of any kind, either expressed, implied, or *
* statutory, including, without limitation, warranties that the *
* Covered Software is free of defects, merchantable, fit for a *
* particular purpose or non-infringing. The entire risk as to the *
* quality and performance of the Covered Software is with You. *
* Should any Covered Software prove defective in any respect, You *
* (not any Contributor) assume the cost of any necessary servicing, *
* repair, or correction. This disclaimer of warranty constitutes an *
* essential part of this License. No use of any Covered Software is *
* authorized under this License except under this disclaimer. *
* *
************************************************************************
************************************************************************
* *
* 7. Limitation of Liability *
* -------------------------- *
* *
* Under no circumstances and under no legal theory, whether tort *
* (including negligence), contract, or otherwise, shall any *
* Contributor, or anyone who distributes Covered Software as *
* permitted above, be liable to You for any direct, indirect, *
* special, incidental, or consequential damages of any character *
* including, without limitation, damages for lost profits, loss of *
* goodwill, work stoppage, computer failure or malfunction, or any *
* and all other commercial damages or losses, even if such party *
* shall have been informed of the possibility of such damages. This *
* limitation of liability shall not apply to liability for death or *
* personal injury resulting from such party's negligence to the *
* extent applicable law prohibits such limitation. Some *
* jurisdictions do not allow the exclusion or limitation of *
* incidental or consequential damages, so this exclusion and *
* limitation may not apply to You. *
* *
************************************************************************
8. Litigation
-------------
Any litigation relating to this License may be brought only in the
courts of a jurisdiction where the defendant maintains its principal
place of business and such litigation shall be governed by laws of that
jurisdiction, without reference to its conflict-of-law provisions.
Nothing in this Section shall prevent a party's ability to bring
cross-claims or counter-claims.
9. Miscellaneous
----------------
This License represents the complete agreement concerning the subject
matter hereof. If any provision of this License is held to be
unenforceable, such provision shall be reformed only to the extent
necessary to make it enforceable. Any law or regulation which provides
that the language of a contract shall be construed against the drafter
shall not be used to construe this License against a Contributor.
10. Versions of the License
---------------------------
10.1. New Versions
Mozilla Foundation is the license steward. Except as provided in Section
10.3, no one other than the license steward has the right to modify or
publish new versions of this License. Each version will be given a
distinguishing version number.
10.2. Effect of New Versions
You may distribute the Covered Software under the terms of the version
of the License under which You originally received the Covered Software,
or under the terms of any subsequent version published by the license
steward.
10.3. Modified Versions
If you create software not governed by this License, and you want to
create a new license for such software, you may create and use a
modified version of this License if you rename the license and remove
any references to the name of the license steward (except to note that
such modified license differs from this License).
10.4. Distributing Source Code Form that is Incompatible With Secondary
Licenses
If You choose to distribute Source Code Form that is Incompatible With
Secondary Licenses under the terms of this version of the License, the
notice described in Exhibit B of this License must be attached.
Exhibit A - Source Code Form License Notice
-------------------------------------------
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
If it is not possible or desirable to put the notice in a particular
file, then You may include the notice in a location (such as a LICENSE
file in a relevant directory) where a recipient would be likely to look
for such a notice.
You may add additional accurate notices of copyright ownership.
Exhibit B - "Incompatible With Secondary Licenses" Notice
---------------------------------------------------------
This Source Code Form is "Incompatible With Secondary Licenses", as
defined by the Mozilla Public License, v. 2.0.

26
Makefile Normal file
View file

@ -0,0 +1,26 @@
.POSIX:
.PHONY: all
all: fmt lint build test
ifdef CI
all: assert-linear
endif
.PHONY: fmt
fmt:
prefix "$@" ./ci/sub/fmt/make.sh
.PHONY: lint
lint:
prefix "$@" go vet --composites=false ./...
.PHONY: build
build:
prefix "$@" go build ./...
.PHONY: test
test:
prefix "$@" ./ci/test.sh
.PHONY: race
race:
prefix "$@" ./ci/test.sh --race ./...
.PHONY: assert-linear
assert-linear:
prefix "$@" ./ci/sub/assert_linear.sh

201
README.md Normal file
View file

@ -0,0 +1,201 @@
<div align="center">
<h1>
<img src="./docs/assets/logo.svg" alt="D2" />
</h1>
<p>A modern DSL that turns text into diagrams.</p>
Language docs: [https://d2-lang.com](https://d2-lang.com)
[![ci](https://github.com/terrastruct/d2/actions/workflows/ci.yml/badge.svg)](https://github.com/terrastruct/d2/actions/workflows/ci.yml)
[![release](https://img.shields.io/github/v/release/terrastruct/d2)](https://github.com/terrastruct/d2/releases)
[![discord](https://img.shields.io/discord/976899413542830181?label=discord)](https://discord.gg/h9VFkAKTsT)
![twitter](https://img.shields.io/twitter/follow/terrastruct?style=social)
[![license](https://img.shields.io/github/license/terrastruct/d2?color=9cf)](./LICENSE.txt)
[![godoc](https://pkg.go.dev/badge/oss.terrastruct.com/d2.svg)](https://pkg.go.dev/oss.terrastruct.com/d2)
<img src="./docs/assets/cli.gif" alt="D2 CLI" />
</div>
# Table of Contents
<!-- toc -->
- [Quickstart (CLI)](#quickstart-cli)
* [MacOS](#macos)
* [Linux/Windows](#linuxwindows)
- [Quickstart (library)](#quickstart-library)
- [Themes](#themes)
- [Fonts](#fonts)
- [Export file types](#export-file-types)
- [Language tooling](#language-tooling)
- [Layout engine](#layout-engine)
- [Comparison](#comparison)
- [Contributing](#contributing)
- [License](#license)
- [Dependencies](#dependencies)
- [Related](#related)
* [VSCode extension](#vscode-extension)
* [Vim extension](#vim-extension)
* [Misc](#misc)
<!-- tocstop -->
## Quickstart (CLI)
The most convenient way to use D2 is to just run it as a CLI executable to
produce SVGs from `.d2` files.
```sh
go install oss.terrastruct.com/d2
echo 'x -> y -> z' > in.d2
d2 --watch in.d2 out.svg
```
A browser window will open with `out.svg` and live-reload on changes to `in.d2`.
### MacOS
Homebrew package coming soon.
### Linux/Windows
We have precompiled binaries on the [releases](https://github.com/terrastruct/d2/releases)
page. D2 will be added to OS-respective package managers soon.
## Quickstart (library)
In addition to being a runnable CLI tool, D2 can also be used to produce diagrams from
Go programs.
```go
import (
"github.com/terrastruct/d2/d2compiler"
"github.com/terrastruct/d2/d2exporter"
"github.com/terrastruct/d2/d2layouts/d2dagrelayout"
"github.com/terrastruct/d2/d2renderers/textmeasure"
"github.com/terrastruct/d2/d2themes/d2themescatalog"
)
func main() {
graph, err := d2compiler.Compile("", strings.NewReader("x -> y"), &d2compiler.CompileOptions{ UTF16: true })
ruler, err := textmeasure.NewRuler()
err = graph.SetDimensions(nil, ruler)
err = d2dagrelayout.Layout(ctx, graph)
diagram, err := d2exporter.Export(ctx, graph, d2themescatalog.NeutralDefault)
ioutil.WriteFile(filepath.Join("out.svg"), d2svg.Render(*diagram), 0600)
}
```
D2 is built to be hackable -- the language has an API built on top of it to make edits
programmatically.
```go
import (
"github.com/terrastruct/d2/d2oracle"
"github.com/terrastruct/d2/d2format"
)
// ...modifying the diagram `x -> y` from above
// Create a shape with the ID, "meow"
graph, err = d2oracle.Create(graph "meow")
// Style the shape green
graph, err = d2oracle.Set(graph "meow.style.fill", "green")
// Create a shape with the ID, "cat"
graph, err = d2oracle.Create(graph "cat")
// Move the shape "meow" inside the container "cat"
graph, err = d2oracle.Move(graph "meow", "cat.meow")
// Prints formatted D2 code
println(d2format.Format(graph.AST))
```
This makes it easy to build functionality on top of D2. Terrastruct uses the above API to
implement editing of D2 from mouse actions in a visual interface.
## Themes
D2 includes a variety of official themes to style your diagrams beautifully right out of
the box. See [./d2themes](./d2themes) to browse the available themes and make or
contribute your own creation.
## Fonts
D2 ships with "Source Sans Pro" as the font in renders. If you wish to use a different
one, please see [./d2renderers/d2fonts](./d2renderers/d2fonts).
## Export file types
D2 currently supports SVG exports. More coming soon.
## Language tooling
D2 is designed with language tooling in mind. D2's parser can parse multiple errors from a
broken program, has an autoformatter, syntax highlighting, and we have plans for LSP's and
more. Good language tooling is necessary for creating and maintaining large diagrams.
The extensions for VSCode and Vim can be found in the [Related](#related) section.
## Layout engine
D2 currently uses the open-source library [dagre](https://github.com/dagrejs/dagre) as its
default layout engine. D2 includes a wrapper around dagre to work around one of its
biggest limitations -- the inability to make container-to-container edges.
Dagre was chosen due to its popularity in other tools, but D2 intends to integrate with a
variety of layout engines, e.g. `dot`, as well as single-purpose layout types like
sequence diagrams. You can choose whichever layout engine you like and works best for the
diagram you're making.
Terrastruct has created a proprietary layout engine called
[TALA](https://terrastruct.com/tala). It has been designed specifically for software
architecture diagrams, though it's good for other domains too. TALA has many advantages
over other layout engines, the biggest being that it isn't constrained to hierarchies, or
any single type like "radial" or "tree" (as almost all layout engines are). For more
information and to download & try TALA, see
[https://github.com/terrastruct/TALA](https://github.com/terrastruct/TALA).
## Comparison
For a comparison against other popular text-to-diagram tools, see
[https://text-to-diagram.com](https://text-to-diagram.com).
## Contributing
Contributions are welcome! See [./docs/CONTRIBUTING.md](./docs/CONTRIBUTING.md).
## License
Copyright © 2022 Terrastruct, Inc. Open-source licensed under the Mozilla Public License
2.0.
## Dependencies
D2 is light on third-party dependencies in the source code. Note that these are bundled
with D2, you do not have to separately install anything.
| Dependency | What it does |
| ----------- | ----------- |
| [slog](https://cdr.dev/slog) | logging (deprecating it is a TODO) |
| [goldmark](https://github.com/yuin/goldmark), [goquery](https://github.com/PuerkitoBio/goquery) | Markdown rendering |
| [chroma](https://github.com/alecthomas/chroma) | syntax highlighting code snippets |
| [pflag](https://github.com/spf13/pflag), [fsnotify](https://github.com/fsnotify/fsnotify), [websocket](https://nhooyr.io/websocket) | CLI functions |
| [v8go](https://rogchap.com/v8go) | Run Javascript (e.g. Dagre layout engine) |
| [gonum](https://gonum.org/v1/plot) | Bezier curve stuff (rendering) |
The rest are helpers we've open-sourced. E.g. [diff](https://oss.terrastruct.com/diff) for
our testing framework.
## Related
### VSCode extension
[https://github.com/terrastruct/d2-vscode](https://github.com/terrastruct/d2-vscode)
### Vim extension
[https://github.com/terrastruct/d2-vim](https://github.com/terrastruct/d2-vim)
### Misc
- [https://github.com/terrastruct/d2-docs](https://github.com/terrastruct/d2-docs)
- [https://github.com/terrastruct/text-to-diagram-com](https://github.com/terrastruct/text-to-diagram-com)

9
c.go Normal file
View file

@ -0,0 +1,9 @@
//go:build cgo
package d2
import "oss.terrastruct.com/d2/d2layouts/d2dagrelayout"
func init() {
dagreLayout = d2dagrelayout.Layout
}

3
ci/sub/README.md Normal file
View file

@ -0,0 +1,3 @@
# ci
Terrastruct's CI scripts.

24
ci/sub/assert_linear.sh Executable file
View file

@ -0,0 +1,24 @@
#!/bin/sh
set -eu
. "$(dirname "$0")/lib.sh"
cd -- "$(dirname "$0")/.."
# assert_linear.sh ensures that the current commit does not contain any PR merge commits
# compared to master as if it does, then that means our diffing mechanisms will be
# incorrect. We want all changes compared to master to be checked, not all changed
# relative to the previous PR into this branch.
if [ "$(git rev-parse --is-shallow-repository)" = true ]; then
git fetch --unshallow origin master
fi
merge_base="$(git merge-base HEAD origin/master)"
merges="$(git --no-pager log --merges --grep="Merge pull request" --grep="\[ci-base\]" --format=%h "$merge_base"..HEAD)"
if [ -n "$merges" ]; then
echoerr <<EOF
Found merge pull request commit(s) in PR: $(_echo "$merges" | tr '\n' ' ')
Each pull request must be merged separately for CI to run correctly.
EOF
exit 1
fi

11
ci/sub/bin/echop Executable file
View file

@ -0,0 +1,11 @@
#!/bin/sh
set -eu
. "$(dirname "$0")/../lib.sh"
prefix="$1"
shift
if [ -z "${COLOR:-}" ]; then
COLOR="$(get_rand_color "$prefix")"
fi
printf '%s: %s\n' "$(setaf "$COLOR" "$prefix")" "$*"

38
ci/sub/bin/prefix Executable file
View file

@ -0,0 +1,38 @@
#!/bin/sh
set -eu
. "$(dirname "$0")/../lib.sh"
main() {
prefix="$1"
shift
COLOR="$(get_rand_color "$prefix")"
prefix="$(setaf "$COLOR" "$prefix")"
_echo "$prefix^:" "$*"
# We need to make sure we exit with a non zero exit if the command fails.
# /bin/sh does not support -o pipefail unfortunately.
fifo="$(mktemp -d)/fifo"
mkfifo "$fifo"
# We add the prefix to all lines and remove any lines about recursive make.
# We cannot silence these with -s which is unfortunate.
sed -e "s#^#$prefix: #" -e "/make\[.\]: warning: -j/d" "$fifo" &
exit_trap() {
code="$?"
end="$(awk 'BEGIN{srand(); print srand()}')"
dur="$((end - start))"
if [ "$code" -eq 0 ]; then
_echo "$prefix\$:" "$(setaf 2 success)" "($(echo_dur $dur))"
else
_echo "$prefix\$:" "$(setaf 1 failure)" "($(echo_dur $dur))"
fi
}
trap exit_trap EXIT
start="$(awk 'BEGIN{srand(); print srand()}')"
"$@" >"$fifo" 2>&1
}
main "$@"

10
ci/sub/bin/xargsd Executable file
View file

@ -0,0 +1,10 @@
#!/bin/sh
set -eu
. "$(dirname "$0")/../lib.sh"
set_changed_files
pattern="$1"
shift
< "$CHANGED_FILES" grep "$pattern" | hide xargs ${CI:+-r} -t -P16 "-n${XARGS_N:-256}" -- "$@"

49
ci/sub/fmt/Makefile Normal file
View file

@ -0,0 +1,49 @@
.POSIX:
.PHONY: all
all:
ifdef CI
git -c color.ui=always diff --exit-code
endif
ifdef CI_FMT_GO
all: go
endif
.PHONY: go
go: gofmt goimports
.PHONY: gofmt
gofmt:
prefix "$@" xargsd '\.go$$' gofmt -s -w
.PHONY: goimports
goimports: gofmt
prefix "$@" xargsd '\.go$$' go run golang.org/x/tools/cmd/goimports@v0.1.12 \
-w -local="$$CI_GOIMPORTS_LOCAL"
ifdef CI_FMT_PRETTIER
all: prettier
endif
.PHONY: prettier
prettier:
prefix "$@" xargsd '\.\(js\|jsx\|ts\|tsx\|scss\|css\|html\)$$' \
npx prettier@2.7.1 --print-width=90 --write
ifdef CI_FMT_MARKDOWN
all: markdown-toc
endif
.PHONY: markdown-toc
markdown-toc:
XARGS_N=1 prefix "$@" xargsd '\.md$$' npx markdown-toc@1.2.0 -i
ifdef CI_FMT_GO_MODULE
all: gomodtidy
endif
.PHONY: gomodtidy
gomodtidy:
prefix "$@" go mod tidy
ifdef CI_FMT_NODE_MODULE
all: yarn
endif
.PHONY: yarn
yarn:
prefix "$@" yarn $${CI:+--immutable} $${CI:+--immutable-cache}

32
ci/sub/fmt/make.sh Executable file
View file

@ -0,0 +1,32 @@
#!/bin/sh
set -eu
. "$(dirname "$0")/../lib.sh"
PATH="$(cd -- "$(dirname "$0")" && pwd)/../bin:$PATH"
set_changed_files
gomod_path="$(search_up go.mod || true)"
if [ "$gomod_path" ]; then
export CI_FMT_GO_MODULE=1
module_name="$(cat "$gomod_path" | head -n1 | cut -d' ' -f2 )"
if [ "${CI_GOIMPORTS_LOCAL:-}" ]; then
export CI_GOIMPORTS_LOCAL="$CI_GOIMPORTS_LOCAL,$module_name"
else
export CI_GOIMPORTS_LOCAL="$module_name"
fi
fi
if search_up package.json > /dev/null; then
export CI_FMT_NODE_MODULE=1
fi
if < "$CHANGED_FILES" grep -qm1 '\.go$'; then
export CI_FMT_GO=1
fi
if < "$CHANGED_FILES" grep -qm1 '\.md$'; then
if [ -z "${CI:-}" ]; then
# Only locally for now.
export CI_FMT_MARKDOWN=1
fi
fi
if < "$CHANGED_FILES" grep -qm1 '\.\(js\|jsx\|ts\|tsx\|scss\|css\|html\)$'; then
export CI_FMT_PRETTIER=1
fi
_make -f "$(dirname "$0")/Makefile" "$@"

340
ci/sub/lib.sh Normal file
View file

@ -0,0 +1,340 @@
#!/bin/sh
if [ "${CI_DEBUG:-}" ]; then
set -x
fi
# ***
# logging
# ***
_echo() {
printf '%s\n' "$*"
}
setaf() {
if [ -z "${TERM:-}" ]; then
export TERM=xterm-256color
fi
tput setaf "$1"
shift
printf '%s' "$*"
tput sgr0
}
echoerr() {
printf '%s ' "$(setaf 1 err:)" >&2
if [ "$#" -gt 0 ]; then
printf '%s\n' "$*" >&2
else
cat >&2
fi
}
sh_c() {
printf '%s %s\n' "$(setaf 3 exec:)" "$*"
"$@"
}
get_rand_color() {
# 1-6 are regular and 9-14 are bright.
# 1,2 and 9,10 are red and green but we use those for success and failure.
pick "$*" 3 4 5 6 11 12 13 14
}
hide() {
out="$(mktemp)"
set +e
"$@" >"$out" 2>&1
code="$?"
set -e
if [ "$code" -eq 0 -a -z "${CI_DEBUG:-}" ]; then
return
fi
cat "$out" >&2
exit "$code"
}
echo_dur() {
local dur=$1
local h=$((dur/60/60))
local m=$((dur/60%60))
local s=$((dur%60))
printf '%dh%dm%ds' "$h" "$m" "$s"
}
sponge() {
dst="$1"
tmp="$(mktemp)"
cat > "$tmp"
cat "$tmp" > "$dst"
}
stripansi() {
# First regex gets rid of standard xterm escape sequences for controlling
# visual attributes.
# The second regex I'm not 100% sure, the reference says it selects the US
# encoding but I'm not sure why that's necessary or why it always occurs
# in tput sgr0 before the standard escape sequence.
# See tput sgr0 | xxd
sed -e $'s/\x1b\[[0-9;]*m//g' -e $'s/\x1b(.//g'
}
runtty() {
case "$(uname)" in
Darwin)
script -q /dev/null "$@"
;;
Linux)
script -eqc "$*"
;;
*)
echoerr "runtty: unsupported OS $(uname)"
esac
}
# ***
# rand
# ***
rand() {(
seed="$1"
range="$2"
seed_file="$(mktemp)"
_echo "$seed" | md5sum > "$seed_file"
shuf -i "$range" -n 1 --random-source="$seed_file"
)}
pick() {(
seed="$1"
shift
i="$(rand "$seed" "1-$#")"
eval "_echo \$$i"
)}
# ***
# git
# ***
set_git_base() {
if [ -n "${GIT_BASE_DONE:-}" ]; then
return
fi
if [ -n "${CI_ALL:-}" ]; then
return
fi
if git show --no-patch --format=%s%n%b | grep -qiF '\[ci-all\]'; then
return
fi
if [ "$(git rev-parse --is-shallow-repository)" = true ]; then
git fetch --unshallow origin master
fi
# Unfortunately --grep searches the whole commit message but we just want the header
# searched. Should fix by using grep directly later.
export GIT_BASE="$(git log --merges --grep="Merge pull request" --grep="\[ci-base\]" --format=%h HEAD~1 | head -n1)"
export GIT_BASE_DONE=1
if [ -n "$GIT_BASE" ]; then
echop make "GIT_BASE=$GIT_BASE"
fi
}
is_changed() {
set_git_base
if [ -z "${GIT_BASE:-}" ]; then
return
fi
! git diff --quiet "$GIT_BASE" -- "$@" ||
[ -n "$(git ls-files --other --exclude-standard -- "$@")" ]
}
set_changed_files() {
set_git_base
filter_exists() {
while read -r p; do
if [ -e "$p" ]; then
printf '%s\n' "$p"
fi
done
}
if [ -n "${CHANGED_FILES:-}" ]; then
return
fi
CHANGED_FILES=./.changed-files
git ls-files --other --exclude-standard > "$CHANGED_FILES"
if [ -n "${GIT_BASE:-}" ]; then
git diff --relative --name-only "$GIT_BASE" | filter_exists >> "$CHANGED_FILES"
else
git ls-files >> "$CHANGED_FILES"
fi
export CHANGED_FILES
}
# ***
# make
# ***
_make() {
if [ "${CI:-}" ]; then
if ! is_changed .; then
return
fi
if [ "${GITHUB_TOKEN:-}" ]; then
git config --global credential.helper store
cat > ~/.git-credentials <<EOF
https://cyborg-ts:$GITHUB_TOKEN@github.com
EOF
fi
git submodule update --init --recursive
fi
if [ -z "${MAKE_LOG:-}" ]; then
CI_MAKE_ROOT=1
export MAKE_LOG="./.make-log"
set +e
# runtty is necessary to allow make to write its output unbuffered. Otherwise the
# output is printed in surges as the write buffer is exceeded rather than a continous
# stream. Remove the runtty prefix to experience the laggy behaviour without it.
runtty make -sj8 "$@" \
| tee /dev/stderr "$MAKE_LOG" \
| stripansi > "$MAKE_LOG.txt"
else
CI_MAKE_ROOT=0
set +e
make -sj8 "$@" 2>&1
fi
code="$?"
set -e
if [ "$code" -ne 0 ]; then
notify "$code"
return "$code"
fi
# make doesn't return a nonsuccess exit code on recipe failures.
if <"$MAKE_LOG" grep -q 'make.* \*\*\* .* Error'; then
notify 1
return 1
fi
if [ -n "${CI:-}" ]; then
# Make sure nothing has changed
if ! git -c color.ui=always diff --exit-code; then
notify 1
return 1
fi
fi
notify 0
}
# ***
# misc
# ***
search_up() {(
file="$1"
git_root="$(git rev-parse --show-toplevel)"
while true; do
if [ -e "$file" ]; then
_echo "$file"
return
fi
if [ "$PWD" = "$git_root" ]; then
break
fi
cd ..
done
return 1
)}
# ***
# integrations
# ***
aws() {
# Without the redirection aws's cli will write directly to /dev/tty bypassing prefix.
command aws "$@" > /dev/stdout
}
notify() {
if [ "$CI_MAKE_ROOT" -eq 0 -o -z "${CI:-}" ]; then
return
fi
if [ -z "${SLACK_WEBHOOK_URL:-}" -a -z "${DISCORD_WEBHOOK_URL:-}" ]; then
# Not all repos need CI failure notifications.
return
fi
if [ -z "${GITHUB_RUN_ID:-}" ]; then
# For testing.
GITHUB_WORKFLOW=ci
GITHUB_JOB=fmt
GITHUB_REPOSITORY=terrastruct/src
GITHUB_RUN_ID=3086720699
GITHUB_JOB=all
elif [ "$GITHUB_REF_PROTECTED" != true ]; then
# We only want to notify on protected branch failures.
return
fi
code="$1"
if [ "$code" -eq 0 ]; then
status=success
emoji=🟢
else
status='failure'
emoji=🛑
if [ "${SLACK_WEBHOOK_URL:-}" ]; then
status="$status <!here>"
fi
fi
GITHUB_JOB_URL="$(curl -fsSL -H "Authorization: token $GITHUB_TOKEN" "https://api.github.com/repos/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID/jobs?per_page=100" | \
jq -r ".jobs[] | select( .name == \"$GITHUB_JOB\") | .html_url")"
if [ -z "$GITHUB_JOB_URL" ]; then
status="failed to query github job URL <!here>"
emoji=🛑
GITHUB_JOB_URL="https://github.com/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID"
fi
commit_sha="$(git rev-parse --short HEAD)"
commit_title="$(git show --no-patch '--format=%s')"
# We need to escape any & < > in $commit_title.
# See https://api.slack.com/reference/surfaces/formatting#escaping
commit_title="$(_echo "$commit_title" | sed -e 's/&/\&amp;/g' )"
commit_title="$(_echo "$commit_title" | sed -e 's/</\&lt;/g' )"
commit_title="$(_echo "$commit_title" | sed -e 's/>/\&gt;/g' )"
# Three differences.
# 1. @here doesn't work in discord code blocks but do in slack.
# 2. URLs don't work in discord code blocks but do in slack.
# 3. content vs text for the request JSON payload.
# 4. Discord handles spacing in and around code blocks really weirdly. If $GITHUB_JOB_URL
# has a newline between it and the end of the code block, it's rendered as a separate
# paragraph instead of just below the code block.
if [ "${DISCORD_WEBHOOK_URL:-}" ]; then
msg="---"
if [ "$code" -ne 0 ]; then
msg="$msg @here"
fi
msg="$msg\`\`\`
$emoji $commit_sha - $commit_title | $GITHUB_WORKFLOW/$GITHUB_JOB: $status
\`\`\`$GITHUB_JOB_URL"
json="{\"content\":$(printf %s "$msg" | jq -sR .)}"
url="$DISCORD_WEBHOOK_URL"
elif [ "${SLACK_WEBHOOK_URL:-}" ]; then
msg="\`\`\`
$emoji $commit_sha - $commit_title | $GITHUB_WORKFLOW/$GITHUB_JOB: $status
$GITHUB_JOB_URL
\`\`\`"
json="{\"text\":$(printf %s "$msg" | jq -sR .)}"
url="$SLACK_WEBHOOK_URL"
fi
sh_c curl -fsSL -X POST -H 'Content-type: application/json' --data "$json" "$url" > /dev/null
}

12
ci/test.sh Executable file
View file

@ -0,0 +1,12 @@
#!/bin/sh
set -eu
cd "$(dirname "$0")/.."
if [ "$*" = "" ]; then
set ./...
fi
if [ "${CI:-}" ]; then
export FORCE_COLOR=1
fi
go test --timeout=30m "$@"

118
cmd/d2/help.go Normal file
View file

@ -0,0 +1,118 @@
package main
import (
"context"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"oss.terrastruct.com/d2/d2plugin"
"oss.terrastruct.com/d2/lib/xmain"
)
func help(ms *xmain.State) {
fmt.Fprintf(ms.Stdout, `Usage:
%s [--watch=false] [--theme=0] file.d2 [file.svg]
%[1]s compiles and renders file.d2 to file.svg
Use - to have d2 read from stdin or write to stdout.
Flags:
%s
Subcommands:
%[1]s layout - Lists available layout engine options with short help
%[1]s layout [layout name] - Display long help for a particular layout engine
See more docs at https://oss.terrastruct.com/d2
`, ms.Name, ms.FlagHelp())
}
func layoutHelp(ctx context.Context, ms *xmain.State) error {
if len(ms.FlagSet.Args()) == 1 {
return shortLayoutHelp(ctx, ms)
} else if len(ms.FlagSet.Args()) == 2 {
return longLayoutHelp(ctx, ms)
} else {
return xmain.UsageErrorf("too many arguments passed")
}
}
func shortLayoutHelp(ctx context.Context, ms *xmain.State) error {
var pluginLines []string
plugins, err := d2plugin.ListPlugins(ctx)
if err != nil {
return err
}
for _, p := range plugins {
pluginLines = append(pluginLines, p.Name+" - "+p.ShortHelp)
}
fmt.Fprintf(ms.Stdout, `Available layout engines found:
%s
Usage:
To use a particular layout engine, set the environment variable D2_LAYOUT=[layout name].
Example:
D2_LAYOUT=dagre d2 in.d2 out.svg
Subcommands:
%s layout [layout name] - Display long help for a particular layout engine
See more docs at https://oss.terrastruct.com/d2
`, strings.Join(pluginLines, "\n"), ms.Name)
return nil
}
func longLayoutHelp(ctx context.Context, ms *xmain.State) error {
layout := ms.FlagSet.Arg(1)
plugin, path, err := d2plugin.FindPlugin(ctx, layout)
if errors.Is(err, exec.ErrNotFound) {
return layoutNotFound(ctx, layout)
}
pluginLocation := "bundled"
if path != "" {
pluginLocation = fmt.Sprintf("executable plugin at %s", humanPath(path))
}
pluginInfo, err := plugin.Info(ctx)
if err != nil {
return err
}
fmt.Fprintf(ms.Stdout, `%s (%s):
%s
`, pluginInfo.Name, pluginLocation, pluginInfo.LongHelp)
return nil
}
func layoutNotFound(ctx context.Context, layout string) error {
var names []string
plugins, err := d2plugin.ListPlugins(ctx)
if err != nil {
return err
}
for _, p := range plugins {
names = append(names, p.Name)
}
return xmain.UsageErrorf(`D2_LAYOUT "%s" is not bundled and could not be found in your $PATH.
The available options are: %s. For details on each option, run "d2 layout".
For more information on setup, please visit https://github.com/terrastruct/d2.`,
layout, strings.Join(names, ", "))
}
func humanPath(fp string) string {
if strings.HasPrefix(fp, os.Getenv("HOME")) {
return filepath.Join("~", strings.TrimPrefix(fp, os.Getenv("HOME")))
}
return fp
}

188
cmd/d2/main.go Normal file
View file

@ -0,0 +1,188 @@
package main
import (
"context"
"errors"
"fmt"
"os/exec"
"path/filepath"
"strconv"
"strings"
"time"
_ "embed"
"github.com/spf13/pflag"
"oss.terrastruct.com/d2"
"oss.terrastruct.com/d2/cmd/version"
"oss.terrastruct.com/d2/d2plugin"
"oss.terrastruct.com/d2/d2renderers/d2svg"
"oss.terrastruct.com/d2/d2renderers/textmeasure"
"oss.terrastruct.com/d2/d2themes"
"oss.terrastruct.com/d2/d2themes/d2themescatalog"
"oss.terrastruct.com/d2/lib/xmain"
)
func main() {
xmain.Main(run)
}
func run(ctx context.Context, ms *xmain.State) (err error) {
// :(
ctx = xmain.DiscardSlog(ctx)
watchFlag := ms.FlagSet.BoolP("watch", "w", false, "watch for changes to input and live reload. Use $PORT and $HOST to specify the listening address.\n$D2_PORT and $D2_HOST are also accepted and take priority. Default is localhost:0")
themeFlag := ms.FlagSet.Int64P("theme", "t", 0, "set the diagram theme. For a list of available options, see https://oss.terrastruct.com/d2")
bundleFlag := ms.FlagSet.BoolP("bundle", "b", true, "bundle all assets and layers into the output svg")
versionFlag := ms.FlagSet.BoolP("version", "v", false, "get the version and check for updates")
debugFlag := ms.FlagSet.BoolP("debug", "d", false, "print debug logs")
err = ms.FlagSet.Parse(ms.Args)
if !errors.Is(err, pflag.ErrHelp) && err != nil {
return fmt.Errorf("failed to parse flags: %w", err)
}
if len(ms.FlagSet.Args()) > 0 {
switch ms.FlagSet.Arg(0) {
case "layout":
return layoutHelp(ctx, ms)
}
}
if errors.Is(err, pflag.ErrHelp) {
help(ms)
return nil
}
if *debugFlag {
ms.Env.Setenv("DEBUG", "1")
}
var inputPath string
var outputPath string
if len(ms.FlagSet.Args()) == 0 {
if versionFlag != nil && *versionFlag {
version.CheckVersion(ctx, ms.Log)
return nil
}
help(ms)
return nil
} else if len(ms.FlagSet.Args()) >= 3 {
return xmain.UsageErrorf("too many arguments passed")
}
if len(ms.FlagSet.Args()) >= 1 {
if ms.FlagSet.Arg(0) == "version" {
version.CheckVersion(ctx, ms.Log)
return nil
}
inputPath = ms.FlagSet.Arg(0)
}
if len(ms.FlagSet.Args()) >= 2 {
outputPath = ms.FlagSet.Arg(1)
} else {
if inputPath == "-" {
outputPath = "-"
} else {
outputPath = renameExt(inputPath, ".svg")
}
}
match := d2themescatalog.Find(*themeFlag)
if match == (d2themes.Theme{}) {
return xmain.UsageErrorf("-t[heme] could not be found. The available options are:\n%s\nYou provided: %d", d2themescatalog.CLIString(), *themeFlag)
}
ms.Env.Setenv("D2_THEME", fmt.Sprintf("%d", *themeFlag))
envD2Layout := ms.Env.Getenv("D2_LAYOUT")
if envD2Layout == "" {
envD2Layout = "dagre"
}
plugin, path, err := d2plugin.FindPlugin(ctx, envD2Layout)
if errors.Is(err, exec.ErrNotFound) {
return layoutNotFound(ctx, envD2Layout)
} else if err != nil {
return err
}
pluginLocation := "bundled"
if path != "" {
pluginLocation = fmt.Sprintf("executable plugin at %s", humanPath(path))
}
ms.Log.Debug.Printf("using layout plugin %s (%s)", envD2Layout, pluginLocation)
if *watchFlag {
if inputPath == "-" {
return xmain.UsageErrorf("-w[atch] cannot be combined with reading input from stdin")
}
ms.Env.Setenv("LOG_TIMESTAMPS", "1")
w, err := newWatcher(ctx, ms, plugin, inputPath, outputPath)
if err != nil {
return err
}
return w.run()
}
ctx, cancel := context.WithTimeout(ctx, time.Minute*2)
defer cancel()
if *bundleFlag {
_ = 343
}
_, err = compile(ctx, ms, plugin, inputPath, outputPath)
if err != nil {
return err
}
ms.Log.Success.Printf("successfully compiled %v to %v", inputPath, outputPath)
return nil
}
func compile(ctx context.Context, ms *xmain.State, plugin d2plugin.Plugin, inputPath, outputPath string) ([]byte, error) {
input, err := ms.ReadPath(inputPath)
if err != nil {
return nil, err
}
ruler, err := textmeasure.NewRuler()
if err != nil {
return nil, err
}
themeID, _ := strconv.ParseInt(ms.Env.Getenv("D2_THEME"), 10, 64)
d, err := d2.Compile(ctx, string(input), &d2.CompileOptions{
Layout: plugin.Layout,
Ruler: ruler,
ThemeID: themeID,
})
if err != nil {
return nil, err
}
svg, err := d2svg.Render(d)
if err != nil {
return nil, err
}
svg, err = plugin.PostProcess(ctx, svg)
if err != nil {
return nil, err
}
err = ms.WritePath(outputPath, svg)
if err != nil {
return nil, err
}
return svg, nil
}
// newExt must include leading .
func renameExt(fp string, newExt string) string {
ext := filepath.Ext(fp)
if ext == "" {
return fp + newExt
} else {
return strings.TrimSuffix(fp, ext) + newExt
}
}

12
cmd/d2/main_test.go Normal file
View file

@ -0,0 +1,12 @@
package main_test
import "testing"
// TODO: Use virtual file system with fs.FS in xmain.State when fs.FS supports writes.
// See https://github.com/golang/go/issues/45757. Or modify.
// We would need to abstract out fsnotify as well to work with the virtual test file system.
// See also https://pkg.go.dev/testing/fstest
// For now cleaning up temp directories after running tests is enough.
func TestRun(t *testing.T) {
t.Parallel()
}

9
cmd/d2/static/watch.css Normal file
View file

@ -0,0 +1,9 @@
#d2c-err {
/* This style was copied from Chrome's svg parser error style. */
white-space: pre-wrap;
border: 2px solid #c77;
padding: 15px;
margin: 15px;
background-color: #fdd;
color: black;
}

53
cmd/d2/static/watch.js Normal file
View file

@ -0,0 +1,53 @@
"use strict";
window.addEventListener("DOMContentLoaded", () => {
init(1000);
});
function init(reconnectDelay) {
const devMode = document.body.dataset.d2DevMode === "true";
const ws = new WebSocket(
`ws://${window.location.host}${window.location.pathname}watch`
);
ws.onopen = () => {
reconnectDelay = 1000;
console.info("watch websocket opened");
};
ws.onmessage = (ev) => {
const msg = JSON.parse(ev.data);
if (devMode) {
console.debug("watch websocket received data", ev);
} else {
console.debug("watch websocket received data");
}
const d2ErrDiv = window.document.querySelector("#d2-err");
if (msg.err) {
d2ErrDiv.innerText = msg.err;
d2ErrDiv.style.display = "block";
d2ErrDiv.scrollIntoView();
} else {
const d2SVG = window.document.querySelector("#d2-svg");
// We could turn d2SVG into an actual SVG element and use outerHTML to fully replace it
// with the result from the renderer but unfortunately that overwrites the #d2-svg ID.
// Even if you add another line to set it afterwards. The parsing/interpretation of outerHTML must be async.
//
// There's no way around that short of parsing out the top level svg tag in the msg and
// setting innerHTML to only the actual svg innards. However then you also need to parse
// out the width, height and viewbox out of the top level SVG tag and update those manually.
d2SVG.innerHTML = msg.svg;
d2ErrDiv.style.display = "none";
}
};
ws.onerror = (ev) => {
console.error("watch websocket connection error", ev);
};
ws.onclose = (ev) => {
console.error(`watch websocket closed with code`, ev.code, `and reason`, ev.reason);
console.info(`reconnecting in ${reconnectDelay / 1000} seconds`);
setTimeout(() => {
if (reconnectDelay < 16000) {
reconnectDelay *= 2;
}
init(reconnectDelay);
}, reconnectDelay);
};
}

534
cmd/d2/watch.go Normal file
View file

@ -0,0 +1,534 @@
package main
import (
"context"
"embed"
_ "embed"
"errors"
"fmt"
"io/fs"
"net"
"net/http"
"os"
"path/filepath"
"runtime"
"sync"
"time"
"github.com/fsnotify/fsnotify"
"nhooyr.io/websocket"
"nhooyr.io/websocket/wsjson"
"oss.terrastruct.com/d2/d2plugin"
"oss.terrastruct.com/d2/lib/xbrowser"
"oss.terrastruct.com/d2/lib/xhttp"
"oss.terrastruct.com/d2/lib/xmain"
)
// Enabled with the build tag "dev".
// See watch_dev.go
// Controls whether the embedded staticFS is used or if files are served directly from the
// file system. Useful for quick iteration in development.
var devMode = false
//go:embed static
var staticFS embed.FS
type watcher struct {
ctx context.Context
cancel context.CancelFunc
wg sync.WaitGroup
devMode bool
ms *xmain.State
layoutPlugin d2plugin.Plugin
inputPath string
outputPath string
compileCh chan struct{}
fw *fsnotify.Watcher
l net.Listener
staticFileServer http.Handler
wsclientsMu sync.Mutex
closing bool
wsclientsWG sync.WaitGroup
wsclients map[*wsclient]struct{}
errMu sync.Mutex
err error
resMu sync.Mutex
res *compileResult
}
type compileResult struct {
Err string `json:"err"`
SVG string `json:"svg"`
}
func newWatcher(ctx context.Context, ms *xmain.State, layoutPlugin d2plugin.Plugin, inputPath, outputPath string) (*watcher, error) {
ctx, cancel := context.WithCancel(ctx)
w := &watcher{
ctx: ctx,
cancel: cancel,
devMode: devMode,
ms: ms,
layoutPlugin: layoutPlugin,
inputPath: inputPath,
outputPath: outputPath,
compileCh: make(chan struct{}, 1),
wsclients: make(map[*wsclient]struct{}),
}
err := w.init()
if err != nil {
return nil, err
}
return w, nil
}
func (w *watcher) init() error {
fw, err := fsnotify.NewWatcher()
if err != nil {
return err
}
w.fw = fw
err = w.initStaticFileServer()
if err != nil {
return err
}
return w.listen()
}
func (w *watcher) initStaticFileServer() error {
// Serve files directly in dev mode for fast iteration.
if w.devMode {
_, file, _, ok := runtime.Caller(0)
if !ok {
return errors.New("d2: runtime failed to provide path of watch.go")
}
staticFilesDir := filepath.Join(filepath.Dir(file), "./static")
w.staticFileServer = http.FileServer(http.Dir(staticFilesDir))
return nil
}
sfs, err := fs.Sub(staticFS, "static")
if err != nil {
return err
}
w.staticFileServer = http.FileServer(http.FS(sfs))
return nil
}
func (w *watcher) run() error {
defer w.close()
w.goFunc(w.watchLoop)
w.goFunc(w.compileLoop)
err := w.goServe()
if err != nil {
return err
}
w.wg.Wait()
w.close()
return w.err
}
func (w *watcher) close() {
w.wsclientsMu.Lock()
if w.closing {
w.wsclientsMu.Unlock()
return
}
w.closing = true
w.wsclientsMu.Unlock()
w.cancel()
if w.fw != nil {
err := w.fw.Close()
w.setErr(err)
}
if w.l != nil {
err := w.l.Close()
w.setErr(err)
}
w.wsclientsWG.Wait()
}
func (w *watcher) setErr(err error) {
w.errMu.Lock()
if w.err == nil {
w.err = err
}
w.errMu.Unlock()
}
func (w *watcher) goFunc(fn func(context.Context) error) {
w.wg.Add(1)
go func() {
defer w.wg.Done()
defer w.cancel()
err := fn(w.ctx)
w.setErr(err)
}()
}
/*
* IMPORTANT
*
* Do not touch watchLoop or ensureAddWatch without consulting @nhooyr
* fsnotify and file system watching APIs in general are notoriously hard
* to use correctly.
*
* This issue is a good summary though it too contains confusion and misunderstandings:
* https://github.com/fsnotify/fsnotify/issues/372
*
* The code was thoroughly considered and experimentally vetted.
*
* TODO: Abstract out file system and fsnotify to test this with 100% coverage. See comment in main_test.go
*/
func (w *watcher) watchLoop(ctx context.Context) error {
lastModified, err := w.ensureAddWatch(ctx)
if err != nil {
return err
}
w.ms.Log.Info.Printf("compiling %v...", w.inputPath)
w.requestCompile()
eatBurstTimer := time.NewTimer(0)
<-eatBurstTimer.C
pollTicker := time.NewTicker(time.Second * 10)
defer pollTicker.Stop()
for {
select {
case <-pollTicker.C:
// In case we missed an event indicating the path is unwatchable and we won't be
// getting any more events.
// File notification APIs are notoriously unreliable. I've personally experienced
// many quirks and so feel this check is justified even if excessive.
mt, err := w.ensureAddWatch(ctx)
if err != nil {
return err
}
if !mt.Equal(lastModified) {
// We missed changes.
lastModified = mt
w.requestCompile()
}
case ev, ok := <-w.fw.Events:
if !ok {
return errors.New("fsnotify watcher closed")
}
w.ms.Log.Debug.Printf("received file system event %v", ev)
mt, err := w.ensureAddWatch(ctx)
if err != nil {
return err
}
if ev.Op == fsnotify.Chmod {
if mt.Equal(lastModified) {
// Benign Chmod.
// See https://github.com/fsnotify/fsnotify/issues/15
continue
}
// We missed changes.
lastModified = mt
}
// The purpose of eatBurstTimer is to wait at least 32 milliseconds after a sequence of
// events to ensure that whomever is editing the file is now done.
//
// For example, On macOS editing with neovim, every write I see a chmod immediately
// followed by a write followed by another chmod. We don't want the three events to
// be treated as two or three compilations, we want them to be batched into one.
//
// Another example would be a very large file where one logical edit becomes write
// events. We wouldn't want to try to compile an incomplete file and then report a
// misleading error.
eatBurstTimer.Reset(time.Millisecond * 32)
case <-eatBurstTimer.C:
w.ms.Log.Info.Printf("detected change in %v: recompiling...", w.inputPath)
w.requestCompile()
case err, ok := <-w.fw.Errors:
if !ok {
return errors.New("fsnotify watcher closed")
}
w.ms.Log.Error.Printf("fsnotify error: %v", err)
case <-ctx.Done():
return ctx.Err()
}
}
}
func (w *watcher) requestCompile() {
select {
case w.compileCh <- struct{}{}:
default:
}
}
func (w *watcher) ensureAddWatch(ctx context.Context) (time.Time, error) {
interval := time.Second
tc := time.NewTimer(0)
<-tc.C
for {
mt, err := w.addWatch(ctx)
if err == nil {
return mt, nil
}
w.ms.Log.Error.Printf("failed to watch inputPath %q: %v (retrying in %v)", w.inputPath, err, interval)
tc.Reset(interval)
select {
case <-tc.C:
if interval < time.Second*16 {
interval *= 2
}
case <-ctx.Done():
return time.Time{}, ctx.Err()
}
}
}
func (w *watcher) addWatch(ctx context.Context) (time.Time, error) {
err := w.fw.Add(w.inputPath)
if err != nil {
return time.Time{}, err
}
var d os.FileInfo
d, err = os.Stat(w.inputPath)
if err != nil {
return time.Time{}, err
}
return d.ModTime(), nil
}
func (w *watcher) compileLoop(ctx context.Context) error {
firstCompile := true
for {
select {
case <-w.compileCh:
case <-ctx.Done():
return ctx.Err()
}
recompiledPrefix := ""
if !firstCompile {
recompiledPrefix = "re"
}
b, err := compile(ctx, w.ms, w.layoutPlugin, w.inputPath, w.outputPath)
if err != nil {
err = fmt.Errorf("failed to %scompile: %w", recompiledPrefix, err)
w.ms.Log.Error.Print(err)
w.broadcast(&compileResult{
Err: err.Error(),
})
} else {
w.ms.Log.Success.Printf("successfully %scompiled %v to %v", recompiledPrefix, w.inputPath, w.outputPath)
w.broadcast(&compileResult{
SVG: string(b),
})
}
if firstCompile {
firstCompile = false
url := fmt.Sprintf("http://%s", w.l.Addr())
err = xbrowser.OpenURL(ctx, w.ms.Env, url)
if err != nil {
w.ms.Log.Warn.Printf("failed to open browser to %v: %v", url, err)
}
}
}
}
func (w *watcher) listen() error {
host := "localhost"
port := "0"
hostEnv := w.ms.Env.Getenv("HOST")
if hostEnv != "" {
host = hostEnv
}
portEnv := w.ms.Env.Getenv("PORT")
if portEnv != "" {
port = portEnv
}
l, err := net.Listen("tcp", net.JoinHostPort(host, port))
if err != nil {
return err
}
w.l = l
w.ms.Log.Success.Printf("listening on http://%v", w.l.Addr())
return nil
}
func (w *watcher) goServe() error {
m := http.NewServeMux()
// TODO: Add cmdlog logging and error reporting middleware
// TODO: Add standard debug/profiling routes
m.HandleFunc("/", w.handleRoot)
m.Handle("/static/", http.StripPrefix("/static", w.staticFileServer))
m.Handle("/watch", xhttp.HandlerFuncAdapter{w.ms.Log, w.handleWatch})
s := xhttp.NewServer(w.ms.Log.Warn, xhttp.Log(w.ms.Log, m))
w.goFunc(func(ctx context.Context) error {
return xhttp.Serve(ctx, time.Second*30, s, w.l)
})
return nil
}
func (w *watcher) getRes() *compileResult {
w.resMu.Lock()
defer w.resMu.Unlock()
return w.res
}
func (w *watcher) handleRoot(hw http.ResponseWriter, r *http.Request) {
hw.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprintf(hw, `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>%s</title>
<script src="./static/watch.js"></script>
<link rel="stylesheet" href="./static/watch.css">
</head>
<body data-d2-dev-mode=%t>
<div id="d2-err" style="display: none"></div>
<div id="d2-svg"></div>
</body>
</html>`, w.outputPath, w.devMode)
}
func (w *watcher) handleWatch(hw http.ResponseWriter, r *http.Request) error {
w.wsclientsMu.Lock()
if w.closing {
w.wsclientsMu.Unlock()
return xhttp.Errorf(http.StatusServiceUnavailable, "server shutting down...", "server shutting down...")
}
// We must register ourselves before we even upgrade the connection to ensure that
// w.close() will wait for us. If we instead registered afterwards, then there is a
// brief period between the hijack and the registration where close may return without
// waiting for us to finish.
w.wsclientsWG.Add(1)
w.wsclientsMu.Unlock()
c, err := websocket.Accept(hw, r, &websocket.AcceptOptions{
CompressionMode: websocket.CompressionDisabled,
})
if err != nil {
w.wsclientsWG.Done()
return err
}
go func() {
defer w.wsclientsWG.Done()
defer c.Close(websocket.StatusInternalError, "the sky is falling")
ctx, cancel := context.WithTimeout(w.ctx, time.Hour)
defer cancel()
cl := &wsclient{
w: w,
resultsCh: make(chan struct{}, 1),
c: c,
}
w.wsclientsMu.Lock()
w.wsclients[cl] = struct{}{}
w.wsclientsMu.Unlock()
defer func() {
w.wsclientsMu.Lock()
delete(w.wsclients, cl)
w.wsclientsMu.Unlock()
}()
ctx = cl.c.CloseRead(ctx)
go wsHeartbeat(ctx, cl.c)
_ = cl.writeLoop(ctx)
}()
return nil
}
type wsclient struct {
w *watcher
resultsCh chan struct{}
c *websocket.Conn
}
func (cl *wsclient) writeLoop(ctx context.Context) error {
for {
res := cl.w.getRes()
if res != nil {
err := cl.write(ctx, res)
if err != nil {
return err
}
}
select {
case <-cl.resultsCh:
case <-ctx.Done():
cl.c.Close(websocket.StatusGoingAway, "server shutting down...")
return ctx.Err()
}
}
}
func (cl *wsclient) write(ctx context.Context, res *compileResult) error {
ctx, cancel := context.WithTimeout(ctx, time.Second*30)
defer cancel()
return wsjson.Write(ctx, cl.c, res)
}
func (w *watcher) broadcast(res *compileResult) {
w.resMu.Lock()
w.res = res
w.resMu.Unlock()
w.wsclientsMu.Lock()
defer w.wsclientsMu.Unlock()
clientsSuffix := ""
if len(w.wsclients) != 1 {
clientsSuffix = "s"
}
w.ms.Log.Info.Printf("broadcasting update to %d client%s", len(w.wsclients), clientsSuffix)
for cl := range w.wsclients {
select {
case cl.resultsCh <- struct{}{}:
default:
}
}
}
func wsHeartbeat(ctx context.Context, c *websocket.Conn) {
defer c.Close(websocket.StatusInternalError, "the sky is falling")
t := time.NewTimer(0)
<-t.C
for {
err := c.Ping(ctx)
if err != nil {
return
}
t.Reset(time.Second * 30)
select {
case <-t.C:
case <-ctx.Done():
return
}
}
}

8
cmd/d2/watch_dev.go Normal file
View file

@ -0,0 +1,8 @@
//go:build dev
// +build dev
package main
func init() {
devMode = true
}

View file

@ -0,0 +1,12 @@
//go:build cgo && !nodagre
package main
import (
"oss.terrastruct.com/d2/d2plugin"
"oss.terrastruct.com/d2/lib/xmain"
)
func main() {
xmain.Main(d2plugin.Serve(d2plugin.DagrePlugin))
}

38
cmd/version/version.go Normal file
View file

@ -0,0 +1,38 @@
package version
import (
"context"
"github.com/google/go-github/github"
"oss.terrastruct.com/cmdlog"
)
// Pre-built binaries will have version set during build time.
var Version = "master (built from source)"
func CheckVersion(ctx context.Context, logger *cmdlog.Logger) {
logger.Info.Printf("D2 version: %s\n", Version)
if Version == "master (built from source)" {
return
}
logger.Info.Printf("Checking for updates...")
latest, err := getLatestVersion(ctx)
if err != nil {
logger.Debug.Printf("Error reaching Github for latest version: %s", err.Error())
} else if Version != "master" && Version != latest {
logger.Info.Printf("A new version of D2 is available: %s -> %s", Version, latest)
}
}
func getLatestVersion(ctx context.Context) (string, error) {
client := github.NewClient(nil)
rep, _, err := client.Repositories.GetLatestRelease(ctx, "terrastruct", "d2")
if err != nil {
return "", err
}
return *rep.TagName, nil
}

58
d2.go Normal file
View file

@ -0,0 +1,58 @@
package d2
import (
"context"
"errors"
"os"
"strings"
"oss.terrastruct.com/d2/d2compiler"
"oss.terrastruct.com/d2/d2exporter"
"oss.terrastruct.com/d2/d2graph"
"oss.terrastruct.com/d2/d2renderers/textmeasure"
"oss.terrastruct.com/d2/d2target"
)
type CompileOptions struct {
UTF16 bool
MeasuredTexts []*d2target.MText
Ruler *textmeasure.Ruler
Layout func(context.Context, *d2graph.Graph) error
ThemeID int64
}
func Compile(ctx context.Context, input string, opts *CompileOptions) (*d2target.Diagram, error) {
if opts == nil {
opts = &CompileOptions{}
}
g, err := d2compiler.Compile("", strings.NewReader(input), &d2compiler.CompileOptions{
UTF16: opts.UTF16,
})
if err != nil {
return nil, err
}
err = g.SetDimensions(opts.MeasuredTexts, opts.Ruler)
if err != nil {
return nil, err
}
if opts.Layout != nil {
err = opts.Layout(ctx, g)
} else if os.Getenv("D2_LAYOUT") == "dagre" && dagreLayout != nil {
err = dagreLayout(ctx, g)
} else {
err = errors.New("no available layout")
}
if err != nil {
return nil, err
}
diagram, err := d2exporter.Export(ctx, g, opts.ThemeID)
return diagram, err
}
// See c.go
var dagreLayout func(context.Context, *d2graph.Graph) error

972
d2ast/d2ast.go Normal file
View file

@ -0,0 +1,972 @@
// d2ast implements the d2 language's abstract syntax tree.
//
// https://github.com/terrastruct/d2-vscode
// https://terrastruct.com/docs/d2/tour/intro/
//
// Special characters to think about in parser:
// #
// """
// ;
// []
// {}
// |
// $
// '
// "
// \
// :
// .
// --
// <>
// *
// &
// ()
package d2ast
import (
"bytes"
"encoding"
"errors"
"fmt"
"math/big"
"strconv"
"strings"
"unicode"
"unicode/utf16"
"unicode/utf8"
"oss.terrastruct.com/xdefer"
)
// Node is the base interface implemented by all d2 AST nodes.
// TODO: add error node for autofmt of incomplete AST
type Node interface {
node()
// Type returns the user friendly name of the node.
Type() string
// GetRange returns the range a node occupies in its file.
GetRange() Range
// TODO: add Children() for walking AST
// Children() []Node
}
var _ Node = &Comment{}
var _ Node = &BlockComment{}
var _ Node = &Null{}
var _ Node = &Boolean{}
var _ Node = &Number{}
var _ Node = &UnquotedString{}
var _ Node = &DoubleQuotedString{}
var _ Node = &SingleQuotedString{}
var _ Node = &BlockString{}
var _ Node = &Substitution{}
var _ Node = &Array{}
var _ Node = &Map{}
var _ Node = &Key{}
var _ Node = &KeyPath{}
var _ Node = &Edge{}
var _ Node = &EdgeIndex{}
// Range represents a range between Start and End in Path.
// It's also used in the d2parser package to represent the range of an error.
//
// note: See docs on Position.
//
// It has a custom JSON string encoding with encoding.TextMarshaler and
// encoding.TextUnmarshaler to keep it compact as the JSON struct encoding is too verbose,
// especially with json.MarshalIndent.
//
// It looks like path,start-end
type Range struct {
Path string
Start Position
End Position
}
var _ fmt.Stringer = Range{}
var _ encoding.TextMarshaler = Range{}
var _ encoding.TextUnmarshaler = &Range{}
func MakeRange(s string) Range {
var r Range
_ = r.UnmarshalText([]byte(s))
return r
}
// String returns a string representation of the range including only the path and start.
//
// If path is empty, it will be omitted.
//
// The format is path:start
func (r Range) String() string {
var s strings.Builder
if r.Path != "" {
s.WriteString(r.Path)
s.WriteByte(':')
}
s.WriteString(r.Start.String())
return s.String()
}
// OneLine returns true if the Range starts and ends on the same line.
func (r Range) OneLine() bool {
return r.Start.Line == r.End.Line
}
// See docs on Range.
func (r Range) MarshalText() ([]byte, error) {
start, _ := r.Start.MarshalText()
end, _ := r.End.MarshalText()
return []byte(fmt.Sprintf("%s,%s-%s", r.Path, start, end)), nil
}
// See docs on Range.
func (r *Range) UnmarshalText(b []byte) (err error) {
defer xdefer.Errorf(&err, "failed to unmarshal Range from %q", b)
i := bytes.LastIndexByte(b, '-')
if i == -1 {
return errors.New("missing End field")
}
end := b[i+1:]
b = b[:i]
i = bytes.LastIndexByte(b, ',')
if i == -1 {
return errors.New("missing Start field")
}
start := b[i+1:]
b = b[:i]
r.Path = string(b)
err = r.Start.UnmarshalText(start)
if err != nil {
return err
}
return r.End.UnmarshalText(end)
}
// Position represents a line:column and byte position in a file.
//
// note: Line and Column are zero indexed.
// note: Column and Byte are UTF-8 byte indexes unless byUTF16 was passed to Position.Advance in
// which they are UTF-16 code unit indexes.
// If intended for Javascript consumption like in the browser or via LSP, byUTF16 is
// set to true.
type Position struct {
Line int
Column int
Byte int
}
var _ fmt.Stringer = Position{}
var _ encoding.TextMarshaler = Position{}
var _ encoding.TextUnmarshaler = &Position{}
// String returns a line:column representation of the position suitable for error messages.
//
// note: Should not normally be used directly, see Range.String()
func (p Position) String() string {
return fmt.Sprintf("%d:%d", p.Line+1, p.Column+1)
}
// See docs on Range.
func (p Position) MarshalText() ([]byte, error) {
return []byte(fmt.Sprintf("%d:%d:%d", p.Line, p.Column, p.Byte)), nil
}
// See docs on Range.
func (p *Position) UnmarshalText(b []byte) (err error) {
defer xdefer.Errorf(&err, "failed to unmarshal Position from %q", b)
fields := bytes.Split(b, []byte{':'})
if len(fields) != 3 {
return errors.New("expected three fields")
}
p.Line, err = strconv.Atoi(string(fields[0]))
if err != nil {
return err
}
p.Column, err = strconv.Atoi(string(fields[1]))
if err != nil {
return err
}
p.Byte, err = strconv.Atoi(string(fields[2]))
return err
}
// From copies src into p. It's used in the d2parser package to set a node's Range.End to
// the parser's current pos on all return paths with defer.
func (p *Position) From(src *Position) {
*p = *src
}
// Advance advances p's Line, Column and Byte by r and returns
// the new Position.
// Set byUTF16 to advance the position as though r represents
// a UTF-16 codepoint.
func (p Position) Advance(r rune, byUTF16 bool) Position {
size := utf8.RuneLen(r)
if byUTF16 {
size = 1
r1, r2 := utf16.EncodeRune(r)
if r1 != '\uFFFD' && r2 != '\uFFFD' {
size = 2
}
}
if r == '\n' {
p.Line++
p.Column = 0
} else {
p.Column += size
}
p.Byte += size
return p
}
func (p Position) Subtract(r rune, byUTF16 bool) Position {
size := utf8.RuneLen(r)
if byUTF16 {
size = 1
r1, r2 := utf16.EncodeRune(r)
if r1 != '\uFFFD' && r2 != '\uFFFD' {
size = 2
}
}
if r == '\n' {
panic("d2ast: cannot subtract newline from Position")
} else {
p.Column -= size
}
p.Byte -= size
return p
}
func (p Position) SubtractString(s string, byUTF16 bool) Position {
for _, r := range s {
p = p.Subtract(r, byUTF16)
}
return p
}
// MapNode is implemented by nodes that may be children of Maps.
type MapNode interface {
Node
mapNode()
}
var _ MapNode = &Comment{}
var _ MapNode = &BlockComment{}
var _ MapNode = &Key{}
var _ MapNode = &Substitution{}
// ArrayNode is implemented by nodes that may be children of Arrays.
type ArrayNode interface {
Node
arrayNode()
}
// See Value for the rest.
var _ ArrayNode = &Comment{}
var _ ArrayNode = &BlockComment{}
var _ ArrayNode = &Substitution{}
// Value is implemented by nodes that may be values of a key.
type Value interface {
ArrayNode
value()
}
// See Scalar for rest.
var _ Value = &Array{}
var _ Value = &Map{}
// Scalar is implemented by nodes that represent scalar values.
type Scalar interface {
Value
scalar()
ScalarString() string
}
// See String for rest.
var _ Scalar = &Null{}
var _ Scalar = &Boolean{}
var _ Scalar = &Number{}
// String is implemented by nodes that represent strings.
type String interface {
Scalar
SetString(string)
Copy() String
_string()
}
var _ String = &UnquotedString{}
var _ String = &SingleQuotedString{}
var _ String = &DoubleQuotedString{}
var _ String = &BlockString{}
func (c *Comment) node() {}
func (c *BlockComment) node() {}
func (n *Null) node() {}
func (b *Boolean) node() {}
func (n *Number) node() {}
func (s *UnquotedString) node() {}
func (s *DoubleQuotedString) node() {}
func (s *SingleQuotedString) node() {}
func (s *BlockString) node() {}
func (s *Substitution) node() {}
func (a *Array) node() {}
func (m *Map) node() {}
func (k *Key) node() {}
func (k *KeyPath) node() {}
func (e *Edge) node() {}
func (i *EdgeIndex) node() {}
func (c *Comment) Type() string { return "comment" }
func (c *BlockComment) Type() string { return "block comment" }
func (n *Null) Type() string { return "null" }
func (b *Boolean) Type() string { return "boolean" }
func (n *Number) Type() string { return "number" }
func (s *UnquotedString) Type() string { return "unquoted string" }
func (s *DoubleQuotedString) Type() string { return "double quoted string" }
func (s *SingleQuotedString) Type() string { return "single quoted string" }
func (s *BlockString) Type() string { return s.Tag + " block string" }
func (s *Substitution) Type() string { return "substitution" }
func (a *Array) Type() string { return "array" }
func (m *Map) Type() string { return "map" }
func (k *Key) Type() string { return "map key" }
func (k *KeyPath) Type() string { return "key path" }
func (e *Edge) Type() string { return "edge" }
func (i *EdgeIndex) Type() string { return "edge index" }
func (c *Comment) GetRange() Range { return c.Range }
func (c *BlockComment) GetRange() Range { return c.Range }
func (n *Null) GetRange() Range { return n.Range }
func (b *Boolean) GetRange() Range { return b.Range }
func (n *Number) GetRange() Range { return n.Range }
func (s *UnquotedString) GetRange() Range { return s.Range }
func (s *DoubleQuotedString) GetRange() Range { return s.Range }
func (s *SingleQuotedString) GetRange() Range { return s.Range }
func (s *BlockString) GetRange() Range { return s.Range }
func (s *Substitution) GetRange() Range { return s.Range }
func (a *Array) GetRange() Range { return a.Range }
func (m *Map) GetRange() Range { return m.Range }
func (k *Key) GetRange() Range { return k.Range }
func (k *KeyPath) GetRange() Range { return k.Range }
func (e *Edge) GetRange() Range { return e.Range }
func (i *EdgeIndex) GetRange() Range { return i.Range }
func (c *Comment) mapNode() {}
func (c *BlockComment) mapNode() {}
func (k *Key) mapNode() {}
func (s *Substitution) mapNode() {}
func (c *Comment) arrayNode() {}
func (c *BlockComment) arrayNode() {}
func (n *Null) arrayNode() {}
func (b *Boolean) arrayNode() {}
func (n *Number) arrayNode() {}
func (s *UnquotedString) arrayNode() {}
func (s *DoubleQuotedString) arrayNode() {}
func (s *SingleQuotedString) arrayNode() {}
func (s *BlockString) arrayNode() {}
func (s *Substitution) arrayNode() {}
func (a *Array) arrayNode() {}
func (m *Map) arrayNode() {}
func (n *Null) value() {}
func (b *Boolean) value() {}
func (n *Number) value() {}
func (s *UnquotedString) value() {}
func (s *DoubleQuotedString) value() {}
func (s *SingleQuotedString) value() {}
func (s *BlockString) value() {}
func (a *Array) value() {}
func (m *Map) value() {}
func (n *Null) scalar() {}
func (b *Boolean) scalar() {}
func (n *Number) scalar() {}
func (s *UnquotedString) scalar() {}
func (s *DoubleQuotedString) scalar() {}
func (s *SingleQuotedString) scalar() {}
func (s *BlockString) scalar() {}
// TODO: mistake, move into parse.go
func (n *Null) ScalarString() string { return n.Type() }
func (b *Boolean) ScalarString() string { return strconv.FormatBool(b.Value) }
func (n *Number) ScalarString() string { return n.Raw }
func (s *UnquotedString) ScalarString() string {
if len(s.Value) == 0 {
return ""
}
if s.Value[0].String == nil {
return ""
}
return *s.Value[0].String
}
func (s *DoubleQuotedString) ScalarString() string {
if len(s.Value) == 0 {
return ""
}
if s.Value[0].String == nil {
return ""
}
return *s.Value[0].String
}
func (s *SingleQuotedString) ScalarString() string { return s.Value }
func (s *BlockString) ScalarString() string { return s.Value }
func (s *UnquotedString) SetString(s2 string) { s.Value = []InterpolationBox{{String: &s2}} }
func (s *DoubleQuotedString) SetString(s2 string) { s.Value = []InterpolationBox{{String: &s2}} }
func (s *SingleQuotedString) SetString(s2 string) { s.Raw = ""; s.Value = s2 }
func (s *BlockString) SetString(s2 string) { s.Value = s2 }
func (s *UnquotedString) Copy() String { tmp := *s; return &tmp }
func (s *DoubleQuotedString) Copy() String { tmp := *s; return &tmp }
func (s *SingleQuotedString) Copy() String { tmp := *s; return &tmp }
func (s *BlockString) Copy() String { tmp := *s; return &tmp }
func (s *UnquotedString) _string() {}
func (s *DoubleQuotedString) _string() {}
func (s *SingleQuotedString) _string() {}
func (s *BlockString) _string() {}
type Comment struct {
Range Range `json:"range"`
Value string `json:"value"`
}
type BlockComment struct {
Range Range `json:"range"`
Value string `json:"value"`
}
type Null struct {
Range Range `json:"range"`
}
type Boolean struct {
Range Range `json:"range"`
Value bool `json:"value"`
}
type Number struct {
Range Range `json:"range"`
Raw string `json:"raw"`
Value *big.Rat `json:"value"`
}
type UnquotedString struct {
Range Range `json:"range"`
Value []InterpolationBox `json:"value"`
}
func FlatUnquotedString(s string) *UnquotedString {
return &UnquotedString{
Value: []InterpolationBox{{String: &s}},
}
}
type DoubleQuotedString struct {
Range Range `json:"range"`
Value []InterpolationBox `json:"value"`
}
func FlatDoubleQuotedString(s string) *DoubleQuotedString {
return &DoubleQuotedString{
Value: []InterpolationBox{{String: &s}},
}
}
type SingleQuotedString struct {
Range Range `json:"range"`
Raw string `json:"raw"`
Value string `json:"value"`
}
type BlockString struct {
Range Range `json:"range"`
// Quote contains the pipe delimiter for the block string.
// e.g. if 5 pipes were used to begin a block string, then Quote == "||||".
// The tag is not included.
Quote string `json:"quote"`
Tag string `json:"tag"`
Value string `json:"value"`
}
type Array struct {
Range Range `json:"range"`
Nodes []ArrayNodeBox `json:"nodes"`
}
type Map struct {
Range Range `json:"range"`
Nodes []MapNodeBox `json:"nodes"`
}
func (m *Map) InsertAfter(cursor, n MapNode) {
afterIndex := len(m.Nodes) - 1
for i, n := range m.Nodes {
if n.Unbox() == cursor {
afterIndex = i
}
}
a := make([]MapNodeBox, 0, len(m.Nodes))
a = append(a, m.Nodes[:afterIndex+1]...)
a = append(a, MakeMapNodeBox(n))
a = append(a, m.Nodes[afterIndex+1:]...)
m.Nodes = a
}
func (m *Map) InsertBefore(cursor, n MapNode) {
beforeIndex := len(m.Nodes)
for i, n := range m.Nodes {
if n.Unbox() == cursor {
beforeIndex = i
}
}
a := make([]MapNodeBox, 0, len(m.Nodes))
a = append(a, m.Nodes[:beforeIndex]...)
a = append(a, MakeMapNodeBox(n))
a = append(a, m.Nodes[beforeIndex:]...)
m.Nodes = a
}
func (m *Map) IsFileMap() bool {
return m.Range.Start.Line == 0 && m.Range.Start.Column == 0
}
// TODO: require @ on import values for readability
type Key struct {
Range Range `json:"range"`
// Indicates this MapKey is an override selector.
Ampersand bool `json:"ampersand,omitempty"`
// At least one of Key and Edges will be set but all four can also be set.
// The following are all valid MapKeys:
// Key:
// x
// Edges:
// x -> y
// Edges and EdgeIndex:
// (x -> y)[*]
// Edges and EdgeKey:
// (x -> y).label
// Key and Edges:
// container.(x -> y)
// Key, Edges and EdgeKey:
// container.(x -> y -> z).label
// Key, Edges, EdgeIndex EdgeKey:
// container.(x -> y -> z)[4].label
Key *KeyPath `json:"key,omitempty"`
Edges []*Edge `json:"edges,omitempty"`
EdgeIndex *EdgeIndex `json:"edge_index,omitempty"`
EdgeKey *KeyPath `json:"edge_key,omitempty"`
Primary ScalarBox `json:"primary,omitempty"`
Value ValueBox `json:"value"`
}
// TODO there's more stuff to compare
func (mk1 *Key) Equals(mk2 *Key) bool {
if (mk1.Key == nil) != (mk2.Key == nil) {
return false
}
if (mk1.EdgeIndex == nil) != (mk2.EdgeIndex == nil) {
return false
}
if (mk1.EdgeKey == nil) != (mk2.EdgeKey == nil) {
return false
}
if len(mk1.Edges) != len(mk2.Edges) {
return false
}
if (mk1.Value.Map == nil) != (mk2.Value.Map == nil) {
return false
}
if (mk1.Value.Unbox() == nil) != (mk2.Value.Unbox() == nil) {
return false
}
if mk1.Key != nil {
if len(mk1.Key.Path) != len(mk2.Key.Path) {
return false
}
for i, id := range mk1.Key.Path {
if id.Unbox().ScalarString() != mk2.Key.Path[i].Unbox().ScalarString() {
return false
}
}
}
if mk1.Value.Map != nil {
if len(mk1.Value.Map.Nodes) != len(mk2.Value.Map.Nodes) {
return false
}
for i := range mk1.Value.Map.Nodes {
if !mk1.Value.Map.Nodes[i].MapKey.Equals(mk2.Value.Map.Nodes[i].MapKey) {
return false
}
}
}
if mk1.Value.Unbox() != nil {
if mk1.Value.ScalarBox().Unbox().ScalarString() != mk2.Value.ScalarBox().Unbox().ScalarString() {
return false
}
}
return true
}
func (mk *Key) SetScalar(scalar ScalarBox) {
if mk.Value.Unbox() != nil && mk.Value.ScalarBox().Unbox() == nil {
mk.Primary = scalar
} else {
mk.Value = MakeValueBox(scalar.Unbox())
}
}
type KeyPath struct {
Range Range `json:"range"`
Path []*StringBox `json:"path"`
}
type Edge struct {
Range Range `json:"range"`
Src *KeyPath `json:"src"`
// empty, < or *
SrcArrow string `json:"src_arrow"`
Dst *KeyPath `json:"dst"`
// empty, > or *
DstArrow string `json:"dst_arrow"`
}
type EdgeIndex struct {
Range Range `json:"range"`
// [n] or [*]
Int *int `json:"int"`
Glob bool `json:"glob"`
}
type Substitution struct {
Range Range `json:"range"`
Spread bool `json:"spread"`
Path []*StringBox `json:"path"`
}
// MapNodeBox is used to box MapNode for JSON persistence.
type MapNodeBox struct {
Comment *Comment `json:"comment,omitempty"`
BlockComment *BlockComment `json:"block_comment,omitempty"`
Substitution *Substitution `json:"substitution,omitempty"`
MapKey *Key `json:"map_key,omitempty"`
}
func MakeMapNodeBox(n MapNode) MapNodeBox {
var box MapNodeBox
switch n := n.(type) {
case *Comment:
box.Comment = n
case *BlockComment:
box.BlockComment = n
case *Substitution:
box.Substitution = n
case *Key:
box.MapKey = n
}
return box
}
func (mb MapNodeBox) Unbox() MapNode {
switch {
case mb.Comment != nil:
return mb.Comment
case mb.BlockComment != nil:
return mb.BlockComment
case mb.Substitution != nil:
return mb.Substitution
case mb.MapKey != nil:
return mb.MapKey
default:
return nil
}
}
// ArrayNodeBox is used to box ArrayNode for JSON persistence.
type ArrayNodeBox struct {
Comment *Comment `json:"comment,omitempty"`
BlockComment *BlockComment `json:"block_comment,omitempty"`
Substitution *Substitution `json:"substitution,omitempty"`
Null *Null `json:"null,omitempty"`
Boolean *Boolean `json:"boolean,omitempty"`
Number *Number `json:"number,omitempty"`
UnquotedString *UnquotedString `json:"unquoted_string,omitempty"`
DoubleQuotedString *DoubleQuotedString `json:"double_quoted_string,omitempty"`
SingleQuotedString *SingleQuotedString `json:"single_quoted_string,omitempty"`
BlockString *BlockString `json:"block_string,omitempty"`
Array *Array `json:"array,omitempty"`
Map *Map `json:"map,omitempty"`
}
func (ab ArrayNodeBox) Unbox() ArrayNode {
switch {
case ab.Comment != nil:
return ab.Comment
case ab.BlockComment != nil:
return ab.BlockComment
case ab.Substitution != nil:
return ab.Substitution
case ab.Null != nil:
return ab.Null
case ab.Boolean != nil:
return ab.Boolean
case ab.Number != nil:
return ab.Number
case ab.UnquotedString != nil:
return ab.UnquotedString
case ab.DoubleQuotedString != nil:
return ab.DoubleQuotedString
case ab.SingleQuotedString != nil:
return ab.SingleQuotedString
case ab.BlockString != nil:
return ab.BlockString
case ab.Array != nil:
return ab.Array
case ab.Map != nil:
return ab.Map
default:
return nil
}
}
// ValueBox is used to box Value for JSON persistence.
type ValueBox struct {
Null *Null `json:"null,omitempty"`
Boolean *Boolean `json:"boolean,omitempty"`
Number *Number `json:"number,omitempty"`
UnquotedString *UnquotedString `json:"unquoted_string,omitempty"`
DoubleQuotedString *DoubleQuotedString `json:"double_quoted_string,omitempty"`
SingleQuotedString *SingleQuotedString `json:"single_quoted_string,omitempty"`
BlockString *BlockString `json:"block_string,omitempty"`
Array *Array `json:"array,omitempty"`
Map *Map `json:"map,omitempty"`
}
func (vb ValueBox) Unbox() Value {
switch {
case vb.Null != nil:
return vb.Null
case vb.Boolean != nil:
return vb.Boolean
case vb.Number != nil:
return vb.Number
case vb.UnquotedString != nil:
return vb.UnquotedString
case vb.DoubleQuotedString != nil:
return vb.DoubleQuotedString
case vb.SingleQuotedString != nil:
return vb.SingleQuotedString
case vb.BlockString != nil:
return vb.BlockString
case vb.Array != nil:
return vb.Array
case vb.Map != nil:
return vb.Map
default:
return nil
}
}
func MakeValueBox(v Value) ValueBox {
var vb ValueBox
switch v := v.(type) {
case *Null:
vb.Null = v
case *Boolean:
vb.Boolean = v
case *Number:
vb.Number = v
case *UnquotedString:
vb.UnquotedString = v
case *DoubleQuotedString:
vb.DoubleQuotedString = v
case *SingleQuotedString:
vb.SingleQuotedString = v
case *BlockString:
vb.BlockString = v
case *Array:
vb.Array = v
case *Map:
vb.Map = v
}
return vb
}
func (vb ValueBox) ScalarBox() ScalarBox {
var sb ScalarBox
sb.Null = vb.Null
sb.Boolean = vb.Boolean
sb.Number = vb.Number
sb.UnquotedString = vb.UnquotedString
sb.DoubleQuotedString = vb.DoubleQuotedString
sb.SingleQuotedString = vb.SingleQuotedString
sb.BlockString = vb.BlockString
return sb
}
func (vb ValueBox) StringBox() *StringBox {
var sb StringBox
sb.UnquotedString = vb.UnquotedString
sb.DoubleQuotedString = vb.DoubleQuotedString
sb.SingleQuotedString = vb.SingleQuotedString
sb.BlockString = vb.BlockString
return &sb
}
// ScalarBox is used to box Scalar for JSON persistence.
// TODO: implement ScalarString()
type ScalarBox struct {
Null *Null `json:"null,omitempty"`
Boolean *Boolean `json:"boolean,omitempty"`
Number *Number `json:"number,omitempty"`
UnquotedString *UnquotedString `json:"unquoted_string,omitempty"`
DoubleQuotedString *DoubleQuotedString `json:"double_quoted_string,omitempty"`
SingleQuotedString *SingleQuotedString `json:"single_quoted_string,omitempty"`
BlockString *BlockString `json:"block_string,omitempty"`
}
func (sb ScalarBox) Unbox() Scalar {
switch {
case sb.Null != nil:
return sb.Null
case sb.Boolean != nil:
return sb.Boolean
case sb.Number != nil:
return sb.Number
case sb.UnquotedString != nil:
return sb.UnquotedString
case sb.DoubleQuotedString != nil:
return sb.DoubleQuotedString
case sb.SingleQuotedString != nil:
return sb.SingleQuotedString
case sb.BlockString != nil:
return sb.BlockString
default:
return nil
}
}
// StringBox is used to box String for JSON persistence.
type StringBox struct {
UnquotedString *UnquotedString `json:"unquoted_string,omitempty"`
DoubleQuotedString *DoubleQuotedString `json:"double_quoted_string,omitempty"`
SingleQuotedString *SingleQuotedString `json:"single_quoted_string,omitempty"`
BlockString *BlockString `json:"block_string,omitempty"`
}
func (sb *StringBox) Unbox() String {
switch {
case sb.UnquotedString != nil:
return sb.UnquotedString
case sb.DoubleQuotedString != nil:
return sb.DoubleQuotedString
case sb.SingleQuotedString != nil:
return sb.SingleQuotedString
case sb.BlockString != nil:
return sb.BlockString
default:
return nil
}
}
// InterpolationBox is used to select between strings and substitutions in unquoted and
// double quoted strings. There is no corresponding interface to avoid unnecessary
// abstraction.
type InterpolationBox struct {
String *string `json:"string,omitempty"`
StringRaw *string `json:"raw_string,omitempty"`
Substitution *Substitution `json:"substitution,omitempty"`
}
// & is only special if it begins a key.
// - is only special if followed by another - in a key.
// ' " and | are only special if they begin an unquoted key or value.
var UnquotedKeySpecials = string([]rune{'#', ';', '\n', '\\', '{', '}', '[', ']', '\'', '"', '|', ':', '.', '-', '<', '>', '*', '&', '(', ')'})
var UnquotedValueSpecials = string([]rune{'#', ';', '\n', '\\', '{', '}', '[', ']', '\'', '"', '|', '$'})
// RawString returns s in a AST String node that can format s in the most aesthetically
// pleasing way.
// TODO: Return StringBox
func RawString(s string, inKey bool) String {
if s == "" {
return FlatDoubleQuotedString(s)
}
if inKey {
for i, r := range s {
switch r {
case '-':
if i+1 < len(s) && s[i+1] != '-' {
continue
}
case '&':
if i > 0 {
continue
}
}
if strings.ContainsRune(UnquotedKeySpecials, r) {
if !strings.ContainsRune(s, '"') {
return FlatDoubleQuotedString(s)
}
if strings.ContainsRune(s, '\n') {
return FlatDoubleQuotedString(s)
}
return &SingleQuotedString{Value: s}
}
}
} else if s == "null" || strings.ContainsAny(s, UnquotedValueSpecials) {
if !strings.ContainsRune(s, '"') && !strings.ContainsRune(s, '$') {
return FlatDoubleQuotedString(s)
}
if strings.ContainsRune(s, '\n') {
return FlatDoubleQuotedString(s)
}
return &SingleQuotedString{Value: s}
}
if hasSurroundingWhitespace(s) {
return FlatDoubleQuotedString(s)
}
return FlatUnquotedString(s)
}
func hasSurroundingWhitespace(s string) bool {
r, _ := utf8.DecodeRuneInString(s)
r2, _ := utf8.DecodeLastRuneInString(s)
return unicode.IsSpace(r) || unicode.IsSpace(r2)
}

813
d2ast/d2ast_test.go Normal file
View file

@ -0,0 +1,813 @@
package d2ast_test
import (
"encoding/json"
"math/big"
math_rand "math/rand"
"reflect"
"strconv"
"strings"
"testing"
"oss.terrastruct.com/xrand"
"oss.terrastruct.com/diff"
"oss.terrastruct.com/d2/d2ast"
"oss.terrastruct.com/d2/d2format"
"oss.terrastruct.com/d2/d2parser"
"oss.terrastruct.com/d2/lib/go2"
)
func TestRange(t *testing.T) {
t.Parallel()
t.Run("String", func(t *testing.T) {
t.Parallel()
testCases := []struct {
name string
r d2ast.Range
exp string
}{
{
name: "one_byte",
r: d2ast.Range{
Path: "/src/example.go",
Start: d2ast.Position{
Line: 10,
Column: 5,
Byte: 100,
},
End: d2ast.Position{
Line: 10,
Column: 6,
Byte: 100,
},
},
exp: "/src/example.go:11:6",
},
{
name: "more_than_one_byte",
r: d2ast.Range{
Path: "/src/example.go",
Start: d2ast.Position{
Line: 10,
Column: 5,
Byte: 100,
},
End: d2ast.Position{
Line: 10,
Column: 7,
Byte: 101,
},
},
exp: "/src/example.go:11:6",
},
{
name: "empty_path",
r: d2ast.Range{
Start: d2ast.Position{
Line: 10,
Column: 5,
Byte: 100,
},
End: d2ast.Position{
Line: 10,
Column: 7,
Byte: 101,
},
},
exp: "11:6",
},
{
name: "start_equal_end",
r: d2ast.Range{
Start: d2ast.Position{
Line: 10,
Column: 5,
Byte: 100,
},
End: d2ast.Position{
Line: 10,
Column: 5,
Byte: 100,
},
},
exp: "11:6",
},
}
for _, tc := range testCases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
if tc.exp != tc.r.String() {
t.Fatalf("expected %q but got %q", tc.exp, tc.r.String())
}
})
}
})
t.Run("UnmarshalText", func(t *testing.T) {
t.Parallel()
testCases := []struct {
name string
in string
exp d2ast.Range
errmsg string
}{
{
name: "success",
in: `"json_test.d2,1:1:0-5:1:50"`,
exp: d2ast.Range{Path: "json_test.d2", Start: d2ast.Position{Line: 1, Column: 1, Byte: 0}, End: d2ast.Position{Line: 5, Column: 1, Byte: 50}},
},
{
name: "err1",
in: `"json_test.d2-5:1:50"`,
errmsg: "missing Start field",
},
{
name: "err2",
in: `"json_test.d2"`,
errmsg: "missing End field",
},
{
name: "err3",
in: `"json_test.d2,1:1:0-5:150"`,
errmsg: "expected three fields",
},
{
name: "err4",
in: `"json_test.d2,1:10-5:1:50"`,
errmsg: "expected three fields",
},
{
name: "err5",
in: `"json_test.d2,a:1:0-5:1:50"`,
errmsg: `strconv.Atoi: parsing "a": invalid syntax`,
},
{
name: "err6",
in: `"json_test.d2,1:c:0-5:1:50"`,
errmsg: `strconv.Atoi: parsing "c": invalid syntax`,
},
}
for _, tc := range testCases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
var r d2ast.Range
err := json.Unmarshal([]byte(tc.in), &r)
if tc.errmsg != "" {
if err == nil {
t.Fatalf("expected error: %#v", err)
}
if !strings.Contains(err.Error(), tc.errmsg) {
t.Fatalf("error message does not contain %q: %q", tc.errmsg, err.Error())
}
} else {
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(tc.exp, r) {
t.Fatalf("expected %#v but got %#v", tc.exp, r)
}
}
})
}
})
t.Run("Advance", func(t *testing.T) {
t.Parallel()
t.Run("UTF-8", func(t *testing.T) {
t.Parallel()
var p d2ast.Position
p = p.Advance('a', false)
diff.AssertJSONEq(t, `"0:1:1"`, p)
p = p.Advance('\n', false)
diff.AssertJSONEq(t, `"1:0:2"`, p)
p = p.Advance('è', false)
diff.AssertJSONEq(t, `"1:2:4"`, p)
p = p.Advance('𐀀', false)
diff.AssertJSONEq(t, `"1:6:8"`, p)
p = p.Subtract('𐀀', false)
diff.AssertJSONEq(t, `"1:2:4"`, p)
p = p.Subtract('è', false)
diff.AssertJSONEq(t, `"1:0:2"`, p)
})
t.Run("UTF-16", func(t *testing.T) {
t.Parallel()
var p d2ast.Position
p = p.Advance('a', true)
diff.AssertJSONEq(t, `"0:1:1"`, p)
p = p.Advance('\n', true)
diff.AssertJSONEq(t, `"1:0:2"`, p)
p = p.Advance('è', true)
diff.AssertJSONEq(t, `"1:1:3"`, p)
p = p.Advance('𐀀', true)
diff.AssertJSONEq(t, `"1:3:5"`, p)
p = p.Subtract('𐀀', true)
diff.AssertJSONEq(t, `"1:1:3"`, p)
p = p.Subtract('è', true)
diff.AssertJSONEq(t, `"1:0:2"`, p)
})
})
}
func TestJSON(t *testing.T) {
t.Parallel()
m := &d2ast.Map{
Range: d2ast.Range{Path: "json_test.d2", Start: d2ast.Position{Line: 0, Column: 0, Byte: 0}, End: d2ast.Position{Line: 5, Column: 1, Byte: 50}},
Nodes: []d2ast.MapNodeBox{
{
Comment: &d2ast.Comment{
Value: `America was discovered by Amerigo Vespucci and was named after him, until
people got tired of living in a place called "Vespuccia" and changed its
name to "America".
-- Mike Harding, "The Armchair Anarchist's Almanac"`,
},
},
{
BlockComment: &d2ast.BlockComment{
Value: `America was discovered by Amerigo Vespucci and was named after him, until
people got tired of living in a place called "Vespuccia" and changed its
name to "America".
-- Mike Harding, "The Armchair Anarchist's Almanac"`,
},
},
{
Substitution: &d2ast.Substitution{
Spread: true,
Path: []*d2ast.StringBox{
{
BlockString: &d2ast.BlockString{
Quote: "|",
Tag: "text",
Value: `America was discovered by Amerigo Vespucci and was named after him, until
people got tired of living in a place called "Vespuccia" and changed its
name to "America".
-- Mike Harding, "The Armchair Anarchist's Almanac"`,
},
},
},
},
},
{
MapKey: &d2ast.Key{
Ampersand: true,
Key: &d2ast.KeyPath{
Path: []*d2ast.StringBox{
{
SingleQuotedString: &d2ast.SingleQuotedString{
Value: "before edges",
},
},
},
},
Edges: []*d2ast.Edge{
{
Src: &d2ast.KeyPath{
Path: []*d2ast.StringBox{
{
SingleQuotedString: &d2ast.SingleQuotedString{
Value: "src",
},
},
},
},
SrcArrow: "*",
Dst: &d2ast.KeyPath{
Path: []*d2ast.StringBox{
{
SingleQuotedString: &d2ast.SingleQuotedString{
Value: "dst",
},
},
},
},
DstArrow: ">",
},
{
Src: &d2ast.KeyPath{
Path: []*d2ast.StringBox{
{
SingleQuotedString: &d2ast.SingleQuotedString{
Value: "dst",
},
},
},
},
Dst: &d2ast.KeyPath{
Path: []*d2ast.StringBox{
{
SingleQuotedString: &d2ast.SingleQuotedString{
Value: "dst2",
},
},
},
},
},
},
EdgeIndex: &d2ast.EdgeIndex{
Glob: true,
},
EdgeKey: &d2ast.KeyPath{
Path: []*d2ast.StringBox{
{
SingleQuotedString: &d2ast.SingleQuotedString{
Value: "after edges",
},
},
},
},
Primary: d2ast.ScalarBox{
Null: &d2ast.Null{},
},
Value: d2ast.ValueBox{
Array: &d2ast.Array{
Nodes: []d2ast.ArrayNodeBox{
{
Boolean: &d2ast.Boolean{
Value: true,
},
},
{
Number: &d2ast.Number{
Raw: "0xFF",
Value: big.NewRat(15, 1),
},
},
{
UnquotedString: &d2ast.UnquotedString{
Value: []d2ast.InterpolationBox{
{
String: go2.Pointer("no quotes needed"),
},
},
},
},
{
UnquotedString: &d2ast.UnquotedString{
Value: []d2ast.InterpolationBox{
{
Substitution: &d2ast.Substitution{},
},
},
},
},
{
DoubleQuotedString: &d2ast.DoubleQuotedString{
Value: []d2ast.InterpolationBox{
{
String: go2.Pointer("no quotes needed"),
},
},
},
},
{
SingleQuotedString: &d2ast.SingleQuotedString{
Value: "rawr",
},
},
{
BlockString: &d2ast.BlockString{
Quote: "|",
Tag: "text",
Value: `America was discovered by Amerigo Vespucci and was named after him, until
people got tired of living in a place called "Vespuccia" and changed its
name to "America".
-- Mike Harding, "The Armchair Anarchist's Almanac"`,
},
},
},
},
},
},
},
},
}
diff.AssertJSONEq(t, `{
"range": "json_test.d2,0:0:0-5:1:50",
"nodes": [
{
"comment": {
"range": ",0:0:0-0:0:0",
"value": "America was discovered by Amerigo Vespucci and was named after him, until\npeople got tired of living in a place called \"Vespuccia\" and changed its\nname to \"America\".\n\t\t-- Mike Harding, \"The Armchair Anarchist's Almanac\""
}
},
{
"block_comment": {
"range": ",0:0:0-0:0:0",
"value": "America was discovered by Amerigo Vespucci and was named after him, until\npeople got tired of living in a place called \"Vespuccia\" and changed its\nname to \"America\".\n\t\t-- Mike Harding, \"The Armchair Anarchist's Almanac\""
}
},
{
"substitution": {
"range": ",0:0:0-0:0:0",
"spread": true,
"path": [
{
"block_string": {
"range": ",0:0:0-0:0:0",
"quote": "|",
"tag": "text",
"value": "America was discovered by Amerigo Vespucci and was named after him, until\n\tpeople got tired of living in a place called \"Vespuccia\" and changed its\n\tname to \"America\".\n\t-- Mike Harding, \"The Armchair Anarchist's Almanac\""
}
}
]
}
},
{
"map_key": {
"range": ",0:0:0-0:0:0",
"ampersand": true,
"key": {
"range": ",0:0:0-0:0:0",
"path": [
{
"single_quoted_string": {
"range": ",0:0:0-0:0:0",
"raw": "",
"value": "before edges"
}
}
]
},
"edges": [
{
"range": ",0:0:0-0:0:0",
"src": {
"range": ",0:0:0-0:0:0",
"path": [
{
"single_quoted_string": {
"range": ",0:0:0-0:0:0",
"raw": "",
"value": "src"
}
}
]
},
"src_arrow": "*",
"dst": {
"range": ",0:0:0-0:0:0",
"path": [
{
"single_quoted_string": {
"range": ",0:0:0-0:0:0",
"raw": "",
"value": "dst"
}
}
]
},
"dst_arrow": ">"
},
{
"range": ",0:0:0-0:0:0",
"src": {
"range": ",0:0:0-0:0:0",
"path": [
{
"single_quoted_string": {
"range": ",0:0:0-0:0:0",
"raw": "",
"value": "dst"
}
}
]
},
"src_arrow": "",
"dst": {
"range": ",0:0:0-0:0:0",
"path": [
{
"single_quoted_string": {
"range": ",0:0:0-0:0:0",
"raw": "",
"value": "dst2"
}
}
]
},
"dst_arrow": ""
}
],
"edge_index": {
"range": ",0:0:0-0:0:0",
"int": null,
"glob": true
},
"edge_key": {
"range": ",0:0:0-0:0:0",
"path": [
{
"single_quoted_string": {
"range": ",0:0:0-0:0:0",
"raw": "",
"value": "after edges"
}
}
]
},
"primary": {
"null": {
"range": ",0:0:0-0:0:0"
}
},
"value": {
"array": {
"range": ",0:0:0-0:0:0",
"nodes": [
{
"boolean": {
"range": ",0:0:0-0:0:0",
"value": true
}
},
{
"number": {
"range": ",0:0:0-0:0:0",
"raw": "0xFF",
"value": "15"
}
},
{
"unquoted_string": {
"range": ",0:0:0-0:0:0",
"value": [
{
"string": "no quotes needed"
}
]
}
},
{
"unquoted_string": {
"range": ",0:0:0-0:0:0",
"value": [
{
"substitution": {
"range": ",0:0:0-0:0:0",
"spread": false,
"path": null
}
}
]
}
},
{
"double_quoted_string": {
"range": ",0:0:0-0:0:0",
"value": [
{
"string": "no quotes needed"
}
]
}
},
{
"single_quoted_string": {
"range": ",0:0:0-0:0:0",
"raw": "",
"value": "rawr"
}
},
{
"block_string": {
"range": ",0:0:0-0:0:0",
"quote": "|",
"tag": "text",
"value": "America was discovered by Amerigo Vespucci and was named after him, until\n\t\t\tpeople got tired of living in a place called \"Vespuccia\" and changed its\n\t\t\tname to \"America\".\n\t\t\t-- Mike Harding, \"The Armchair Anarchist's Almanac\""
}
}
]
}
}
}
}
]
}`, m)
}
func testRawStringKey(t *testing.T, key string) {
ast := d2ast.RawString(key, true)
enc := d2format.Format(ast)
k, err := d2parser.ParseKey(enc)
if err != nil {
t.Fatal(err)
}
if len(k.Path) != 1 {
t.Fatalf("unexpected key length: %#v", k.Path)
}
err = diff.Runes(key, k.Path[0].Unbox().ScalarString())
if err != nil {
t.Fatal(err)
}
}
func testRawStringValue(t *testing.T, value string) {
ast := d2ast.RawString(value, false)
enc := d2format.Format(ast)
v, err := d2parser.ParseValue(enc)
if err != nil {
t.Fatal(err)
}
ps, ok := v.(d2ast.Scalar)
if !ok {
t.Fatalf("unexpected value type: %#v", v)
}
err = diff.Runes(value, ps.ScalarString())
if err != nil {
t.Fatal(err)
}
}
func TestRawString(t *testing.T) {
t.Parallel()
t.Run("chaos", func(t *testing.T) {
t.Parallel()
t.Run("pinned", func(t *testing.T) {
t.Parallel()
pinnedTestCases := []struct {
name string
str string
}{
{
name: "1",
str: "\U000b64cd\U0008b732\U0009632c\U000983f8\U000f42d4\U000c4749\U00041723\uf584蝉\U00100cd5\U0003325d\U0003e4d2\U0007ff0e\U000e03d8\U000b0431\U00042053\U0001b3ea𠒹\U0006d9cf\U000c5b1c\U00019a3c\U000f3c3d\U0004acedଶ\U0009da18\U0001a0bb\U000b6bfd\U00015ebd\U00088c5a녈\U00078277\U000eaa58\U0009266b\U000d85ae\U000d6ce8譊𣱡\U0008ac84\U000a722f\U000d3d35\U00072581\U000c3423\U000a1753\U00082014\U0001bde6\U0010bf47炏\U000423fa\U0007df70\U00088aaf\U00074e5e\U000ee80b\U000e3d53\U0003f542\U0001ad9f\U00031408\U000cce7e\U00082172\u202f",
},
{
name: "2",
str: "'\"Tc\U000d148d\U000dd61a\U0007cf68OO\U000b87a9\U000c073a\U000e7828n\U00068a9fc\U0004fbf5\x041\\'''",
},
{
name: "3",
str: "\r\U00057d53\x01'\U00042e5a\U0007be73T\U000fb916\x01\U000e0e4afL]\U000474d1\x15\U00083bc0\fbT\ue09bs{vP\U000b3d33\x0f\U0007ad13\x10\U00098b38\x1d\U000cf9da\n ",
},
}
for _, tc := range pinnedTestCases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
t.Run("key", func(t *testing.T) {
t.Parallel()
testRawStringKey(t, tc.str)
})
t.Run("value", func(t *testing.T) {
t.Parallel()
testRawStringValue(t, tc.str)
})
})
}
})
for i := 0; i < 1000; i++ {
i := i
t.Run(strconv.Itoa(i), func(t *testing.T) {
t.Parallel()
s := xrand.String(math_rand.Intn(99), nil)
t.Logf("testing: %q", s)
t.Run("key", func(t *testing.T) {
t.Parallel()
testRawStringKey(t, s)
})
t.Run("value", func(t *testing.T) {
t.Parallel()
testRawStringValue(t, s)
})
})
}
})
testCases := []struct {
name string
str string
exp string
inKey bool
}{
{
name: "empty",
str: ``,
exp: `""`,
},
{
name: "null",
str: `null`,
exp: `"null"`,
},
{
name: "simple",
str: `wearisome_condition_of_humanity`,
exp: `wearisome_condition_of_humanity`,
},
{
name: "specials_double",
str: `'#;#;#'`,
exp: `"'#;#;#'"`,
},
{
name: "specials_single_quote",
str: `"cambridge"`,
exp: `'"cambridge"'`,
},
{
name: "specials_single_dollar",
str: `$bingo`,
exp: `'$bingo'`,
},
{
name: "not_key_specials",
str: `------`,
exp: `------`,
},
{
name: "key_specials_double",
str: `-----`,
exp: `"-----"`,
inKey: true,
},
{
name: "key_specials_single",
str: `"cambridge"`,
exp: `'"cambridge"'`,
inKey: true,
},
{
name: "key_specials_unquoted",
str: `square-2`,
exp: `square-2`,
inKey: true,
},
{
name: "multiline",
str: `||||yes
yes
yes
yes
||||`,
exp: `"||||yes\nyes\nyes\nyes\n||||"`,
inKey: true,
},
{
name: "leading_whitespace",
str: ` yoho_park `,
exp: `" yoho_park "`,
},
{
name: "leading_whitespace_newlines",
str: ` yoho
_park `,
exp: `" yoho\n_park "`,
},
{
name: "leading_space_double_quotes_and_newlines",
str: ` "yoho"
_park `,
exp: `" \"yoho\"\n_park "`,
},
}
for _, tc := range testCases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
ast := d2ast.RawString(tc.str, tc.inKey)
diff.AssertStringEq(t, tc.exp, d2format.Format(ast))
})
}
}

15
d2ast/error.go Normal file
View file

@ -0,0 +1,15 @@
package d2ast
// TODO: Right now this is here to be available in both the Parser and Compiler but
// eventually we should make this a real part of the AST so that autofmt works on
// files with parse errors and semantically it makes more sense.
// Compile would continue to maintain a separate set of errors and then we'd do a
// merge & sort to get the final list of errors for user display.
type Error struct {
Range Range `json:"range"`
Message string `json:"errmsg"`
}
func (e Error) Error() string {
return e.Message
}

259
d2chaos/d2chaos.go Normal file
View file

@ -0,0 +1,259 @@
package d2chaos
import (
"fmt"
mathrand "math/rand"
"strings"
"time"
"oss.terrastruct.com/d2/d2ast"
"oss.terrastruct.com/d2/d2format"
"oss.terrastruct.com/d2/d2graph"
"oss.terrastruct.com/d2/d2oracle"
"oss.terrastruct.com/d2/d2target"
"oss.terrastruct.com/d2/lib/go2"
)
func GenDSL(maxi int) (_ string, err error) {
gs := &dslGenState{
rand: mathrand.New(mathrand.NewSource(time.Now().UnixNano())),
g: d2graph.NewGraph(&d2ast.Map{}),
nodeShapes: make(map[string]string),
}
err = gs.gen(maxi)
if err != nil {
return "", err
}
return d2format.Format(gs.g.AST), nil
}
type dslGenState struct {
rand *mathrand.Rand
g *d2graph.Graph
nodesArr []string
nodeShapes map[string]string
}
func (gs *dslGenState) gen(maxi int) error {
maxi = gs.rand.Intn(maxi) + 1
for i := 0; i < maxi; i++ {
switch gs.roll(25, 75) {
case 0:
// 25% chance of creating a new node.
err := gs.node()
if err != nil {
return err
}
case 1:
// 75% chance of connecting two random nodes with a random label.
err := gs.edge()
if err != nil {
return err
}
}
}
return nil
}
func (gs *dslGenState) genNode(containerID string) (string, error) {
nodeID := gs.randStr(32, true)
if containerID != "" {
nodeID = containerID + "." + nodeID
}
var err error
gs.g, nodeID, err = d2oracle.Create(gs.g, nodeID)
if err != nil {
return "", err
}
gs.nodesArr = append(gs.nodesArr, nodeID)
gs.nodeShapes[nodeID] = "square"
return nodeID, nil
}
func (gs *dslGenState) node() error {
containerID := ""
var err error
if gs.roll(25, 75) == 1 {
// 75% chance of creating this as a child under a container.
containerID, err = gs.randContainer()
if err != nil {
return err
}
}
nodeID, err := gs.genNode(containerID)
if err != nil {
return err
}
if gs.roll(25, 75) == 0 {
// 25% chance of adding a label.
gs.g, err = d2oracle.Set(gs.g, nodeID, nil, go2.Pointer(gs.randStr(256, false)))
if err != nil {
return err
}
}
if gs.roll(25, 75) == 1 {
// 75% chance of adding a shape.
randShape := gs.randShape()
gs.g, err = d2oracle.Set(gs.g, nodeID+".shape", nil, go2.Pointer(randShape))
if err != nil {
return err
}
gs.nodeShapes[nodeID] = randShape
}
return nil
}
func (gs *dslGenState) edge() error {
var src string
var dst string
var err error
for {
src, err = gs.randNode()
if err != nil {
return err
}
dst, err = gs.randNode()
if err != nil {
return err
}
if src != dst {
break
}
err = gs.node()
if err != nil {
return err
}
}
srcArrow := "-"
if gs.randBool() {
srcArrow = "<"
}
dstArrow := "-"
if gs.randBool() {
dstArrow = ">"
if srcArrow == "<" {
dstArrow = "->"
}
}
key := fmt.Sprintf("%s %s%s %s", src, srcArrow, dstArrow, dst)
gs.g, key, err = d2oracle.Create(gs.g, key)
if err != nil {
return err
}
if gs.randBool() {
gs.g, err = d2oracle.Set(gs.g, key, nil, go2.Pointer(gs.randStr(128, false)))
if err != nil {
return err
}
}
return nil
}
func (gs *dslGenState) randContainer() (string, error) {
containers := go2.Filter(gs.nodesArr, func(x string) bool {
shape := gs.nodeShapes[x]
return shape != "image" &&
shape != "code" &&
shape != "sql_table" &&
shape != "text" &&
shape != "class"
})
if len(containers) == 0 {
return "", nil
}
return containers[gs.rand.Intn(len(containers))], nil
}
func (gs *dslGenState) randNode() (string, error) {
if len(gs.nodesArr) == 0 {
return gs.genNode("")
}
return gs.nodesArr[gs.rand.Intn(len(gs.nodesArr))], nil
}
func (gs *dslGenState) randBool() bool {
return gs.rand.Intn(2) == 0
}
// TODO go back to using xrand.String, currently some incompatibility with
// stuffing these strings into a script for dagre
func randRune() rune {
if mathrand.Int31n(100) == 0 {
// Generate newline 1% of the time.
return '\n'
}
return mathrand.Int31n(128) + 1
}
func String(n int, exclude []rune) string {
var b strings.Builder
for i := 0; i < n; i++ {
r := randRune()
excluded := false
for _, xr := range exclude {
if r == xr {
excluded = true
break
}
}
if excluded {
i--
continue
}
b.WriteRune(r)
}
return b.String()
}
func (gs *dslGenState) randStr(n int, inKey bool) string {
// Underscores have semantic meaning (parent)
// Backticks are for opening and closing these strings
// Curly braces can trigger templating
// \\ triggers octal sequences
s := String(gs.rand.Intn(n), []rune{
rune('_'),
rune('`'),
rune('}'),
rune('{'),
rune('\\'),
})
as := d2ast.RawString(s, inKey)
return d2format.Format(as)
}
func (gs *dslGenState) randShape() string {
for {
s := shapes[gs.rand.Intn(len(shapes))]
if s != "image" {
return s
}
}
}
func (gs *dslGenState) roll(probs ...int) int {
max := 0
for _, p := range probs {
max += p
}
n := gs.rand.Intn(max)
var acc int
for i, p := range probs {
if n >= acc && n < acc+p {
return i
}
acc += p
}
panic("d2chaos: unreachable")
}
var shapes = d2target.Shapes

183
d2chaos/d2chaos_test.go Normal file
View file

@ -0,0 +1,183 @@
package d2chaos_test
import (
"context"
"fmt"
"io/ioutil"
"os"
"path/filepath"
"runtime/debug"
"strconv"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"oss.terrastruct.com/d2/d2chaos"
"oss.terrastruct.com/d2/d2compiler"
"oss.terrastruct.com/d2/d2exporter"
"oss.terrastruct.com/d2/d2layouts/d2dagrelayout"
"oss.terrastruct.com/d2/d2renderers/textmeasure"
"oss.terrastruct.com/d2/lib/log"
)
// usage: D2_CHAOS_MAXI=100 D2_CHAOS_N=100 ./ci/test.sh ./d2chaos
//
// D2_CHAOS_MAXI controls the number of iterations that the dsl generator
// should go through to generate each input D2. It's roughly equivalent to
// the complexity level of each input D2.
//
// D2_CHAOS_N controls the number of D2 texts to generate and run the full
// D2 flow on.
//
// All generated texts are stored in ./out/<n>.d2 and also ./out/<n>.d2.goenc
// The goenc version is the text encoded as a Go string. It lets you replay
// a test by adding it to testPinned below as you can just copy paste the go
// string in.
//
// If D2Chaos fails on CI and you need to investigate the input text that caused the
// failure, all generated texts will be available in the d2chaos-test and d2chaos-race
// github actions artifacts.
func TestD2Chaos(t *testing.T) {
t.Parallel()
const outDir = "out"
err := os.MkdirAll(outDir, 0755)
if err != nil {
t.Fatal(err)
}
t.Logf("writing generated files to %s", outDir)
t.Run("pinned", func(t *testing.T) {
testPinned(t, outDir)
})
n := 1
if os.Getenv("D2_CHAOS_N") != "" {
envn, err := strconv.Atoi(os.Getenv("D2_CHAOS_N"))
if err != nil {
t.Errorf("failed to atoi $D2_CHAOS_N: %v", err)
} else {
n = envn
}
}
maxi := 10
if os.Getenv("D2_CHAOS_MAXI") != "" {
envMaxi, err := strconv.Atoi(os.Getenv("D2_CHAOS_MAXI"))
if err != nil {
t.Errorf("failed to atoi $D2_CHAOS_MAXI: %v", err)
} else {
maxi = envMaxi
}
}
for i := 0; i < n; i++ {
i := i
t.Run("", func(t *testing.T) {
t.Parallel()
text, err := d2chaos.GenDSL(maxi)
if err != nil {
t.Fatal(err)
}
textPath := filepath.Join(outDir, fmt.Sprintf("%d.d2", i))
test(t, textPath, text)
})
}
}
func test(t *testing.T, textPath, text string) {
t.Logf("writing d2 to %v (%d bytes)", textPath, len(text))
err := ioutil.WriteFile(textPath, []byte(text), 0644)
if err != nil {
t.Fatal(err)
}
goencText := fmt.Sprintf("%#v", text)
t.Logf("writing d2.goenc to %v (%d bytes)", textPath+".goenc", len(goencText))
err = ioutil.WriteFile(textPath+".goenc", []byte(goencText), 0644)
if err != nil {
t.Fatal(err)
}
g, err := d2compiler.Compile("", strings.NewReader(text), nil)
if err != nil {
t.Fatal(err)
}
t.Run("layout", func(t *testing.T) {
defer func() {
r := recover()
if r != nil {
t.Errorf("recovered layout engine panic: %#v\n%s", r, debug.Stack())
}
}()
ctx := log.WithTB(context.Background(), t, nil)
ruler, err := textmeasure.NewRuler()
assert.Nil(t, err)
err = g.SetDimensions(nil, ruler)
assert.Nil(t, err)
err = d2dagrelayout.Layout(ctx, g)
if err != nil {
t.Fatal(err)
}
_, err = d2exporter.Export(ctx, g, 0)
if err != nil {
t.Fatal(err)
}
})
}
func testPinned(t *testing.T, outDir string) {
t.Parallel()
outDir = filepath.Join(outDir, t.Name())
err := os.MkdirAll(outDir, 0755)
if err != nil {
t.Fatal(err)
}
t.Logf("writing generated files to %v", outDir)
testCases := []struct {
name string
text string
}{
{
name: "internal class edge",
text: "a: {\n shape: class\n b -> c\n }",
},
{
name: "table edge order",
text: "B: {shape: sql_table}\n B.C -- D\n B.C -- D\n D -- B.C\n B -- D\n A -- D",
},
{
name: "child to container edge",
text: "a.b -> a",
},
{
name: "sequence",
text: "a: {shape: step}\nb: {shape: step}\na -> b\n",
},
{
name: "orientation",
text: "a: {\n b\n c\n }\n a <- a.c\n a.b -> a\n",
},
}
for _, tc := range testCases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
textPath := filepath.Join(outDir, fmt.Sprintf("%s.d2", tc.name))
test(t, textPath, tc.text)
})
}
}

850
d2compiler/compile.go Normal file
View file

@ -0,0 +1,850 @@
package d2compiler
import (
"errors"
"fmt"
"io"
"net/url"
"strconv"
"strings"
"oss.terrastruct.com/d2/d2ast"
"oss.terrastruct.com/d2/d2format"
"oss.terrastruct.com/d2/d2graph"
"oss.terrastruct.com/d2/d2parser"
"oss.terrastruct.com/d2/d2target"
)
// TODO: should Parse even be exported? guess not. IR should contain list of files and
// their AST.
type CompileOptions struct {
UTF16 bool
}
func Compile(path string, r io.RuneReader, opts *CompileOptions) (*d2graph.Graph, error) {
if opts == nil {
opts = &CompileOptions{}
}
var pe d2parser.ParseError
ast, err := d2parser.Parse(path, r, &d2parser.ParseOptions{
UTF16: opts.UTF16,
})
if err != nil {
if !errors.As(err, &pe) {
return nil, err
}
}
return compileAST(path, pe, ast)
}
func compileAST(path string, pe d2parser.ParseError, ast *d2ast.Map) (*d2graph.Graph, error) {
g := d2graph.NewGraph(ast)
c := &compiler{
path: path,
err: pe,
}
c.compileKeys(g.Root, ast)
if len(c.err.Errors) == 0 {
c.validateKeys(g.Root, ast)
}
c.compileEdges(g.Root, ast)
// TODO: simplify removeContainer by running before compileEdges
c.compileShapes(g.Root)
c.validateNear(g)
if len(c.err.Errors) > 0 {
return nil, c.err
}
return g, nil
}
type compiler struct {
path string
err d2parser.ParseError
}
func (c *compiler) errorf(start d2ast.Position, end d2ast.Position, f string, v ...interface{}) {
r := d2ast.Range{
Path: c.path,
Start: start,
End: end,
}
f = "%v: " + f
v = append([]interface{}{r}, v...)
c.err.Errors = append(c.err.Errors, d2ast.Error{
Range: r,
Message: fmt.Sprintf(f, v...),
})
}
func (c *compiler) compileKeys(obj *d2graph.Object, m *d2ast.Map) {
for _, n := range m.Nodes {
if n.MapKey != nil && n.MapKey.Key != nil && len(n.MapKey.Edges) == 0 {
c.compileKey(obj, m, n.MapKey)
}
}
}
func (c *compiler) compileEdges(obj *d2graph.Object, m *d2ast.Map) {
for _, n := range m.Nodes {
if n.MapKey != nil {
if len(n.MapKey.Edges) > 0 {
obj := obj
if n.MapKey.Key != nil {
ida := d2graph.Key(n.MapKey.Key)
parent, resolvedIDA, err := d2graph.ResolveUnderscoreKey(ida, obj)
if err != nil {
c.errorf(n.MapKey.Range.Start, n.MapKey.Range.End, err.Error())
return
}
unresolvedObj := obj
obj = parent.EnsureChild(resolvedIDA)
parent.AppendReferences(ida, d2graph.Reference{
Key: n.MapKey.Key,
MapKey: n.MapKey,
Scope: m,
}, unresolvedObj)
}
c.compileEdgeMapKey(obj, m, n.MapKey)
}
if n.MapKey.Key != nil && n.MapKey.Value.Map != nil {
c.compileEdges(obj.EnsureChild(d2graph.Key(n.MapKey.Key)), n.MapKey.Value.Map)
}
}
}
}
// compileArrowheads compiles keywords for edge arrowhead attributes by
// 1. creating a fake, detached parent
// 2. compiling the arrowhead field as a fake object onto that fake parent
// 3. transferring the relevant attributes onto the edge
func (c *compiler) compileArrowheads(edge *d2graph.Edge, m *d2ast.Map, mk *d2ast.Key) bool {
arrowheadKey := mk.Key
if mk.EdgeKey != nil {
arrowheadKey = mk.EdgeKey
}
if arrowheadKey == nil || len(arrowheadKey.Path) == 0 {
return false
}
key := arrowheadKey.Path[0].Unbox().ScalarString()
var field *d2graph.Attributes
if key == "source-arrowhead" {
if edge.SrcArrowhead == nil {
edge.SrcArrowhead = &d2graph.Attributes{}
}
field = edge.SrcArrowhead
} else if key == "target-arrowhead" {
if edge.DstArrowhead == nil {
edge.DstArrowhead = &d2graph.Attributes{}
}
field = edge.DstArrowhead
} else {
return false
}
fakeParent := &d2graph.Object{
Children: make(map[string]*d2graph.Object),
}
detachedMK := &d2ast.Key{
Key: arrowheadKey,
Primary: mk.Primary,
Value: mk.Value,
}
c.compileKey(fakeParent, m, detachedMK)
fakeObj := fakeParent.ChildrenArray[0]
c.compileShapes(fakeObj)
if fakeObj.Attributes.Shape.Value != "" {
field.Shape = fakeObj.Attributes.Shape
}
if fakeObj.Attributes.Label.Value != "" && fakeObj.Attributes.Label.Value != "source-arrowhead" && fakeObj.Attributes.Label.Value != "target-arrowhead" {
field.Label = fakeObj.Attributes.Label
}
if fakeObj.Attributes.Style.Filled != nil {
field.Style.Filled = fakeObj.Attributes.Style.Filled
}
return true
}
func (c *compiler) compileAttributes(attrs *d2graph.Attributes, mk *d2ast.Key) {
var reserved string
var ok bool
if mk.EdgeKey != nil {
_, reserved, ok = c.compileFlatKey(mk.EdgeKey)
} else if mk.Key != nil {
_, reserved, ok = c.compileFlatKey(mk.Key)
}
if !ok {
return
}
if reserved == "" || reserved == "label" {
attrs.Label.MapKey = mk
} else if reserved == "shape" {
attrs.Shape.MapKey = mk
} else if reserved == "opacity" {
attrs.Style.Opacity = &d2graph.Scalar{MapKey: mk}
} else if reserved == "stroke" {
attrs.Style.Stroke = &d2graph.Scalar{MapKey: mk}
} else if reserved == "fill" {
attrs.Style.Fill = &d2graph.Scalar{MapKey: mk}
} else if reserved == "stroke-width" {
attrs.Style.StrokeWidth = &d2graph.Scalar{MapKey: mk}
} else if reserved == "stroke-dash" {
attrs.Style.StrokeDash = &d2graph.Scalar{MapKey: mk}
} else if reserved == "border-radius" {
attrs.Style.BorderRadius = &d2graph.Scalar{MapKey: mk}
} else if reserved == "shadow" {
attrs.Style.Shadow = &d2graph.Scalar{MapKey: mk}
} else if reserved == "3d" {
// TODO this should be movd to validateKeys, as shape may not be set yet
if attrs.Shape.Value != "" && !strings.EqualFold(attrs.Shape.Value, d2target.ShapeSquare) && !strings.EqualFold(attrs.Shape.Value, d2target.ShapeRectangle) {
c.errorf(mk.Range.Start, mk.Range.End, `key "3d" can only be applied to squares and rectangles`)
return
}
attrs.Style.ThreeDee = &d2graph.Scalar{MapKey: mk}
} else if reserved == "multiple" {
attrs.Style.Multiple = &d2graph.Scalar{MapKey: mk}
} else if reserved == "font" {
attrs.Style.Font = &d2graph.Scalar{MapKey: mk}
} else if reserved == "font-size" {
attrs.Style.FontSize = &d2graph.Scalar{MapKey: mk}
} else if reserved == "font-color" {
attrs.Style.FontColor = &d2graph.Scalar{MapKey: mk}
} else if reserved == "animated" {
attrs.Style.Animated = &d2graph.Scalar{MapKey: mk}
} else if reserved == "bold" {
attrs.Style.Bold = &d2graph.Scalar{MapKey: mk}
} else if reserved == "italic" {
attrs.Style.Italic = &d2graph.Scalar{MapKey: mk}
} else if reserved == "underline" {
attrs.Style.Underline = &d2graph.Scalar{MapKey: mk}
} else if reserved == "filled" {
attrs.Style.Filled = &d2graph.Scalar{MapKey: mk}
} else if reserved == "width" {
attrs.Width = &d2graph.Scalar{MapKey: mk}
} else if reserved == "height" {
attrs.Height = &d2graph.Scalar{MapKey: mk}
}
}
func (c *compiler) compileKey(obj *d2graph.Object, m *d2ast.Map, mk *d2ast.Key) {
ida, reserved, ok := c.compileFlatKey(mk.Key)
if !ok {
return
}
if reserved == "desc" {
return
}
resolvedObj, resolvedIDA, err := d2graph.ResolveUnderscoreKey(ida, obj)
if err != nil {
c.errorf(mk.Range.Start, mk.Range.End, err.Error())
return
}
if resolvedObj != obj {
obj = resolvedObj
}
parent := obj
if len(resolvedIDA) > 0 {
unresolvedObj := obj
obj = parent.EnsureChild(resolvedIDA)
parent.AppendReferences(ida, d2graph.Reference{
Key: mk.Key,
MapKey: mk,
Scope: m,
}, unresolvedObj)
} else if obj.Parent == nil {
// Top level reserved key set on root.
return
}
if len(mk.Edges) > 0 {
return
}
c.compileAttributes(&obj.Attributes, mk)
if obj.Attributes.Style.Animated != nil {
c.errorf(mk.Range.Start, mk.Range.End, `key "animated" can only be applied to edges`)
return
}
c.applyScalar(&obj.Attributes, reserved, mk.Value.ScalarBox())
if mk.Value.Map != nil {
if reserved != "" {
c.errorf(mk.Range.Start, mk.Range.End, "cannot set reserved key %q to a map", reserved)
return
}
obj.Map = mk.Value.Map
c.compileKeys(obj, mk.Value.Map)
}
c.applyScalar(&obj.Attributes, reserved, mk.Primary)
}
func (c *compiler) applyScalar(attrs *d2graph.Attributes, reserved string, box d2ast.ScalarBox) {
scalar := box.Unbox()
if scalar == nil {
return
}
switch reserved {
case "shape":
in := d2target.IsShape(scalar.ScalarString())
_, isArrowhead := d2target.Arrowheads[scalar.ScalarString()]
if !in && !isArrowhead {
c.errorf(scalar.GetRange().Start, scalar.GetRange().End, "unknown shape %q", scalar.ScalarString())
return
}
if box.Null != nil {
attrs.Shape.Value = ""
} else {
attrs.Shape.Value = scalar.ScalarString()
}
if attrs.Shape.Value == d2target.ShapeCode {
// Explicit code shape is plaintext.
attrs.Language = d2target.ShapeText
}
return
case "icon":
iconURL, err := url.Parse(scalar.ScalarString())
if err != nil {
c.errorf(scalar.GetRange().Start, scalar.GetRange().End, "bad icon url %#v: %s", scalar.ScalarString(), err)
return
}
attrs.Icon = iconURL
return
case "near":
nearKey, err := d2parser.ParseKey(scalar.ScalarString())
if err != nil {
c.errorf(scalar.GetRange().Start, scalar.GetRange().End, "bad near key %#v: %s", scalar.ScalarString(), err)
return
}
attrs.NearKey = nearKey
return
case "tooltip":
attrs.Tooltip = scalar.ScalarString()
return
case "width":
_, err := strconv.Atoi(scalar.ScalarString())
if err != nil {
c.errorf(scalar.GetRange().Start, scalar.GetRange().End, "non-integer width %#v: %s", scalar.ScalarString(), err)
return
}
attrs.Width.Value = scalar.ScalarString()
return
case "height":
_, err := strconv.Atoi(scalar.ScalarString())
if err != nil {
c.errorf(scalar.GetRange().Start, scalar.GetRange().End, "non-integer height %#v: %s", scalar.ScalarString(), err)
return
}
attrs.Height.Value = scalar.ScalarString()
return
case "link":
attrs.Link = scalar.ScalarString()
return
}
if _, ok := d2graph.StyleKeywords[reserved]; ok {
if err := attrs.Style.Apply(reserved, scalar.ScalarString()); err != nil {
c.errorf(scalar.GetRange().Start, scalar.GetRange().End, err.Error())
}
return
}
if box.Null != nil {
// TODO: delete obj
attrs.Label.Value = ""
} else {
attrs.Label.Value = scalar.ScalarString()
}
bs := box.BlockString
if bs != nil && reserved == "" {
attrs.Language = bs.Tag
fullTag, ok := ShortToFullLanguageAliases[bs.Tag]
if ok {
attrs.Language = fullTag
}
if attrs.Language == "markdown" {
attrs.Shape.Value = d2target.ShapeText
} else {
attrs.Shape.Value = d2target.ShapeCode
}
}
}
func (c *compiler) compileEdgeMapKey(obj *d2graph.Object, m *d2ast.Map, mk *d2ast.Key) {
if mk.EdgeIndex != nil {
edge, ok := obj.HasEdge(mk)
if ok {
c.appendEdgeReferences(obj, m, mk)
edge.References = append(edge.References, d2graph.EdgeReference{
Edge: mk.Edges[0],
MapKey: mk,
MapKeyEdgeIndex: 0,
Scope: m,
ScopeObj: obj,
})
c.compileEdge(edge, m, mk)
}
return
}
for i, e := range mk.Edges {
if e.Src == nil || e.Dst == nil {
continue
}
edge, err := obj.Connect(d2graph.Key(e.Src), d2graph.Key(e.Dst), e.SrcArrow == "<", e.DstArrow == ">", "")
if err != nil {
c.errorf(e.Range.Start, e.Range.End, err.Error())
continue
}
edge.References = append(edge.References, d2graph.EdgeReference{
Edge: e,
MapKey: mk,
MapKeyEdgeIndex: i,
Scope: m,
ScopeObj: obj,
})
c.compileEdge(edge, m, mk)
}
c.appendEdgeReferences(obj, m, mk)
}
func (c *compiler) compileEdge(edge *d2graph.Edge, m *d2ast.Map, mk *d2ast.Key) {
if mk.Key == nil && mk.EdgeKey == nil {
if len(mk.Edges) == 1 {
edge.Attributes.Label.MapKey = mk
}
c.applyScalar(&edge.Attributes, "", mk.Value.ScalarBox())
c.applyScalar(&edge.Attributes, "", mk.Primary)
} else {
c.compileEdgeKey(edge, m, mk)
}
if mk.Value.Map != nil && mk.EdgeKey == nil {
for _, n := range mk.Value.Map.Nodes {
if n.MapKey == nil {
continue
}
if len(n.MapKey.Edges) > 0 {
c.errorf(mk.Range.Start, mk.Range.End, `edges cannot be nested within another edge`)
continue
}
if n.MapKey.Key == nil {
continue
}
for _, p := range n.MapKey.Key.Path {
_, ok := d2graph.ReservedKeywords[strings.ToLower(p.Unbox().ScalarString())]
if !ok {
c.errorf(mk.Range.Start, mk.Range.End, `edge map keys must be reserved keywords`)
return
}
}
c.compileEdgeKey(edge, m, n.MapKey)
}
}
}
func (c *compiler) compileEdgeKey(edge *d2graph.Edge, m *d2ast.Map, mk *d2ast.Key) {
var r string
var ok bool
// Give precedence to EdgeKeys
// x.(a -> b)[0].style.opacity: 0.4
// We want to compile the style.opacity, not the x
if mk.EdgeKey != nil {
_, r, ok = c.compileFlatKey(mk.EdgeKey)
} else if mk.Key != nil {
_, r, ok = c.compileFlatKey(mk.Key)
}
if !ok {
return
}
ok = c.compileArrowheads(edge, m, mk)
if ok {
return
}
c.compileAttributes(&edge.Attributes, mk)
c.applyScalar(&edge.Attributes, r, mk.Value.ScalarBox())
if mk.Value.Map != nil {
for _, n := range mk.Value.Map.Nodes {
if n.MapKey != nil {
c.compileEdgeKey(edge, m, n.MapKey)
}
}
}
}
func (c *compiler) appendEdgeReferences(obj *d2graph.Object, m *d2ast.Map, mk *d2ast.Key) {
for i, e := range mk.Edges {
if e.Src != nil {
ida := d2graph.Key(e.Src)
parent, _, err := d2graph.ResolveUnderscoreKey(ida, obj)
if err != nil {
c.errorf(mk.Range.Start, mk.Range.End, err.Error())
return
}
parent.AppendReferences(ida, d2graph.Reference{
Key: e.Src,
MapKey: mk,
MapKeyEdgeIndex: i,
Scope: m,
}, obj)
}
if e.Dst != nil {
ida := d2graph.Key(e.Dst)
parent, _, err := d2graph.ResolveUnderscoreKey(ida, obj)
if err != nil {
c.errorf(mk.Range.Start, mk.Range.End, err.Error())
return
}
parent.AppendReferences(ida, d2graph.Reference{
Key: e.Dst,
MapKey: mk,
MapKeyEdgeIndex: i,
Scope: m,
}, obj)
}
}
}
func (c *compiler) compileFlatKey(k *d2ast.KeyPath) ([]string, string, bool) {
k2 := *k
var reserved string
for i, s := range k.Path {
keyword := strings.ToLower(s.Unbox().ScalarString())
_, isReserved := d2graph.ReservedKeywords[keyword]
_, isReservedHolder := d2graph.ReservedKeywordHolders[keyword]
if isReserved && !isReservedHolder {
reserved = keyword
k2.Path = k2.Path[:i]
break
}
}
if len(k2.Path) < len(k.Path)-1 {
c.errorf(k.Range.Start, k.Range.End, "reserved key %q cannot have children", reserved)
return nil, "", false
}
return d2graph.Key(&k2), reserved, true
}
// TODO add more, e.g. C, bash
var ShortToFullLanguageAliases = map[string]string{
"md": "markdown",
"js": "javascript",
"go": "golang",
"py": "python",
"rb": "ruby",
"ts": "typescript",
}
var FullToShortLanguageAliases map[string]string
func (c *compiler) compileShapes(obj *d2graph.Object) {
for _, obj := range obj.ChildrenArray {
switch obj.Attributes.Shape.Value {
case d2target.ShapeClass:
c.compileClass(obj)
case d2target.ShapeSQLTable:
c.compileSQLTable(obj)
case d2target.ShapeImage:
c.compileImage(obj)
}
c.compileShapes(obj)
}
for _, obj := range obj.ChildrenArray {
switch obj.Attributes.Shape.Value {
case d2target.ShapeClass, d2target.ShapeSQLTable:
flattenContainer(obj.Graph, obj)
}
if obj.IDVal == "style" {
obj.Parent.Attributes.Style = obj.Attributes.Style
if obj.Graph != nil {
flattenContainer(obj.Graph, obj)
removeObject(obj.Graph, obj)
}
}
}
}
func (c *compiler) compileImage(obj *d2graph.Object) {
if obj.Attributes.Icon == nil {
c.errorf(obj.Attributes.Shape.MapKey.Range.Start, obj.Attributes.Shape.MapKey.Range.End, `image shape must include an "icon" field`)
}
}
func (c *compiler) compileClass(obj *d2graph.Object) {
obj.Class = &d2target.Class{}
for _, f := range obj.ChildrenArray {
if f.IDVal == "style" {
continue
}
visiblity := "public"
name := f.IDVal
// See https://www.uml-diagrams.org/visibility.html
if name != "" {
switch name[0] {
case '+':
name = name[1:]
case '-':
visiblity = "private"
name = name[1:]
case '#':
visiblity = "protected"
name = name[1:]
}
}
if !strings.Contains(f.IDVal, "(") {
typ := f.Attributes.Label.Value
if typ == f.IDVal {
typ = ""
}
obj.Class.Fields = append(obj.Class.Fields, d2target.ClassField{
Name: name,
Type: typ,
Visibility: visiblity,
})
} else {
// TODO: Not great, AST should easily allow specifying alternate primary field
// as an explicit label should change the name.
returnType := f.Attributes.Label.Value
if returnType == f.IDVal {
returnType = "void"
}
obj.Class.Methods = append(obj.Class.Methods, d2target.ClassMethod{
Name: name,
Return: returnType,
Visibility: visiblity,
})
}
}
}
func (c *compiler) compileSQLTable(obj *d2graph.Object) {
obj.SQLTable = &d2target.SQLTable{}
parentID := obj.Parent.AbsID()
tableID := obj.AbsID()
for _, col := range obj.ChildrenArray {
if col.IDVal == "style" {
continue
}
typ := col.Attributes.Label.Value
if typ == col.IDVal {
// Not great, AST should easily allow specifying alternate primary field
// as an explicit label should change the name.
typ = ""
}
d2Col := d2target.SQLColumn{
Name: col.IDVal,
Type: typ,
}
// The only map a sql table field could have is to specify constraint
if col.Map != nil {
for _, n := range col.Map.Nodes {
if n.MapKey.Key == nil || len(n.MapKey.Key.Path) == 0 {
continue
}
if n.MapKey.Key.Path[0].Unbox().ScalarString() == "constraint" {
d2Col.Constraint = n.MapKey.Value.StringBox().Unbox().ScalarString()
}
}
}
absID := col.AbsID()
for _, e := range obj.Graph.Edges {
srcID := e.Src.AbsID()
dstID := e.Dst.AbsID()
// skip edges between columns of the same table
if strings.HasPrefix(srcID, tableID) && strings.HasPrefix(dstID, tableID) {
continue
}
if srcID == absID {
// Frontend isn't aware of container IDs.
d2Col.Reference = strings.TrimPrefix(dstID, parentID+".")
relSrc := strings.TrimPrefix(absID, parentID+".")
e.Attributes.Label.Value = fmt.Sprintf("%s %s %s", relSrc, e.ArrowString(), d2Col.Reference)
// removeContainer() will adjust the edge to point to the table and not inside.
break
}
}
obj.SQLTable.Columns = append(obj.SQLTable.Columns, d2Col)
}
}
// TODO too similar to flattenContainer, should reconcile in a refactor
func removeObject(g *d2graph.Graph, obj *d2graph.Object) {
for i := 0; i < len(obj.Graph.Objects); i++ {
if obj.Graph.Objects[i] == obj {
obj.Graph.Objects = append(obj.Graph.Objects[:i], obj.Graph.Objects[i+1:]...)
break
}
}
delete(obj.Parent.Children, obj.ID)
for i, child := range obj.Parent.ChildrenArray {
if obj == child {
obj.Parent.ChildrenArray = append(obj.Parent.ChildrenArray[:i], obj.Parent.ChildrenArray[i+1:]...)
break
}
}
}
func flattenContainer(g *d2graph.Graph, obj *d2graph.Object) {
absID := obj.AbsID()
toRemove := map[*d2graph.Edge]struct{}{}
toAdd := []*d2graph.Edge{}
for i := 0; i < len(g.Edges); i++ {
e := g.Edges[i]
srcID := e.Src.AbsID()
dstID := e.Dst.AbsID()
srcIsChild := strings.HasPrefix(srcID, absID+".")
dstIsChild := strings.HasPrefix(dstID, absID+".")
if srcIsChild && dstIsChild {
toRemove[e] = struct{}{}
} else if srcIsChild {
toRemove[e] = struct{}{}
if dstID == absID {
continue
}
toAdd = append(toAdd, e)
} else if dstIsChild {
toRemove[e] = struct{}{}
if srcID == absID {
continue
}
toAdd = append(toAdd, e)
}
}
for _, e := range toAdd {
var newEdge *d2graph.Edge
if strings.HasPrefix(e.Src.AbsID(), absID+".") {
newEdge, _ = g.Root.Connect(obj.AbsIDArray(), e.Dst.AbsIDArray(), e.SrcArrow, e.DstArrow, e.Attributes.Label.Value)
} else {
newEdge, _ = g.Root.Connect(e.Src.AbsIDArray(), obj.AbsIDArray(), e.SrcArrow, e.DstArrow, e.Attributes.Label.Value)
}
// TODO more attributes
newEdge.Attributes.Label = e.Attributes.Label
newEdge.References = e.References
}
updatedEdges := []*d2graph.Edge{}
for _, e := range g.Edges {
if _, is := toRemove[e]; is {
continue
}
updatedEdges = append(updatedEdges, e)
}
g.Edges = updatedEdges
for i := 0; i < len(g.Objects); i++ {
child := g.Objects[i]
if strings.HasPrefix(child.AbsID(), absID+".") {
g.Objects = append(g.Objects[:i], g.Objects[i+1:]...)
i--
delete(obj.Children, child.ID)
for i, child2 := range obj.ChildrenArray {
if child == child2 {
obj.ChildrenArray = append(obj.ChildrenArray[:i], obj.ChildrenArray[i+1:]...)
break
}
}
}
}
}
func (c *compiler) validateKey(obj *d2graph.Object, m *d2ast.Map, mk *d2ast.Key) {
ida, reserved, ok := c.compileFlatKey(mk.Key)
if !ok {
return
}
if reserved == "" && obj.Attributes.Shape.Value == d2target.ShapeImage {
c.errorf(mk.Range.Start, mk.Range.End, "image shapes cannot have children.")
}
if reserved == "width" && obj.Attributes.Shape.Value != d2target.ShapeImage {
c.errorf(mk.Range.Start, mk.Range.End, "width is only applicable to image shapes.")
}
if reserved == "height" && obj.Attributes.Shape.Value != d2target.ShapeImage {
c.errorf(mk.Range.Start, mk.Range.End, "height is only applicable to image shapes.")
}
in := d2target.IsShape(obj.Attributes.Shape.Value)
_, arrowheadIn := d2target.Arrowheads[obj.Attributes.Shape.Value]
if !in && arrowheadIn {
c.errorf(mk.Range.Start, mk.Range.End, fmt.Sprintf(`invalid shape, can only set "%s" for arrowheads`, obj.Attributes.Shape.Value))
}
resolvedObj, resolvedIDA, err := d2graph.ResolveUnderscoreKey(ida, obj)
if err != nil {
c.errorf(mk.Range.Start, mk.Range.End, err.Error())
return
}
if resolvedObj != obj {
obj = resolvedObj
}
parent := obj
if len(resolvedIDA) > 0 {
obj, _ = parent.HasChild(resolvedIDA)
} else if obj.Parent == nil {
return
}
if len(mk.Edges) > 0 {
return
}
if mk.Value.Map != nil {
c.validateKeys(obj, mk.Value.Map)
}
}
func (c *compiler) validateKeys(obj *d2graph.Object, m *d2ast.Map) {
for _, n := range m.Nodes {
if n.MapKey != nil && n.MapKey.Key != nil && len(n.MapKey.Edges) == 0 {
c.validateKey(obj, m, n.MapKey)
}
}
}
func (c *compiler) validateNear(g *d2graph.Graph) {
for _, obj := range g.Objects {
if obj.Attributes.NearKey != nil {
_, ok := g.Root.HasChild(d2graph.Key(obj.Attributes.NearKey))
if !ok {
c.errorf(obj.Attributes.NearKey.GetRange().Start, obj.Attributes.NearKey.GetRange().End, "near key %#v does not exist. It must be the absolute path to a shape.", d2format.Format(obj.Attributes.NearKey))
continue
}
}
}
}
func init() {
FullToShortLanguageAliases = make(map[string]string, len(ShortToFullLanguageAliases))
for k, v := range ShortToFullLanguageAliases {
FullToShortLanguageAliases[v] = k
}
}

1522
d2compiler/compile_test.go Normal file

File diff suppressed because it is too large Load diff

6
d2compiler/doc.go Normal file
View file

@ -0,0 +1,6 @@
// Package d2compiler implements a parser, compiler and autoformatter for the Terrastruct d2
// diagramming language.
//
// https://github.com/terrastruct/d2-vscode
// https://terrastruct.com/docs/d2/tour/intro/
package d2compiler

233
d2exporter/export.go Normal file
View file

@ -0,0 +1,233 @@
package d2exporter
import (
"context"
"strconv"
"oss.terrastruct.com/d2/d2graph"
"oss.terrastruct.com/d2/d2target"
"oss.terrastruct.com/d2/d2themes"
"oss.terrastruct.com/d2/d2themes/d2themescatalog"
)
func Export(ctx context.Context, g *d2graph.Graph, themeID int64) (*d2target.Diagram, error) {
theme := d2themescatalog.Find(themeID)
diagram := d2target.NewDiagram()
diagram.Shapes = make([]d2target.Shape, len(g.Objects))
for i := range g.Objects {
diagram.Shapes[i] = toShape(g.Objects[i], &theme)
}
diagram.Connections = make([]d2target.Connection, len(g.Edges))
for i := range g.Edges {
diagram.Connections[i] = toConnection(g.Edges[i], &theme)
}
return diagram, nil
}
func applyTheme(shape *d2target.Shape, obj *d2graph.Object, theme *d2themes.Theme) {
shape.Stroke = obj.GetStroke(theme, shape.StrokeDash)
shape.Fill = obj.GetFill(theme)
if obj.Attributes.Shape.Value == d2target.ShapeText {
shape.Color = theme.Colors.Neutrals.N1
}
}
func applyStyles(shape *d2target.Shape, obj *d2graph.Object) {
if obj.Attributes.Style.Opacity != nil {
shape.Opacity, _ = strconv.ParseFloat(obj.Attributes.Style.Opacity.Value, 64)
}
if obj.Attributes.Style.StrokeDash != nil {
shape.StrokeDash, _ = strconv.ParseFloat(obj.Attributes.Style.StrokeDash.Value, 64)
}
if obj.Attributes.Style.Fill != nil {
shape.Fill = obj.Attributes.Style.Fill.Value
}
if obj.Attributes.Style.Stroke != nil {
shape.Stroke = obj.Attributes.Style.Stroke.Value
}
if obj.Attributes.Style.StrokeWidth != nil {
shape.StrokeWidth, _ = strconv.Atoi(obj.Attributes.Style.StrokeWidth.Value)
}
if obj.Attributes.Style.Shadow != nil {
shape.Shadow, _ = strconv.ParseBool(obj.Attributes.Style.Shadow.Value)
}
if obj.Attributes.Style.ThreeDee != nil {
shape.ThreeDee, _ = strconv.ParseBool(obj.Attributes.Style.ThreeDee.Value)
}
if obj.Attributes.Style.Multiple != nil {
shape.Multiple, _ = strconv.ParseBool(obj.Attributes.Style.Multiple.Value)
}
if obj.Attributes.Style.BorderRadius != nil {
shape.BorderRadius, _ = strconv.Atoi(obj.Attributes.Style.BorderRadius.Value)
}
if obj.Attributes.Style.FontColor != nil {
shape.Color = obj.Attributes.Style.FontColor.Value
}
if obj.Attributes.Shape.Value != d2target.ShapeText {
if obj.Attributes.Style.Italic != nil {
shape.Italic, _ = strconv.ParseBool(obj.Attributes.Style.Italic.Value)
}
if obj.Attributes.Style.Bold != nil {
shape.Bold, _ = strconv.ParseBool(obj.Attributes.Style.Bold.Value)
}
if obj.Attributes.Style.Underline != nil {
shape.Underline, _ = strconv.ParseBool(obj.Attributes.Style.Underline.Value)
}
if obj.Attributes.Style.Font != nil {
shape.FontFamily = obj.Attributes.Style.Font.Value
}
}
}
func toShape(obj *d2graph.Object, theme *d2themes.Theme) d2target.Shape {
shape := d2target.BaseShape()
shape.SetType(obj.Attributes.Shape.Value)
shape.ID = obj.AbsID()
shape.Pos = d2target.NewPoint(int(obj.TopLeft.X), int(obj.TopLeft.Y))
shape.Width = int(obj.Width)
shape.Height = int(obj.Height)
text := obj.Text()
shape.FontSize = text.FontSize
shape.Level = int(obj.Level())
applyStyles(shape, obj)
applyTheme(shape, obj, theme)
shape.Color = text.GetColor(theme, shape.Italic)
applyStyles(shape, obj)
switch obj.Attributes.Shape.Value {
case d2target.ShapeCode, d2target.ShapeText:
shape.Language = obj.Attributes.Language
shape.Label = obj.Attributes.Label.Value
case d2target.ShapeClass:
shape.Class = *obj.Class
// The label is the header for classes and tables, which is set in client to be 4 px larger than the object's set font size
shape.FontSize -= 4
case d2target.ShapeSQLTable:
shape.SQLTable = *obj.SQLTable
shape.FontSize -= 4
}
shape.Label = text.Text
shape.LabelWidth = text.Dimensions.Width
shape.LabelHeight = text.Dimensions.Height
if obj.LabelPosition != nil {
shape.LabelPosition = *obj.LabelPosition
}
shape.Tooltip = obj.Attributes.Tooltip
shape.Link = obj.Attributes.Link
shape.Icon = obj.Attributes.Icon
if obj.IconPosition != nil {
shape.IconPosition = *obj.IconPosition
}
return *shape
}
func toConnection(edge *d2graph.Edge, theme *d2themes.Theme) d2target.Connection {
connection := d2target.BaseConnection()
connection.ID = edge.AbsID()
// edge.Edge.ID = go2.StringToIntHash(connection.ID)
text := edge.Text()
if edge.SrcArrow {
connection.SrcArrow = d2target.TriangleArrowhead
if edge.SrcArrowhead != nil {
if edge.SrcArrowhead.Shape.Value != "" {
filled := false
if edge.SrcArrowhead.Style.Filled != nil {
filled, _ = strconv.ParseBool(edge.SrcArrowhead.Style.Filled.Value)
}
connection.SrcArrow = d2target.ToArrowhead(edge.SrcArrowhead.Shape.Value, filled)
}
}
}
if edge.SrcArrowhead != nil {
if edge.SrcArrowhead.Label.Value != "" {
connection.SrcLabel = edge.SrcArrowhead.Label.Value
}
}
if edge.DstArrow {
connection.DstArrow = d2target.TriangleArrowhead
if edge.DstArrowhead != nil {
if edge.DstArrowhead.Shape.Value != "" {
filled := false
if edge.DstArrowhead.Style.Filled != nil {
filled, _ = strconv.ParseBool(edge.DstArrowhead.Style.Filled.Value)
}
connection.DstArrow = d2target.ToArrowhead(edge.DstArrowhead.Shape.Value, filled)
}
}
}
if edge.DstArrowhead != nil {
if edge.DstArrowhead.Label.Value != "" {
connection.DstLabel = edge.DstArrowhead.Label.Value
}
}
if edge.Attributes.Style.Opacity != nil {
connection.Opacity, _ = strconv.ParseFloat(edge.Attributes.Style.Opacity.Value, 64)
}
if edge.Attributes.Style.StrokeDash != nil {
connection.StrokeDash, _ = strconv.ParseFloat(edge.Attributes.Style.StrokeDash.Value, 64)
}
connection.Stroke = edge.GetStroke(theme, connection.StrokeDash)
if edge.Attributes.Style.Stroke != nil {
connection.Stroke = edge.Attributes.Style.Stroke.Value
}
if edge.Attributes.Style.StrokeWidth != nil {
connection.StrokeWidth, _ = strconv.Atoi(edge.Attributes.Style.StrokeWidth.Value)
}
connection.FontSize = text.FontSize
if edge.Attributes.Style.FontSize != nil {
connection.FontSize, _ = strconv.Atoi(edge.Attributes.Style.FontSize.Value)
}
if edge.Attributes.Style.Animated != nil {
connection.Animated, _ = strconv.ParseBool(edge.Attributes.Style.Animated.Value)
}
connection.Tooltip = edge.Attributes.Tooltip
connection.Icon = edge.Attributes.Icon
if edge.Attributes.Style.Italic != nil {
connection.Italic, _ = strconv.ParseBool(edge.Attributes.Style.Italic.Value)
}
connection.Color = text.GetColor(theme, connection.Italic)
if edge.Attributes.Style.FontColor != nil {
connection.Color = edge.Attributes.Style.FontColor.Value
}
if edge.Attributes.Style.Bold != nil {
connection.Bold, _ = strconv.ParseBool(edge.Attributes.Style.Bold.Value)
}
if edge.Attributes.Style.Font != nil {
connection.FontFamily = edge.Attributes.Style.Font.Value
}
connection.Label = text.Text
connection.LabelWidth = text.Dimensions.Width
connection.LabelHeight = text.Dimensions.Height
if edge.LabelPosition != nil {
connection.LabelPosition = *edge.LabelPosition
}
if edge.LabelPercentage != nil {
connection.LabelPercentage = *edge.LabelPercentage
}
connection.Route = edge.Route
connection.IsCurve = edge.IsCurve
connection.Src = edge.Src.AbsID()
connection.Dst = edge.Dst.AbsID()
return *connection
}

259
d2exporter/export_test.go Normal file
View file

@ -0,0 +1,259 @@
package d2exporter_test
import (
"context"
"path/filepath"
"strings"
"testing"
"cdr.dev/slog"
"oss.terrastruct.com/diff"
"github.com/stretchr/testify/assert"
"oss.terrastruct.com/d2/d2compiler"
"oss.terrastruct.com/d2/d2exporter"
"oss.terrastruct.com/d2/d2layouts/d2dagrelayout"
"oss.terrastruct.com/d2/d2renderers/textmeasure"
"oss.terrastruct.com/d2/d2target"
"oss.terrastruct.com/d2/d2themes/d2themescatalog"
"oss.terrastruct.com/d2/lib/geo"
"oss.terrastruct.com/d2/lib/log"
)
type testCase struct {
name string
dsl string
themeID int64
assertions func(t *testing.T, d *d2target.Diagram)
}
func TestExport(t *testing.T) {
t.Parallel()
t.Run("shape", testShape)
t.Run("connection", testConnection)
t.Run("label", testLabel)
t.Run("theme", testTheme)
}
func testShape(t *testing.T) {
tcs := []testCase{
{
name: "basic",
dsl: `x
`,
},
{
name: "synonyms",
dsl: `x: {shape: circle}
y: {shape: square}
`,
},
{
name: "text_color",
dsl: `x: |md yo | { style.font-color: red }
`,
},
{
name: "border-radius",
dsl: `Square: "" { style.border-radius: 5 }
`,
},
{
name: "image_dimensions",
dsl: `hey: "" {
icon: https://icons.terrastruct.com/essentials/004-picture.svg
shape: image
width: 200
height: 230
}
`,
assertions: func(t *testing.T, d *d2target.Diagram) {
if d.Shapes[0].Width != 200 {
t.Fatalf("expected width 200, got %v", d.Shapes[0].Width)
}
if d.Shapes[0].Height != 230 {
t.Fatalf("expected height 230, got %v", d.Shapes[0].Height)
}
},
},
}
runa(t, tcs)
}
func testConnection(t *testing.T) {
tcs := []testCase{
{
name: "basic",
dsl: `x -> y
`,
},
{
name: "stroke-dash",
dsl: `x -> y: { style.stroke-dash: 4 }
`,
},
{
name: "arrowhead",
dsl: `x -> y: {
source-arrowhead: If you've done six impossible things before breakfast, why not round it
target-arrowhead: {
label: A man with one watch knows what time it is.
shape: diamond
style.filled: true
}
}
`,
},
{
// This is a regression test where a connection with stroke-dash of 0 on terrastruct flagship theme would have a diff color
// than a connection without stroke dash
themeID: d2themescatalog.FlagshipTerrastruct.ID,
name: "theme_stroke-dash",
dsl: `x -> y: { style.stroke-dash: 0 }
x -> y
`,
},
}
runa(t, tcs)
}
func testLabel(t *testing.T) {
tcs := []testCase{
{
name: "basic_shape",
dsl: `x: yo
`,
},
{
name: "shape_font_color",
dsl: `x: yo { style.font-color: red }
`,
},
{
name: "connection_font_color",
dsl: `x -> y: yo { style.font-color: red }
`,
},
}
runa(t, tcs)
}
func testTheme(t *testing.T) {
tcs := []testCase{
{
name: "shape_without_bold",
themeID: d2themescatalog.FlagshipTerrastruct.ID,
dsl: `x: {
style.bold: false
}
`,
},
{
name: "shape_with_italic",
themeID: d2themescatalog.FlagshipTerrastruct.ID,
dsl: `x: {
style.italic: true
}
`,
},
{
name: "connection_without_italic",
dsl: `x -> y: asdf { style.italic: false }
`,
themeID: d2themescatalog.FlagshipTerrastruct.ID,
},
{
name: "connection_with_italic",
dsl: `x -> y: asdf {
style.italic: true
}
`,
themeID: d2themescatalog.FlagshipTerrastruct.ID,
},
{
name: "connection_with_bold",
dsl: `x -> y: asdf {
style.bold: true
}
`,
themeID: d2themescatalog.FlagshipTerrastruct.ID,
},
}
runa(t, tcs)
}
func runa(t *testing.T, tcs []testCase) {
for _, tc := range tcs {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
run(t, tc)
})
}
}
func run(t *testing.T, tc testCase) {
ctx := context.Background()
ctx = log.WithTB(ctx, t, nil)
ctx = log.Leveled(ctx, slog.LevelDebug)
g, err := d2compiler.Compile("", strings.NewReader(tc.dsl), &d2compiler.CompileOptions{
UTF16: true,
})
if err != nil {
t.Fatal(err)
}
ruler, err := textmeasure.NewRuler()
assert.Nil(t, err)
err = g.SetDimensions(nil, ruler)
assert.Nil(t, err)
err = d2dagrelayout.Layout(ctx, g)
if err != nil {
t.Fatal(err)
}
got, err := d2exporter.Export(ctx, g, tc.themeID)
if err != nil {
t.Fatal(err)
}
if tc.assertions != nil {
t.Run("assertions", func(t *testing.T) {
tc.assertions(t, got)
})
}
// This test is agnostic of layout changes
for i := range got.Shapes {
got.Shapes[i].Pos = d2target.Point{}
got.Shapes[i].Width = 0
got.Shapes[i].Height = 0
got.Shapes[i].LabelWidth = 0
got.Shapes[i].LabelHeight = 0
got.Shapes[i].LabelPosition = ""
}
for i := range got.Connections {
got.Connections[i].Route = []*geo.Point{}
got.Connections[i].LabelWidth = 0
got.Connections[i].LabelHeight = 0
got.Connections[i].LabelPosition = ""
}
err = diff.Testdata(filepath.Join("..", "testdata", "d2exporter", t.Name()), got)
if err != nil {
t.Fatal(err)
}
}

88
d2format/escape.go Normal file
View file

@ -0,0 +1,88 @@
package d2format
import (
"strings"
"oss.terrastruct.com/d2/d2ast"
)
func escapeSingleQuotedValue(s string) string {
var b strings.Builder
for _, r := range s {
switch r {
case '\'':
b.WriteByte('\'')
case '\n':
// TODO: Unified string syntax.
b.WriteByte('\\')
b.WriteByte('n')
continue
}
b.WriteRune(r)
}
return b.String()
}
func escapeDoubledQuotedValue(s string, inKey bool) string {
var b strings.Builder
for _, r := range s {
switch r {
case '"', '\\':
b.WriteByte('\\')
case '\n':
b.WriteByte('\\')
b.WriteByte('n')
continue
}
if !inKey && r == '$' {
b.WriteByte('\\')
}
b.WriteRune(r)
}
return b.String()
}
func escapeUnquotedValue(s string, inKey bool) string {
if len(s) == 0 {
return `""`
}
if strings.EqualFold(s, "null") {
return "\\null"
}
var b strings.Builder
for i, r := range s {
switch r {
case '\'', '"', '|':
if i == 0 {
b.WriteByte('\\')
}
case '\n':
b.WriteByte('\\')
b.WriteByte('n')
continue
default:
if inKey {
switch r {
case '-':
if i+1 < len(s) && s[i+1] == '-' {
b.WriteByte('\\')
}
case '&':
if i == 0 {
b.WriteByte('\\')
}
default:
if strings.ContainsRune(d2ast.UnquotedKeySpecials, r) {
b.WriteByte('\\')
}
}
} else if strings.ContainsRune(d2ast.UnquotedValueSpecials, r) {
b.WriteByte('\\')
}
}
b.WriteRune(r)
}
return b.String()
}

294
d2format/escape_test.go Normal file
View file

@ -0,0 +1,294 @@
package d2format_test
import (
"testing"
"oss.terrastruct.com/diff"
"oss.terrastruct.com/d2/d2ast"
"oss.terrastruct.com/d2/d2format"
)
func TestEscapeSingleQuoted(t *testing.T) {
t.Parallel()
testCases := []struct {
name string
str string
exp string
}{
{
name: "simple",
str: `Things will be bright in P.M. Love is a snowmobile racing across the tundra, which suddenly flips.`,
exp: `'Things will be bright in P.M. Love is a snowmobile racing across the tundra, which suddenly flips.'`,
},
{
name: "single_quotes",
str: `'rawr'`,
exp: `'''rawr'''`,
},
{
name: "newlines",
str: `
`,
exp: `'\n\n\n'`,
},
}
for _, tc := range testCases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
diff.AssertStringEq(t, tc.exp, d2format.Format(&d2ast.SingleQuotedString{
Value: tc.str,
}))
})
}
}
func TestEscapeDoubleQuoted(t *testing.T) {
t.Parallel()
testCases := []struct {
name string
str string
exp string
inKey bool
}{
{
name: "simple",
str: `Things will be bright in P.M. Love is a snowmobile racing across the tundra, which suddenly flips.`,
exp: `"Things will be bright in P.M. Love is a snowmobile racing across the tundra, which suddenly flips."`,
},
{
name: "specials_1",
str: `"\x`,
exp: `"\"\\x"`,
},
{
name: "specials_2",
str: `$$3es`,
exp: `"\$\$3es"`,
},
{
name: "newlines",
str: `
`,
exp: `"\n\n\n"`,
},
{
name: "specials_key",
str: `$$3es`,
exp: `"$$3es"`,
inKey: true,
},
}
for _, tc := range testCases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
var n d2ast.Node
if tc.inKey {
n = &d2ast.KeyPath{
Path: []*d2ast.StringBox{
d2ast.MakeValueBox(d2ast.FlatDoubleQuotedString(tc.str)).StringBox(),
},
}
} else {
n = d2ast.FlatDoubleQuotedString(tc.str)
}
diff.AssertStringEq(t, tc.exp, d2format.Format(n))
})
}
}
func TestEscapeUnquoted(t *testing.T) {
t.Parallel()
testCases := []struct {
name string
str string
exp string
inKey bool
}{
{
name: "simple",
str: `Change your thoughts and you change your world.`,
exp: `Change your thoughts and you change your world.`,
},
{
name: "specials_1",
str: `meow;{};meow`,
exp: `meow\;\{\}\;meow`,
},
{
name: "specials_2",
str: `'meow-#'`,
exp: `\'meow-\#'`,
},
{
name: "specials_3",
str: `#meow|`,
exp: `\#meow|`,
},
{
name: "specials_key_1",
str: `<---->`,
exp: `\<\-\-\--\>`,
inKey: true,
},
{
name: "specials_key_2",
str: `:::::`,
exp: `\:\:\:\:\:`,
inKey: true,
},
{
name: "specials_key_3",
str: `&&OKAY!! Turn on the sound ONLY for TRYNEL CARPETING, FULLY-EQUIPPED`,
exp: `\&&OKAY!! Turn on the sound ONLY for TRYNEL CARPETING, FULLY-EQUIPPED`,
inKey: true,
},
{
name: "specials_key_4",
str: `*-->`,
exp: `\*\--\>`,
inKey: true,
},
{
name: "specials_key_4_notkey",
str: `*-->`,
exp: `*-->`,
},
{
name: "null",
str: `null`,
exp: `\null`,
},
{
name: "empty",
str: ``,
exp: `""`,
},
{
name: "newlines",
str: `
`,
exp: `\n\n\n`,
},
}
for _, tc := range testCases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
var n d2ast.Node
if tc.inKey {
n = &d2ast.KeyPath{
Path: []*d2ast.StringBox{
d2ast.MakeValueBox(d2ast.FlatUnquotedString(tc.str)).StringBox(),
},
}
} else {
n = d2ast.FlatUnquotedString(tc.str)
}
diff.AssertStringEq(t, tc.exp, d2format.Format(n))
})
}
}
func TestEscapeBlockString(t *testing.T) {
t.Parallel()
testCases := []struct {
name string
tag string
quote string
value string
exp string
}{
{
name: "oneline",
value: `Change your thoughts and you change your world.`,
exp: `| Change your thoughts and you change your world. |`,
},
{
name: "multiline",
value: `Change your thoughts and you change your world.
`,
exp: `|
Change your thoughts and you change your world.
|`,
},
{
name: "empty",
value: ``,
exp: `| |`,
},
{
name: "quote_1",
value: `|%%% %%%|`,
quote: "%%%",
exp: `|%%%% |%%% %%%| %%%%|`,
},
{
name: "quote_2",
value: `|%%% %%%%|`,
quote: "%%%",
exp: `|%%%%% |%%% %%%%| %%%%%|`,
},
{
name: "quote_3",
value: `||`,
quote: "",
exp: `||| || |||`,
},
{
name: "tag",
value: `This must be morning. I never could get the hang of mornings.`,
tag: "html",
exp: `|html This must be morning. I never could get the hang of mornings. |`,
},
{
name: "bad_tag",
value: `This must be morning. I never could get the hang of mornings.`,
tag: "ok ok",
exp: `|ok ok This must be morning. I never could get the hang of mornings. |`,
},
}
for _, tc := range testCases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
n := &d2ast.BlockString{
Quote: tc.quote,
Tag: tc.tag,
Value: tc.value,
}
diff.AssertStringEq(t, tc.exp, d2format.Format(n))
})
}
}
// TODO: chaos test each

399
d2format/format.go Normal file
View file

@ -0,0 +1,399 @@
package d2format
import (
"strconv"
"strings"
"oss.terrastruct.com/d2/d2ast"
)
// TODO: edges with shared path should be fmted as <rel>.(x -> y)
func Format(n d2ast.Node) string {
var p printer
p.node(n)
return p.sb.String()
}
type printer struct {
sb strings.Builder
indentStr string
inKey bool
}
func (p *printer) indent() {
p.indentStr += " " + " "
}
func (p *printer) deindent() {
p.indentStr = p.indentStr[:len(p.indentStr)-2]
}
func (p *printer) newline() {
p.sb.WriteByte('\n')
p.sb.WriteString(p.indentStr)
}
func (p *printer) node(n d2ast.Node) {
switch n := n.(type) {
case *d2ast.Comment:
p.comment(n)
case *d2ast.BlockComment:
p.blockComment(n)
case *d2ast.Null:
p.sb.WriteString("null")
case *d2ast.Boolean:
p.sb.WriteString(strconv.FormatBool(n.Value))
case *d2ast.Number:
p.sb.WriteString(n.Raw)
case *d2ast.UnquotedString:
p.interpolationBoxes(n.Value, false)
case *d2ast.DoubleQuotedString:
p.sb.WriteByte('"')
p.interpolationBoxes(n.Value, true)
p.sb.WriteByte('"')
case *d2ast.SingleQuotedString:
p.sb.WriteByte('\'')
if n.Raw == "" {
n.Raw = escapeSingleQuotedValue(n.Value)
}
p.sb.WriteString(escapeSingleQuotedValue(n.Value))
p.sb.WriteByte('\'')
case *d2ast.BlockString:
p.blockString(n)
case *d2ast.Substitution:
p.substitution(n)
case *d2ast.Array:
p.array(n)
case *d2ast.Map:
p._map(n)
case *d2ast.Key:
p.mapKey(n)
case *d2ast.KeyPath:
p.key(n)
case *d2ast.Edge:
p.edge(n)
case *d2ast.EdgeIndex:
p.edgeIndex(n)
}
}
func (p *printer) comment(c *d2ast.Comment) {
lines := strings.Split(c.Value, "\n")
for i, line := range lines {
p.sb.WriteString("#")
if line != "" && !strings.HasPrefix(line, " ") {
p.sb.WriteByte(' ')
}
p.sb.WriteString(line)
if i < len(lines)-1 {
p.newline()
}
}
}
func (p *printer) blockComment(bc *d2ast.BlockComment) {
p.sb.WriteString(`"""`)
if bc.Range.OneLine() {
p.sb.WriteByte(' ')
}
lines := strings.Split(bc.Value, "\n")
for _, l := range lines {
if !bc.Range.OneLine() {
if l == "" {
p.sb.WriteByte('\n')
} else {
p.newline()
}
}
p.sb.WriteString(l)
}
if !bc.Range.OneLine() {
p.newline()
} else {
p.sb.WriteByte(' ')
}
p.sb.WriteString(`"""`)
}
func (p *printer) interpolationBoxes(boxes []d2ast.InterpolationBox, isDoubleString bool) {
for _, b := range boxes {
if b.Substitution != nil {
p.substitution(b.Substitution)
continue
}
if b.StringRaw == nil {
var s string
if isDoubleString {
s = escapeDoubledQuotedValue(*b.String, p.inKey)
} else {
s = escapeUnquotedValue(*b.String, p.inKey)
}
b.StringRaw = &s
}
p.sb.WriteString(*b.StringRaw)
}
}
func (p *printer) blockString(bs *d2ast.BlockString) {
quote := bs.Quote
for strings.Contains(bs.Value, "|"+quote) {
if quote == "" {
quote += "|"
} else {
quote += string(quote[len(quote)-1])
}
}
for strings.Contains(bs.Value, quote+"|") {
quote += string(quote[len(quote)-1])
}
if bs.Range == (d2ast.Range{}) {
if strings.IndexByte(bs.Value, '\n') > -1 {
bs.Range = d2ast.MakeRange(",1:0:0-2:0:0")
}
bs.Value = strings.TrimSpace(bs.Value)
}
p.sb.WriteString("|" + quote)
p.sb.WriteString(bs.Tag)
if !bs.Range.OneLine() {
p.indent()
} else {
p.sb.WriteByte(' ')
}
lines := strings.Split(bs.Value, "\n")
for _, l := range lines {
if !bs.Range.OneLine() {
if l == "" {
p.sb.WriteByte('\n')
} else {
p.newline()
}
}
p.sb.WriteString(l)
}
if !bs.Range.OneLine() {
p.deindent()
p.newline()
} else if bs.Value != "" {
p.sb.WriteByte(' ')
}
p.sb.WriteString(quote + "|")
}
func (p *printer) path(els []*d2ast.StringBox) {
for i, s := range els {
p.node(s.Unbox())
if i < len(els)-1 {
p.sb.WriteByte('.')
}
}
}
func (p *printer) substitution(s *d2ast.Substitution) {
if s.Spread {
p.sb.WriteString("...")
}
p.sb.WriteString("${")
p.path(s.Path)
p.sb.WriteByte('}')
}
func (p *printer) array(a *d2ast.Array) {
p.sb.WriteByte('[')
if !a.Range.OneLine() {
p.indent()
}
prev := d2ast.Node(a)
for i := 0; i < len(a.Nodes); i++ {
nb := a.Nodes[i]
n := nb.Unbox()
// Handle inline comments.
if i > 0 && (nb.Comment != nil || nb.BlockComment != nil) {
if n.GetRange().Start.Line == prev.GetRange().End.Line && n.GetRange().OneLine() {
p.sb.WriteByte(' ')
p.node(n)
continue
}
}
if !a.Range.OneLine() {
if prev != a {
if n.GetRange().Start.Line-prev.GetRange().End.Line > 1 {
p.sb.WriteByte('\n')
}
}
p.newline()
} else if i > 0 {
p.sb.WriteString("; ")
}
p.node(n)
prev = n
}
if !a.Range.OneLine() {
p.deindent()
p.newline()
}
p.sb.WriteByte(']')
}
func (p *printer) _map(m *d2ast.Map) {
if !m.IsFileMap() {
p.sb.WriteByte('{')
if !m.Range.OneLine() {
p.indent()
}
}
prev := d2ast.Node(m)
for i := 0; i < len(m.Nodes); i++ {
nb := m.Nodes[i]
n := nb.Unbox()
// Handle inline comments.
if i > 0 && (nb.Comment != nil || nb.BlockComment != nil) {
if n.GetRange().Start.Line == prev.GetRange().End.Line && n.GetRange().OneLine() {
p.sb.WriteByte(' ')
p.node(n)
continue
}
}
if !m.Range.OneLine() {
if prev != m {
if n.GetRange().Start.Line-prev.GetRange().End.Line > 1 {
p.sb.WriteByte('\n')
}
}
if !m.IsFileMap() || i > 0 {
p.newline()
}
} else if i > 0 {
p.sb.WriteString("; ")
}
p.node(n)
prev = n
}
if !m.IsFileMap() {
if !m.Range.OneLine() {
p.deindent()
p.newline()
}
p.sb.WriteByte('}')
} else if len(m.Nodes) > 0 {
// Always write a trailing newline for nonempty file maps.
p.sb.WriteByte('\n')
}
}
func (p *printer) mapKey(mk *d2ast.Key) {
if mk.Ampersand {
p.sb.WriteByte('&')
}
if mk.Key != nil {
p.key(mk.Key)
}
if len(mk.Edges) > 0 {
if mk.Key != nil {
p.sb.WriteByte('.')
}
if mk.Key != nil || mk.EdgeIndex != nil || mk.EdgeKey != nil {
p.sb.WriteByte('(')
}
if mk.Edges[0].Src != nil {
p.key(mk.Edges[0].Src)
p.sb.WriteByte(' ')
}
for i, e := range mk.Edges {
p.edgeArrowAndDst(e)
if i < len(mk.Edges)-1 {
p.sb.WriteByte(' ')
}
}
if mk.Key != nil || mk.EdgeIndex != nil || mk.EdgeKey != nil {
p.sb.WriteByte(')')
}
if mk.EdgeIndex != nil {
p.edgeIndex(mk.EdgeIndex)
}
if mk.EdgeKey != nil {
p.sb.WriteByte('.')
p.key(mk.EdgeKey)
}
}
if mk.Primary.Unbox() != nil {
p.sb.WriteString(": ")
p.node(mk.Primary.Unbox())
}
if mk.Value.Map != nil && len(mk.Value.Map.Nodes) == 0 {
return
}
if mk.Value.Unbox() != nil {
if mk.Primary.Unbox() == nil {
p.sb.WriteString(": ")
} else {
p.sb.WriteByte(' ')
}
p.node(mk.Value.Unbox())
}
}
func (p *printer) key(k *d2ast.KeyPath) {
p.inKey = true
if k != nil {
p.path(k.Path)
}
p.inKey = false
}
func (p *printer) edge(e *d2ast.Edge) {
if e.Src != nil {
p.key(e.Src)
p.sb.WriteByte(' ')
}
p.edgeArrowAndDst(e)
}
func (p *printer) edgeArrowAndDst(e *d2ast.Edge) {
if e.SrcArrow == "" {
p.sb.WriteByte('-')
} else {
p.sb.WriteString(e.SrcArrow)
}
if e.DstArrow == "" {
p.sb.WriteByte('-')
} else {
if e.SrcArrow != "" {
p.sb.WriteByte('-')
}
p.sb.WriteString(e.DstArrow)
}
if e.Dst != nil {
p.sb.WriteByte(' ')
p.key(e.Dst)
}
}
func (p *printer) edgeIndex(ei *d2ast.EdgeIndex) {
p.sb.WriteByte('[')
if ei.Glob {
p.sb.WriteByte('*')
} else {
p.sb.WriteString(strconv.Itoa(*ei.Int))
}
p.sb.WriteByte(']')
}

626
d2format/format_test.go Normal file
View file

@ -0,0 +1,626 @@
package d2format_test
import (
"fmt"
"strings"
"testing"
"oss.terrastruct.com/diff"
"oss.terrastruct.com/d2/d2format"
"oss.terrastruct.com/d2/d2parser"
)
func TestPrint(t *testing.T) {
t.Parallel()
testCases := []struct {
name string
in string
exp string
}{
{
name: "basic",
in: `
x -> y
`,
exp: `x -> y
`,
},
{
name: "complex",
in: `
sql_example : sql_example {
board : {
shape: sql_table
id: int {constraint: primary_key}
frame: int {constraint: foreign_key}
diagram: int {constraint: foreign_key}
board_objects: jsonb
last_updated: timestamp with time zone
last_thumbgen: timestamp with time zone
dsl : text
}
# Normal.
board.diagram -> diagrams.id
# Self referential.
diagrams.id -> diagrams.representation
# SrcArrow test.
diagrams.id <- views . diagram
diagrams.id <-> steps . diagram
diagrams: {
shape: sql_table
id: {type: int ; constraint: primary_key}
representation: {type: jsonb}
}
views: {
shape: sql_table
id: {type: int; constraint: primary_key}
representation: {type: jsonb}
diagram: int {constraint: foreign_key}
}
steps: {
shape: sql_table
id: { type: int; constraint: primary_key }
representation: { type: jsonb }
diagram: int {constraint: foreign_key}
}
meow <- diagrams.id
}
D2 AST Parser {
shape: class
+prevRune : rune
prevColumn : int
+eatSpace(eatNewlines bool): (rune, error)
unreadRune()
\#scanKey(r rune): (k Key, _ error)
}
"""dmaskkldsamkld """
"""
dmaskdmasl
mdlkasdaskml
daklsmdakms
"""
bs: |
dmasmdkals
dkmsamdklsa
|
bs2: | mdsalldkams|
y-->q: meow
x->y->z
meow: {
x: |` + "`" + `
meow
meow
` + "`" + `| {
}
}
"meow\t": ok
`,
exp: `sql_example: sql_example {
board: {
shape: sql_table
id: int {constraint: primary_key}
frame: int {constraint: foreign_key}
diagram: int {constraint: foreign_key}
board_objects: jsonb
last_updated: timestamp with time zone
last_thumbgen: timestamp with time zone
dsl: text
}
# Normal.
board.diagram -> diagrams.id
# Self referential.
diagrams.id -> diagrams.representation
# SrcArrow test.
diagrams.id <- views.diagram
diagrams.id <-> steps.diagram
diagrams: {
shape: sql_table
id: {type: int; constraint: primary_key}
representation: {type: jsonb}
}
views: {
shape: sql_table
id: {type: int; constraint: primary_key}
representation: {type: jsonb}
diagram: int {constraint: foreign_key}
}
steps: {
shape: sql_table
id: {type: int; constraint: primary_key}
representation: {type: jsonb}
diagram: int {constraint: foreign_key}
}
meow <- diagrams.id
}
D2 AST Parser: {
shape: class
+prevRune: rune
prevColumn: int
+eatSpace(eatNewlines bool): (rune, error)
unreadRune()
\#scanKey(r rune): (k Key, _ error)
}
""" dmaskkldsamkld """
"""
dmaskdmasl
mdlkasdaskml
daklsmdakms
"""
bs: |md
dmasmdkals
dkmsamdklsa
|
bs2: |md mdsalldkams |
y -> q: meow
x -> y -> z
meow: {
x: |` + "`" + `md
meow
meow
` + "`" + `|
}
"meow\t": ok
`,
},
{
name: "block_comment",
in: `
"""
D2 AST Parser2: {
shape: class
reader: io.RuneReader
readerPos: d2ast.Position
lookahead: "[]rune"
lookaheadPos: d2ast.Position
peek() (r rune, eof bool)
-rewind(): ()
+commit()
\#peekn(n int) (s string, eof bool)
}
"""
`,
exp: `"""
D2 AST Parser2: {
shape: class
reader: io.RuneReader
readerPos: d2ast.Position
lookahead: "[]rune"
lookaheadPos: d2ast.Position
peek() (r rune, eof bool)
-rewind(): ()
+commit()
\#peekn(n int) (s string, eof bool)
}
"""
`,
},
{
name: "block_string_indent",
in: `
parent: {
example_code: |` + "`" + `go
package fs
type FS interface {
Open(name string) (File, error)
}
type File interface {
Stat() (FileInfo, error)
Read([]byte) (int, error)
Close() error
}
var (
ErrInvalid = errInvalid() // "invalid argument"
ErrPermission = errPermission() // "permission denied"
ErrExist = errExist() // "file already exists"
ErrNotExist = errNotExist() // "file does not exist"
ErrClosed = errClosed() // "file already closed"
)
` + "`" + `|}`,
exp: `parent: {
example_code: |` + "`" + `go
package fs
type FS interface {
Open(name string) (File, error)
}
type File interface {
Stat() (FileInfo, error)
Read([]byte) (int, error)
Close() error
}
var (
ErrInvalid = errInvalid() // "invalid argument"
ErrPermission = errPermission() // "permission denied"
ErrExist = errExist() // "file already exists"
ErrNotExist = errNotExist() // "file does not exist"
ErrClosed = errClosed() // "file already closed"
)
` + "`" + `|
}
`,
},
{
// This one we test that the common indent is stripped before the correct indent is
// applied.
name: "block_string_indent_2",
in: `
parent: {
example_code: |` + "`" + `go
package fs
type FS interface {
Open(name string) (File, error)
}
type File interface {
Stat() (FileInfo, error)
Read([]byte) (int, error)
Close() error
}
var (
ErrInvalid = errInvalid() // "invalid argument"
ErrPermission = errPermission() // "permission denied"
ErrExist = errExist() // "file already exists"
ErrNotExist = errNotExist() // "file does not exist"
ErrClosed = errClosed() // "file already closed"
)
` + "`" + `|}`,
exp: `parent: {
example_code: |` + "`" + `go
package fs
type FS interface {
Open(name string) (File, error)
}
type File interface {
Stat() (FileInfo, error)
Read([]byte) (int, error)
Close() error
}
var (
ErrInvalid = errInvalid() // "invalid argument"
ErrPermission = errPermission() // "permission denied"
ErrExist = errExist() // "file already exists"
ErrNotExist = errNotExist() // "file does not exist"
ErrClosed = errClosed() // "file already closed"
)
` + "`" + `|
}
`,
},
{
// This one we test that the common indent is stripped before the correct indent is
// applied even when there's too much indent.
name: "block_string_indent_3",
in: `
parent: {
example_code: |` + "`" + `go
package fs
type FS interface {
Open(name string) (File, error)
}
type File interface {
Stat() (FileInfo, error)
Read([]byte) (int, error)
Close() error
}
var (
ErrInvalid = errInvalid() // "invalid argument"
ErrPermission = errPermission() // "permission denied"
ErrExist = errExist() // "file already exists"
ErrNotExist = errNotExist() // "file does not exist"
ErrClosed = errClosed() // "file already closed"
)
` + "`" + `|}`,
exp: `parent: {
example_code: |` + "`" + `go
package fs
type FS interface {
Open(name string) (File, error)
}
type File interface {
Stat() (FileInfo, error)
Read([]byte) (int, error)
Close() error
}
var (
ErrInvalid = errInvalid() // "invalid argument"
ErrPermission = errPermission() // "permission denied"
ErrExist = errExist() // "file already exists"
ErrNotExist = errNotExist() // "file does not exist"
ErrClosed = errClosed() // "file already closed"
)
` + "`" + `|
}
`,
},
{
// This one has 3 space indent and whitespace only lines.
name: "block_string_uneven_indent",
in: `
parent: {
example_code: |` + "`" + `go
package fs
type FS interface {
Open(name string) (File, error)
}
type File interface {
Stat() (FileInfo, error)
Read([]byte) (int, error)
Close() error
}
var (
ErrInvalid = errInvalid() // "invalid argument"
ErrPermission = errPermission() // "permission denied"
ErrExist = errExist() // "file already exists"
ErrNotExist = errNotExist() // "file does not exist"
ErrClosed = errClosed() // "file already closed"
)
` + "`" + `|}`,
exp: `parent: {
example_code: |` + "`" + `go
package fs
type FS interface {
Open(name string) (File, error)
}
type File interface {
Stat() (FileInfo, error)
Read([]byte) (int, error)
Close() error
}
var (
ErrInvalid = errInvalid() // "invalid argument"
ErrPermission = errPermission() // "permission denied"
ErrExist = errExist() // "file already exists"
ErrNotExist = errNotExist() // "file does not exist"
ErrClosed = errClosed() // "file already closed"
)
` + "`" + `|
}
`,
},
{
// This one has 3 space indent and large whitespace only lines.
name: "block_string_uneven_indent_2",
in: `
parent: {
example_code: |` + "`" + `go
package fs
type FS interface {
Open(name string) (File, error)
}
` + "`" + `|}`,
exp: `parent: {
example_code: |` + "`" + `go
package fs
type FS interface {
Open(name string) (File, error)
}
` + "`" + `|
}
`,
},
{
name: "block_comment_indent",
in: `
parent: {
"""
hello
""" }`,
exp: `parent: {
"""
hello
"""
}
`,
},
{
name: "scalars",
in: `x: null
y: true
z: 343`,
exp: `x: null
y: true
z: 343
`,
},
{
name: "substitution",
in: `x: ${ok}; y: [...${yes}]`,
exp: `x: ${ok}; y: [...${yes}]
`,
},
{
name: "line_comment_block",
in: `# wsup
# hello
# The Least Successful Collector`,
exp: `# wsup
# hello
# The Least Successful Collector
`,
},
{
name: "inline_comment",
in: `hello: x # soldier
more`,
exp: `hello: x # soldier
more
`,
},
{
name: "array_one_line",
in: `a: [1;2;3;4]`,
exp: `a: [1; 2; 3; 4]
`,
},
{
name: "array",
in: `a: [
hi # Fraud is the homage that force pays to reason.
1
2
3
4
5; 6; 7
]`,
exp: `a: [
hi # Fraud is the homage that force pays to reason.
1
2
3
4
5
6
7
]
`,
},
{
name: "ampersand",
in: `&scenario: red`,
exp: `&scenario: red
`,
},
{
name: "complex_edge",
in: `pre.(src -> dst -> more)[3].post`,
exp: `pre.(src -> dst -> more)[3].post
`,
},
{
name: "edge_index_glob",
in: `(x -> y)[*]`,
exp: `(x -> y)[*]
`,
},
{
name: "bidirectional",
in: `x<>y`,
exp: `x <-> y
`,
},
{
name: "empty_map",
in: `x: {}
`,
exp: `x
`,
},
}
for _, tc := range testCases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
ast, err := d2parser.Parse(fmt.Sprintf("%s.d2", t.Name()), strings.NewReader(tc.in), nil)
if err != nil {
t.Fatal(err)
}
diff.AssertStringEq(t, tc.exp, d2format.Format(ast))
})
}
}
func TestEdge(t *testing.T) {
t.Parallel()
mk, err := d2parser.ParseMapKey(`(x -> y)[0]`)
if err != nil {
t.Fatal(err)
}
if len(mk.Edges) != 1 {
t.Fatalf("expected one edge: %#v", mk.Edges)
}
diff.AssertStringEq(t, `x -> y`, d2format.Format(mk.Edges[0]))
diff.AssertStringEq(t, `[0]`, d2format.Format(mk.EdgeIndex))
}

158
d2graph/color_helper.go Normal file
View file

@ -0,0 +1,158 @@
package d2graph
import "regexp"
// namedColors is a list of valid CSS colors
var namedColors = []string{
"transparent",
"aliceblue",
"antiquewhite",
"aqua",
"aquamarine",
"azure",
"beige",
"bisque",
"black",
"blanchedalmond",
"blue",
"blueviolet",
"brown",
"burlywood",
"cadetblue",
"chartreuse",
"chocolate",
"coral",
"cornflowerblue",
"cornsilk",
"crimson",
"cyan",
"darkblue",
"darkcyan",
"darkgoldenrod",
"darkgray",
"darkgrey",
"darkgreen",
"darkkhaki",
"darkmagenta",
"darkolivegreen",
"darkorange",
"darkorchid",
"darkred",
"darksalmon",
"darkseagreen",
"darkslateblue",
"darkslategray",
"darkslategrey",
"darkturquoise",
"darkviolet",
"deeppink",
"deepskyblue",
"dimgray",
"dimgrey",
"dodgerblue",
"firebrick",
"floralwhite",
"forestgreen",
"fuchsia",
"gainsboro",
"ghostwhite",
"gold",
"goldenrod",
"gray",
"grey",
"green",
"greenyellow",
"honeydew",
"hotpink",
"indianred",
"indigo",
"ivory",
"khaki",
"lavender",
"lavenderblush",
"lawngreen",
"lemonchiffon",
"lightblue",
"lightcoral",
"lightcyan",
"lightgoldenrodyellow",
"lightgray",
"lightgrey",
"lightgreen",
"lightpink",
"lightsalmon",
"lightseagreen",
"lightskyblue",
"lightslategray",
"lightslategrey",
"lightsteelblue",
"lightyellow",
"lime",
"limegreen",
"linen",
"magenta",
"maroon",
"mediumaquamarine",
"mediumblue",
"mediumorchid",
"mediumpurple",
"mediumseagreen",
"mediumslateblue",
"mediumspringgreen",
"mediumturquoise",
"mediumvioletred",
"midnightblue",
"mintcream",
"mistyrose",
"moccasin",
"navajowhite",
"navy",
"oldlace",
"olive",
"olivedrab",
"orange",
"orangered",
"orchid",
"palegoldenrod",
"palegreen",
"paleturquoise",
"palevioletred",
"papayawhip",
"peachpuff",
"peru",
"pink",
"plum",
"powderblue",
"purple",
"rebeccapurple",
"red",
"rosybrown",
"royalblue",
"saddlebrown",
"salmon",
"sandybrown",
"seagreen",
"seashell",
"sienna",
"silver",
"skyblue",
"slateblue",
"slategray",
"slategrey",
"snow",
"springgreen",
"steelblue",
"tan",
"teal",
"thistle",
"tomato",
"turquoise",
"violet",
"wheat",
"white",
"whitesmoke",
"yellow",
"yellowgreen",
}
var colorHexRegex = regexp.MustCompile(`^#(([0-9a-fA-F]{2}){3}|([0-9a-fA-F]){3})$`)

1092
d2graph/d2graph.go Normal file

File diff suppressed because it is too large Load diff

50
d2graph/d2graph_test.go Normal file
View file

@ -0,0 +1,50 @@
package d2graph_test
import (
"strings"
"testing"
"oss.terrastruct.com/diff"
"oss.terrastruct.com/d2/d2graph"
"oss.terrastruct.com/d2/d2parser"
)
func TestKey(t *testing.T) {
t.Parallel()
testCases := []struct {
name string
key string
exp string
}{
{
name: "simple",
key: "meow.foo.bar",
exp: "meow.foo.bar",
},
{
name: "specials_1",
key: `'null.$$$.---'''.",,,.{}{}-\\-><"`,
exp: `"null.$$$.---'".",,,.{}{}-\\-><"`,
},
{
name: "specials_2",
key: `"&&####;;".| ;;::** |`,
exp: `"&&####;;".";;::**"`,
},
}
for _, tc := range testCases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
k, err := d2parser.ParseKey(tc.key)
if err != nil {
t.Fatal(err)
}
diff.AssertStringEq(t, tc.exp, strings.Join(d2graph.Key(k), "."))
})
}
}

10
d2graph/font_helper.go Normal file
View file

@ -0,0 +1,10 @@
package d2graph
var systemFonts = []string{
"DEFAULT",
"SERIOUS",
"DIGITAL",
"EDUCATIONAL",
"NEWSPAPER",
"MONO",
}

159
d2graph/serde.go Normal file
View file

@ -0,0 +1,159 @@
package d2graph
import (
"encoding/json"
"oss.terrastruct.com/d2/lib/go2"
)
type SerializedGraph struct {
Root SerializedObject `json:"root"`
Edges []SerializedEdge `json:"edges"`
Objects []SerializedObject `json:"objects"`
}
type SerializedObject map[string]interface{}
type SerializedEdge map[string]interface{}
func DeserializeGraph(bytes []byte, g *Graph) error {
var sg *SerializedGraph
err := json.Unmarshal(bytes, &sg)
if err != nil {
return err
}
g.Root = &Object{
Graph: g,
Children: make(map[string]*Object),
}
idToObj := make(map[string]*Object)
idToObj[""] = g.Root
var objects []*Object
for _, so := range sg.Objects {
var o Object
if err := convert(so, &o); err != nil {
return err
}
objects = append(objects, &o)
idToObj[so["AbsID"].(string)] = &o
}
for _, so := range append(sg.Objects, sg.Root) {
if so["ChildrenArray"] != nil {
children := make(map[string]*Object)
var childrenArray []*Object
for _, id := range so["ChildrenArray"].([]interface{}) {
o := idToObj[id.(string)]
childrenArray = append(childrenArray, o)
children[id.(string)] = o
o.Parent = idToObj[so["AbsID"].(string)]
}
idToObj[so["AbsID"].(string)].Children = children
idToObj[so["AbsID"].(string)].ChildrenArray = childrenArray
}
}
var edges []*Edge
for _, se := range sg.Edges {
var e Edge
if err := convert(se, &e); err != nil {
return err
}
if se["Src"] != nil {
e.Src = idToObj[se["Src"].(string)]
}
if se["Dst"] != nil {
e.Dst = idToObj[se["Dst"].(string)]
}
edges = append(edges, &e)
}
g.Objects = objects
g.Edges = edges
return nil
}
func SerializeGraph(g *Graph) ([]byte, error) {
sg := SerializedGraph{}
root, err := toSerializedObject(g.Root)
if err != nil {
return nil, err
}
sg.Root = root
var sobjects []SerializedObject
for _, o := range g.Objects {
so, err := toSerializedObject(o)
if err != nil {
return nil, err
}
sobjects = append(sobjects, so)
}
sg.Objects = sobjects
var sedges []SerializedEdge
for _, e := range g.Edges {
se, err := toSerializedEdge(e)
if err != nil {
return nil, err
}
sedges = append(sedges, se)
}
sg.Edges = sedges
return json.Marshal(sg)
}
func toSerializedObject(o *Object) (SerializedObject, error) {
var so SerializedObject
if err := convert(o, &so); err != nil {
return nil, err
}
so["AbsID"] = o.AbsID()
if len(o.ChildrenArray) > 0 {
var children []string
for _, c := range o.ChildrenArray {
children = append(children, c.AbsID())
}
so["ChildrenArray"] = children
}
return so, nil
}
func toSerializedEdge(e *Edge) (SerializedEdge, error) {
var se SerializedEdge
if err := convert(e, &se); err != nil {
return nil, err
}
if e.Src != nil {
se["Src"] = go2.Pointer(e.Src.AbsID())
}
if e.Dst != nil {
se["Dst"] = go2.Pointer(e.Dst.AbsID())
}
return se, nil
}
func convert[T, Q any](from T, to *Q) error {
b, err := json.Marshal(from)
if err != nil {
return err
}
if err := json.Unmarshal(b, to); err != nil {
return err
}
return nil
}

55
d2graph/serde_test.go Normal file
View file

@ -0,0 +1,55 @@
package d2graph_test
import (
"strings"
"testing"
"github.com/stretchr/testify/assert"
"oss.terrastruct.com/d2/d2compiler"
"oss.terrastruct.com/d2/d2graph"
)
func TestSerialization(t *testing.T) {
t.Parallel()
g, err := d2compiler.Compile("", strings.NewReader("a.a.b -> a.a.c"), nil)
if err != nil {
t.Fatal(err)
}
asserts := func(g *d2graph.Graph) {
assert.Equal(t, 4, len(g.Objects))
assert.Equal(t, 1, len(g.Root.ChildrenArray))
assert.Equal(t, 1, len(g.Root.ChildrenArray[0].ChildrenArray))
assert.Equal(t, 2, len(g.Root.ChildrenArray[0].ChildrenArray[0].ChildrenArray))
assert.Equal(t,
g.Root.ChildrenArray[0],
g.Root.ChildrenArray[0].ChildrenArray[0].Parent,
)
assert.Equal(t,
g.Root,
g.Root.ChildrenArray[0].Parent,
)
assert.Equal(t, 1, len(g.Edges))
assert.Equal(t, "b", g.Edges[0].Src.ID)
assert.Equal(t, "c", g.Edges[0].Dst.ID)
}
asserts(g)
b, err := d2graph.SerializeGraph(g)
if err != nil {
t.Fatal(err)
}
var newG d2graph.Graph
err = d2graph.DeserializeGraph(b, &newG)
if err != nil {
t.Fatal(err)
}
asserts(&newG)
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,248 @@
package d2dagrelayout
import (
"context"
_ "embed"
"encoding/json"
"fmt"
"math"
"strings"
"cdr.dev/slog"
v8 "rogchap.com/v8go"
"oss.terrastruct.com/xdefer"
"oss.terrastruct.com/d2/d2graph"
"oss.terrastruct.com/d2/d2target"
"oss.terrastruct.com/d2/lib/geo"
"oss.terrastruct.com/d2/lib/go2"
"oss.terrastruct.com/d2/lib/label"
"oss.terrastruct.com/d2/lib/log"
"oss.terrastruct.com/d2/lib/shape"
)
//go:embed setup.js
var setupJS string
//go:embed dagre.js
var dagreJS string
type DagreNode struct {
X float64 `json:"x"`
Y float64 `json:"y"`
Width float64 `json:"width"`
Height float64 `json:"height"`
}
type DagreEdge struct {
Points []*geo.Point `json:"points"`
}
type dagreGraphAttrs struct {
// for a top to bottom graph: ranksep is y spacing, nodesep is x spacing, edgesep is x spacing
ranksep int
edgesep int
nodesep int
// graph direction: tb (top to bottom)| bt | lr | rl
rankdir string
}
func Layout(ctx context.Context, d2graph *d2graph.Graph) (err error) {
defer xdefer.Errorf(&err, "failed to dagre layout")
debugJS := false
v8ctx := v8.NewContext()
if _, err := v8ctx.RunScript(dagreJS, "dagre.js"); err != nil {
return err
}
if _, err := v8ctx.RunScript(setupJS, "setup.js"); err != nil {
return err
}
configJS := setGraphAttrs(dagreGraphAttrs{
ranksep: 100,
edgesep: 40,
nodesep: 60,
rankdir: "tb",
})
if _, err := v8ctx.RunScript(configJS, "config.js"); err != nil {
return err
}
loadScript := ""
for _, obj := range d2graph.Objects {
id := obj.AbsID()
loadScript += generateAddNodeLine(id, obj.Attributes.Label.Value, int(obj.Width), int(obj.Height))
if obj.Parent != d2graph.Root {
loadScript += generateAddParentLine(id, obj.Parent.AbsID())
}
}
for _, edge := range d2graph.Edges {
// dagre doesn't work with edges to containers so we connect container edges to their first child instead (going all the way down)
// we will chop the edge where it intersects the container border so it only shows the edge from the container
src := edge.Src
for len(src.Children) > 0 && src.Class == nil && src.SQLTable == nil {
src = src.ChildrenArray[0]
}
dst := edge.Dst
for len(dst.Children) > 0 && dst.Class == nil && dst.SQLTable == nil {
dst = dst.ChildrenArray[0]
}
if edge.SrcArrow && !edge.DstArrow {
// for `b <- a`, edge.Edge is `a -> b` and we expect this routing result
src, dst = dst, src
}
loadScript += generateAddEdgeLine(src.AbsID(), dst.AbsID(), edge.AbsID(), edge.Attributes.Label.Value)
}
if debugJS {
log.Debug(ctx, "script", slog.F("all", setupJS+configJS+loadScript))
}
if _, err := v8ctx.RunScript(loadScript, "load.js"); err != nil {
return err
}
if _, err := v8ctx.RunScript(`dagre.layout(g)`, "layout.js"); err != nil {
if debugJS {
log.Warn(ctx, "layout error", slog.F("err", err))
}
return err
}
// val, err := v8ctx.RunScript("JSON.stringify(dagre.graphlib.json.write(g))", "q.js")
// if err != nil {
// return err
// }
// log.Debug(ctx, "graph", slog.F("json", val.String()))
for i, obj := range d2graph.Objects {
val, err := v8ctx.RunScript(fmt.Sprintf("JSON.stringify(g.node(g.nodes()[%d]))", i), "value.js")
if err != nil {
return err
}
var dn DagreNode
if err := json.Unmarshal([]byte(val.String()), &dn); err != nil {
return err
}
// dagre gives center of node
obj.TopLeft = geo.NewPoint(math.Round(dn.X-dn.Width/2), math.Round(dn.Y-dn.Height/2))
obj.Width = dn.Width
obj.Height = dn.Height
if obj.LabelWidth != nil && obj.LabelHeight != nil {
if len(obj.ChildrenArray) > 0 {
obj.LabelPosition = go2.Pointer(string(label.InsideTopCenter))
} else {
obj.LabelPosition = go2.Pointer(string(label.InsideMiddleCenter))
}
}
if obj.Attributes.Icon != nil {
obj.IconPosition = go2.Pointer(string(label.InsideMiddleCenter))
}
}
for i, edge := range d2graph.Edges {
val, err := v8ctx.RunScript(fmt.Sprintf("JSON.stringify(g.edge(g.edges()[%d]))", i), "value.js")
if err != nil {
return err
}
var de DagreEdge
if err := json.Unmarshal([]byte(val.String()), &de); err != nil {
return err
}
points := make([]*geo.Point, len(de.Points))
for i := range de.Points {
if edge.SrcArrow && !edge.DstArrow {
points[len(de.Points)-i-1] = de.Points[i].Copy()
} else {
points[i] = de.Points[i].Copy()
}
}
startIndex, endIndex := 0, len(points)-1
start, end := points[startIndex], points[endIndex]
// chop where edge crosses the source/target boxes since container edges were routed to a descendant
for i := 1; i < len(points); i++ {
segment := *geo.NewSegment(points[i-1], points[i])
if intersections := edge.Src.Box.Intersections(segment); len(intersections) > 0 {
start = intersections[0]
startIndex = i - 1
}
if intersections := edge.Dst.Box.Intersections(segment); len(intersections) > 0 {
end = intersections[0]
endIndex = i
break
}
}
srcShape := shape.NewShape(d2target.DSL_SHAPE_TO_SHAPE_TYPE[strings.ToLower(edge.Src.Attributes.Shape.Value)], edge.Src.Box)
dstShape := shape.NewShape(d2target.DSL_SHAPE_TO_SHAPE_TYPE[strings.ToLower(edge.Dst.Attributes.Shape.Value)], edge.Dst.Box)
// trace the edge to the specific shape's border
points[startIndex] = shape.TraceToShapeBorder(srcShape, start, points[startIndex+1])
points[endIndex] = shape.TraceToShapeBorder(dstShape, end, points[endIndex-1])
points = points[startIndex : endIndex+1]
// build a curved path from the dagre route
vectors := make([]geo.Vector, 0, len(points)-1)
for i := 1; i < len(points); i++ {
vectors = append(vectors, points[i-1].VectorTo(points[i]))
}
path := make([]*geo.Point, 0)
path = append(path, points[0])
path = append(path, points[0].AddVector(vectors[0].Multiply(.8)))
for i := 1; i < len(vectors)-2; i++ {
p := points[i]
v := vectors[i]
path = append(path, p.AddVector(v.Multiply(.2)))
path = append(path, p.AddVector(v.Multiply(.5)))
path = append(path, p.AddVector(v.Multiply(.8)))
}
path = append(path, points[len(points)-2].AddVector(vectors[len(vectors)-1].Multiply(.2)))
path = append(path, points[len(points)-1])
edge.IsCurve = true
edge.Route = path
// compile needs to assign edge label positions
if edge.Attributes.Label.Value != "" {
edge.LabelPosition = go2.Pointer(string(label.InsideMiddleCenter))
}
}
return nil
}
func setGraphAttrs(attrs dagreGraphAttrs) string {
return fmt.Sprintf(`g.setGraph({
ranksep: %d,
edgesep: %d,
nodesep: %d,
rankdir: "%s",
});
`,
attrs.ranksep,
attrs.edgesep,
attrs.nodesep,
attrs.rankdir,
)
}
func generateAddNodeLine(id, label string, width, height int) string {
return fmt.Sprintf("g.setNode(`%s`, { label: `%s`, width: %d, height: %d });\n", id, label, width, height)
}
func generateAddParentLine(childID, parentID string) string {
return fmt.Sprintf("g.setParent(`%s`, `%s`);\n", childID, parentID)
}
func generateAddEdgeLine(fromID, toID, edgeID, label string) string {
// in dagre v is from, w is to, name is to uniquely identify
return fmt.Sprintf("g.setEdge({v:`%s`, w:`%s`, name:`%s`, label:`%s`});\n", fromID, toID, edgeID, label)
}

View file

@ -0,0 +1,7 @@
var g = new dagre.graphlib.Graph({ compound: true, multigraph: true });
g.setDefaultNodeLabel(function () {
return {};
});
g.setDefaultEdgeLabel(function () {
return {};
});

2102
d2oracle/edit.go Normal file

File diff suppressed because it is too large Load diff

4989
d2oracle/edit_test.go Normal file

File diff suppressed because it is too large Load diff

51
d2oracle/get.go Normal file
View file

@ -0,0 +1,51 @@
package d2oracle
import (
"fmt"
"oss.terrastruct.com/d2/d2graph"
"oss.terrastruct.com/d2/d2parser"
)
func GetParentID(g *d2graph.Graph, absID string) (string, error) {
mk, err := d2parser.ParseMapKey(absID)
if err != nil {
return "", err
}
obj, ok := g.Root.HasChild(d2graph.Key(mk.Key))
if !ok {
return "", fmt.Errorf("%v parent not found", absID)
}
return obj.Parent.AbsID(), nil
}
func GetObj(g *d2graph.Graph, absID string) *d2graph.Object {
mk, _ := d2parser.ParseMapKey(absID)
obj, _ := g.Root.HasChild(d2graph.Key(mk.Key))
return obj
}
func GetEdge(g *d2graph.Graph, absID string) *d2graph.Edge {
for _, e := range g.Edges {
if e.AbsID() == absID {
return e
}
}
return nil
}
func IsLabelKeyID(key, label string) bool {
mk, err := d2parser.ParseMapKey(key)
if err != nil {
return false
}
if len(mk.Edges) > 0 {
return false
}
if mk.Key == nil {
return false
}
return mk.Key.Path[len(mk.Key.Path)-1].Unbox().ScalarString() == label
}

20
d2oracle/get_test.go Normal file
View file

@ -0,0 +1,20 @@
package d2oracle_test
import (
"testing"
"github.com/stretchr/testify/assert"
"oss.terrastruct.com/d2/d2oracle"
)
func TestIsLabelKeyID(t *testing.T) {
t.Parallel()
assert.Equal(t, true, d2oracle.IsLabelKeyID("x", "x"))
assert.Equal(t, true, d2oracle.IsLabelKeyID("y.x", "x"))
assert.Equal(t, true, d2oracle.IsLabelKeyID(`x."y.x"`, "y.x"))
assert.Equal(t, false, d2oracle.IsLabelKeyID("x", "y"))
assert.Equal(t, false, d2oracle.IsLabelKeyID("x->y", "y"))
}

1707
d2parser/parse.go Normal file

File diff suppressed because it is too large Load diff

391
d2parser/parse_test.go Normal file
View file

@ -0,0 +1,391 @@
package d2parser_test
import (
"fmt"
"path/filepath"
"strings"
"testing"
"oss.terrastruct.com/diff"
"oss.terrastruct.com/d2/d2ast"
"oss.terrastruct.com/d2/d2parser"
)
// TODO: next step for parser is writing as many tests and grouping them nicely
// TODO: add assertions
// to layout *all* expected behavior.
func TestParse(t *testing.T) {
t.Parallel()
testCases := []struct {
name string
text string
// exp is in testdata/d2parser/TestParse/${name}.json
}{
{
name: "empty",
text: ``,
},
{
name: "semicolons",
text: `;;;;;`,
},
{
name: "bad_curly",
text: `;;;};;;`,
},
{
name: "one_line_comment",
text: `
# hello
`,
},
{
name: "multiline_comment",
text: `
# hello
# world
# earth
#
#globe
# very good
# not so bad
#
#yes indeed
#The good (I am convinced, for one)
#Is but the bad one leaves undone.
#Once your reputation's done
#You can live a life of fun.
# -- Wilhelm Busch
`,
},
{
name: "one_line_block_comment",
text: `
""" dmaslkmdlksa """
`,
},
{
name: "block_comment",
text: `
""" dmaslkmdlksa
dasmlkdas
mkdlasdmkas
dmsakldmklsadsa
dsmakldmaslk
damklsdmklas
echo hi
x """
""" ok
meow
"""
`,
},
{
name: "key",
text: `
x
`,
},
{
name: "edge",
text: `
x -> y
`,
},
{
name: "multiple_edges",
text: `
x -> y -> z
`,
},
{
name: "key_with_edge",
text: `
x.(z->q)
`,
},
{
name: "edge_key",
text: `
x.(z->q)[343].hola: false
`,
},
{
name: "subst",
text: `
x -> y: ${meow.ok}
`,
},
{
name: "primary",
text: `
x -> y: ${meow.ok} {
label: |
"Hi, I'm Preston A. Mantis, president of Consumers Retail Law Outlet. As you
can see by my suit and the fact that I have all these books of equal height
on the shelves behind me, I am a trained legal attorney. Do you have a car
or a job? Do you ever walk around? If so, you probably have the makings of
an excellent legal case. Although of course every case is different, I
would definitely say that based on my experience and training, there's no
reason why you shouldn't come out of this thing with at least a cabin
cruiser.
"Remember, at the Preston A. Mantis Consumers Retail Law Outlet, our motto
is: 'It is very difficult to disprove certain kinds of pain.'"
-- Dave Barry, "Pain and Suffering"
|
}
`,
},
{
name: "()_keys",
text: `
my_fn() -> wowa()
meow.(x -> y -> z)[3].shape: "all hail corn"
`,
},
{
name: "errs",
text: `
--: meow]]]
meow][: ok
ok: "dmsadmakls" dsamkldkmsa
s.shape: orochimaru
x.shape: dasdasdas
wow:
:
[]
{}
"""
wsup
"""
'
meow: ${ok}
meow.(x->)[:
x -> x
x: [][]𐀀𐀀𐀀𐀀𐀀𐀀
`,
},
{
name: "block_string",
text: `
x: ||
meow
meo
# ok
code
yes
||
x: || meow
meo
# ok
code
yes ||
# compat
x: |` + "`" + `
meow
meow
meow
` + "`" + `| {
}
`,
},
{
name: "trailing_whitespace",
text: `
s.shape: orochimaru
`,
},
{
name: "table_and_class",
text: `
sql_example: sql_example {
board: {
shape: sql_table
id: int {constraint: primary_key}
frame: int {constraint: foreign_key}
diagram: int {constraint: foreign_key}
board_objects: jsonb
last_updated: timestamp with time zone
last_thumbgen: timestamp with time zone
dsl: text
}
# Normal.
board.diagram -> diagrams.id
# Self referential.
diagrams.id -> diagrams.representation
# SrcArrow test.
diagrams.id <- views.diagram
diagrams.id <-> steps.diagram
diagrams: {
shape: sql_table
id: {type: int, constraint: primary_key}
representation: {type: jsonb}
}
views: {
shape: sql_table
id: {type: int, constraint: primary_key}
representation: {type: jsonb}
diagram: int {constraint: foreign_key}
}
# steps: {
# shape: sql_table
# id: {type: int, constraint: primary_key}
# representation: {type: jsonb}
# diagram: int {constraint: foreign_key}
# }
# Uncomment to make autolayout panic:
meow <- diagrams.id
}
D2 AST Parser: {
shape: class
+prevRune: rune
prevColumn: int
+eatSpace(eatNewlines bool): (rune, error)
unreadRune()
\#scanKey(r rune): (k Key, _ error)
}
`,
},
{
name: "missing_map_value",
text: `
x:
`,
},
{
name: "edge_line_continuation",
text: `
super long shape id here --\
-> super long shape id even longer here
`,
},
{
name: "edge_line_continuation_2",
text: `
super long shape id here --\
> super long shape id even longer here
`,
},
{
name: "field_line_continuation",
text: `
meow \
ok \
super: yes \
wow so cool
\
xd \
\
ok does it work: hopefully
`,
},
{
name: "block_with_delims",
text: `
a: ||
|pipe|
||
"""
b: ""
"""
`,
},
{
name: "block_one_line",
text: `
a: | hello |
""" hello """
`,
},
{
name: "block_trailing_space",
text: `
x: |
meow
|
""" hello
"""
`,
},
{
name: "block_edge_case",
text: `
x: | meow
hello
yes
|
`,
},
{
name: "single_quote_block_string",
text: `
x: |'
bs
'|
not part of block string
`,
},
{
name: "edge_group_value",
text: `
q.(x -> y).z: (rawr)
`,
},
}
for _, tc := range testCases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
d2Path := fmt.Sprintf("d2/testdata/d2parser/%v.d2", t.Name())
ast, err := d2parser.Parse(d2Path, strings.NewReader(tc.text), nil)
got := struct {
AST *d2ast.Map `json:"ast"`
Err error `json:"err"`
}{
AST: ast,
Err: err,
}
err = diff.Testdata(filepath.Join("..", "testdata", "d2parser", t.Name()), got)
if err != nil {
t.Fatal(err)
}
})
}
}

116
d2plugin/exec.go Normal file
View file

@ -0,0 +1,116 @@
package d2plugin
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"os/exec"
"time"
"oss.terrastruct.com/xdefer"
"oss.terrastruct.com/d2/d2graph"
)
// execPlugin uses the binary at pathname with the plugin protocol to implement
// the Plugin interface.
//
// The layout plugin protocol works as follows.
//
// Info
// 1. The binary is invoked with info as the first argument.
// 2. The stdout of the binary is unmarshalled into PluginInfo.
//
// Layout
// 1. The binary is invoked with layout as the first argument and the json marshalled
// d2graph.Graph on stdin.
// 2. The stdout of the binary is unmarshalled into a d2graph.Graph
//
// PostProcess
// 1. The binary is invoked with postprocess as the first argument and the
// bytes of the SVG render on stdin.
// 2. The stdout of the binary is bytes of SVG with any post-processing.
//
// If any errors occur the binary will exit with a non zero status code and write
// the error to stderr.
type execPlugin struct {
path string
}
func (p execPlugin) Info(ctx context.Context) (_ *PluginInfo, err error) {
ctx, cancel := context.WithTimeout(ctx, time.Second*10)
defer cancel()
cmd := exec.CommandContext(ctx, p.path, "info")
defer xdefer.Errorf(&err, "failed to run %v", cmd.Args)
stdout, err := cmd.Output()
if err != nil {
ee := &exec.ExitError{}
if errors.As(err, &ee) && len(ee.Stderr) > 0 {
return nil, fmt.Errorf("%v\nstderr:\n%s", ee, ee.Stderr)
}
return nil, err
}
var info PluginInfo
err = json.Unmarshal(stdout, &info)
if err != nil {
return nil, fmt.Errorf("failed to unmarshal json: %w", err)
}
return &info, nil
}
func (p execPlugin) Layout(ctx context.Context, g *d2graph.Graph) error {
ctx, cancel := context.WithTimeout(ctx, time.Minute)
defer cancel()
graphBytes, err := d2graph.SerializeGraph(g)
if err != nil {
return err
}
cmd := exec.CommandContext(ctx, p.path, "layout")
buffer := bytes.Buffer{}
buffer.Write(graphBytes)
cmd.Stdin = &buffer
stdout, err := cmd.Output()
if err != nil {
ee := &exec.ExitError{}
if errors.As(err, &ee) && len(ee.Stderr) > 0 {
return fmt.Errorf("%v\nstderr:\n%s", ee, ee.Stderr)
}
return err
}
err = d2graph.DeserializeGraph(stdout, g)
if err != nil {
return fmt.Errorf("failed to unmarshal json: %w", err)
}
return nil
}
func (p execPlugin) PostProcess(ctx context.Context, in []byte) ([]byte, error) {
ctx, cancel := context.WithTimeout(ctx, time.Minute)
defer cancel()
cmd := exec.CommandContext(ctx, p.path, "postprocess")
cmd.Stdin = bytes.NewBuffer(in)
stdout, err := cmd.Output()
if err != nil {
ee := &exec.ExitError{}
if errors.As(err, &ee) && len(ee.Stderr) > 0 {
return nil, fmt.Errorf("%v\nstderr:\n%s", ee, ee.Stderr)
}
return nil, err
}
return stdout, nil
}

110
d2plugin/plugin.go Normal file
View file

@ -0,0 +1,110 @@
// Package d2plugin enables the d2 CLI to run functions bundled
// with the d2 binary or via external plugin binaries.
//
// Binary plugins are stored in $PATH with the prefix d2plugin-*. i.e the binary for
// dagre might be d2plugin-dagre. See ListPlugins() below.
package d2plugin
import (
"context"
"os/exec"
"oss.terrastruct.com/d2/d2graph"
"oss.terrastruct.com/d2/lib/xexec"
)
// plugins contains the bundled d2 plugins.
//
// See plugin_* files for the plugins available for bundling.
var plugins []Plugin
type Plugin interface {
// Info returns the current info information of the plugin.
Info(context.Context) (*PluginInfo, error)
// Layout runs the plugin's autolayout algorithm on the input graph
// and returns a new graph with the computed placements.
Layout(context.Context, *d2graph.Graph) error
// PostProcess makes changes to the default render
PostProcess(context.Context, []byte) ([]byte, error)
}
// PluginInfo is the current info information of a plugin.
// note: The two fields Type and Path are not set by the plugin
// itself but only in ListPlugins.
type PluginInfo struct {
Name string `json:"name"`
ShortHelp string `json:"shortHelp"`
LongHelp string `json:"longHelp"`
// These two are set by ListPlugins and not the plugin itself.
// bundled | binary
Type string `json:"type"`
// If Type == binary then this contains the absolute path to the binary.
Path string `json:"path"`
}
const binaryPrefix = "d2plugin-"
func ListPlugins(ctx context.Context) ([]*PluginInfo, error) {
// 1. Run Info on all bundled plugins in the global plugins array.
// - set Type for each bundled plugin to "bundled".
// 2. Iterate through directories in $PATH and look for executables within these
// directories with the prefix d2plugin-*
// 3. Run each plugin binary with the argument info. e.g. d2plugin-dagre info
var infoSlice []*PluginInfo
for _, p := range plugins {
info, err := p.Info(ctx)
if err != nil {
return nil, err
}
info.Type = "bundled"
infoSlice = append(infoSlice, info)
}
matches, err := xexec.SearchPath(binaryPrefix)
if err != nil {
return nil, err
}
for _, path := range matches {
p := &execPlugin{path: path}
info, err := p.Info(ctx)
if err != nil {
return nil, err
}
info.Type = "binary"
info.Path = path
infoSlice = append(infoSlice, info)
}
return infoSlice, nil
}
// FindPlugin finds the plugin with the given name.
// 1. It first searches the bundled plugins in the global plugins slice.
// 2. If not found, it then searches each directory in $PATH for a binary with the name
// d2plugin-<name>.
// **NOTE** When D2 upgrades to go 1.19, remember to ignore exec.ErrDot
// 3. If such a binary is found, it builds an execPlugin in exec.go
// to get a plugin implementation around the binary and returns it.
func FindPlugin(ctx context.Context, name string) (Plugin, string, error) {
for _, p := range plugins {
info, err := p.Info(ctx)
if err != nil {
return nil, "", err
}
if info.Name == name {
return p, "", nil
}
}
path, err := exec.LookPath(binaryPrefix + name)
if err != nil {
return nil, "", err
}
return &execPlugin{path: path}, path, nil
}

41
d2plugin/plugin_dagre.go Normal file
View file

@ -0,0 +1,41 @@
//go:build cgo && !nodagre
package d2plugin
import (
"context"
"oss.terrastruct.com/d2/d2graph"
"oss.terrastruct.com/d2/d2layouts/d2dagrelayout"
)
var DagrePlugin = dagrePlugin{}
func init() {
plugins = append(plugins, DagrePlugin)
}
type dagrePlugin struct{}
func (p dagrePlugin) Info(context.Context) (*PluginInfo, error) {
return &PluginInfo{
Name: "dagre",
ShortHelp: "The directed graph layout library Dagre",
LongHelp: `dagre is a directed graph layout library for JavaScript.
See https://github.com/dagrejs/dagre
The implementation of this plugin is at: https://github.com/terrastruct/d2/tree/master/d2plugin/d2dagrelayout
note: dagre is the primary layout algorithm for text to diagram generator Mermaid.js.
See https://github.com/mermaid-js/mermaid
We have a useful comparison at https://text-to-diagram.com/?example=basic&a=d2&b=mermaid
`,
}, nil
}
func (p dagrePlugin) Layout(ctx context.Context, g *d2graph.Graph) error {
return d2dagrelayout.Layout(ctx, g)
}
func (p dagrePlugin) PostProcess(ctx context.Context, in []byte) ([]byte, error) {
return in, nil
}

96
d2plugin/serve.go Normal file
View file

@ -0,0 +1,96 @@
package d2plugin
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"oss.terrastruct.com/d2/d2graph"
"oss.terrastruct.com/d2/lib/xmain"
)
// Serve returns a xmain.RunFunc that will invoke the plugin p as necessary to service the
// calling d2 CLI.
//
// See implementation of d2plugin-dagre in the ./cmd directory.
//
// Also see execPlugin in exec.go for the d2 binary plugin protocol.
func Serve(p Plugin) func(context.Context, *xmain.State) error {
return func(ctx context.Context, ms *xmain.State) (err error) {
if len(ms.Args) < 1 {
return errors.New("expected first argument to plugin binary to be function name")
}
reqFunc := ms.Args[0]
switch ms.Args[0] {
case "info":
return info(ctx, p, ms)
case "layout":
return layout(ctx, p, ms)
case "postprocess":
return postProcess(ctx, p, ms)
default:
return fmt.Errorf("unrecognized command: %s", reqFunc)
}
}
}
func info(ctx context.Context, p Plugin, ms *xmain.State) error {
info, err := p.Info(ctx)
if err != nil {
return err
}
b, err := json.Marshal(info)
if err != nil {
return err
}
_, err = ms.Stdout.Write(b)
if err != nil {
return err
}
return nil
}
func layout(ctx context.Context, p Plugin, ms *xmain.State) error {
in, err := io.ReadAll(ms.Stdin)
if err != nil {
return err
}
var g d2graph.Graph
if err := d2graph.DeserializeGraph(in, &g); err != nil {
return fmt.Errorf("failed to unmarshal input to graph: %s", in)
}
err = p.Layout(ctx, &g)
if err != nil {
return err
}
b, err := d2graph.SerializeGraph(&g)
if err != nil {
return err
}
_, err = ms.Stdout.Write(b)
if err != nil {
return err
}
return nil
}
func postProcess(ctx context.Context, p Plugin, ms *xmain.State) error {
in, err := io.ReadAll(ms.Stdin)
if err != nil {
return err
}
out, err := p.PostProcess(ctx, in)
if err != nil {
return err
}
_, err = ms.Stdout.Write(out)
if err != nil {
return err
}
return nil
}

View file

@ -0,0 +1,6 @@
The fonts bundled with D2 are open-source fonts licensed under SIL OPEN FONT
LICENSE.
Attribution:
http://scripts.sil.org/OFL

View file

@ -0,0 +1,16 @@
# d2fonts
The SVG renderer embeds fonts directly into the SVG as base64 data. This is to give
determinstic outputs and load without a network call.
To include your own font, e.g. `Helvetica`, you must include the Truetype glyphs:
- `./ttf/Helvetica-Bold.ttf`
- `./ttf/Helvetica-Italic.ttf`
- `./ttf/Helvetica-Regular.ttf`
You must also include an encoded version of these of mimetype `application/font-woff`:
- `./ttf/Helvetica-Bold.txt`
- `./ttf/Helvetica-Italic.txt`
- `./ttf/Helvetica-Regular.txt`
If you include a font to contribute, it must have an open license.

View file

@ -0,0 +1,141 @@
// d2fonts holds fonts for renderings
// TODO write a script to do this as part of CI
package d2fonts
import (
"embed"
"strings"
)
type FontFamily int
type FontStyle string
type Font struct {
Family FontFamily
Style FontStyle
Size int
}
func (f FontFamily) Font(size int, style FontStyle) Font {
return Font{
Family: f,
Style: style,
Size: size,
}
}
const (
FONT_SIZE_XS = 13
FONT_SIZE_S = 14
FONT_SIZE_M = 16
FONT_SIZE_L = 20
FONT_SIZE_XL = 24
FONT_SIZE_XXL = 28
FONT_SIZE_XXXL = 32
FONT_STYLE_REGULAR FontStyle = "regular"
FONT_STYLE_BOLD FontStyle = "bold"
FONT_STYLE_ITALIC FontStyle = "italic"
SourceSansPro FontFamily = iota
SourceCodePro FontFamily = iota
)
var FontSizes = []int{
FONT_SIZE_XS,
FONT_SIZE_S,
FONT_SIZE_M,
FONT_SIZE_L,
FONT_SIZE_XL,
FONT_SIZE_XXL,
FONT_SIZE_XXXL,
}
var FontStyles = []FontStyle{
FONT_STYLE_REGULAR,
FONT_STYLE_BOLD,
FONT_STYLE_ITALIC,
}
var FontFamilies = []FontFamily{
SourceSansPro,
SourceCodePro,
}
//go:embed encoded/SourceSansPro-Regular.txt
var sourceSansProRegularBase64 string
//go:embed encoded/SourceSansPro-Bold.txt
var sourceSansProBoldBase64 string
//go:embed encoded/SourceSansPro-Italic.txt
var sourceSansProItalicBase64 string
//go:embed encoded/SourceCodePro-Regular.txt
var sourceCodeProRegularBase64 string
//go:embed ttf/*
var fontFacesFS embed.FS
var FontEncodings map[Font]string
var FontFaces map[Font][]byte
func init() {
FontEncodings = map[Font]string{
{
Family: SourceSansPro,
Style: FONT_STYLE_REGULAR,
}: sourceSansProRegularBase64,
{
Family: SourceSansPro,
Style: FONT_STYLE_BOLD,
}: sourceSansProBoldBase64,
{
Family: SourceSansPro,
Style: FONT_STYLE_ITALIC,
}: sourceSansProItalicBase64,
{
Family: SourceCodePro,
Style: FONT_STYLE_REGULAR,
}: sourceCodeProRegularBase64,
}
for k, v := range FontEncodings {
FontEncodings[k] = strings.TrimSuffix(v, "\n")
}
FontFaces = map[Font][]byte{}
b, err := fontFacesFS.ReadFile("ttf/SourceSansPro-Regular.ttf")
if err != nil {
panic(err)
}
FontFaces[Font{
Family: SourceSansPro,
Style: FONT_STYLE_REGULAR,
}] = b
b, err = fontFacesFS.ReadFile("ttf/SourceCodePro-Regular.ttf")
if err != nil {
panic(err)
}
FontFaces[Font{
Family: SourceCodePro,
Style: FONT_STYLE_REGULAR,
}] = b
b, err = fontFacesFS.ReadFile("ttf/SourceSansPro-Bold.ttf")
if err != nil {
panic(err)
}
FontFaces[Font{
Family: SourceSansPro,
Style: FONT_STYLE_BOLD,
}] = b
b, err = fontFacesFS.ReadFile("ttf/SourceSansPro-Italic.ttf")
if err != nil {
panic(err)
}
FontFaces[Font{
Family: SourceSansPro,
Style: FONT_STYLE_ITALIC,
}] = b
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -0,0 +1,6 @@
github-markdown.css derived and modified from https://github.com/sindresorhus/github-markdown-css.
Attribution:
MIT License
https://github.com/sindresorhus/github-markdown-css/blob/main/license

135
d2renderers/d2svg/class.go Normal file
View file

@ -0,0 +1,135 @@
package d2svg
import (
"fmt"
"io"
"strings"
"oss.terrastruct.com/d2/d2target"
"oss.terrastruct.com/d2/lib/geo"
"oss.terrastruct.com/d2/lib/label"
)
func classHeader(box *geo.Box, text string, textWidth, textHeight, fontSize float64) string {
str := fmt.Sprintf(`<rect class="class_header" x="%f" y="%f" width="%f" height="%f" fill="black" />`,
box.TopLeft.X, box.TopLeft.Y, box.Width, box.Height)
if text != "" {
tl := label.InsideMiddleCenter.GetPointOnBox(
box,
0,
textWidth,
textHeight,
)
str += fmt.Sprintf(`<text class="%s" x="%f" y="%f" style="%s">%s</text>`,
// TODO use monospace font
"text",
tl.X+textWidth/2,
tl.Y+textHeight*3/4,
fmt.Sprintf("text-anchor:%s;font-size:%vpx;fill:%s",
"middle",
4+fontSize,
"white",
),
escapeText(text),
)
}
return str
}
const (
prefixPadding = 10
prefixWidth = 20
typePadding = 20
)
func classRow(box *geo.Box, prefix, nameText, typeText string, fontSize float64) string {
// Row is made up of prefix, name, and type
// e.g. | + firstName string |
prefixTL := label.InsideMiddleLeft.GetPointOnBox(
box,
prefixPadding,
box.Width,
fontSize,
)
typeTR := label.InsideMiddleRight.GetPointOnBox(
box,
typePadding,
0,
fontSize,
)
accentColor := "rgb(13, 50, 178)"
return strings.Join([]string{
fmt.Sprintf(`<text class="text" x="%f" y="%f" style="%s">%s</text>`,
prefixTL.X,
prefixTL.Y+fontSize*3/4,
fmt.Sprintf("text-anchor:%s;font-size:%vpx;fill:%s", "start", fontSize, accentColor),
prefix,
),
fmt.Sprintf(`<text class="text" x="%f" y="%f" style="%s">%s</text>`,
prefixTL.X+prefixWidth,
prefixTL.Y+fontSize*3/4,
fmt.Sprintf("text-anchor:%s;font-size:%vpx;fill:%s", "start", fontSize, "black"),
escapeText(nameText),
),
fmt.Sprintf(`<text class="text" x="%f" y="%f" style="%s">%s</text>`,
typeTR.X,
typeTR.Y+fontSize*3/4,
fmt.Sprintf("text-anchor:%s;font-size:%vpx;fill:%s", "end", fontSize, accentColor),
escapeText(typeText),
),
}, "\n")
}
func visibilityToken(visibility string) string {
switch visibility {
case "protected":
return "#"
case "private":
return "-"
default:
return "+"
}
}
func drawClass(writer io.Writer, targetShape d2target.Shape) {
fmt.Fprintf(writer, `<rect class="shape" x="%d" y="%d" width="%d" height="%d" style="%s" />`,
targetShape.Pos.X, targetShape.Pos.Y, targetShape.Width, targetShape.Height, shapeStyle(targetShape))
box := geo.NewBox(
geo.NewPoint(float64(targetShape.Pos.X), float64(targetShape.Pos.Y)),
float64(targetShape.Width),
float64(targetShape.Height),
)
rowHeight := box.Height / float64(2+len(targetShape.Class.Fields)+len(targetShape.Class.Methods))
headerBox := geo.NewBox(box.TopLeft, box.Width, 2*rowHeight)
fmt.Fprint(writer,
classHeader(headerBox, targetShape.Label, float64(targetShape.LabelWidth), float64(targetShape.LabelHeight), float64(targetShape.FontSize)),
)
rowBox := geo.NewBox(box.TopLeft.Copy(), box.Width, rowHeight)
rowBox.TopLeft.Y += headerBox.Height
for _, f := range targetShape.Class.Fields {
fmt.Fprint(writer,
classRow(rowBox, visibilityToken(f.Visibility), f.Name, f.Type, float64(targetShape.FontSize)),
)
rowBox.TopLeft.Y += rowHeight
}
fmt.Fprintf(writer, `<line x1="%f" y1="%f" x2="%f" y2="%f" style="%s" />`,
rowBox.TopLeft.X, rowBox.TopLeft.Y,
rowBox.TopLeft.X+rowBox.Width, rowBox.TopLeft.Y,
fmt.Sprintf("stroke-width:1;stroke:%v", targetShape.Stroke))
for _, m := range targetShape.Class.Methods {
fmt.Fprint(writer,
classRow(rowBox, visibilityToken(m.Visibility), m.Name, m.Return, float64(targetShape.FontSize)),
)
rowBox.TopLeft.Y += rowHeight
}
}

53
d2renderers/d2svg/code.go Normal file
View file

@ -0,0 +1,53 @@
package d2svg
import (
"strings"
"github.com/alecthomas/chroma"
"github.com/alecthomas/chroma/formatters/svg"
)
// Copied private functions from chroma. Their public functions do too much (write the whole SVG document)
// https://github.com/alecthomas/chroma
// >>> BEGIN
var svgEscaper = strings.NewReplacer(
`&`, "&amp;",
`<`, "&lt;",
`>`, "&gt;",
`"`, "&quot;",
` `, "&#160;",
` `, "&#160;&#160;&#160;&#160;",
)
func styleToSVG(style *chroma.Style) map[chroma.TokenType]string {
converted := map[chroma.TokenType]string{}
bg := style.Get(chroma.Background)
// Convert the style.
for t := range chroma.StandardTypes {
entry := style.Get(t)
if t != chroma.Background {
entry = entry.Sub(bg)
}
if entry.IsZero() {
continue
}
converted[t] = svg.StyleEntryToSVG(entry)
}
return converted
}
func styleAttr(styles map[chroma.TokenType]string, tt chroma.TokenType) string {
if _, ok := styles[tt]; !ok {
tt = tt.SubCategory()
if _, ok := styles[tt]; !ok {
tt = tt.Category()
if _, ok := styles[tt]; !ok {
return ""
}
}
}
return styles[tt]
}
// <<< END

794
d2renderers/d2svg/d2svg.go Normal file
View file

@ -0,0 +1,794 @@
// d2svg implements an SVG renderer for d2 diagrams.
// The input is d2exporter's output
package d2svg
import (
"bytes"
_ "embed"
"encoding/xml"
"errors"
"fmt"
"hash/fnv"
"io"
"strings"
"math"
"github.com/alecthomas/chroma"
"github.com/alecthomas/chroma/formatters"
"github.com/alecthomas/chroma/lexers"
"github.com/alecthomas/chroma/styles"
"oss.terrastruct.com/d2/d2renderers/d2fonts"
"oss.terrastruct.com/d2/d2renderers/textmeasure"
"oss.terrastruct.com/d2/d2target"
"oss.terrastruct.com/d2/lib/geo"
"oss.terrastruct.com/d2/lib/go2"
"oss.terrastruct.com/d2/lib/label"
"oss.terrastruct.com/d2/lib/shape"
)
const (
padding = 100
MIN_ARROWHEAD_STROKE_WIDTH = 2
)
//go:embed github-markdown.css
var mdCSS string
func setViewbox(writer io.Writer, diagram *d2target.Diagram) (width int, height int) {
tl, br := diagram.BoundingBox()
w := br.X - tl.X + padding*2
h := br.Y - tl.Y + padding*2
// TODO minify
// TODO background stuff. e.g. dotted, grid, colors
fmt.Fprintf(writer, `<?xml version="1.0" encoding="utf-8"?>
<svg
style="background: white;"
xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
width="%d" height="%d" viewBox="%d %d %d %d">`, w, h, tl.X-padding, tl.Y-padding, w, h)
return w, h
}
func arrowheadMarkerID(isTarget bool, connection d2target.Connection) string {
var arrowhead d2target.Arrowhead
if isTarget {
arrowhead = connection.DstArrow
} else {
arrowhead = connection.SrcArrow
}
return fmt.Sprintf("mk-%s", hash(fmt.Sprintf("%s,%t,%d,%s",
arrowhead, isTarget, connection.StrokeWidth, connection.Stroke,
)))
}
func arrowheadDimensions(arrowhead d2target.Arrowhead, strokeWidth float64) (width, height float64) {
var widthMultiplier float64
var heightMultiplier float64
switch arrowhead {
case d2target.ArrowArrowhead:
widthMultiplier = 5
heightMultiplier = 5
case d2target.TriangleArrowhead:
widthMultiplier = 5
heightMultiplier = 6
case d2target.LineArrowhead:
widthMultiplier = 5
heightMultiplier = 8
case d2target.FilledDiamondArrowhead:
widthMultiplier = 11
heightMultiplier = 7
case d2target.DiamondArrowhead:
widthMultiplier = 11
heightMultiplier = 9
}
clippedStrokeWidth := go2.Max(MIN_ARROWHEAD_STROKE_WIDTH, strokeWidth)
return clippedStrokeWidth * widthMultiplier, clippedStrokeWidth * heightMultiplier
}
func arrowheadMarker(isTarget bool, id string, connection d2target.Connection) string {
arrowhead := connection.DstArrow
if !isTarget {
arrowhead = connection.SrcArrow
}
strokeWidth := float64(connection.StrokeWidth)
width, height := arrowheadDimensions(arrowhead, strokeWidth)
var path string
switch arrowhead {
case d2target.ArrowArrowhead:
attrs := fmt.Sprintf(`class="connection" fill="%s" stroke-width="%d"`, connection.Stroke, connection.StrokeWidth)
if isTarget {
path = fmt.Sprintf(`<polygon %s points="%f,%f %f,%f %f,%f %f,%f" />`,
attrs,
0., 0.,
width, height/2,
0., height,
width/4, height/2,
)
} else {
path = fmt.Sprintf(`<polygon %s points="%f,%f %f,%f %f,%f %f,%f" />`,
attrs,
0., height/2,
width, 0.,
width*3/4, height/2,
width, height,
)
}
case d2target.TriangleArrowhead:
attrs := fmt.Sprintf(`class="connection" fill="%s" stroke-width="%d"`, connection.Stroke, connection.StrokeWidth)
if isTarget {
path = fmt.Sprintf(`<polygon %s points="%f,%f %f,%f %f,%f" />`,
attrs,
0., 0.,
width, height/2.0,
0., height,
)
} else {
path = fmt.Sprintf(`<polygon %s points="%f,%f %f,%f %f,%f" />`,
attrs,
width, 0.,
0., height/2.0,
width, height,
)
}
case d2target.LineArrowhead:
attrs := fmt.Sprintf(`class="connection" fill="none" stroke="%s" stroke-width="%d"`, connection.Stroke, connection.StrokeWidth)
if isTarget {
path = fmt.Sprintf(`<polyline %s points="%f,%f %f,%f %f,%f"/>`,
attrs,
strokeWidth/2, strokeWidth/2,
width-strokeWidth/2, height/2,
strokeWidth/2, height-strokeWidth/2,
)
} else {
path = fmt.Sprintf(`<polyline %s points="%f,%f %f,%f %f,%f"/>`,
attrs,
width-strokeWidth/2, strokeWidth/2,
strokeWidth/2, height/2,
width-strokeWidth/2, height-strokeWidth/2,
)
}
case d2target.FilledDiamondArrowhead:
attrs := fmt.Sprintf(`class="connection" fill="%s" stroke-width="%d"`, connection.Stroke, connection.StrokeWidth)
if isTarget {
path = fmt.Sprintf(`<polygon %s points="%f,%f %f,%f %f,%f %f,%f" />`,
attrs,
0., height/2.0,
width/2.0, 0.,
width, height/2.0,
width/2.0, height,
)
} else {
path = fmt.Sprintf(`<polygon %s points="%f,%f %f,%f %f,%f %f,%f" />`,
attrs,
0., height/2.0,
width/2.0, 0.,
width, height/2.0,
width/2.0, height,
)
}
case d2target.DiamondArrowhead:
attrs := fmt.Sprintf(`class="connection" fill="white" stroke="%s" stroke-width="%d"`, connection.Stroke, connection.StrokeWidth)
if isTarget {
path = fmt.Sprintf(`<polygon %s points="%f,%f %f,%f %f,%f %f,%f" />`,
attrs,
0., height/2.0,
width/2, height/8,
width, height/2.0,
width/2.0, height*0.9,
)
} else {
path = fmt.Sprintf(`<polygon %s points="%f,%f %f,%f %f,%f %f,%f" />`,
attrs,
width/8, height/2.0,
width*0.6, height/8,
width*1.1, height/2.0,
width*0.6, height*7/8,
)
}
default:
return ""
}
var refX float64
refY := height / 2
switch arrowhead {
case d2target.DiamondArrowhead:
if isTarget {
refX = width - 0.6*strokeWidth
} else {
refX = width/8 + 0.6*strokeWidth
}
width *= 1.1
default:
if isTarget {
refX = width - 3/2*strokeWidth
} else {
refX = 3 / 2 * strokeWidth
}
}
return strings.Join([]string{
fmt.Sprintf(`<marker id="%s" markerWidth="%f" markerHeight="%f" refX="%f" refY="%f"`,
id, width, height, refX, refY,
),
fmt.Sprintf(`viewBox="%f %f %f %f"`, 0., 0., width, height),
`orient="auto" markerUnits="userSpaceOnUse">`,
path,
"</marker>",
}, " ")
}
// compute the (dx, dy) adjustment to apply to get the arrowhead-adjusted end point
func arrowheadAdjustment(start, end *geo.Point, arrowhead d2target.Arrowhead, strokeWidth int) *geo.Point {
distance := float64(strokeWidth) / 2.0
if arrowhead != d2target.NoArrowhead {
distance += float64(strokeWidth)
}
v := geo.NewVector(end.X-start.X, end.Y-start.Y)
return v.Unit().Multiply(-distance).ToPoint()
}
// returns the path's d attribute for the given connection
func pathData(connection d2target.Connection) string {
var path []string
route := connection.Route
sourceAdjustment := arrowheadAdjustment(route[0], route[1], connection.SrcArrow, connection.StrokeWidth)
path = append(path, fmt.Sprintf("M %f %f",
route[0].X-sourceAdjustment.X,
route[0].Y-sourceAdjustment.Y,
))
if connection.IsCurve {
i := 1
for ; i < len(route)-3; i += 3 {
path = append(path, fmt.Sprintf("C %f %f %f %f %f %f",
route[i].X, route[i].Y,
route[i+1].X, route[i+1].Y,
route[i+2].X, route[i+2].Y,
))
}
// final curve target adjustment
targetAdjustment := arrowheadAdjustment(route[i+1], route[i+2], connection.DstArrow, connection.StrokeWidth)
path = append(path, fmt.Sprintf("C %f %f %f %f %f %f",
route[i].X, route[i].Y,
route[i+1].X, route[i+1].Y,
route[i+2].X+targetAdjustment.X,
route[i+2].Y+targetAdjustment.Y,
))
} else {
for i := 1; i < len(route)-1; i++ {
prevSource := route[i-1]
prevTarget := route[i]
currTarget := route[i+1]
prevVector := prevSource.VectorTo(prevTarget)
currVector := prevTarget.VectorTo(currTarget)
dist := geo.EuclideanDistance(prevTarget.X, prevTarget.Y, currTarget.X, currTarget.Y)
units := math.Min(10, dist/2)
prevTranslations := prevVector.Unit().Multiply(units).ToPoint()
currTranslations := currVector.Unit().Multiply(units).ToPoint()
path = append(path, fmt.Sprintf("L %f %f",
prevTarget.X-prevTranslations.X,
prevTarget.Y-prevTranslations.Y,
))
// If the segment length is too small, instead of drawing 2 arcs, just skip this segment and bezier curve to the next one
if units < 10 && i < len(route)-2 {
nextTarget := route[i+2]
nextVector := geo.NewVector(nextTarget.X-currTarget.X, nextTarget.Y-currTarget.Y)
i++
nextTranslations := nextVector.Unit().Multiply(units).ToPoint()
// These 2 bezier control points aren't just at the corner -- they are reflected at the corner, which causes the curve to be ~tangent to the corner,
// which matches how the two arcs look
path = append(path, fmt.Sprintf("C %f %f %f %f %f %f",
// Control point
prevTarget.X+prevTranslations.X,
prevTarget.Y+prevTranslations.Y,
// Control point
currTarget.X-nextTranslations.X,
currTarget.Y-nextTranslations.Y,
// Where curve ends
currTarget.X+nextTranslations.X,
currTarget.Y+nextTranslations.Y,
))
} else {
path = append(path, fmt.Sprintf("S %f %f %f %f",
prevTarget.X,
prevTarget.Y,
prevTarget.X+currTranslations.X,
prevTarget.Y+currTranslations.Y,
))
}
}
lastPoint := route[len(route)-1]
secondToLastPoint := route[len(route)-2]
targetAdjustment := arrowheadAdjustment(secondToLastPoint, lastPoint, connection.DstArrow, connection.StrokeWidth)
path = append(path, fmt.Sprintf("L %f %f",
lastPoint.X+targetAdjustment.X,
lastPoint.Y+targetAdjustment.Y,
))
}
return strings.Join(path, " ")
}
func labelMask(id string, connection d2target.Connection, labelTL, tl, br *geo.Point) string {
width := br.X - tl.X
height := br.Y - tl.Y
return strings.Join([]string{
fmt.Sprintf(`<mask id="%s" maskUnits="userSpaceOnUse" x="%f" y="%f" width="%f" height="%f">`,
id, tl.X, tl.Y, width, height,
),
fmt.Sprintf(`<rect x="%f" y="%f" width="%f" height="%f" fill="white"></rect>`,
tl.X, tl.Y, width, height,
),
fmt.Sprintf(`<rect x="%f" y="%f" width="%d" height="%d" fill="black"></rect>`,
labelTL.X, labelTL.Y,
connection.LabelWidth,
connection.LabelHeight,
),
`</mask>`,
}, "\n")
}
func drawConnection(writer io.Writer, connection d2target.Connection, markers map[string]struct{}) {
var markerStart string
if connection.SrcArrow != d2target.NoArrowhead {
id := arrowheadMarkerID(false, connection)
if _, in := markers[id]; !in {
marker := arrowheadMarker(false, id, connection)
if marker == "" {
panic(fmt.Sprintf("received empty arrow head marker for: %#v", connection))
}
fmt.Fprint(writer, marker)
markers[id] = struct{}{}
}
markerStart = fmt.Sprintf(`marker-start="url(#%s)" `, id)
}
var markerEnd string
if connection.DstArrow != d2target.NoArrowhead {
id := arrowheadMarkerID(true, connection)
if _, in := markers[id]; !in {
marker := arrowheadMarker(true, id, connection)
if marker == "" {
panic(fmt.Sprintf("received empty arrow head marker for: %#v", connection))
}
fmt.Fprint(writer, marker)
markers[id] = struct{}{}
}
markerEnd = fmt.Sprintf(`marker-end="url(#%s)" `, id)
}
var labelTL *geo.Point
var mask string
if connection.Label != "" {
labelTL = connection.GetLabelTopLeft()
labelTL.X = math.Round(labelTL.X)
labelTL.Y = math.Round(labelTL.Y)
if label.Position(connection.LabelPosition).IsOnEdge() {
strokeWidth := float64(connection.StrokeWidth)
tl, br := geo.Route(connection.Route).GetBoundingBox()
tl.X -= strokeWidth
tl.Y -= strokeWidth
br.X += strokeWidth
br.Y += strokeWidth
if connection.SrcArrow != d2target.NoArrowhead {
width, height := arrowheadDimensions(connection.SrcArrow, strokeWidth)
tl.X -= width
tl.Y -= height
br.X += width
br.Y += height
}
if connection.DstArrow != d2target.NoArrowhead {
width, height := arrowheadDimensions(connection.DstArrow, strokeWidth)
tl.X -= width
tl.Y -= height
br.X += width
br.Y += height
}
tl.X = math.Min(tl.X, labelTL.X)
tl.Y = math.Min(tl.Y, labelTL.Y)
br.X = math.Max(br.X, labelTL.X+float64(connection.LabelWidth))
br.Y = math.Max(br.Y, labelTL.Y+float64(connection.LabelHeight))
maskID := fmt.Sprintf("mask-%s", hash(connection.ID))
fmt.Fprint(writer, labelMask(maskID, connection, labelTL, tl, br))
mask = fmt.Sprintf(`mask="url(#%s)" `, maskID)
}
}
fmt.Fprintf(writer, `<path d="%s" class="connection" style="fill:none;%s" %s%s%s/>`,
pathData(connection),
connectionStyle(connection),
markerStart,
markerEnd,
mask,
)
if connection.Label != "" {
fontClass := "text"
if connection.Bold {
fontClass += "-bold"
} else if connection.Italic {
fontClass += "-italic"
}
textStyle := fmt.Sprintf("text-anchor:%s;font-size:%vpx;fill:%s", "middle", connection.FontSize, "black")
x := labelTL.X + float64(connection.LabelWidth)/2
y := labelTL.Y + float64(connection.FontSize)
fmt.Fprintf(writer, `<text class="%s" x="%f" y="%f" style="%s">%s</text>`,
fontClass,
x, y,
textStyle,
renderText(connection.Label, x, float64(connection.LabelHeight)),
)
}
}
func drawShape(writer io.Writer, targetShape d2target.Shape) error {
tl := geo.NewPoint(float64(targetShape.Pos.X), float64(targetShape.Pos.Y))
width := float64(targetShape.Width)
height := float64(targetShape.Height)
style := shapeStyle(targetShape)
shapeType := d2target.DSL_SHAPE_TO_SHAPE_TYPE[targetShape.Type]
s := shape.NewShape(shapeType, geo.NewBox(tl, width, height))
switch targetShape.Type {
case d2target.ShapeClass:
drawClass(writer, targetShape)
return nil
case d2target.ShapeSQLTable:
drawTable(writer, targetShape)
return nil
case d2target.ShapeOval:
rx := width / 2
ry := height / 2
cx := tl.X + rx
cy := tl.Y + ry
fmt.Fprintf(writer, `<ellipse class="shape" cx="%f" cy="%f" rx="%f" ry="%f" style="%s" />`,
cx, cy, rx, ry, style,
)
case d2target.ShapeImage:
fmt.Fprintf(writer, `<image class="shape" href="%s" x="%d" y="%d" width="%d" height="%d" style="%s" />`,
targetShape.Icon.String(),
targetShape.Pos.X, targetShape.Pos.Y, targetShape.Width, targetShape.Height, style)
case d2target.ShapeText:
case d2target.ShapeCode:
// TODO should standardize "" to rectangle
case d2target.ShapeRectangle, "":
fmt.Fprintf(writer, `<rect class="shape" x="%d" y="%d" width="%d" height="%d" style="%s" />`,
targetShape.Pos.X, targetShape.Pos.Y, targetShape.Width, targetShape.Height, style)
default:
for _, pathData := range s.GetSVGPathData() {
fmt.Fprintf(writer, `<path class="shape" d="%s" style="%s" />`, pathData, style)
}
}
if targetShape.Icon != nil && targetShape.Type != d2target.ShapeImage {
iconPosition := label.Position(targetShape.IconPosition)
var box *geo.Box
if iconPosition.IsOutside() {
box = s.GetBox()
} else {
box = s.GetInnerBox()
}
iconSize := targetShape.GetIconSize(box)
tl := iconPosition.GetPointOnBox(box, label.PADDING, float64(iconSize), float64(iconSize))
fmt.Fprintf(writer, `<image href="%s" x="%f" y="%f" width="%d" height="%d" />`,
targetShape.Icon.String(),
tl.X,
tl.Y,
iconSize,
iconSize,
)
}
if targetShape.Label != "" {
labelPosition := label.Position(targetShape.LabelPosition)
var box *geo.Box
if labelPosition.IsOutside() {
box = s.GetBox()
} else {
box = s.GetInnerBox()
}
labelTL := labelPosition.GetPointOnBox(box, label.PADDING, float64(targetShape.LabelWidth), float64(targetShape.LabelHeight))
fontClass := "text"
if targetShape.Bold {
fontClass += "-bold"
} else if targetShape.Italic {
fontClass += "-italic"
}
switch targetShape.Type {
case d2target.ShapeCode:
lexer := lexers.Get(targetShape.Language)
if lexer == nil {
return fmt.Errorf("code snippet lexer for %s not found", targetShape.Language)
}
style := styles.Get("github")
if style == nil {
return errors.New(`code snippet style "github" not found`)
}
formatter := formatters.Get("svg")
if formatter == nil {
return errors.New(`code snippet formatter "svg" not found`)
}
iterator, err := lexer.Tokenise(nil, targetShape.Label)
if err != nil {
return err
}
svgStyles := styleToSVG(style)
containerStyle := fmt.Sprintf(`stroke: %s;fill:%s`, targetShape.Stroke, style.Get(chroma.Background).Background.String())
fmt.Fprintf(writer, `<g transform="translate(%f %f)" style="opacity:%f">`, box.TopLeft.X, box.TopLeft.Y, targetShape.Opacity)
fmt.Fprintf(writer, `<rect class="shape" width="%d" height="%d" style="%s" />`,
targetShape.Width, targetShape.Height, containerStyle)
// Padding
fmt.Fprintf(writer, `<g transform="translate(6 6)">`)
for index, tokens := range chroma.SplitTokensIntoLines(iterator.Tokens()) {
// TODO mono font looks better with 1.2 em (use px equivalent), but textmeasure needs to account for it. Not obvious how that should be done
fmt.Fprintf(writer, "<text class=\"text-mono\" x=\"0\" y=\"%fem\" xml:space=\"preserve\">", 1*float64(index+1))
for _, token := range tokens {
text := svgEscaper.Replace(token.String())
attr := styleAttr(svgStyles, token.Type)
if attr != "" {
text = fmt.Sprintf("<tspan %s>%s</tspan>", attr, text)
}
fmt.Fprint(writer, text)
}
fmt.Fprint(writer, "</text>")
}
fmt.Fprintf(writer, "</g></g>")
case d2target.ShapeText:
render, err := textmeasure.RenderMarkdown(targetShape.Label)
if err != nil {
return err
}
fmt.Fprintf(writer, `<g><foreignObject requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility" x="%f" y="%f" width="%d" height="%d">`,
box.TopLeft.X, box.TopLeft.Y, targetShape.Width, targetShape.Height,
)
// we need the self closing form in this svg/xhtml context
render = strings.ReplaceAll(render, "<hr>", "<hr />")
fmt.Fprintf(writer, `<div xmlns="http://www.w3.org/1999/xhtml" class="md">%v</div>`, render)
fmt.Fprint(writer, `</foreignObject></g>`)
default:
textStyle := fmt.Sprintf("text-anchor:%s;font-size:%vpx;fill:%s", "middle", targetShape.FontSize, "black")
x := labelTL.X + float64(targetShape.LabelWidth)/2
// text is vertically positioned at its baseline which is at labelTL+FontSize
y := labelTL.Y + float64(targetShape.FontSize)
fmt.Fprintf(writer, `<text class="%s" x="%f" y="%f" style="%s">%s</text>`,
fontClass,
x, y,
textStyle,
renderText(targetShape.Label, x, float64(targetShape.LabelHeight)),
)
}
}
return nil
}
func escapeText(text string) string {
buf := new(bytes.Buffer)
_ = xml.EscapeText(buf, []byte(text))
return buf.String()
}
func renderText(text string, x, height float64) string {
if !strings.Contains(text, "\n") {
return escapeText(text)
}
rendered := []string{}
lines := strings.Split(text, "\n")
for i, line := range lines {
dy := height / float64(len(lines))
if i == 0 {
dy = 0
}
escaped := escapeText(line)
if escaped == "" {
// if there are multiple newlines in a row we still need text for the tspan to render
escaped = " "
}
rendered = append(rendered, fmt.Sprintf(`<tspan x="%f" dy="%f">%s</tspan>`, x, dy, escaped))
}
return strings.Join(rendered, "")
}
func shapeStyle(shape d2target.Shape) string {
out := ""
out += fmt.Sprintf(`fill:%s;`, shape.Fill)
out += fmt.Sprintf(`stroke:%s;`, shape.Stroke)
out += fmt.Sprintf(`opacity:%f;`, shape.Opacity)
out += fmt.Sprintf(`stroke-width:%d;`, shape.StrokeWidth)
return out
}
func connectionStyle(connection d2target.Connection) string {
out := ""
out += fmt.Sprintf(`stroke:%s;`, connection.Stroke)
out += fmt.Sprintf(`opacity:%f;`, connection.Opacity)
out += fmt.Sprintf(`stroke-width:%d;`, connection.StrokeWidth)
if connection.StrokeDash != 0 {
dashSize, gapSize := getStrokeDashAttributes(float64(connection.StrokeWidth), connection.StrokeDash)
out += fmt.Sprintf(`stroke-dasharray:%f,%f;`, dashSize, gapSize)
}
return out
}
func getStrokeDashAttributes(strokeWidth, dashGapSize float64) (float64, float64) {
// as the stroke width gets thicker, the dash gap gets smaller
scale := math.Log10(-0.6*strokeWidth+10.6)*0.5 + 0.5
scaledDashSize := strokeWidth * dashGapSize
scaledGapSize := scale * scaledDashSize
return scaledDashSize, scaledGapSize
}
func embedFonts(buf *bytes.Buffer) {
content := buf.String()
buf.WriteString(`<style type="text/css"><![CDATA[`)
triggers := []string{
`class="text"`,
`class="md"`,
}
for _, t := range triggers {
if strings.Contains(content, t) {
fmt.Fprintf(buf, `
.text {
font-family: "font-regular";
}
@font-face {
font-family: font-regular;
src: url("%s");
}`,
d2fonts.FontEncodings[d2fonts.SourceSansPro.Font(0, d2fonts.FONT_STYLE_REGULAR)])
break
}
}
triggers = []string{
`class="text-bold"`,
`<b>`,
`<strong>`,
}
for _, t := range triggers {
if strings.Contains(content, t) {
fmt.Fprintf(buf, `
.text-bold {
font-family: "font-bold";
}
@font-face {
font-family: font-bold;
src: url("%s");
}`,
d2fonts.FontEncodings[d2fonts.SourceSansPro.Font(0, d2fonts.FONT_STYLE_BOLD)])
break
}
}
triggers = []string{
`class="text-italic"`,
`<em>`,
`<dfn>`,
}
for _, t := range triggers {
if strings.Contains(content, t) {
fmt.Fprintf(buf, `
.text-italic {
font-family: "font-italic";
}
@font-face {
font-family: font-italic;
src: url("%s");
}`,
d2fonts.FontEncodings[d2fonts.SourceSansPro.Font(0, d2fonts.FONT_STYLE_ITALIC)])
break
}
}
if strings.Contains(content, `class="text-mono"`) {
fmt.Fprintf(buf, `
.text-mono {
font-family: "font-mono";
}
@font-face {
font-family: font-mono;
src: url("%s");
}`,
d2fonts.FontEncodings[d2fonts.SourceCodePro.Font(0, d2fonts.FONT_STYLE_REGULAR)])
}
buf.WriteString(`]]></style>`)
}
// TODO minify output at end
func Render(diagram *d2target.Diagram) ([]byte, error) {
buf := &bytes.Buffer{}
_, _ = setViewbox(buf, diagram)
buf.WriteString(`<style type="text/css">
<![CDATA[
.shape {
shape-rendering: geometricPrecision;
}
.connection {
stroke-linecap: round;
stroke-linejoin: round;
}
]]>
</style>`)
hasMarkdown := false
for _, s := range diagram.Shapes {
if s.Label != "" && s.Type == d2target.ShapeText {
hasMarkdown = true
break
}
}
if hasMarkdown {
fmt.Fprintf(buf, `<style type="text/css">%s</style>`, mdCSS)
}
// SVG has no notion of z-index. The z-index is effectively the order it's drawn.
// So draw from the least nested to most nested
highest := 1
for _, s := range diagram.Shapes {
highest = go2.Max(highest, s.Level)
}
for i := 1; i <= highest; i++ {
for _, s := range diagram.Shapes {
if s.Level == i {
err := drawShape(buf, s)
if err != nil {
return nil, err
}
}
}
}
markers := map[string]struct{}{}
for _, c := range diagram.Connections {
drawConnection(buf, c, markers)
}
embedFonts(buf)
buf.WriteString(`</svg>`)
return buf.Bytes(), nil
}
func hash(s string) string {
const secret = "lalalas"
h := fnv.New32a()
h.Write([]byte(fmt.Sprintf("%s%s", s, secret)))
return fmt.Sprint(h.Sum32())
}

View file

@ -0,0 +1,785 @@
.md em,
.md dfn {
font-family: "font-italic";
}
.md b,
.md strong {
font-family: "font-bold";
}
/* based on https://github.com/sindresorhus/github-markdown-css */
@media (prefers-color-scheme: dark) {
.md {
color-scheme: dark;
--color-fg-default: #c9d1d9;
--color-fg-muted: #8b949e;
--color-fg-subtle: #484f58;
--color-canvas-default: #0d1117;
--color-canvas-subtle: #161b22;
--color-border-default: #30363d;
--color-border-muted: #21262d;
--color-neutral-muted: rgba(110, 118, 129, 0.4);
--color-accent-fg: #58a6ff;
--color-accent-emphasis: #1f6feb;
--color-attention-subtle: rgba(187, 128, 9, 0.15);
--color-danger-fg: #f85149;
}
}
@media (prefers-color-scheme: light) {
.md {
color-scheme: light;
--color-fg-default: #24292f;
--color-fg-muted: #57606a;
--color-fg-subtle: #6e7781;
--color-canvas-default: #ffffff;
--color-canvas-subtle: #f6f8fa;
--color-border-default: #d0d7de;
--color-border-muted: hsla(210, 18%, 87%, 1);
--color-neutral-muted: rgba(175, 184, 193, 0.2);
--color-accent-fg: #0969da;
--color-accent-emphasis: #0969da;
--color-attention-subtle: #fff8c5;
--color-danger-fg: #cf222e;
}
}
.md {
-ms-text-size-adjust: 100%;
-webkit-text-size-adjust: 100%;
margin: 0;
color: var(--color-fg-default);
background-color: var(--color-canvas-default);
font-family: "font-regular";
font-size: 16px;
line-height: 1.5;
word-wrap: break-word;
}
.md details,
.md figcaption,
.md figure {
display: block;
}
.md summary {
display: list-item;
}
.md [hidden] {
display: none !important;
}
.md a {
background-color: transparent;
color: var(--color-accent-fg);
text-decoration: none;
}
.md a:active,
.md a:hover {
outline-width: 0;
}
.md abbr[title] {
border-bottom: none;
text-decoration: underline dotted;
}
.md dfn {
font-style: italic;
}
.md h1 {
margin: 0.67em 0;
font-weight: 600;
padding-bottom: 0.3em;
font-size: 2em;
border-bottom: 1px solid var(--color-border-muted);
}
.md mark {
background-color: var(--color-attention-subtle);
color: var(--color-text-primary);
}
.md small {
font-size: 90%;
}
.md sub,
.md sup {
font-size: 75%;
line-height: 0;
position: relative;
vertical-align: baseline;
}
.md sub {
bottom: -0.25em;
}
.md sup {
top: -0.5em;
}
.md img {
border-style: none;
max-width: 100%;
box-sizing: content-box;
background-color: var(--color-canvas-default);
}
.md code,
.md kbd,
.md pre,
.md samp {
font-family: monospace, monospace;
font-size: 1em;
}
.md figure {
margin: 1em 40px;
}
.md hr {
box-sizing: content-box;
overflow: hidden;
background: transparent;
border-bottom: 1px solid var(--color-border-muted);
height: 0.25em;
padding: 0;
margin: 24px 0;
background-color: var(--color-border-default);
border: 0;
}
.md input {
font: inherit;
margin: 0;
overflow: visible;
font-family: inherit;
font-size: inherit;
line-height: inherit;
}
.md [type="button"],
.md [type="reset"],
.md [type="submit"] {
-webkit-appearance: button;
}
.md [type="button"]::-moz-focus-inner,
.md [type="reset"]::-moz-focus-inner,
.md [type="submit"]::-moz-focus-inner {
border-style: none;
padding: 0;
}
.md [type="button"]:-moz-focusring,
.md [type="reset"]:-moz-focusring,
.md [type="submit"]:-moz-focusring {
outline: 1px dotted ButtonText;
}
.md [type="checkbox"],
.md [type="radio"] {
box-sizing: border-box;
padding: 0;
}
.md [type="number"]::-webkit-inner-spin-button,
.md [type="number"]::-webkit-outer-spin-button {
height: auto;
}
.md [type="search"] {
-webkit-appearance: textfield;
outline-offset: -2px;
}
.md [type="search"]::-webkit-search-cancel-button,
.md [type="search"]::-webkit-search-decoration {
-webkit-appearance: none;
}
.md ::-webkit-input-placeholder {
color: inherit;
opacity: 0.54;
}
.md ::-webkit-file-upload-button {
-webkit-appearance: button;
font: inherit;
}
.md a:hover {
text-decoration: underline;
}
.md hr::before {
display: table;
content: "";
}
.md hr::after {
display: table;
clear: both;
content: "";
}
.md table {
border-spacing: 0;
border-collapse: collapse;
display: block;
width: max-content;
max-width: 100%;
overflow: auto;
}
.md td,
.md th {
padding: 0;
}
.md details summary {
cursor: pointer;
}
.md details:not([open]) > *:not(summary) {
display: none !important;
}
.md kbd {
display: inline-block;
padding: 3px 5px;
font: 11px ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, Liberation Mono,
monospace;
line-height: 10px;
color: var(--color-fg-default);
vertical-align: middle;
background-color: var(--color-canvas-subtle);
border: solid 1px var(--color-neutral-muted);
border-bottom-color: var(--color-neutral-muted);
border-radius: 6px;
box-shadow: inset 0 -1px 0 var(--color-neutral-muted);
}
.md h1,
.md h2,
.md h3,
.md h4,
.md h5,
.md h6 {
margin-top: 24px;
margin-bottom: 16px;
font-weight: 600;
line-height: 1.25;
}
.md h2 {
font-weight: 600;
padding-bottom: 0.3em;
font-size: 1.5em;
border-bottom: 1px solid var(--color-border-muted);
}
.md h3 {
font-weight: 600;
font-size: 1.25em;
}
.md h4 {
font-weight: 600;
font-size: 1em;
}
.md h5 {
font-weight: 600;
font-size: 0.875em;
}
.md h6 {
font-weight: 600;
font-size: 0.85em;
color: var(--color-fg-muted);
}
.md p {
margin-top: 0;
margin-bottom: 10px;
}
.md blockquote {
margin: 0;
padding: 0 1em;
color: var(--color-fg-muted);
border-left: 0.25em solid var(--color-border-default);
}
.md ul,
.md ol {
margin-top: 0;
margin-bottom: 0;
padding-left: 2em;
}
.md ol ol,
.md ul ol {
list-style-type: lower-roman;
}
.md ul ul ol,
.md ul ol ol,
.md ol ul ol,
.md ol ol ol {
list-style-type: lower-alpha;
}
.md dd {
margin-left: 0;
}
.md tt,
.md code {
font-family: ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, Liberation Mono,
monospace;
font-size: 12px;
}
.md pre {
margin-top: 0;
margin-bottom: 0;
font-family: ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, Liberation Mono,
monospace;
font-size: 12px;
word-wrap: normal;
}
.md ::placeholder {
color: var(--color-fg-subtle);
opacity: 1;
}
.md input::-webkit-outer-spin-button,
.md input::-webkit-inner-spin-button {
margin: 0;
-webkit-appearance: none;
appearance: none;
}
.md::before {
display: table;
content: "";
}
.md::after {
display: table;
clear: both;
content: "";
}
.md > *:first-child {
margin-top: 0 !important;
}
.md > *:last-child {
margin-bottom: 0 !important;
}
.md a:not([href]) {
color: inherit;
text-decoration: none;
}
.md .absent {
color: var(--color-danger-fg);
}
.md .anchor {
float: left;
padding-right: 4px;
margin-left: -20px;
line-height: 1;
}
.md .anchor:focus {
outline: none;
}
.md p,
.md blockquote,
.md ul,
.md ol,
.md dl,
.md table,
.md pre,
.md details {
margin-top: 0;
margin-bottom: 16px;
}
.md blockquote > :first-child {
margin-top: 0;
}
.md blockquote > :last-child {
margin-bottom: 0;
}
.md sup > a::before {
content: "[";
}
.md sup > a::after {
content: "]";
}
.md h1:hover .anchor,
.md h2:hover .anchor,
.md h3:hover .anchor,
.md h4:hover .anchor,
.md h5:hover .anchor,
.md h6:hover .anchor {
text-decoration: none;
}
.md h1 tt,
.md h1 code,
.md h2 tt,
.md h2 code,
.md h3 tt,
.md h3 code,
.md h4 tt,
.md h4 code,
.md h5 tt,
.md h5 code,
.md h6 tt,
.md h6 code {
padding: 0 0.2em;
font-size: inherit;
}
.md ul.no-list,
.md ol.no-list {
padding: 0;
list-style-type: none;
}
.md ol[type="1"] {
list-style-type: decimal;
}
.md ol[type="a"] {
list-style-type: lower-alpha;
}
.md ol[type="i"] {
list-style-type: lower-roman;
}
.md div > ol:not([type]) {
list-style-type: decimal;
}
.md ul ul,
.md ul ol,
.md ol ol,
.md ol ul {
margin-top: 0;
margin-bottom: 0;
}
.md li > p {
margin-top: 16px;
}
.md li + li {
margin-top: 0.25em;
}
.md dl {
padding: 0;
}
.md dl dt {
padding: 0;
margin-top: 16px;
font-size: 1em;
font-style: italic;
font-weight: 600;
}
.md dl dd {
padding: 0 16px;
margin-bottom: 16px;
}
.md table th {
font-weight: 600;
}
.md table th,
.md table td {
padding: 6px 13px;
border: 1px solid var(--color-border-default);
}
.md table tr {
background-color: var(--color-canvas-default);
border-top: 1px solid var(--color-border-muted);
}
.md table tr:nth-child(2n) {
background-color: var(--color-canvas-subtle);
}
.md table img {
background-color: transparent;
}
.md img[align="right"] {
padding-left: 20px;
}
.md img[align="left"] {
padding-right: 20px;
}
.md span.frame {
display: block;
overflow: hidden;
}
.md span.frame > span {
display: block;
float: left;
width: auto;
padding: 7px;
margin: 13px 0 0;
overflow: hidden;
border: 1px solid var(--color-border-default);
}
.md span.frame span img {
display: block;
float: left;
}
.md span.frame span span {
display: block;
padding: 5px 0 0;
clear: both;
color: var(--color-fg-default);
}
.md span.align-center {
display: block;
overflow: hidden;
clear: both;
}
.md span.align-center > span {
display: block;
margin: 13px auto 0;
overflow: hidden;
text-align: center;
}
.md span.align-center span img {
margin: 0 auto;
text-align: center;
}
.md span.align-right {
display: block;
overflow: hidden;
clear: both;
}
.md span.align-right > span {
display: block;
margin: 13px 0 0;
overflow: hidden;
text-align: right;
}
.md span.align-right span img {
margin: 0;
text-align: right;
}
.md span.float-left {
display: block;
float: left;
margin-right: 13px;
overflow: hidden;
}
.md span.float-left span {
margin: 13px 0 0;
}
.md span.float-right {
display: block;
float: right;
margin-left: 13px;
overflow: hidden;
}
.md span.float-right > span {
display: block;
margin: 13px auto 0;
overflow: hidden;
text-align: right;
}
.md code,
.md tt {
padding: 0.2em 0.4em;
margin: 0;
font-size: 85%;
background-color: var(--color-neutral-muted);
border-radius: 6px;
}
.md code br,
.md tt br {
display: none;
}
.md del code {
text-decoration: inherit;
}
.md pre code {
font-size: 100%;
}
.md pre > code {
padding: 0;
margin: 0;
word-break: normal;
white-space: pre;
background: transparent;
border: 0;
}
.md .highlight {
margin-bottom: 16px;
}
.md .highlight pre {
margin-bottom: 0;
word-break: normal;
}
.md .highlight pre,
.md pre {
padding: 16px;
overflow: auto;
font-size: 85%;
line-height: 1.45;
background-color: var(--color-canvas-subtle);
border-radius: 6px;
}
.md pre code,
.md pre tt {
display: inline;
max-width: auto;
padding: 0;
margin: 0;
overflow: visible;
line-height: inherit;
word-wrap: normal;
background-color: transparent;
border: 0;
}
.md .csv-data td,
.md .csv-data th {
padding: 5px;
overflow: hidden;
font-size: 12px;
line-height: 1;
text-align: left;
white-space: nowrap;
}
.md .csv-data .blob-num {
padding: 10px 8px 9px;
text-align: right;
background: var(--color-canvas-default);
border: 0;
}
.md .csv-data tr {
border-top: 0;
}
.md .csv-data th {
font-weight: 600;
background: var(--color-canvas-subtle);
border-top: 0;
}
.md .footnotes {
font-size: 12px;
color: var(--color-fg-muted);
border-top: 1px solid var(--color-border-default);
}
.md .footnotes ol {
padding-left: 16px;
}
.md .footnotes li {
position: relative;
}
.md .footnotes li:target::before {
position: absolute;
top: -8px;
right: -8px;
bottom: -8px;
left: -24px;
pointer-events: none;
content: "";
border: 2px solid var(--color-accent-emphasis);
border-radius: 6px;
}
.md .footnotes li:target {
color: var(--color-fg-default);
}
.md .task-list-item {
list-style-type: none;
}
.md .task-list-item label {
font-weight: 400;
}
.md .task-list-item.enabled label {
cursor: pointer;
}
.md .task-list-item + .task-list-item {
margin-top: 3px;
}
.md .task-list-item .handle {
display: none;
}
.md .task-list-item-checkbox {
margin: 0 0.2em 0.25em -1.6em;
vertical-align: middle;
}
.md .contains-task-list:dir(rtl) .task-list-item-checkbox {
margin: 0 -1.6em 0.25em 0.2em;
}

136
d2renderers/d2svg/table.go Normal file
View file

@ -0,0 +1,136 @@
package d2svg
import (
"fmt"
"io"
"math"
"strings"
"oss.terrastruct.com/d2/d2target"
"oss.terrastruct.com/d2/lib/geo"
"oss.terrastruct.com/d2/lib/label"
)
func tableHeader(box *geo.Box, text string, textWidth, textHeight, fontSize float64) string {
str := fmt.Sprintf(`<rect class="class_header" x="%f" y="%f" width="%f" height="%f" fill="%s" />`,
box.TopLeft.X, box.TopLeft.Y, box.Width, box.Height, "#0a0f25")
if text != "" {
tl := label.InsideMiddleLeft.GetPointOnBox(
box,
20,
textWidth,
textHeight,
)
str += fmt.Sprintf(`<text class="%s" x="%f" y="%f" style="%s">%s</text>`,
"text",
tl.X,
tl.Y+textHeight*3/4,
fmt.Sprintf("text-anchor:%s;font-size:%vpx;fill:%s",
"start",
4+fontSize,
"white",
),
escapeText(text),
)
}
return str
}
func tableRow(box *geo.Box, nameText, typeText, constraintText string, fontSize, longestNameWidth float64) string {
// Row is made up of name, type, and constraint
// e.g. | diagram int FK |
nameTL := label.InsideMiddleLeft.GetPointOnBox(
box,
prefixPadding,
box.Width,
fontSize,
)
constraintTR := label.InsideMiddleRight.GetPointOnBox(
box,
typePadding,
0,
fontSize,
)
// TODO theme based
primaryColor := "rgb(13, 50, 178)"
accentColor := "rgb(74, 111, 243)"
neutralColor := "rgb(103, 108, 126)"
return strings.Join([]string{
fmt.Sprintf(`<text class="text" x="%f" y="%f" style="%s">%s</text>`,
nameTL.X,
nameTL.Y+fontSize*3/4,
fmt.Sprintf("text-anchor:%s;font-size:%vpx;fill:%s", "start", fontSize, primaryColor),
escapeText(nameText),
),
// TODO light font
fmt.Sprintf(`<text class="text" x="%f" y="%f" style="%s">%s</text>`,
nameTL.X+longestNameWidth,
nameTL.Y+fontSize*3/4,
fmt.Sprintf("text-anchor:%s;font-size:%vpx;fill:%s", "start", fontSize, neutralColor),
escapeText(typeText),
),
fmt.Sprintf(`<text class="text" x="%f" y="%f" style="%s">%s</text>`,
constraintTR.X,
constraintTR.Y+fontSize*3/4,
fmt.Sprintf("text-anchor:%s;font-size:%vpx;fill:%s;letter-spacing:2px;", "end", fontSize, accentColor),
constraintText,
),
}, "\n")
}
func constraintAbbr(constraint string) string {
switch constraint {
case "primary_key":
return "PK"
case "foreign_key":
return "FK"
case "unique":
return "UNQ"
default:
return ""
}
}
func drawTable(writer io.Writer, targetShape d2target.Shape) {
fmt.Fprintf(writer, `<rect class="shape" x="%d" y="%d" width="%d" height="%d" style="%s" />`,
targetShape.Pos.X, targetShape.Pos.Y, targetShape.Width, targetShape.Height, shapeStyle(targetShape))
box := geo.NewBox(
geo.NewPoint(float64(targetShape.Pos.X), float64(targetShape.Pos.Y)),
float64(targetShape.Width),
float64(targetShape.Height),
)
rowHeight := box.Height / float64(1+len(targetShape.SQLTable.Columns))
headerBox := geo.NewBox(box.TopLeft, box.Width, rowHeight)
fmt.Fprint(writer,
tableHeader(headerBox, targetShape.Label, float64(targetShape.LabelWidth), float64(targetShape.LabelHeight), float64(targetShape.FontSize)),
)
fontSize := float64(targetShape.FontSize)
var longestNameWidth float64
for _, f := range targetShape.SQLTable.Columns {
// TODO measure text
longestNameWidth = math.Max(longestNameWidth, float64(len(f.Name))*fontSize*5/9)
}
rowBox := geo.NewBox(box.TopLeft.Copy(), box.Width, rowHeight)
rowBox.TopLeft.Y += headerBox.Height
for _, f := range targetShape.SQLTable.Columns {
fmt.Fprint(writer,
tableRow(rowBox, f.Name, f.Type, constraintAbbr(f.Constraint), fontSize, longestNameWidth),
)
rowBox.TopLeft.Y += rowHeight
fmt.Fprintf(writer, `<line x1="%f" y1="%f" x2="%f" y2="%f" style="stroke-width:2;stroke:#0a0f25" />`,
rowBox.TopLeft.X, rowBox.TopLeft.Y,
rowBox.TopLeft.X+rowBox.Width, rowBox.TopLeft.Y,
)
}
}

View file

@ -0,0 +1,7 @@
Parts of this package have been derived and modified from a portion of
https://github.com/faiface/pixel.
Attribution:
MIT License
https://github.com/faiface/pixel/blob/master/LICENSE

View file

@ -0,0 +1,242 @@
package textmeasure
import (
"sort"
"unicode"
"golang.org/x/image/font"
"golang.org/x/image/math/fixed"
"oss.terrastruct.com/d2/lib/geo"
)
// glyph describes one glyph in an atlas.
type glyph struct {
dot *geo.Point
frame *rect
advance float64
}
// atlas is a set of pre-drawn glyphs of a fixed set of runes. This allows for efficient text drawing.
type atlas struct {
face font.Face
mapping map[rune]glyph
ascent float64
descent float64
lineHeight float64
}
// NewAtlas creates a new atlas containing glyphs of the union of the given sets of runes (plus
// unicode.ReplacementChar) from the given font face.
//
// Creating an atlas is rather expensive, do not create a new atlas each frame.
//
// Do not destroy or close the font.Face after creating the atlas. atlas still uses it.
func NewAtlas(face font.Face, runeSets ...[]rune) *atlas {
seen := make(map[rune]bool)
runes := []rune{unicode.ReplacementChar}
for _, set := range runeSets {
for _, r := range set {
if !seen[r] {
runes = append(runes, r)
seen[r] = true
}
}
}
fixedMapping, fixedBounds := makeSquareMapping(face, runes, fixed.I(2))
bounds := &rect{
tl: geo.NewPoint(
i2f(fixedBounds.Min.X),
i2f(fixedBounds.Min.Y),
),
br: geo.NewPoint(
i2f(fixedBounds.Max.X),
i2f(fixedBounds.Max.Y),
),
}
mapping := make(map[rune]glyph)
for r, fg := range fixedMapping {
mapping[r] = glyph{
dot: geo.NewPoint(
i2f(fg.dot.X),
bounds.br.Y-(i2f(fg.dot.Y)-bounds.tl.Y),
),
frame: rect{
tl: geo.NewPoint(
i2f(fg.frame.Min.X),
bounds.br.Y-(i2f(fg.frame.Min.Y)-bounds.tl.Y),
),
br: geo.NewPoint(
i2f(fg.frame.Max.X),
bounds.br.Y-(i2f(fg.frame.Max.Y)-bounds.tl.Y),
),
}.norm(),
advance: i2f(fg.advance),
}
}
return &atlas{
face: face,
mapping: mapping,
ascent: i2f(face.Metrics().Ascent),
descent: i2f(face.Metrics().Descent),
lineHeight: i2f(face.Metrics().Height),
}
}
func (a *atlas) contains(r rune) bool {
_, ok := a.mapping[r]
return ok
}
// glyph returns the description of r within the atlas.
func (a *atlas) glyph(r rune) glyph {
return a.mapping[r]
}
// Kern returns the kerning distance between runes r0 and r1. Positive distance means that the
// glyphs should be further apart.
func (a *atlas) Kern(r0, r1 rune) float64 {
return i2f(a.face.Kern(r0, r1))
}
// Ascent returns the distance from the top of the line to the baseline.
func (a *atlas) Ascent() float64 {
return a.ascent
}
// Descent returns the distance from the baseline to the bottom of the line.
func (a *atlas) Descent() float64 {
return a.descent
}
// DrawRune returns parameters necessary for drawing a rune glyph.
//
// Rect is a rectangle where the glyph should be positioned. frame is the glyph frame inside the
// atlas's Picture. NewDot is the new position of the dot.
func (a *atlas) DrawRune(prevR, r rune, dot *geo.Point) (rect2, frame, bounds *rect, newDot *geo.Point) {
if !a.contains(r) {
r = unicode.ReplacementChar
}
if !a.contains(unicode.ReplacementChar) {
return newRect(), newRect(), newRect(), dot
}
if !a.contains(prevR) {
prevR = unicode.ReplacementChar
}
if prevR >= 0 {
dot.X += a.Kern(prevR, r)
}
glyph := a.glyph(r)
subbed := geo.NewPoint(
dot.X-glyph.dot.X,
dot.Y-glyph.dot.Y,
)
rect2 = &rect{
tl: geo.NewPoint(
glyph.frame.tl.X+subbed.X,
glyph.frame.tl.Y+subbed.Y,
),
br: geo.NewPoint(
glyph.frame.br.X+subbed.X,
glyph.frame.br.Y+subbed.Y,
),
}
bounds = rect2
if bounds.w()*bounds.h() != 0 {
bounds = &rect{
tl: geo.NewPoint(
bounds.tl.X,
dot.Y-a.Descent(),
),
br: geo.NewPoint(
bounds.br.X,
dot.Y+a.Ascent(),
),
}
}
dot.X += glyph.advance
return rect2, glyph.frame, bounds, dot
}
type fixedGlyph struct {
dot fixed.Point26_6
frame fixed.Rectangle26_6
advance fixed.Int26_6
}
// makeSquareMapping finds an optimal glyph arrangement of the given runes, so that their common
// bounding box is as square as possible.
func makeSquareMapping(face font.Face, runes []rune, padding fixed.Int26_6) (map[rune]fixedGlyph, fixed.Rectangle26_6) {
width := sort.Search(int(fixed.I(1024*1024)), func(i int) bool {
width := fixed.Int26_6(i)
_, bounds := makeMapping(face, runes, padding, width)
return bounds.Max.X-bounds.Min.X >= bounds.Max.Y-bounds.Min.Y
})
return makeMapping(face, runes, padding, fixed.Int26_6(width))
}
// makeMapping arranges glyphs of the given runes into rows in such a way, that no glyph is located
// fully to the right of the specified width. Specifically, it places glyphs in a row one by one and
// once it reaches the specified width, it starts a new row.
func makeMapping(face font.Face, runes []rune, padding, width fixed.Int26_6) (map[rune]fixedGlyph, fixed.Rectangle26_6) {
mapping := make(map[rune]fixedGlyph)
bounds := fixed.Rectangle26_6{}
dot := fixed.P(0, 0)
for _, r := range runes {
b, advance, ok := face.GlyphBounds(r)
if !ok {
continue
}
// this is important for drawing, artifacts arise otherwise
frame := fixed.Rectangle26_6{
Min: fixed.P(b.Min.X.Floor(), b.Min.Y.Floor()),
Max: fixed.P(b.Max.X.Ceil(), b.Max.Y.Ceil()),
}
dot.X -= frame.Min.X
frame = frame.Add(dot)
mapping[r] = fixedGlyph{
dot: dot,
frame: frame,
advance: advance,
}
bounds = bounds.Union(frame)
dot.X = frame.Max.X
// padding + align to integer
dot.X += padding
dot.X = fixed.I(dot.X.Ceil())
// width exceeded, new row
if frame.Max.X >= width {
dot.X = 0
dot.Y += face.Metrics().Ascent + face.Metrics().Descent
// padding + align to integer
dot.Y += padding
dot.Y = fixed.I(dot.Y.Ceil())
}
}
return mapping, bounds
}
func i2f(i fixed.Int26_6) float64 {
return float64(i) / (1 << 6)
}

View file

@ -0,0 +1,325 @@
package textmeasure
import (
"bytes"
"math"
"strings"
"unicode/utf8"
"github.com/PuerkitoBio/goquery"
"github.com/yuin/goldmark"
goldmarkHtml "github.com/yuin/goldmark/renderer/html"
"golang.org/x/net/html"
"oss.terrastruct.com/d2/d2renderers/d2fonts"
"oss.terrastruct.com/d2/lib/go2"
)
var markdownRenderer goldmark.Markdown
const (
MarkdownFontSize = d2fonts.FONT_SIZE_M
MarkdownLineHeight = 1.5
MarkdownLineHeightPx = MarkdownFontSize * MarkdownLineHeight
PaddingLeft_ul_ol = 32
MarginBottom_ul = MarkdownFontSize
MarginTop_li_p = MarkdownFontSize
MarginBottom_p = MarkdownFontSize
LineHeight_h = 1.25
MarginTop_h = 24
MarginBottom_h = 16
PaddingBottom_h1_h2_em = 0.3
Height_hr = 4
MarginTopBottom_hr = 24
Padding_pre = 16
MarginBottom_pre = 16
PaddingLR_blockquote_em = 1.
MarginBottom_blockquote = 16
BorderLeft_blockquote_em = 0.25
FONT_SIZE_H1 = d2fonts.FONT_SIZE_XXXL
FONT_SIZE_H2 = d2fonts.FONT_SIZE_XL
FONT_SIZE_H3 = d2fonts.FONT_SIZE_L
FONT_SIZE_H4 = d2fonts.FONT_SIZE_M
FONT_SIZE_H5 = d2fonts.FONT_SIZE_S
FONT_SIZE_H6 = d2fonts.FONT_SIZE_XS
)
var HeaderToFontSize = map[string]int{
"h1": FONT_SIZE_H1,
"h2": FONT_SIZE_H2,
"h3": FONT_SIZE_H3,
"h4": FONT_SIZE_H4,
"h5": FONT_SIZE_H5,
"h6": FONT_SIZE_H6,
}
var HeaderFonts map[string]d2fonts.Font
func RenderMarkdown(m string) (string, error) {
var output bytes.Buffer
if err := markdownRenderer.Convert([]byte(m), &output); err != nil {
return "", err
}
return output.String(), nil
}
func init() {
HeaderFonts = make(map[string]d2fonts.Font)
for header, fontSize := range HeaderToFontSize {
HeaderFonts[header] = d2fonts.SourceSansPro.Font(fontSize, d2fonts.FONT_STYLE_BOLD)
}
markdownRenderer = goldmark.New(
goldmark.WithRendererOptions(
goldmarkHtml.WithUnsafe(),
),
)
}
func MeasureMarkdown(mdText string, ruler *Ruler) (width, height int, err error) {
render, err := RenderMarkdown(mdText)
if err != nil {
return width, height, err
}
doc, err := goquery.NewDocumentFromReader(strings.NewReader(render))
if err != nil {
return width, height, err
}
{
originalLineHeight := ruler.LineHeightFactor
ruler.LineHeightFactor = MarkdownLineHeight
defer func() {
ruler.LineHeightFactor = originalLineHeight
}()
}
font := d2fonts.SourceSansPro.Font(MarkdownFontSize, d2fonts.FONT_STYLE_REGULAR)
// TODO consider setting a max width + (manual) text wrapping
bodyNode := doc.Find("body").First().Nodes[0]
bodyWidth, bodyHeight, _, _ := ruler.measureNode(0, bodyNode, font)
return int(math.Ceil(bodyWidth)), int(math.Ceil(bodyHeight)), nil
}
func hasPrev(n *html.Node) bool {
if n.PrevSibling == nil {
return false
}
if strings.TrimSpace(n.PrevSibling.Data) == "" {
return hasPrev(n.PrevSibling)
}
return true
}
func hasNext(n *html.Node) bool {
if n.NextSibling == nil {
return false
}
// skip over empty text nodes
if strings.TrimSpace(n.NextSibling.Data) == "" {
return hasNext(n.NextSibling)
}
return true
}
func getPrev(n *html.Node) *html.Node {
if n == nil {
return nil
}
if strings.TrimSpace(n.Data) == "" {
if next := getNext(n.PrevSibling); next != nil {
return next
}
}
return n
}
func getNext(n *html.Node) *html.Node {
if n == nil {
return nil
}
if strings.TrimSpace(n.Data) == "" {
if next := getNext(n.NextSibling); next != nil {
return next
}
}
return n
}
func isBlockElement(elType string) bool {
switch elType {
case "blockquote",
"div",
"h1", "h2", "h3", "h4", "h5", "h6",
"hr",
"li",
"ol",
"p",
"pre",
"ul":
return true
default:
return false
}
}
func hasAncestorElement(n *html.Node, elType string) bool {
if n.Parent == nil {
return false
}
if n.Parent.Type == html.ElementNode && n.Parent.Data == elType {
return true
}
return hasAncestorElement(n.Parent, elType)
}
// measures node dimensions to match rendering with styles in github-markdown.css
func (ruler *Ruler) measureNode(depth int, n *html.Node, font d2fonts.Font) (width, height, marginTop, marginBottom float64) {
switch n.Type {
case html.TextNode:
if strings.TrimSpace(n.Data) == "" {
return
}
spaceWidths := 0.
// consecutive leading/trailing spaces end up rendered as a single space
spaceRune, _ := utf8.DecodeRuneInString(" ")
// measure will not include leading or trailing whitespace, so we have to add in the space width
spaceWidth := ruler.atlases[font].glyph(spaceRune).advance
str := n.Data
hasCodeParent := n.Parent != nil && n.Parent.Type == html.ElementNode && (n.Parent.Data == "pre" || n.Parent.Data == "code")
if !hasCodeParent {
str = strings.ReplaceAll(n.Data, "\n", " ")
}
if strings.HasPrefix(str, " ") {
str = strings.TrimPrefix(str, " ")
if hasPrev(n) {
spaceWidths += spaceWidth
}
}
if strings.HasSuffix(str, " ") {
str = strings.TrimSuffix(str, " ")
if hasNext(n) {
spaceWidths += spaceWidth
}
}
w, h := ruler.MeasurePrecise(font, str)
w += spaceWidths
// fmt.Printf("%d:%s width %v height %v fontStyle %s\n", depth, n.Data, w, h, font.Style)
if h > 0 && h < MarkdownLineHeightPx {
h = MarkdownLineHeightPx
}
return w, h, 0, 0
case html.ElementNode:
// fmt.Printf("%d: %v node\n", depth, n.Data)
switch n.Data {
case "h1", "h2", "h3", "h4", "h5", "h6":
font = HeaderFonts[n.Data]
originalLineHeight := ruler.LineHeightFactor
ruler.LineHeightFactor = LineHeight_h
defer func() {
ruler.LineHeightFactor = originalLineHeight
}()
case "em":
font.Style = d2fonts.FONT_STYLE_ITALIC
case "b", "strong":
font.Style = d2fonts.FONT_STYLE_BOLD
case "pre", "code":
// TODO monospaced font
}
if n.FirstChild != nil {
first := getNext(n.FirstChild)
last := getPrev(n.LastChild)
var prevMarginBottom float64
for child := n.FirstChild; child != nil; child = child.NextSibling {
childWidth, childHeight, childMarginTop, childMarginBottom := ruler.measureNode(depth+1, child, font)
if child.Type == html.ElementNode && isBlockElement(child.Data) {
if child == first {
if n.Data == "blockquote" {
childMarginTop = 0.
}
marginTop = go2.Max(marginTop, childMarginTop)
} else {
marginDiff := childMarginTop - prevMarginBottom
if marginDiff > 0 {
childHeight += marginDiff
}
}
if child == last {
if n.Data == "blockquote" {
childMarginBottom = 0.
}
marginBottom = go2.Max(marginBottom, childMarginBottom)
} else {
childHeight += childMarginBottom
prevMarginBottom = childMarginBottom
}
height += childHeight
width = go2.Max(width, childWidth)
} else {
marginTop = go2.Max(marginTop, childMarginTop)
marginBottom = go2.Max(marginBottom, childMarginBottom)
width += childWidth
height = go2.Max(height, childHeight)
}
}
}
switch n.Data {
case "blockquote":
width += float64(font.Size) * (2*PaddingLR_blockquote_em + BorderLeft_blockquote_em)
marginBottom = go2.Max(marginBottom, MarginBottom_blockquote)
case "p":
if n.Parent != nil && n.Parent.Type == html.ElementNode && n.Parent.Data == "li" {
marginTop = go2.Max(marginTop, MarginTop_li_p)
}
marginBottom = go2.Max(marginBottom, MarginBottom_p)
case "h1", "h2", "h3", "h4", "h5", "h6":
marginTop = go2.Max(marginTop, MarginTop_h)
marginBottom = go2.Max(marginBottom, MarginBottom_h)
switch n.Data {
case "h1", "h2":
height += float64(HeaderToFontSize[n.Data]) * PaddingBottom_h1_h2_em
}
case "li":
width += PaddingLeft_ul_ol
if hasPrev(n) {
marginTop = go2.Max(marginTop, 4)
}
case "ol", "ul":
if hasAncestorElement(n, "ul") || hasAncestorElement(n, "ol") {
marginTop = 0
marginBottom = 0
} else {
marginBottom = go2.Max(marginBottom, MarginBottom_ul)
}
case "pre":
width += 2 * Padding_pre
height += 2 * Padding_pre
marginBottom = go2.Max(marginBottom, MarginBottom_pre)
case "hr":
height += Height_hr
marginTop = go2.Max(marginTop, MarginTopBottom_hr)
marginBottom = go2.Max(marginBottom, MarginTopBottom_hr)
}
// fmt.Printf("%d:%s width %v height %v mt %v mb %v\n", depth, n.Data, width, height, marginTop, marginBottom)
}
return width, height, marginTop, marginBottom
}

View file

@ -0,0 +1,51 @@
package textmeasure
import (
"math"
"oss.terrastruct.com/d2/lib/geo"
)
type rect struct {
tl *geo.Point
br *geo.Point
}
func newRect() *rect {
return &rect{
tl: geo.NewPoint(0, 0),
br: geo.NewPoint(0, 0),
}
}
func (r rect) w() float64 {
return r.br.X - r.tl.X
}
func (r rect) h() float64 {
return r.br.Y - r.tl.Y
}
// norm returns the Rect in normal form, such that Max is component-wise greater or equal than Min.
func (r rect) norm() *rect {
return &rect{
tl: geo.NewPoint(
math.Min(r.tl.X, r.br.X),
math.Min(r.tl.Y, r.br.Y),
),
br: geo.NewPoint(
math.Max(r.tl.X, r.br.X),
math.Max(r.tl.Y, r.br.Y),
),
}
}
func (r1 *rect) union(r2 *rect) *rect {
r := newRect()
r.tl.X = math.Min(r1.tl.X, r2.tl.X)
r.tl.Y = math.Min(r1.tl.Y, r2.tl.Y)
r.br.X = math.Max(r1.br.X, r2.br.X)
r.br.Y = math.Max(r1.br.Y, r2.br.Y)
return r
}

View file

@ -0,0 +1,208 @@
// Ported from https://github.com/faiface/pixel/tree/master/text
// Trimmed down to essentials of measuring text
package textmeasure
import (
"math"
"unicode"
"unicode/utf8"
"github.com/golang/freetype/truetype"
"oss.terrastruct.com/d2/d2renderers/d2fonts"
"oss.terrastruct.com/d2/lib/geo"
)
// ASCII is a set of all ASCII runes. These runes are codepoints from 32 to 127 inclusive.
var ASCII []rune
func init() {
ASCII = make([]rune, unicode.MaxASCII-32)
for i := range ASCII {
ASCII[i] = rune(32 + i)
}
}
// Ruler allows for effiecient and convenient text drawing.
//
// To create a Ruler object, use the New constructor:
// txt := text.New(pixel.ZV, text.NewAtlas(face, text.ASCII))
//
// As suggested by the constructor, a Ruler object is always associated with one font face and a
// fixed set of runes. For example, the Ruler we created above can draw text using the font face
// contained in the face variable and is capable of drawing ASCII characters.
//
// Here we create a Ruler object which can draw ASCII and Katakana characters:
// txt := text.New(0, text.NewAtlas(face, text.ASCII, text.RangeTable(unicode.Katakana)))
//
// Similarly to IMDraw, Ruler functions as a buffer. It implements io.Writer interface, so writing
// text to it is really simple:
// fmt.Print(txt, "Hello, world!")
//
// Newlines, tabs and carriage returns are supported.
//
// Finally, if we want the written text to show up on some other Target, we can draw it:
// txt.Draw(target)
//
// Ruler exports two important fields: Orig and Dot. Dot is the position where the next character
// will be written. Dot is automatically moved when writing to a Ruler object, but you can also
// manipulate it manually. Orig specifies the text origin, usually the top-left dot position. Dot is
// always aligned to Orig when writing newlines. The Clear method resets the Dot to Orig.
type Ruler struct {
// Orig specifies the text origin, usually the top-left dot position. Dot is always aligned
// to Orig when writing newlines.
Orig *geo.Point
// Dot is the position where the next character will be written. Dot is automatically moved
// when writing to a Ruler object, but you can also manipulate it manually
Dot *geo.Point
// lineHeight is the vertical distance between two lines of text.
//
// Example:
// txt.lineHeight = 1.5 * txt.atlas.lineHeight
LineHeightFactor float64
lineHeights map[d2fonts.Font]float64
// tabWidth is the horizontal tab width. Tab characters will align to the multiples of this
// width.
//
// Example:
// txt.tabWidth = 8 * txt.atlas.glyph(' ').Advance
tabWidths map[d2fonts.Font]float64
atlases map[d2fonts.Font]*atlas
buf []byte
prevR rune
bounds *rect
}
// New creates a new Ruler capable of drawing runes contained in the provided atlas. Orig and Dot
// will be initially set to orig.
//
// Here we create a Ruler capable of drawing ASCII characters using the Go Regular font.
// ttf, err := truetype.Parse(goregular.TTF)
// if err != nil {
// panic(err)
// }
// face := truetype.NewFace(ttf, &truetype.Options{
// Size: 14,
// })
// txt := text.New(orig, text.NewAtlas(face, text.ASCII))
func NewRuler() (*Ruler, error) {
lineHeights := make(map[d2fonts.Font]float64)
tabWidths := make(map[d2fonts.Font]float64)
atlases := make(map[d2fonts.Font]*atlas)
for _, fontFamily := range d2fonts.FontFamilies {
for _, fontSize := range d2fonts.FontSizes {
for _, fontStyle := range d2fonts.FontStyles {
font := d2fonts.Font{
Family: fontFamily,
Style: fontStyle,
}
if _, ok := d2fonts.FontFaces[font]; !ok {
continue
}
ttf, err := truetype.Parse(d2fonts.FontFaces[font])
if err != nil {
return nil, err
}
// Added after, since FontFaces lookup is size-agnostic
font.Size = fontSize
face := truetype.NewFace(ttf, &truetype.Options{
Size: float64(fontSize),
})
atlas := NewAtlas(face, ASCII)
atlases[font] = atlas
lineHeights[font] = atlas.lineHeight
tabWidths[font] = atlas.glyph(' ').advance * 4
}
}
}
origin := geo.NewPoint(0, 0)
txt := &Ruler{
Orig: origin,
Dot: origin.Copy(),
LineHeightFactor: 1.,
lineHeights: lineHeights,
tabWidths: tabWidths,
atlases: atlases,
}
txt.clear()
return txt, nil
}
func (t *Ruler) Measure(font d2fonts.Font, s string) (width, height int) {
w, h := t.MeasurePrecise(font, s)
return int(math.Ceil(w)), int(math.Ceil(h))
}
func (t *Ruler) MeasurePrecise(font d2fonts.Font, s string) (width, height float64) {
t.clear()
t.buf = append(t.buf, s...)
t.drawBuf(font)
b := t.bounds
return b.w(), b.h()
}
// clear removes all written text from the Ruler. The Dot field is reset to Orig.
func (txt *Ruler) clear() {
txt.prevR = -1
txt.bounds = newRect()
txt.Dot = txt.Orig.Copy()
}
// controlRune checks if r is a control rune (newline, tab, ...). If it is, a new dot position and
// true is returned. If r is not a control rune, the original dot and false is returned.
func (txt *Ruler) controlRune(r rune, dot *geo.Point, font d2fonts.Font) (newDot *geo.Point, control bool) {
switch r {
case '\n':
dot.X = txt.Orig.X
dot.Y -= txt.LineHeightFactor * txt.lineHeights[font]
case '\r':
dot.X = txt.Orig.X
case '\t':
rem := math.Mod(dot.X-txt.Orig.X, txt.tabWidths[font])
rem = math.Mod(rem, rem+txt.tabWidths[font])
if rem == 0 {
rem = txt.tabWidths[font]
}
dot.X += rem
default:
return dot, false
}
return dot, true
}
func (txt *Ruler) drawBuf(font d2fonts.Font) {
if !utf8.FullRune(txt.buf) {
return
}
for utf8.FullRune(txt.buf) {
r, l := utf8.DecodeRune(txt.buf)
txt.buf = txt.buf[l:]
var control bool
txt.Dot, control = txt.controlRune(r, txt.Dot, font)
if control {
continue
}
var bounds *rect
_, _, bounds, txt.Dot = txt.atlases[font].DrawRune(txt.prevR, r, txt.Dot)
txt.prevR = r
if txt.bounds.w()*txt.bounds.h() == 0 {
txt.bounds = bounds
} else {
txt.bounds = txt.bounds.union(bounds)
}
}
}

View file

@ -0,0 +1,115 @@
package textmeasure_test
import (
"fmt"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"oss.terrastruct.com/d2/d2renderers/d2fonts"
"oss.terrastruct.com/d2/d2renderers/textmeasure"
)
var txts = []string{
"Jesus is my POSTMASTER GENERAL ...",
"Don't let go of what you've got hold of, until you have hold of something else.",
"To get something clean, one has to get something dirty.",
"The notes blatted skyward as they rose over the Canada geese, feathered",
"There is no such thing as a problem without a gift for you in its hands.",
"Baseball is a skilled game. It's America's game - it, and high taxes.",
"He is truly wise who gains wisdom from another's mishap.",
"If you have never been hated by your child, you have never been a parent.",
"Your only obligation in any lifetime is to be true to yourself. Being",
"The computing field is always in need of new cliches.",
}
func TestTextMeasure(t *testing.T) {
ruler, err := textmeasure.NewRuler()
if err != nil {
t.Fatal(err)
}
// For a set of random strings, test each char increases width but not height
for _, txt := range txts {
txt = strings.ReplaceAll(txt, " ", "")
for i := 1; i < len(txt)-1; i++ {
w1, h1 := ruler.Measure(d2fonts.SourceSansPro.Font(d2fonts.FONT_SIZE_M, d2fonts.FONT_STYLE_REGULAR), txt[:i])
w2, h2 := ruler.Measure(d2fonts.SourceSansPro.Font(d2fonts.FONT_SIZE_M, d2fonts.FONT_STYLE_REGULAR), txt[:i+1])
assert.Equal(t, h1, h2)
assert.Less(t, w1, w2, fmt.Sprintf(`"%s" vs "%s"`, txt[:i], txt[:i+1]))
}
}
// For a set of random strings, test that adding newlines increases height each time
for _, txt := range txts {
whitespaces := strings.Count(txt, " ")
for i := 0; i < whitespaces-1; i++ {
txt1 := strings.Replace(txt, " ", "\n", i)
txt2 := strings.Replace(txt, " ", "\n", i+1)
w1, h1 := ruler.Measure(d2fonts.SourceSansPro.Font(d2fonts.FONT_SIZE_M, d2fonts.FONT_STYLE_REGULAR), txt1)
w2, h2 := ruler.Measure(d2fonts.SourceSansPro.Font(d2fonts.FONT_SIZE_M, d2fonts.FONT_STYLE_REGULAR), txt2)
assert.Less(t, h1, h2)
assert.Less(t, w2, w1)
}
}
}
func TestFontMeasure(t *testing.T) {
ruler, err := textmeasure.NewRuler()
if err != nil {
t.Fatal(err)
}
// For a set of random strings, test that font sizes are strictly increasing
for _, txt := range txts {
for i := 0; i < len(d2fonts.FontSizes)-1; i++ {
w1, h1 := ruler.Measure(d2fonts.SourceSansPro.Font(d2fonts.FontSizes[i], d2fonts.FONT_STYLE_REGULAR), txt)
w2, h2 := ruler.Measure(d2fonts.SourceSansPro.Font(d2fonts.FontSizes[i+1], d2fonts.FONT_STYLE_REGULAR), txt)
assert.Less(t, h1, h2)
assert.Less(t, w1, w2)
}
}
}
type dimensions struct {
width, height int
}
var mdTexts = map[string]dimensions{
`
- [Overview](#overview) ok _this is all measured_
`: {245, 24},
`
_italics are all measured correctly_
`: {214, 24},
`
**bold is measured correctly**
`: {187, 24},
`
**Note:** This document
`: {141, 24},
`
**Note:**
`: {37, 24},
}
func TestTextMeasureMarkdown(t *testing.T) {
ruler, err := textmeasure.NewRuler()
if err != nil {
t.Fatal(err)
}
for text, dims := range mdTexts {
width, height, err := textmeasure.MeasureMarkdown(text, ruler)
if err != nil {
t.Fatal(err)
}
assert.Equal(t, dims.width, width, text)
assert.Equal(t, dims.height, height, text)
}
}

44
d2target/class.go Normal file
View file

@ -0,0 +1,44 @@
package d2target
import (
"fmt"
"oss.terrastruct.com/d2/d2renderers/d2fonts"
)
type Class struct {
Fields []ClassField `json:"fields"`
Methods []ClassMethod `json:"methods"`
}
type ClassField struct {
Name string `json:"name"`
Type string `json:"type"`
Visibility string `json:"visibility"`
}
func (cf ClassField) Text() *MText {
return &MText{
Text: fmt.Sprintf("%s%s", cf.Name, cf.Type),
FontSize: d2fonts.FONT_SIZE_L,
IsBold: false,
IsItalic: false,
Shape: "class",
}
}
type ClassMethod struct {
Name string `json:"name"`
Return string `json:"return"`
Visibility string `json:"visibility"`
}
func (cm ClassMethod) Text() *MText {
return &MText{
Text: fmt.Sprintf("%s%s", cm.Name, cm.Return),
FontSize: d2fonts.FONT_SIZE_L,
IsBold: false,
IsItalic: false,
Shape: "class",
}
}

411
d2target/d2target.go Normal file
View file

@ -0,0 +1,411 @@
package d2target
import (
"math"
"net/url"
"strings"
"oss.terrastruct.com/d2/d2themes"
"oss.terrastruct.com/d2/lib/geo"
"oss.terrastruct.com/d2/lib/go2"
"oss.terrastruct.com/d2/lib/label"
"oss.terrastruct.com/d2/lib/shape"
)
const (
DEFAULT_ICON_SIZE = 32
MAX_ICON_SIZE = 64
)
type Diagram struct {
Name string `json:"name"`
Description string `json:"description,omitempty"`
Shapes []Shape `json:"shapes"`
Connections []Connection `json:"connections"`
}
func (diagram Diagram) BoundingBox() (topLeft, bottomRight Point) {
x1 := int(math.MaxInt64)
y1 := int(math.MaxInt64)
x2 := int(-math.MaxInt64)
y2 := int(-math.MaxInt64)
for _, targetShape := range diagram.Shapes {
x1 = go2.Min(x1, targetShape.Pos.X)
y1 = go2.Min(y1, targetShape.Pos.Y)
x2 = go2.Max(x2, targetShape.Pos.X+targetShape.Width)
y2 = go2.Max(y2, targetShape.Pos.Y+targetShape.Height)
if targetShape.Label != "" {
labelPosition := label.Position(targetShape.LabelPosition)
if !labelPosition.IsOutside() {
continue
}
shapeType := DSL_SHAPE_TO_SHAPE_TYPE[targetShape.Type]
s := shape.NewShape(shapeType,
geo.NewBox(
geo.NewPoint(float64(targetShape.Pos.X), float64(targetShape.Pos.Y)),
float64(targetShape.Width),
float64(targetShape.Height),
),
)
labelTL := labelPosition.GetPointOnBox(s.GetBox(), label.PADDING, float64(targetShape.LabelWidth), float64(targetShape.LabelHeight))
x1 = go2.Min(x1, int(labelTL.X))
y1 = go2.Min(y1, int(labelTL.Y))
x2 = go2.Max(x2, int(labelTL.X)+targetShape.LabelWidth)
y2 = go2.Max(y2, int(labelTL.Y)+targetShape.LabelHeight)
}
}
for _, connection := range diagram.Connections {
for _, point := range connection.Route {
x1 = go2.Min(x1, int(math.Floor(point.X)))
y1 = go2.Min(y1, int(math.Floor(point.Y)))
x2 = go2.Max(x2, int(math.Ceil(point.X)))
y2 = go2.Max(y2, int(math.Ceil(point.Y)))
}
if connection.Label != "" {
labelTL := connection.GetLabelTopLeft()
x1 = go2.Min(x1, int(labelTL.X))
y1 = go2.Min(y1, int(labelTL.Y))
x2 = go2.Max(x2, int(labelTL.X)+connection.LabelWidth)
y2 = go2.Max(y2, int(labelTL.Y)+connection.LabelHeight)
}
}
return Point{x1, y1}, Point{x2, y2}
}
func NewDiagram() *Diagram {
return &Diagram{}
}
type Shape struct {
ID string `json:"id"`
Type string `json:"type"`
Pos Point `json:"pos"`
Width int `json:"width"`
Height int `json:"height"`
Level int `json:"level"`
Opacity float64 `json:"opacity"`
StrokeDash float64 `json:"strokeDash"`
StrokeWidth int `json:"strokeWidth"`
BorderRadius int `json:"borderRadius"`
Fill string `json:"fill"`
Stroke string `json:"stroke"`
Shadow bool `json:"shadow"`
ThreeDee bool `json:"3d"`
Multiple bool `json:"multiple"`
Tooltip string `json:"tooltip"`
Link string `json:"link"`
Icon *url.URL `json:"icon"`
IconPosition string `json:"iconPosition"`
Class
SQLTable
Text
LabelPosition string `json:"labelPosition,omitempty"`
}
func (s *Shape) SetType(t string) {
// Some types are synonyms of other types, but with hinting for autolayout
// They should only have one representation in the final export
if strings.EqualFold(t, ShapeCircle) {
t = ShapeOval
} else if strings.EqualFold(t, ShapeSquare) {
t = ShapeRectangle
}
s.Type = strings.ToLower(t)
}
type Text struct {
Label string `json:"label"`
FontSize int `json:"fontSize"`
FontFamily string `json:"fontFamily"`
Language string `json:"language"`
Color string `json:"color"`
Italic bool `json:"italic"`
Bold bool `json:"bold"`
Underline bool `json:"underline"`
LabelWidth int `json:"labelWidth"`
LabelHeight int `json:"labelHeight"`
}
func BaseShape() *Shape {
return &Shape{
Opacity: 1,
StrokeDash: 0,
StrokeWidth: 2,
Text: Text{
Bold: true,
FontFamily: "DEFAULT",
},
}
}
type Connection struct {
ID string `json:"id"`
Src string `json:"src"`
SrcArrow Arrowhead `json:"srcArrow"`
SrcLabel string `json:"srcLabel"`
Dst string `json:"dst"`
DstArrow Arrowhead `json:"dstArrow"`
DstLabel string `json:"dstLabel"`
Opacity float64 `json:"opacity"`
StrokeDash float64 `json:"strokeDash"`
StrokeWidth int `json:"strokeWidth"`
Stroke string `json:"stroke"`
Text
LabelPosition string `json:"labelPosition"`
LabelPercentage float64 `json:"labelPercentage"`
Route []*geo.Point `json:"route"`
IsCurve bool `json:"isCurve,omitempty"`
Animated bool `json:"animated"`
Tooltip string `json:"tooltip"`
Icon *url.URL `json:"icon"`
}
func BaseConnection() *Connection {
return &Connection{
SrcArrow: NoArrowhead,
DstArrow: NoArrowhead,
Route: make([]*geo.Point, 0),
Opacity: 1,
StrokeDash: 0,
StrokeWidth: 2,
Text: Text{
Italic: true,
FontFamily: "DEFAULT",
},
}
}
func (c *Connection) GetLabelTopLeft() *geo.Point {
return label.Position(c.LabelPosition).GetPointOnRoute(
c.Route,
float64(c.StrokeWidth),
c.LabelPercentage,
float64(c.LabelWidth),
float64(c.LabelHeight),
)
}
type Arrowhead string
const (
NoArrowhead Arrowhead = "none"
ArrowArrowhead Arrowhead = "arrow"
TriangleArrowhead Arrowhead = "triangle"
DiamondArrowhead Arrowhead = "diamond"
FilledDiamondArrowhead Arrowhead = "filled-diamond"
// For fat arrows
LineArrowhead Arrowhead = "line"
)
var Arrowheads = map[string]struct{}{
string(NoArrowhead): {},
string(ArrowArrowhead): {},
string(TriangleArrowhead): {},
string(DiamondArrowhead): {},
string(FilledDiamondArrowhead): {},
}
func ToArrowhead(arrowheadType string, filled bool) Arrowhead {
switch arrowheadType {
case string(DiamondArrowhead):
if filled {
return FilledDiamondArrowhead
}
return DiamondArrowhead
case string(ArrowArrowhead):
return ArrowArrowhead
default:
return TriangleArrowhead
}
}
type Point struct {
X int `json:"x"`
Y int `json:"y"`
}
func NewPoint(x, y int) Point {
return Point{X: x, Y: y}
}
const (
ShapeRectangle = "rectangle"
ShapeSquare = "square"
ShapePage = "page"
ShapeParallelogram = "parallelogram"
ShapeDocument = "document"
ShapeCylinder = "cylinder"
ShapeQueue = "queue"
ShapePackage = "package"
ShapeStep = "step"
ShapeCallout = "callout"
ShapeStoredData = "stored_data"
ShapePerson = "person"
ShapeDiamond = "diamond"
ShapeOval = "oval"
ShapeCircle = "circle"
ShapeHexagon = "hexagon"
ShapeCloud = "cloud"
ShapeText = "text"
ShapeCode = "code"
ShapeClass = "class"
ShapeSQLTable = "sql_table"
ShapeImage = "image"
)
var Shapes = []string{
ShapeRectangle,
ShapeSquare,
ShapePage,
ShapeParallelogram,
ShapeDocument,
ShapeCylinder,
ShapeQueue,
ShapePackage,
ShapeStep,
ShapeCallout,
ShapeStoredData,
ShapePerson,
ShapeDiamond,
ShapeOval,
ShapeCircle,
ShapeHexagon,
ShapeCloud,
ShapeText,
ShapeCode,
ShapeClass,
ShapeSQLTable,
ShapeImage,
}
func IsShape(s string) bool {
if s == "" {
// Default shape is rectangle.
return true
}
for _, s2 := range Shapes {
if strings.EqualFold(s, s2) {
return true
}
}
return false
}
type MText struct {
Text string `json:"text"`
FontSize int `json:"fontSize"`
IsBold bool `json:"isBold"`
IsItalic bool `json:"isItalic"`
Language string `json:"language"`
Shape string `json:"shape"`
Dimensions TextDimensions `json:"dimensions,omitempty"`
}
type TextDimensions struct {
Width int `json:"width"`
Height int `json:"height"`
}
func NewTextDimensions(w, h int) *TextDimensions {
return &TextDimensions{Width: w, Height: h}
}
func (text MText) GetColor(theme *d2themes.Theme, isItalic bool) string {
if isItalic {
return theme.Colors.Neutrals.N2
}
return theme.Colors.Neutrals.N1
}
var DSL_SHAPE_TO_SHAPE_TYPE = map[string]string{
"": shape.SQUARE_TYPE,
ShapeRectangle: shape.SQUARE_TYPE,
ShapeSquare: shape.REAL_SQUARE_TYPE,
ShapePage: shape.PAGE_TYPE,
ShapeParallelogram: shape.PARALLELOGRAM_TYPE,
ShapeDocument: shape.DOCUMENT_TYPE,
ShapeCylinder: shape.CYLINDER_TYPE,
ShapeQueue: shape.QUEUE_TYPE,
ShapePackage: shape.PACKAGE_TYPE,
ShapeStep: shape.STEP_TYPE,
ShapeCallout: shape.CALLOUT_TYPE,
ShapeStoredData: shape.STORED_DATA_TYPE,
ShapePerson: shape.PERSON_TYPE,
ShapeDiamond: shape.DIAMOND_TYPE,
ShapeOval: shape.OVAL_TYPE,
ShapeCircle: shape.CIRCLE_TYPE,
ShapeHexagon: shape.HEXAGON_TYPE,
ShapeCloud: shape.CLOUD_TYPE,
ShapeText: shape.TEXT_TYPE,
ShapeCode: shape.CODE_TYPE,
ShapeClass: shape.CLASS_TYPE,
ShapeSQLTable: shape.TABLE_TYPE,
ShapeImage: shape.IMAGE_TYPE,
}
var SHAPE_TYPE_TO_DSL_SHAPE map[string]string
func init() {
SHAPE_TYPE_TO_DSL_SHAPE = make(map[string]string, len(DSL_SHAPE_TO_SHAPE_TYPE))
for k, v := range DSL_SHAPE_TO_SHAPE_TYPE {
SHAPE_TYPE_TO_DSL_SHAPE[v] = k
}
}
func (s *Shape) GetIconSize(box *geo.Box) int {
iconPosition := label.Position(s.IconPosition)
minDimension := int(math.Min(box.Width, box.Height))
halfMinDimension := int(math.Ceil(0.5 * float64(minDimension)))
var size int
if iconPosition == label.InsideMiddleCenter {
size = halfMinDimension
} else {
size = go2.IntMin(
minDimension,
go2.IntMax(DEFAULT_ICON_SIZE, halfMinDimension),
)
}
size = go2.IntMin(size, MAX_ICON_SIZE)
if !iconPosition.IsOutside() {
size = go2.IntMin(size,
go2.IntMin(
go2.IntMax(int(box.Width)-2*label.PADDING, 0),
go2.IntMax(int(box.Height)-2*label.PADDING, 0),
),
)
}
return size
}

28
d2target/sqltable.go Normal file
View file

@ -0,0 +1,28 @@
package d2target
import (
"fmt"
"oss.terrastruct.com/d2/d2renderers/d2fonts"
)
type SQLTable struct {
Columns []SQLColumn `json:"columns"`
}
type SQLColumn struct {
Name string `json:"name"`
Type string `json:"type"`
Constraint string `json:"constraint"`
Reference string `json:"reference"`
}
func (c SQLColumn) Text() *MText {
return &MText{
Text: fmt.Sprintf("%s%s%s%s", c.Name, c.Type, c.Constraint, c.Reference),
FontSize: d2fonts.FONT_SIZE_L,
IsBold: false,
IsItalic: false,
Shape: "sql_table",
}
}

18
d2themes/README.md Normal file
View file

@ -0,0 +1,18 @@
# d2themes
`d2themes` defines themes for D2. You can add a new one in `./d2themescatalog`, give a
unique ID, and specify it in the CLI or library to see it.
# Color coding guide
<img src="../docs/assets/themes_coding.png" />
# Color coding example
<img src="../docs/assets/themes_coding_example.png" />
# Container gradients
To distinguish container nesting, objects get progressively lighter the more nested it is.
<img src="../docs/assets/themes_gradients.png" width="300px" />

60
d2themes/d2themes.go Normal file
View file

@ -0,0 +1,60 @@
// d2themes defines themes to make d2 diagrams pretty
// Color codes: darkest (N1) -> lightest (N7)
package d2themes
type Theme struct {
ID int64 `json:"id"`
Name string `json:"name"`
Colors ColorPalette `json:"colors"`
}
type Neutral struct {
N1 string `json:"n1"`
N2 string `json:"n2"`
N3 string `json:"n3"`
N4 string `json:"n4"`
N5 string `json:"n5"`
N7 string `json:"n7"`
}
type ColorPalette struct {
// So far the palette only contains the colors used in d2 shapes, the full theme includes more colors
// https://www.figma.com/file/n79RbPiHFUTO4PPPdpDu7w/%5BKW%5D-GUI-features?node-id=2268%3A120792
Neutrals Neutral `json:"neutrals"`
// Base Colors: used for containers
B1 string `json:"b1"`
B2 string `json:"b2"`
B3 string `json:"b3"`
B4 string `json:"b4"`
B5 string `json:"b5"`
B6 string `json:"b6"`
// Alternative colors A
AA2 string `json:"aa2"`
AA4 string `json:"aa4"`
AA5 string `json:"aa5"`
// Alternative colors B
AB4 string `json:"ab4"`
AB5 string `json:"ab5"`
}
var CoolNeutral = Neutral{
N1: "#0A0F25",
N2: "#676C7E",
N3: "#9499AB",
N4: "#CFD2DD",
N5: "#F0F3F9",
N7: "#FFFFFF",
}
var WarmNeutral = Neutral{
N1: "#170206",
N2: "#535152",
N3: "#787777",
N4: "#CCCACA",
N5: "#DFDCDC",
N7: "#FFFFFF",
}

View file

@ -0,0 +1,25 @@
package d2themescatalog
import "oss.terrastruct.com/d2/d2themes"
var Aubergine = d2themes.Theme{
ID: 7,
Name: "Aubergine",
Colors: d2themes.ColorPalette{
Neutrals: d2themes.CoolNeutral,
B1: "#170034",
B2: "#7639C5",
B3: "#8F70D1",
B4: "#D0B9F5",
B5: "#E7DEFF",
B6: "#F4F0FF",
AA2: "#0F66B7",
AA4: "#87BFF3",
AA5: "#BCDDFB",
AB4: "#92E3E3",
AB5: "#D7F5F5",
},
}

View file

@ -0,0 +1,41 @@
package d2themescatalog
import (
"fmt"
"strings"
"oss.terrastruct.com/d2/d2themes"
)
var Catalog = []d2themes.Theme{
NeutralDefault,
NeutralGrey,
FlagshipTerrastruct,
MixedBerryBlue,
CoolClassics,
GrapeSoda,
Aubergine,
ColorblindClear,
VanillaNitroCola,
OrangeCreamsicle,
ShirleyTemple,
EarthTones,
}
func Find(id int64) d2themes.Theme {
for _, theme := range Catalog {
if theme.ID == id {
return theme
}
}
return d2themes.Theme{}
}
func CLIString() string {
var s strings.Builder
for _, t := range Catalog {
s.WriteString(fmt.Sprintf("- %s: %d\n", t.Name, t.ID))
}
return s.String()
}

View file

@ -0,0 +1,25 @@
package d2themescatalog
import "oss.terrastruct.com/d2/d2themes"
var ColorblindClear = d2themes.Theme{
ID: 8,
Name: "Colorblind clear",
Colors: d2themes.ColorPalette{
Neutrals: d2themes.CoolNeutral,
B1: "#010E31",
B2: "#173688",
B3: "#5679D4",
B4: "#84A1EC",
B5: "#C8D6F9",
B6: "#E5EDFF",
AA2: "#048E63",
AA4: "#A6E2D0",
AA5: "#CAF2E6",
AB4: "#FFDA90",
AB5: "#FFF0D1",
},
}

View file

@ -0,0 +1,25 @@
package d2themescatalog
import "oss.terrastruct.com/d2/d2themes"
var CoolClassics = d2themes.Theme{
ID: 4,
Name: "Cool classics",
Colors: d2themes.ColorPalette{
Neutrals: d2themes.CoolNeutral,
B1: "#000536",
B2: "#0F66B7",
B3: "#4393DD",
B4: "#87BFF3",
B5: "#BCDDFB",
B6: "#E5F3FF",
AA2: "#076F6F",
AA4: "#77DEDE",
AA5: "#C3F8F8",
AB4: "#C1A2F3",
AB5: "#DACEFB",
},
}

View file

@ -0,0 +1,25 @@
package d2themescatalog
import "oss.terrastruct.com/d2/d2themes"
var NeutralDefault = d2themes.Theme{
ID: 0,
Name: "Neutral default",
Colors: d2themes.ColorPalette{
Neutrals: d2themes.CoolNeutral,
B1: "#0D32B2",
B2: "#0D32B2",
B3: "#E3E9FD",
B4: "#E3E9FD",
B5: "#EDF0FD",
B6: "#F7F8FE",
AA2: "#4A6FF3",
AA4: "#EDF0FD",
AA5: "#F7F8FE",
AB4: "#EDF0FD",
AB5: "#F7F8FE",
},
}

View file

@ -0,0 +1,25 @@
package d2themescatalog
import "oss.terrastruct.com/d2/d2themes"
var EarthTones = d2themes.Theme{
ID: 103,
Name: "Earth tones",
Colors: d2themes.ColorPalette{
Neutrals: d2themes.WarmNeutral,
B1: "#1E1303",
B2: "#55452F",
B3: "#9A876C",
B4: "#C9B9A1",
B5: "#E9DBCA",
B6: "#FAF1E6",
AA2: "#D35F0A",
AA4: "#FABA8A",
AA5: "#FFE0C7",
AB4: "#FFE767",
AB5: "#FFF2AA",
},
}

View file

@ -0,0 +1,25 @@
package d2themescatalog
import "oss.terrastruct.com/d2/d2themes"
var FlagshipTerrastruct = d2themes.Theme{
ID: 3,
Name: "Flagship Terrastruct",
Colors: d2themes.ColorPalette{
Neutrals: d2themes.CoolNeutral,
B1: "#000E3D",
B2: "#234CDA",
B3: "#6B8AFB",
B4: "#A6B8F8",
B5: "#D2DBFD",
B6: "#E7EAFF",
AA2: "#5829DC",
AA4: "#B4AEF8",
AA5: "#E4DBFF",
AB4: "#7FDBF8",
AB5: "#C3F0FF",
},
}

View file

@ -0,0 +1,25 @@
package d2themescatalog
import "oss.terrastruct.com/d2/d2themes"
var GrapeSoda = d2themes.Theme{
ID: 6,
Name: "Grape soda",
Colors: d2themes.ColorPalette{
Neutrals: d2themes.CoolNeutral,
B1: "#170034",
B2: "#7639C5",
B3: "#8F70D1",
B4: "#C1A2F3",
B5: "#DACEFB",
B6: "#F2EDFF",
AA2: "#0F66B7",
AA4: "#87BFF3",
AA5: "#BCDDFB",
AB4: "#EA99C6",
AB5: "#FFDAEF",
},
}

View file

@ -0,0 +1,25 @@
package d2themescatalog
import "oss.terrastruct.com/d2/d2themes"
var MixedBerryBlue = d2themes.Theme{
ID: 5,
Name: "Mixed berry blue",
Colors: d2themes.ColorPalette{
Neutrals: d2themes.CoolNeutral,
B1: "#000536",
B2: "#0F66B7",
B3: "#4393DD",
B4: "#87BFF3",
B5: "#BCDDFB",
B6: "#E5F3FF",
AA2: "#7639C5",
AA4: "#C1A2F3",
AA5: "#DACEFB",
AB4: "#EA99C6",
AB5: "#FFDEF1",
},
}

View file

@ -0,0 +1,25 @@
package d2themescatalog
import "oss.terrastruct.com/d2/d2themes"
var NeutralGrey = d2themes.Theme{
ID: 1,
Name: "Neutral Grey",
Colors: d2themes.ColorPalette{
Neutrals: d2themes.CoolNeutral,
B1: "#0A0F25",
B2: "#676C7E",
B3: "#9499AB",
B4: "#CFD2DD",
B5: "#DEE1EB",
B6: "#EEF1F8",
AA2: "#676C7E",
AA4: "#CFD2DD",
AA5: "#DEE1EB",
AB4: "#CFD2DD",
AB5: "#DEE1EB",
},
}

Some files were not shown because too many files have changed in this diff Show more